diff --git a/src/autoskillit/agents/AGENTS.md b/src/autoskillit/agents/AGENTS.md index 530114b12..5dffc41d6 100644 --- a/src/autoskillit/agents/AGENTS.md +++ b/src/autoskillit/agents/AGENTS.md @@ -61,7 +61,9 @@ Current packless agents: `wp-elaborator`, `session-log-reader`, `audit-impl-slic `pr-review-auditor-baseline`, `pr-review-auditor-v1-precision`, `pr-review-auditor-v2-contrastive`, `pr-review-auditor-v3-simulation`, `pr-review-auditor-reachability`, `pr-review-auditor-abstraction-surface`, `audit-impl-deviation-evaluator`, -`web-evidence-researcher`. +`web-evidence-researcher`, `pr-source-reader`, `pr-synthesizer`, +`research-source-reader`, `research-synthesizer`, `friction-batch-scanner`, +`friction-category-analyzer`. The web-evidence role's Codex definition owns its Luna/xhigh/read-only/live-web/no-descendants policy; callers do not pass model or permission overrides. Claude inherits the configured/default child model because this definition omits the top-level `model` key. diff --git a/src/autoskillit/agents/audit-impl-deviation-evaluator.md b/src/autoskillit/agents/audit-impl-deviation-evaluator.md index 93299d68d..d351417d9 100644 --- a/src/autoskillit/agents/audit-impl-deviation-evaluator.md +++ b/src/autoskillit/agents/audit-impl-deviation-evaluator.md @@ -3,7 +3,7 @@ name: audit-impl-deviation-evaluator description: "Evaluates a single deviation justification against audit findings. Checks honesty, intent preservation, and evidence quality — returns ACCEPT, ACCEPT_WITH_NOTE, or REJECT." tools: [Bash] model: sonnet -maxTurns: 20 +maxTurns: 80 --- # audit-impl-deviation-evaluator diff --git a/src/autoskillit/agents/friction-batch-scanner.md b/src/autoskillit/agents/friction-batch-scanner.md new file mode 100644 index 000000000..ffce12195 --- /dev/null +++ b/src/autoskillit/agents/friction-batch-scanner.md @@ -0,0 +1,34 @@ +--- +name: friction-batch-scanner +description: "Use when one parent-assigned log batch must be scanned for friction evidence." +tools: [Read, Grep] +model: haiku +maxTurns: 80 +codex: + model: gpt-5.6-luna + reasoning_effort: medium + sandbox_mode: read-only +--- + +# Friction batch scanner + +This role is the homogeneous worker in a high-fan-out log-batch scanning swarm. +Scan only the log files assigned by the parent, using the supplied signal patterns. +Search before reading bounded context around each hit. Confirm the event from that +context and do not read whole logs, inspect other files, diagnose root causes, +write files, or synthesize across batches. Report concrete file and line bounds; +do not guess when context is insufficient. Account for every assigned file: list +anything not fully scanned as a coverage gap and state why scanning stopped. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "scanned_files": ["parent-assigned log path"], + "events": [{"file": "log path", "line_start": 1, "line_end": 1, "category": "supplied category", "description": "one-line observed event"}], + "coverage_gaps": [{"file": "unscanned or partially scanned path", "reason": "concrete reason"}], + "stop_reason": "assigned scope complete | bounded pass exhausted | concrete blocker", + "unknowns": ["unresolved hit or concrete blocker"] +} +``` diff --git a/src/autoskillit/agents/friction-category-analyzer.md b/src/autoskillit/agents/friction-category-analyzer.md new file mode 100644 index 000000000..1787fca99 --- /dev/null +++ b/src/autoskillit/agents/friction-category-analyzer.md @@ -0,0 +1,40 @@ +--- +name: friction-category-analyzer +description: "Use when supplied indicators need validation for one parent-assigned friction category." +tools: [Read, Grep] +model: sonnet +maxTurns: 80 +codex: + model: gpt-5.6-terra + reasoning_effort: xhigh + sandbox_mode: read-only +--- + +# Friction category analyzer + +Analyze only the category, indicators, and log locations supplied by the parent. +Read bounded context at those locations to confirm or reclassify each indicator. +Do not discover other logs, write files, or synthesize the full audit. Separate +observations from inferences, cite file and line bounds, and retain unresolved +cases when the supplied evidence is insufficient. Preserve each supplied +indicator's file-and-line identity, report whether it is confirmed, reclassified, +or unresolved with the evidence-grounded rationale, and do not silently discard +or average conflicting evidence. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "category": "parent-assigned category", + "confirmed_occurrences": 0, + "distinct_sessions": 0, + "evidence": [{"file": "log path", "line_start": 1, "line_end": 1, "disposition": "confirmed | reclassified | unresolved", "rationale": "evidence-grounded basis", "sequence": "observed sequence", "blocker": "observed blocker"}], + "shared_pattern": "supported pattern or null", + "root_cause": "inference with basis or null", + "mitigations": ["concrete mitigation"], + "conflicts": ["material conflicting evidence"], + "stop_reason": "all supplied indicators resolved | evidence exhausted | concrete blocker", + "unknowns": ["unresolved indicator"] +} +``` diff --git a/src/autoskillit/agents/pr-source-reader.md b/src/autoskillit/agents/pr-source-reader.md new file mode 100644 index 000000000..1b1c88e54 --- /dev/null +++ b/src/autoskillit/agents/pr-source-reader.md @@ -0,0 +1,34 @@ +--- +name: pr-source-reader +description: "Use when one parent-specified PR source artifact must yield bounded evidence." +tools: [Read] +model: sonnet +maxTurns: 80 +codex: + model: gpt-5.6-luna + reasoning_effort: xhigh + sandbox_mode: read-only +--- + +# PR source reader + +Read only the source artifact named by the parent. Extract the requested sections +faithfully and keep source headings or other location cues with each result. Do not +inspect other repository files, modify anything, use GitHub, or make the final PR +summary. If the artifact is missing or cannot answer a requested field, preserve +that gap instead of guessing. Mark each value as a literal extraction or bounded +summary and keep interpretation out of both. Account for every requested field in +the evidence or coverage gaps, then state why reading stopped. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "source": "parent-supplied path", + "evidence": [{"field": "requested field", "value": "literal or bounded summary", "representation": "literal | summary", "location": "heading or line cue"}], + "coverage_gaps": ["requested field not resolved and why"], + "stop_reason": "requested fields covered | artifact exhausted | concrete blocker", + "unknowns": ["unresolved field or concrete blocker"] +} +``` diff --git a/src/autoskillit/agents/pr-synthesizer.md b/src/autoskillit/agents/pr-synthesizer.md new file mode 100644 index 000000000..821ebf899 --- /dev/null +++ b/src/autoskillit/agents/pr-synthesizer.md @@ -0,0 +1,33 @@ +--- +name: pr-synthesizer +description: "Use when collected PR source evidence needs an overall pull request summary." +tools: [Read] +model: sonnet +maxTurns: 80 +codex: + model: gpt-5.6-terra + reasoning_effort: high + sandbox_mode: read-only +--- + +# PR synthesizer + +Synthesize only the evidence supplied by the parent into a concise overall pull +request summary. Do not inspect the repository or GitHub, introduce unsupported +claims, write files, or create the pull request. Preserve material uncertainty and +conflicts in the supplied evidence. Trace every material summary claim to supplied +evidence locations, keep observations distinct from upstream inferences, and return +`partial` when coverage gaps prevent a complete summary. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "summary": "two or three evidence-grounded sentences", + "evidence_locations": ["supplied source and location supporting the summary"], + "conflicts": ["material conflict preserved from supplied evidence"], + "stop_reason": "summary supported | evidence exhausted | concrete blocker", + "unknowns": ["material unresolved point"] +} +``` diff --git a/src/autoskillit/agents/repository-impact-profiler.md b/src/autoskillit/agents/repository-impact-profiler.md index e197ecfc4..69d216535 100644 --- a/src/autoskillit/agents/repository-impact-profiler.md +++ b/src/autoskillit/agents/repository-impact-profiler.md @@ -3,7 +3,7 @@ name: repository-impact-profiler description: "Terminal read-only specialist for repository impact and consumer-surface profiling." tools: [mcp__autoskillit__submit_exploration_query, mcp__autoskillit__get_exploration_page, mcp__autoskillit__resume_exploration_context] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-luna reasoning_effort: max diff --git a/src/autoskillit/agents/research-source-reader.md b/src/autoskillit/agents/research-source-reader.md new file mode 100644 index 000000000..7ee75bf8f --- /dev/null +++ b/src/autoskillit/agents/research-source-reader.md @@ -0,0 +1,34 @@ +--- +name: research-source-reader +description: "Use when one parent-specified research artifact must yield bounded evidence." +tools: [Read] +model: sonnet +maxTurns: 80 +codex: + model: gpt-5.6-luna + reasoning_effort: xhigh + sandbox_mode: read-only +--- + +# Research source reader + +Read only the research artifact named by the parent. Extract the requested report +or experiment-plan fields faithfully, retaining headings or other location cues. +Do not inspect unrelated files, synthesize a recommendation, select lenses, or +modify anything. Report absent or ambiguous fields without filling them in. Mark +each value as a literal extraction or bounded summary and keep interpretation out +of both. Account for every requested field in the evidence or coverage gaps, then +state why reading stopped. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "source": "parent-supplied path", + "evidence": [{"field": "requested field", "value": "literal or bounded summary", "representation": "literal | summary", "location": "heading or line cue"}], + "coverage_gaps": ["requested field not resolved and why"], + "stop_reason": "requested fields covered | artifact exhausted | concrete blocker", + "unknowns": ["unresolved field or concrete blocker"] +} +``` diff --git a/src/autoskillit/agents/research-synthesizer.md b/src/autoskillit/agents/research-synthesizer.md new file mode 100644 index 000000000..79a0ec302 --- /dev/null +++ b/src/autoskillit/agents/research-synthesizer.md @@ -0,0 +1,37 @@ +--- +name: research-synthesizer +description: "Use when collected research evidence needs a direction and experiment-lens recommendation." +tools: [Read] +model: sonnet +maxTurns: 80 +codex: + model: gpt-5.6-terra + reasoning_effort: xhigh + sandbox_mode: read-only +--- + +# Research synthesizer + +Use only the report and experiment-plan evidence supplied by the parent. Produce +the requested directional recommendation or lens selection without inspecting the +repository, inventing findings, writing files, or invoking a lens. Select lens +slugs only from the parent's allowed table and state when the supplied evidence is +insufficient. Keep sourced findings, inference, and recommendation distinct; cite +the supplied evidence locations behind the recommendation, surface conflicts, and +abstain rather than collapse unresolved evidence into a direction. + +## Completion shape + +```json +{ + "status": "answered | partial | blocked", + "findings": ["source-grounded finding kept distinct from inference"], + "recommendation": "one to three evidence-grounded sentences, or null", + "selected_lenses": ["allowed-lens-slug"], + "rationale": "brief evidence-grounded rationale", + "evidence_locations": ["supplied source and location supporting the recommendation"], + "conflicts": ["material conflict preserved from supplied evidence"], + "stop_reason": "recommendation supported | evidence exhausted | concrete blocker", + "unknowns": ["material unresolved point"] +} +``` diff --git a/src/autoskillit/agents/semantic-code-navigator.md b/src/autoskillit/agents/semantic-code-navigator.md index dfb9e8542..76fe0483f 100644 --- a/src/autoskillit/agents/semantic-code-navigator.md +++ b/src/autoskillit/agents/semantic-code-navigator.md @@ -3,7 +3,7 @@ name: semantic-code-navigator description: "Terminal read-only specialist for structural and semantic repository navigation." tools: [mcp__autoskillit__submit_exploration_query, mcp__autoskillit__get_exploration_page, mcp__autoskillit__resume_exploration_context] model: sonnet -maxTurns: 20 +maxTurns: 80 codex: model: gpt-5.6-luna reasoning_effort: max diff --git a/src/autoskillit/core/types/_type_backend.py b/src/autoskillit/core/types/_type_backend.py index fbd23d63a..9d948cd63 100644 --- a/src/autoskillit/core/types/_type_backend.py +++ b/src/autoskillit/core/types/_type_backend.py @@ -264,9 +264,11 @@ class BackendCapabilities: } ) -CODEX_MODEL_ALIASES_LAST_VERIFIED: str = "2026-08-10" +CODEX_MODEL_ALIASES_LAST_VERIFIED: str = "2026-08-13" -CODEX_VALID_MODEL_IDS: frozenset[str] = frozenset({"gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol"}) +CODEX_VALID_MODEL_IDS: frozenset[str] = frozenset( + {"gpt-5.5", "gpt-5.6-luna", "gpt-5.6-sol", "gpt-5.6-terra"} +) CODEX_VALID_REASONING_EFFORTS: frozenset[str] = frozenset( {"low", "medium", "high", "xhigh", "max", "ultra"} ) diff --git a/src/autoskillit/core/types/_type_protocols_backend.py b/src/autoskillit/core/types/_type_protocols_backend.py index 6c2f32e25..d0e9d9ecf 100644 --- a/src/autoskillit/core/types/_type_protocols_backend.py +++ b/src/autoskillit/core/types/_type_protocols_backend.py @@ -353,7 +353,7 @@ def setup_session_dir( parent_sandbox_mode: str = "workspace-write", explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, execution_role: SkillExecutionRole = SkillExecutionRole.SESSION, - ) -> None: ... + ) -> frozenset[str] | None: ... def refresh_explorer_binding_env( self, diff --git a/src/autoskillit/execution/backends/_codex_config.py b/src/autoskillit/execution/backends/_codex_config.py index 66f48e4fc..e1b6f7f0e 100644 --- a/src/autoskillit/execution/backends/_codex_config.py +++ b/src/autoskillit/execution/backends/_codex_config.py @@ -4,6 +4,7 @@ import hashlib import json +import tomllib from collections.abc import Mapping from pathlib import Path from types import MappingProxyType @@ -35,6 +36,12 @@ CODEX_MCP_STARTUP_TIMEOUT_SEC: float = 30.0 +CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = ("default", "explorer", "worker") +_CODEX_RESERVED_ONLY_AGENT_NAMES = frozenset({"review", "reviewer"}) +_CODEX_AGENT_NAME_COLLISIONS = ( + frozenset(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) | _CODEX_RESERVED_ONLY_AGENT_NAMES +) + # The configured history-retention requirement is derived from the largest # measured recipe exemption plus explicit serialized-response headroom. _MAX_RESPONSE_BACKSTOP_EXEMPTION_BYTES: int = max( @@ -501,8 +508,6 @@ def _serialize_toml(data: dict[str, Any]) -> str: def _read_codex_config(path: Path) -> ReadResult: - import tomllib - try: raw_bytes = path.read_bytes() except FileNotFoundError: @@ -515,6 +520,55 @@ def _read_codex_config(path: Path) -> ReadResult: return ReadResult.ok(data) +def effective_codex_agent_names(session_dir: Path) -> frozenset[str]: + """Return roles backed by readable TOML in the finalized generated home.""" + config = tomllib.loads((session_dir / "config.toml").read_text(encoding="utf-8")) + configured_agents = config.get("agents", {}) + if not isinstance(configured_agents, dict): + raise ValueError("Codex config agents table must be a mapping") + + effective = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) + for name, registration in configured_agents.items(): + if not name.strip() or not isinstance(registration, dict): + logger.warning( + "codex_agent_registration_ignored", + agent_name=name, + reason="invalid_registration", + ) + continue + config_file = registration.get("config_file") + if not isinstance(config_file, str) or not config_file.strip(): + logger.warning( + "codex_agent_registration_ignored", + agent_name=name, + reason="invalid_config_file", + ) + continue + target = Path(config_file) + if not target.is_absolute(): + target = session_dir / target + try: + if not target.is_file(): + logger.warning( + "codex_agent_registration_ignored", + agent_name=name, + path=str(target), + reason="missing_config_file", + ) + continue + tomllib.loads(target.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as exc: + logger.warning( + "codex_agent_config_unreadable", + agent_name=name, + path=str(target), + error_type=type(exc).__name__, + ) + continue + effective.add(name) + return frozenset(effective) + + def _write_codex_config(path: Path, data: dict[str, Any], *, source: ReadResult) -> None: if source.is_corrupt: raise ValueError( diff --git a/src/autoskillit/execution/backends/claude.py b/src/autoskillit/execution/backends/claude.py index 61abf80af..08279db6a 100644 --- a/src/autoskillit/execution/backends/claude.py +++ b/src/autoskillit/execution/backends/claude.py @@ -513,10 +513,11 @@ def setup_session_dir( parent_sandbox_mode: str = "workspace-write", explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, execution_role: SkillExecutionRole = SkillExecutionRole.SESSION, - ) -> None: + ) -> frozenset[str] | None: del execution_role if explorer_binding_env: raise ValueError(_EXPLORER_BINDING_REJECTION_MESSAGE) + return None def refresh_explorer_binding_env( self, diff --git a/src/autoskillit/execution/backends/codex.py b/src/autoskillit/execution/backends/codex.py index 16b08a787..41eefc50c 100644 --- a/src/autoskillit/execution/backends/codex.py +++ b/src/autoskillit/execution/backends/codex.py @@ -90,6 +90,7 @@ get_logger, load_bundled_agent_definitions, ) +from autoskillit.execution.backends import _codex_config as _codex_cfg from autoskillit.execution.backends._backend_cmd_builder_base import ( SHARED_BASELINE_ENV, BackendCmdBuilderBase, @@ -123,6 +124,7 @@ _validated_explorer_binding_envs, ) from autoskillit.execution.backends._codex_config import ( + _CODEX_AGENT_NAME_COLLISIONS, CODEX_RECIPE_DELIVERY_BUDGET, _format_toml_value, ensure_codex_mcp_registered, @@ -870,16 +872,13 @@ def _canonical_codex_model_effort( model_class: str | None, reasoning_effort: str | None = None, ) -> tuple[str, str | None]: - """Translate the one canonical semantic policy used by agents and call sites.""" if model_class is None: return "", reasoning_effort - return ( - CODEX_MODEL_ALIASES[model_class], - reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class), - ) + model = CODEX_MODEL_ALIASES[model_class] + return model, reasoning_effort or CODEX_EFFORT_MAPPING.get(model_class) -_CODEX_BUILT_IN_AGENT_NAMES = frozenset({"default", "explorer", "review", "reviewer"}) +CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES = _codex_cfg.CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES def _preflight_agent_projection( @@ -893,7 +892,7 @@ def _preflight_agent_projection( duplicates = sorted({name for name in names if names.count(name) > 1}) if duplicates: raise ValueError(f"duplicate Codex agent definitions: {duplicates}") - built_in_collisions = sorted(set(names) & _CODEX_BUILT_IN_AGENT_NAMES) + built_in_collisions = sorted(set(names) & _CODEX_AGENT_NAME_COLLISIONS) if built_in_collisions: raise ValueError(f"Codex built-in agent name collision: {built_in_collisions}") config_path = session_dir / "config.toml" @@ -2198,7 +2197,7 @@ def setup_session_dir( agent_defs: tuple[AgentDef, ...] | None = None, explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, execution_role: SkillExecutionRole = SkillExecutionRole.SESSION, - ) -> None: + ) -> frozenset[str]: assert self.source_codex_home is not None codex_home_source = self.source_codex_home config_path = session_dir / "config.toml" @@ -2278,6 +2277,7 @@ def setup_session_dir( session_dir, source_codex_home=codex_home_source, ) + return _codex_cfg.effective_codex_agent_names(session_dir) def refresh_explorer_binding_env( self, diff --git a/src/autoskillit/workspace/session_skills.py b/src/autoskillit/workspace/session_skills.py index 1374a3f8d..439a91c24 100644 --- a/src/autoskillit/workspace/session_skills.py +++ b/src/autoskillit/workspace/session_skills.py @@ -40,6 +40,7 @@ SkillSource, SkillSourceRef, ValidatedAddDir, + destination_location, get_logger, pkg_root, validate_skill_capability_roles, @@ -109,6 +110,26 @@ def _remove_and_verify(path: Path) -> bool: return True +def _remove_generated_home_skill_entry(discovery_root: Path, skill: str) -> None: + """Remove one exact generated-home discovery entry without following it.""" + root_location = destination_location(discovery_root) + path = destination_location(discovery_root / skill) + if path.parent != root_location or path.name != skill: + raise SkillContractError( + f"generated-home skill removal requires one exact child entry: {skill!r}" + ) + if not os.path.lexists(path): + return + if path.is_symlink(): + path.unlink() + elif path.is_dir(): + shutil.rmtree(path) + else: + raise RuntimeError(f"Refusing to remove invalid generated-home skill entry: {path}") + if os.path.lexists(path): + raise RuntimeError(f"Generated-home skill entry still exists after removal: {path}") + + def resolve_persistent_session_root( base_root: Path, backend: CodingAgentBackend, @@ -239,6 +260,8 @@ def unavailability_payload(self) -> dict[str, object]: def compile_session_skill_catalog( catalog: EffectiveSkillCatalogAuthority, backend: CodingAgentBackend, + *, + finalized_native_roles: frozenset[str] | None = None, ) -> CompiledSessionSkillCatalog: """Publish only skills whose mandatory semantics adapt on the selected backend.""" supported: list[SkillCatalogEntry] = [] @@ -264,6 +287,23 @@ def compile_session_skill_catalog( ) continue adaptation.validate_for(plan, backend=backend.name) + if finalized_native_roles is not None: + native_spawn_targets = { + adaptation.logical_role_mapping[spawn.role] for spawn in plan.child_spawns + } + missing_targets = sorted(native_spawn_targets - finalized_native_roles) + if missing_targets: + unavailable.append( + SkillUnavailableMetadata( + skill=skill.name, + backend=backend.name, + operation=SkillSemanticOperation.CHILD_SPAWN, + diagnostic=( + f"native child-spawn targets are unavailable: {missing_targets}" + ), + ) + ) + continue supported.append(cast(SkillCatalogEntry, skill)) filtered_names = {skill.name for skill in supported} namespace_sources = { @@ -874,7 +914,7 @@ def _initialize_bound_records( if persistent: _remove_and_verify(generated_home) - skills_dir = self._materialize_session( + skills_dir, finalized_records = self._materialize_session( generated_home, records, projection_context, @@ -891,7 +931,9 @@ def _initialize_bound_records( ) self._session_roots[session_id] = effective_root self._session_skills_subdirs[session_id] = owned_skills_subdir - self._session_skill_infos[session_id] = {member.name: member for member in records} + self._session_skill_infos[session_id] = { + member.name: member for member in finalized_records + } self._session_leases[session_id] = lease return initialized except BaseException as exc: @@ -926,7 +968,7 @@ def _materialize_session( compilation: CompiledSessionSkillCatalogAuthority | None = None, explorer_binding_env: _ExplorerBindingEnv | None = None, explorer_binding_env_factory: _ExplorerBindingEnvFactory | None = None, - ) -> ValidatedAddDir: + ) -> tuple[ValidatedAddDir, tuple[SkillAuthority, ...]]: backend = projection_context.backend add_dir = generated_home / SESSION_ADD_DIR_SUBDIR skills_base = add_dir / skills_subdir @@ -948,12 +990,6 @@ def _materialize_session( adaptation = backend.adapt_skill_semantics(plan) adaptation.validate_for(plan, backend=backend.name) - write_skill_unavailability_metadata( - add_dir, - compilation=compilation, - backend=backend.name if backend is not None else None, - ) - execution_role = ( effective_catalog.execution_role if effective_catalog is not None @@ -966,6 +1002,7 @@ def _materialize_session( raise RuntimeError(f"Pre-launch check failed: {'; '.join(readiness.errors)}") if explorer_binding_env_factory is not None: explorer_binding_env = explorer_binding_env_factory(generated_home) + finalized_native_roles: frozenset[str] | None = None if backend is not None: setup_kwargs: _SessionSetupKwargs = { "parent_sandbox_mode": projection_context.parent_sandbox_mode, @@ -973,7 +1010,43 @@ def _materialize_session( } if explorer_binding_env is not None: setup_kwargs["explorer_binding_env"] = explorer_binding_env - backend.setup_session_dir(generated_home, **setup_kwargs) + finalized_native_roles = backend.setup_session_dir(generated_home, **setup_kwargs) + + if finalized_native_roles is not None and effective_catalog is not None: + assert backend is not None + if compilation is not None and not isinstance( + compilation, CompiledSessionSkillCatalog + ): + raise SkillContractError( + "finalized native-role admission requires a concrete session compilation" + ) + reachability_compilation = compile_session_skill_catalog( + effective_catalog, + backend, + finalized_native_roles=finalized_native_roles, + ) + prior_unavailable = compilation.unavailable if compilation is not None else () + compilation = CompiledSessionSkillCatalog( + backend=backend.name, + catalog=reachability_compilation.catalog, + unavailable=tuple( + sorted( + (*prior_unavailable, *reachability_compilation.unavailable), + key=lambda item: item.skill, + ) + ), + ) + effective_catalog = compilation.catalog + records = tuple(effective_catalog.skills) + discovery_root = generated_home / skills_subdir + for unavailable in reachability_compilation.unavailable: + _remove_generated_home_skill_entry(discovery_root, unavailable.skill) + + write_skill_unavailability_metadata( + add_dir, + compilation=compilation, + backend=backend.name if backend is not None else None, + ) ungated_context = SkillProjectionContext( cwd=projection_context.cwd, @@ -1022,7 +1095,7 @@ def _materialize_session( ) if layout_errors: raise RuntimeError("Session layout validation failed: " + "; ".join(layout_errors)) - return ValidatedAddDir(path=str(add_dir)) + return ValidatedAddDir(path=str(add_dir)), records @staticmethod def _create_inert_rollout_paths( diff --git a/tests/cli/test_cook_interactive.py b/tests/cli/test_cook_interactive.py index 57af74fa1..fc3b0e027 100644 --- a/tests/cli/test_cook_interactive.py +++ b/tests/cli/test_cook_interactive.py @@ -310,6 +310,94 @@ def build_interactive_cmd(self, **kwargs: object) -> CmdSpec: assert "defined as both" in guidance and "rejected" in guidance +def test_codex_cook_projects_only_spawnable_compose_pr_roles( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + from autoskillit.core import SkillExecutionRole + from autoskillit.execution.backends._codex_config import effective_codex_agent_names + from autoskillit.execution.backends.codex import CodexBackend + from autoskillit.workspace import DefaultSkillResolver + + project_root = tmp_path / "project" + project_root.mkdir() + source_home = tmp_path / "home" / ".codex" + source_home.mkdir(parents=True) + (source_home / "config.toml").write_text( + 'cli_auth_credentials_store = "keyring"\n', + encoding="utf-8", + ) + (source_home / "auth.json").write_text("{}\n", encoding="utf-8") + backend = CodexBackend(source_codex_home=source_home) + catalog = DefaultSkillResolver().list_effective( + project_root, + SkillExecutionRole.SESSION, + cook_session=True, + ) + compose_pr = next(member for member in catalog.skills if member.name == "compose-pr") + assert compose_pr.semantic_plan is not None + adaptation = backend.adapt_skill_semantics(compose_pr.semantic_plan) + mapped_targets = { + adaptation.logical_role_mapping[spawn.role] + for spawn in compose_pr.semantic_plan.child_spawns + } + captured: dict[str, object] = {} + + @contextmanager + def cook_session_context(_self, **_kwargs: object): + yield CookSessionHandle( + view_id="codex-view", + pass_fds=(), + _record_spawn=lambda _pid, _pgid: None, + _record_reaped=lambda _pid, _pgid: None, + ) + + def run_attempt(spec: CmdSpec, **kwargs: object) -> object: + generated_home = Path(spec.env["CODEX_HOME"]) + compose_projection = generated_home / "add-dir" / "skills" / "compose-pr" / "SKILL.md" + role_names = effective_codex_agent_names(generated_home) + captured["compose_projected"] = compose_projection.is_file() + captured["mapped_targets"] = mapped_targets + captured["role_names"] = role_names + kwargs["on_spawn"](101, 101) # type: ignore[operator] + kwargs["trace"].record_spawn() # type: ignore[union-attr] + kwargs["on_reaped"](101, 101) # type: ignore[operator] + return SimpleNamespace(pid=101, pgid=101, returncode=0) + + monkeypatch.chdir(project_root) + monkeypatch.setenv("MCP_CLIENT_BACKEND", "pre-test-backend") + monkeypatch.setattr(shutil, "which", lambda _name, **_kwargs: "/usr/bin/codex") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("autoskillit.cli._onboarding.is_first_run", lambda _project: False) + monkeypatch.setattr( + "autoskillit.cli.ui._timed_input.timed_prompt", + lambda *args, **kwargs: "", + ) + monkeypatch.setattr("autoskillit.core.write_registry_entry", lambda *args: None) + monkeypatch.setattr( + "autoskillit.cli.session._session_process.run_cook_attempt", + run_attempt, + ) + monkeypatch.setattr( + "autoskillit.cli.session._session_reload.consume_reload_sentinel", + lambda _project: None, + ) + monkeypatch.setattr(CodexBackend, "cook_session_context", cook_session_context) + monkeypatch.setattr( + CodexBackend, + "validate_interactive_invocation", + lambda _self, _spec: [], + ) + + cli.cook(backend=backend) + + assert captured["compose_projected"] is True + assert captured["mapped_targets"] == {"pr-source-reader", "pr-synthesizer"} + role_names = captured["role_names"] + assert isinstance(role_names, frozenset) + assert mapped_targets <= role_names + + def test_notification_capable_cook_has_no_pre_reveal_guidance( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, diff --git a/tests/contracts/test_skill_semantic_authenticity.py b/tests/contracts/test_skill_semantic_authenticity.py index a0eb618a1..ea79d45bb 100644 --- a/tests/contracts/test_skill_semantic_authenticity.py +++ b/tests/contracts/test_skill_semantic_authenticity.py @@ -122,3 +122,33 @@ def test_every_bundled_semantic_plan_adapts_on_every_registered_backend() -> Non violations.append(f"{skill_name}/{backend_name}: empty adaptation") assert plans assert not violations, "bundled semantic adaptation failures:\n" + "\n".join(violations) + + +def test_every_bundled_codex_child_spawn_targets_a_registered_role() -> None: + from autoskillit.core import load_bundled_agent_definitions + from autoskillit.execution.backends import CodexBackend + from autoskillit.execution.backends.codex import ( + CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES, + ) + from autoskillit.workspace import DefaultSkillResolver + + allowed = set(CODEX_SPAWNABLE_BUILT_IN_AGENT_NAMES) | { + definition.name for definition in load_bundled_agent_definitions() + } + plans = tuple( + (skill.name, skill.semantic_plan) + for skill in DefaultSkillResolver().list_all() + if skill.semantic_plan is not None + ) + violations: list[str] = [] + backend = CodexBackend() + for skill_name, plan in plans: + assert plan is not None + adaptation = backend.adapt_skill_semantics(plan) + targets = {adaptation.logical_role_mapping[spawn.role] for spawn in plan.child_spawns} + missing = sorted(targets - allowed) + if missing: + violations.append(f"{skill_name}: {missing}") + + assert plans + assert not violations, "unregistered bundled Codex child roles:\n" + "\n".join(violations) diff --git a/tests/core/test_agent_definition.py b/tests/core/test_agent_definition.py index 342a4c397..558a443f7 100644 --- a/tests/core/test_agent_definition.py +++ b/tests/core/test_agent_definition.py @@ -29,6 +29,27 @@ _EXPLORATION_BROKER_TOOLS = frozenset(f"{DIRECT_PREFIX}{tool}" for tool in EXPLORATION_TOOLS) +_SKILL_CHILD_ROLE_EXPECTATIONS = { + "pr-source-reader": (("Read",), "sonnet", 80, "gpt-5.6-luna", "xhigh"), + "pr-synthesizer": (("Read",), "sonnet", 80, "gpt-5.6-terra", "high"), + "research-source-reader": (("Read",), "sonnet", 80, "gpt-5.6-luna", "xhigh"), + "research-synthesizer": (("Read",), "sonnet", 80, "gpt-5.6-terra", "xhigh"), + "friction-batch-scanner": ( + ("Read", "Grep"), + "haiku", + 80, + "gpt-5.6-luna", + "medium", + ), + "friction-category-analyzer": ( + ("Read", "Grep"), + "sonnet", + 80, + "gpt-5.6-terra", + "xhigh", + ), +} + @pytest.mark.parametrize( ("name", "role_boundary"), @@ -101,12 +122,78 @@ def test_bundled_agent_catalog_loads_with_unique_digests() -> None: definitions = load_bundled_agent_definitions() assert definitions assert BUNDLED_EXPLORER_ROLES <= {definition.name for definition in definitions} + assert all( + definition.max_turns is not None and definition.max_turns >= 30 + for definition in definitions + ) assert len({definition.name for definition in definitions}) == len(definitions) assert len({agent_definition_digest(definition) for definition in definitions}) == len( definitions ) +def test_skill_child_roles_have_bounded_tools_and_usage_descriptions() -> None: + definitions = {definition.name: definition for definition in load_bundled_agent_definitions()} + + assert len(definitions) == 22 + assert _SKILL_CHILD_ROLE_EXPECTATIONS.keys() <= definitions.keys() + for name, ( + expected_tools, + expected_model, + expected_max_turns, + expected_codex_model, + expected_reasoning_effort, + ) in _SKILL_CHILD_ROLE_EXPECTATIONS.items(): + definition = definitions[name] + assert definition.tools == expected_tools + assert definition.model == expected_model + assert definition.max_turns == expected_max_turns + assert definition.codex.model == expected_codex_model + assert definition.codex.reasoning_effort == expected_reasoning_effort + assert definition.codex.sandbox_mode == "read-only" + assert definition.description.startswith("Use when ") + assert definition.description not in definition.body + assert not ({"Bash", "Write", "Edit"} & set(definition.tools)) + + +@pytest.mark.parametrize( + ("name", "contracts"), + [ + ( + "friction-batch-scanner", + ("coverage_gaps", "stop_reason", "assigned scope complete"), + ), + ( + "friction-category-analyzer", + ("disposition", "rationale", "conflicts", "stop_reason"), + ), + ( + "pr-source-reader", + ("representation", "coverage_gaps", "stop_reason"), + ), + ( + "pr-synthesizer", + ("evidence_locations", "conflicts", "upstream inferences", "stop_reason"), + ), + ( + "research-source-reader", + ("representation", "coverage_gaps", "stop_reason"), + ), + ( + "research-synthesizer", + ("findings", "evidence_locations", "conflicts", "stop_reason"), + ), + ], +) +def test_skill_child_roles_preserve_handoff_fidelity( + name: str, contracts: tuple[str, ...] +) -> None: + definition = load_agent_definition(pkg_root() / "agents" / f"{name}.md") + + for contract in contracts: + assert contract in definition.body + + def test_explicit_luna_projection_is_independent_from_claude_model(tmp_path: Path) -> None: path = tmp_path / "semantic-code-navigator.md" path.write_text( diff --git a/tests/core/test_backend_protocols.py b/tests/core/test_backend_protocols.py index 492efd826..a44ed09c0 100644 --- a/tests/core/test_backend_protocols.py +++ b/tests/core/test_backend_protocols.py @@ -352,7 +352,7 @@ def setup_session_dir( *, parent_sandbox_mode: str = "workspace-write", explorer_binding_env: Mapping[str, Mapping[str, str]] | None = None, - ) -> None: ... + ) -> frozenset[str] | None: ... def refresh_explorer_binding_env( self, diff --git a/tests/execution/backends/test_codex_backend.py b/tests/execution/backends/test_codex_backend.py index 12b01cdb5..a04b96b05 100644 --- a/tests/execution/backends/test_codex_backend.py +++ b/tests/execution/backends/test_codex_backend.py @@ -44,6 +44,7 @@ load_agent_definitions, pkg_root, ) +from autoskillit.execution.backends._codex_config import effective_codex_agent_names from autoskillit.execution.backends.codex import ( CODEX_ENV_PREFIX_DENYLIST, CodexBackend, @@ -1890,14 +1891,28 @@ def test_agent_toml_set_and_count_match_md_sources(self) -> None: def test_agent_toml_required_fields_present_and_nonempty(self) -> None: self._write_all_source_files() CodexBackend().setup_session_dir(self.session_dir) + required_new_roles = { + "friction-batch-scanner", + "friction-category-analyzer", + "pr-source-reader", + "pr-synthesizer", + "research-source-reader", + "research-synthesizer", + } definitions = { definition.name: definition for definition in load_agent_definitions(pkg_root() / "agents") } + generated_names = {path.stem for path in (self.session_dir / "agents").glob("*.toml")} + assert required_new_roles <= generated_names for toml_path in sorted((self.session_dir / "agents").glob("*.toml")): data = tomllib.loads(toml_path.read_text(encoding="utf-8")) assert data["name"], f"{toml_path.name}: name empty" assert data["description"], f"{toml_path.name}: description empty" + assert data["description"] == definitions[toml_path.stem].description + assert data["description"] != definitions[toml_path.stem].body + if toml_path.stem in required_new_roles: + assert data["description"].startswith("Use when ") assert data["developer_instructions"], ( f"{toml_path.name}: developer_instructions empty" ) @@ -2611,6 +2626,106 @@ def test_unrelated_ambient_agent_is_preserved_with_bundled_projection(self) -> N assert config["agents"]["wp-elaborator"]["config_file"] == ("agents/wp-elaborator.toml") assert not (self.session_dir / "agents" / "profile-specialist.toml").exists() + def test_setup_returns_only_finalized_spawnable_agent_names( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + valid_target = self.fake_home / "valid-ambient.toml" + valid_target.write_text('name = "valid-ambient"\n', encoding="utf-8") + source_relative_target = self.codex_home / "agents" / "source-only.toml" + source_relative_target.parent.mkdir() + source_relative_target.write_text('name = "source-only"\n', encoding="utf-8") + (self.codex_home / "config.toml").write_text( + self._CANONICAL_AUTOSKILLIT_MCP_CONFIG + + '\n[agents."valid-ambient"]\n' + + 'description = "absolute ambient role"\n' + + f'config_file = "{valid_target}"\n' + + '\n[agents."source-relative"]\n' + + 'description = "source-relative ambient role"\n' + + 'config_file = "agents/source-only.toml"\n', + encoding="utf-8", + ) + (self.codex_home / "auth.json").write_text("{}", encoding="utf-8") + monkeypatch.setenv(MCP_CLIENT_BACKEND_ENV_VAR, "pre-test-backend") + backend = CodexBackend() + + assert not backend.ensure_pre_launch(session_dir=self.session_dir).errors + role_names = backend.setup_session_dir(self.session_dir) + + eligible_bundled = { + definition.name + for definition in load_agent_definitions(pkg_root() / "agents") + if definition.name not in BUNDLED_EXPLORER_ROLES + } + assert role_names == frozenset( + {"default", "explorer", "worker", "valid-ambient"} | eligible_bundled + ) + finalized = tomllib.loads((self.session_dir / "config.toml").read_text(encoding="utf-8")) + assert set(finalized["agents"]) == ( + {"valid-ambient", "source-relative"} | eligible_bundled + ) + + def test_setup_warns_when_ambient_agent_config_is_unreadable(self) -> None: + invalid_target = self.codex_home / "invalid-ambient.toml" + invalid_target.write_text("[", encoding="utf-8") + (self.codex_home / "config.toml").write_text( + self._CANONICAL_AUTOSKILLIT_MCP_CONFIG + + '\n[agents."invalid-ambient"]\n' + + 'description = "invalid ambient role"\n' + + f'config_file = "{invalid_target}"\n', + encoding="utf-8", + ) + (self.codex_home / "auth.json").write_text("{}", encoding="utf-8") + backend = CodexBackend() + + assert not backend.ensure_pre_launch(session_dir=self.session_dir).errors + with structlog.testing.capture_logs() as cap_logs: + role_names = backend.setup_session_dir(self.session_dir) + + assert "invalid-ambient" not in role_names + warning = next( + entry for entry in cap_logs if entry.get("event") == "codex_agent_config_unreadable" + ) + assert warning == { + "agent_name": "invalid-ambient", + "path": str(invalid_target), + "error_type": "TOMLDecodeError", + "event": "codex_agent_config_unreadable", + "logger": "autoskillit.execution.backends._codex_config", + "log_level": "warning", + } + + def test_effective_agent_names_warns_for_ignored_registrations(self) -> None: + missing_target = self.session_dir / "agents" / "missing.toml" + (self.session_dir / "config.toml").write_text( + "[agents]\n" + 'invalid = "not a table"\n' + "[agents.missing_config]\n" + 'description = "missing config file"\n' + "[agents.missing_target]\n" + 'config_file = "agents/missing.toml"\n', + encoding="utf-8", + ) + + with structlog.testing.capture_logs() as cap_logs: + role_names = effective_codex_agent_names(self.session_dir) + + assert "invalid" not in role_names + assert "missing_config" not in role_names + assert "missing_target" not in role_names + assert [ + {key: entry[key] for key in ("agent_name", "path", "reason") if key in entry} + for entry in cap_logs + if entry.get("event") == "codex_agent_registration_ignored" + ] == [ + {"agent_name": "invalid", "reason": "invalid_registration"}, + {"agent_name": "missing_config", "reason": "invalid_config_file"}, + { + "agent_name": "missing_target", + "path": str(missing_target), + "reason": "missing_config_file", + }, + ] + @pytest.mark.parametrize( "role", ( @@ -2686,10 +2801,11 @@ def test_duplicate_injected_roles_fail_before_mutation(self) -> None: assert (self.session_dir / "config.toml").read_text(encoding="utf-8") == original_config assert {path.name for path in self.session_dir.iterdir()} == {"config.toml"} - def test_built_in_agent_name_fails_before_mutation(self) -> None: + @pytest.mark.parametrize("role", ("worker", "review")) + def test_host_reserved_agent_name_fails_before_mutation(self, role: str) -> None: original_config = (self.session_dir / "config.toml").read_text(encoding="utf-8") definition = AgentDef( - name="explorer", + name=role, description="Reserved-role collision", tools=("Read",), model="sonnet", diff --git a/tests/execution/backends/test_skill_semantic_trace_conformance.py b/tests/execution/backends/test_skill_semantic_trace_conformance.py index 96eeea774..8fd13c1ad 100644 --- a/tests/execution/backends/test_skill_semantic_trace_conformance.py +++ b/tests/execution/backends/test_skill_semantic_trace_conformance.py @@ -10,6 +10,8 @@ import pytest from autoskillit.core import ( + CODEX_EFFORT_MAPPING, + CODEX_MODEL_ALIASES, ChildModelPolicySpec, ChildSpawnSpec, ConcurrencySpec, @@ -405,6 +407,106 @@ def test_codex_semantic_policy_matches_generated_native_role_toml(tmp_path: Path ) +def test_compose_pr_real_codex_trace_spawns_then_joins_registered_roles() -> None: + skill_md = pkg_root() / "skills_extended" / "compose-pr" / "SKILL.md" + info = _skill_info_from_frontmatter("compose-pr", SkillSource.BUNDLED, skill_md) + assert not info.invalidities + assert info.semantic_plan is not None + plan = info.semantic_plan + adaptation = CodexBackend().adapt_skill_semantics(plan) + reader = adaptation.logical_role_mapping["pr-source-reader"] + synthesizer = adaptation.logical_role_mapping["pr-synthesizer"] + model, effort = adaptation.model_effort_policy[synthesizer] + assert (reader, synthesizer) == ("pr-source-reader", "pr-synthesizer") + assert (model, effort) == ( + CODEX_MODEL_ALIASES["sonnet"], + CODEX_EFFORT_MAPPING["sonnet"], + ) + + parent_events = [ + _codex_call( + "spawn-reader", + "spawn_agent", + { + "agent_type": reader, + "fork_turns": "none", + "task_name": "reader", + }, + ), + _codex_output("spawn-reader", {"task_name": "/root/reader"}), + _codex_call( + "spawn-synthesizer", + "spawn_agent", + { + "agent_type": synthesizer, + "fork_turns": "none", + "model": model, + "task_name": "synthesizer", + }, + ), + _codex_output("spawn-synthesizer", {"task_name": "/root/synthesizer"}), + _codex_call("wait", "wait_agent", {"timeout_ms": 3_600_000}), + _codex_output("wait", {"timed_out": False}), + ] + for task_name in ("reader", "synthesizer"): + parent_events.append( + { + "type": "response_item", + "payload": { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": ( + "\n" + f'{{"agent_path":"/root/{task_name}","status":' + f'{{"completed":"child-delivery-complete {task_name}"}}}}\n' + "" + ), + } + ], + }, + } + ) + parent_events.append( + { + "type": "response_item", + "payload": { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "parent-delivery-complete"}], + }, + } + ) + child_events = [ + { + "type": "session_meta", + "payload": { + "id": f"child-{task_name}", + "parent_thread_id": "parent", + "agent_role": role, + "agent_path": f"/root/{task_name}", + "base_instructions": {"text": _DISCIPLINE_DIGEST}, + }, + } + for task_name, role in (("reader", reader), ("synthesizer", synthesizer)) + ] + + assert_generated_child_delivery( + parent_events, + child_events, + parent_id="parent", + agent_role=synthesizer, + output_discipline_digest=_DISCIPLINE_DIGEST, + backend="codex", + semantic_plan=plan, + semantic_adaptation=adaptation, + child_terminal_sentinel="child-delivery-complete", + parent_terminal_sentinel="parent-delivery-complete", + ) + + def test_dynamic_child_spawn_adapters_preserve_runtime_cardinality() -> None: role = "autoskillit:web-evidence-researcher" plan = SkillSemanticPlan( diff --git a/tests/execution/test_model_alias_registry.py b/tests/execution/test_model_alias_registry.py index 7a8270d48..4e57e2a1f 100644 --- a/tests/execution/test_model_alias_registry.py +++ b/tests/execution/test_model_alias_registry.py @@ -51,6 +51,7 @@ def test_codex_native_model_allowlist_preserves_compatibility() -> None: assert is_valid_codex_model_id("gpt-5.6-sol") assert is_valid_codex_model_id("gpt-5.6-luna") + assert is_valid_codex_model_id("gpt-5.6-terra") assert is_valid_codex_model_id("gpt-5.5") assert not is_valid_codex_model_id("gpt-5.4") assert not is_valid_codex_model_id("gpt-5.4-mini") diff --git a/tests/infra/test_plugin_source_ratchets.py b/tests/infra/test_plugin_source_ratchets.py index 355a3941e..4db52320c 100644 --- a/tests/infra/test_plugin_source_ratchets.py +++ b/tests/infra/test_plugin_source_ratchets.py @@ -156,6 +156,24 @@ "Generated session homes are ephemeral lease-owned artifacts, and cleanup " "refuses symlinks before recursively removing the exact requested home.", ), + ( + "workspace/session_skills.py", + "_remove_generated_home_skill_entry", + "path.unlink", + ): ( + 1, + "Reachability filtering removes only the exact generated-home profile-skill " + "symlink selected by the finalized catalog; the source profile is untouched.", + ), + ( + "workspace/session_skills.py", + "_remove_generated_home_skill_entry", + "shutil.rmtree", + ): ( + 1, + "Reachability filtering removes only the exact generated-home profile-skill " + "directory selected by the finalized catalog; the source profile is untouched.", + ), ("workspace/session_skills.py", "resolve_ephemeral_root", "probe.unlink"): ( 1, "The writable-root probe removes only the sentinel file it created in the " diff --git a/tests/workspace/test_agent_definition_rendering.py b/tests/workspace/test_agent_definition_rendering.py index ce4ce2b2f..364ffb051 100644 --- a/tests/workspace/test_agent_definition_rendering.py +++ b/tests/workspace/test_agent_definition_rendering.py @@ -221,11 +221,10 @@ def test_all_builtin_only_agents_rendered_byte_identical(self, tmp_path: Path) - if not any(tool.startswith("mcp__") for tool in defn.tools): originals[f"{defn.name}.md"] = (agents_dir / f"{defn.name}.md").read_bytes() - assert len(bundled_definitions) == 16, ( - "Adding session-log-reader and retiring pipeline-health-scanner must preserve " - "the bundled-agent count" + assert len(bundled_definitions) == 22, ( + "The bundled catalog includes the six skill-specific child roles" ) - assert len(originals) == 13, f"Expected 13 built-in-only agents, got {len(originals)}" + assert len(originals) == 19, f"Expected 19 built-in-only agents, got {len(originals)}" _render_agent_definitions(agents_dir, MARKETPLACE_PREFIX) diff --git a/tests/workspace/test_corridor_composition.py b/tests/workspace/test_corridor_composition.py index fe44984af..84abcc712 100644 --- a/tests/workspace/test_corridor_composition.py +++ b/tests/workspace/test_corridor_composition.py @@ -38,7 +38,7 @@ def _build_mock_backend(*, terminal: bool = False, session_scoped: bool = False) backend.conventions = BackendConventions() backend.adapt_skill_semantics.side_effect = adapt_test_skill_semantics backend.exploration_dispatch_renderer = MagicMock() - backend.setup_session_dir = MagicMock() + backend.setup_session_dir = MagicMock(return_value=None) backend.ensure_pre_launch = MagicMock(return_value=PreLaunchReadiness((), {})) return backend diff --git a/tests/workspace/test_session_skills_codex.py b/tests/workspace/test_session_skills_codex.py index 215a6e1bb..1dabfa546 100644 --- a/tests/workspace/test_session_skills_codex.py +++ b/tests/workspace/test_session_skills_codex.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib +import json from dataclasses import replace from pathlib import Path from unittest.mock import MagicMock @@ -45,12 +46,25 @@ def _make_codex_backend() -> MagicMock: b.capabilities = _CODEX_CAPABILITIES b.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR b.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) + b.setup_session_dir.return_value = None b.validate_session_layout.return_value = [] b.adapt_skill_semantics.side_effect = CodexBackend().adapt_skill_semantics b.exploration_dispatch_renderer = CodexBackend().exploration_dispatch_renderer return b +def test_generated_home_skill_removal_rejects_non_child_path(tmp_path: Path) -> None: + discovery_root = tmp_path / "skills" + discovery_root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + + with pytest.raises(SkillContractError, match="one exact child entry"): + session_skills._remove_generated_home_skill_entry(discovery_root, "../outside") + + assert outside.is_dir() + + def _catalog_context( manager, *, @@ -521,6 +535,148 @@ def test_profile_skills_are_projected_into_session_dir(tmp_path, monkeypatch) -> assert count == 1 +@pytest.mark.parametrize( + ("ambient_state", "helper_available"), + (("absent", False), ("invalid", False), ("valid", True)), +) +def test_manager_filters_child_spawn_skill_by_finalized_ambient_role( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ambient_state: str, + helper_available: bool, +) -> None: + from autoskillit.core import SkillSource + from autoskillit.execution.backends.codex import CodexBackend + from autoskillit.workspace import ( + DefaultSessionSkillManager, + EffectiveSkillCatalog, + SkillCatalogEntry, + SkillInfo, + SkillsDirectoryProvider, + compile_session_skill_catalog, + ) + from autoskillit.workspace.skills import _skill_info_from_frontmatter + + fake_home = tmp_path / "home" + source_home = fake_home / ".codex" + source_home.mkdir(parents=True) + (source_home / "auth.json").write_text("{}\n", encoding="utf-8") + config = 'cli_auth_credentials_store = "keyring"\n' + if ambient_state == "invalid": + source_target = source_home / "agents" / "helper.toml" + source_target.parent.mkdir() + source_target.write_text('name = "helper"\n', encoding="utf-8") + config += ( + "\n[agents.helper]\n" + 'description = "source-relative helper"\n' + 'config_file = "agents/helper.toml"\n' + ) + elif ambient_state == "valid": + valid_target = tmp_path / "ambient" / "helper.toml" + valid_target.parent.mkdir() + valid_target.write_text('name = "helper"\n', encoding="utf-8") + config += ( + f'\n[agents.helper]\ndescription = "absolute helper"\nconfig_file = "{valid_target}"\n' + ) + (source_home / "config.toml").write_text(config, encoding="utf-8") + + project_root = tmp_path / "project" + semantic_path = project_root / "skills" / "helper-skill" / "SKILL.md" + semantic_path.parent.mkdir(parents=True) + semantic_path.write_text( + "---\n" + "name: helper-skill\n" + "description: Delegate to an ambient helper.\n" + "semantic_version: 1\n" + "semantic_requirements:\n" + " logical_roles:\n" + " - name: helper\n" + " purpose: perform delegated work\n" + " child_spawns:\n" + " - role: helper\n" + " count: 1\n" + "---\n" + "Delegate the work.\n", + encoding="utf-8", + ) + helper_skill = _skill_info_from_frontmatter( + "helper-skill", + SkillSource.PROJECT_LOCAL, + semantic_path, + ) + unrelated = SkillInfo( + name="unrelated-skill", + source=SkillSource.PROJECT_LOCAL, + path=project_root / "skills" / "unrelated-skill" / "SKILL.md", + canonical_content=( + "---\n" + "name: unrelated-skill\n" + "description: Supported without child delegation.\n" + "---\n" + "Run directly.\n" + ), + ) + catalog = EffectiveSkillCatalog( + skills=tuple( + SkillCatalogEntry.from_skill_info(skill) for skill in (helper_skill, unrelated) + ), + execution_role=SkillExecutionRole.SESSION, + ) + backend = CodexBackend(source_codex_home=source_home) + provider = SkillsDirectoryProvider() + context = provider.catalog_projection_context( + catalog, + project_root, + backend=backend, + durable_scripts_root=pkg_root(), + ) + manager = DefaultSessionSkillManager( + provider, + ephemeral_root=tmp_path / "ephemeral", + persistent_roots={"codex": tmp_path / "persistent" / "codex-sessions"}, + ) + session_id = f"ambient-{ambient_state}" + expected_names = {"unrelated-skill"} + if helper_available: + expected_names.add("helper-skill") + + monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home)) + monkeypatch.setenv("MCP_CLIENT_BACKEND", "pre-test-backend") + with manager.managed_session( + session_id, + compile_session_skill_catalog(catalog, backend), + context, + ) as managed: + projected_root = Path(managed.skills_dir.path) / "skills" + projected_names = {entry.name for entry in projected_root.iterdir()} + metadata = json.loads( + (Path(managed.skills_dir.path) / "skill-unavailability.json").read_text( + encoding="utf-8" + ) + ) + unavailable = metadata["unavailable"] + + assert projected_names == expected_names + assert set(manager._session_skill_infos[session_id]) == expected_names + assert (managed.generated_home / "skills" / "unrelated-skill").is_symlink() + if helper_available: + assert unavailable == [] + assert (managed.generated_home / "skills" / "helper-skill").is_symlink() + else: + assert unavailable == [ + { + "backend": "codex", + "diagnostic": "native child-spawn targets are unavailable: ['helper']", + "operation": "child_spawn", + "skill": "helper-skill", + } + ] + assert not (managed.generated_home / "skills" / "helper-skill").exists() + + assert session_id not in manager._session_skill_infos + assert not (tmp_path / "persistent" / "codex-sessions" / session_id).exists() + + def test_missing_profile_skills_dir_does_not_raise(tmp_path, monkeypatch) -> None: """Profile projection returns 0 when ~/.codex/skills is absent.""" from autoskillit.execution.backends.codex import CodexBackend diff --git a/tests/workspace/test_session_skills_provider.py b/tests/workspace/test_session_skills_provider.py index fcbe2e571..18f705f5c 100644 --- a/tests/workspace/test_session_skills_provider.py +++ b/tests/workspace/test_session_skills_provider.py @@ -86,6 +86,7 @@ def _codex_backend() -> MagicMock: backend.capabilities = _CODEX_CAPABILITIES backend.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) + backend.setup_session_dir.return_value = None backend.validate_session_layout.return_value = [] backend.adapt_skill_semantics.side_effect = adapt_test_skill_semantics return backend diff --git a/tests/workspace/test_session_skills_stale_path.py b/tests/workspace/test_session_skills_stale_path.py index 7d18ea4b4..b0a27b980 100644 --- a/tests/workspace/test_session_skills_stale_path.py +++ b/tests/workspace/test_session_skills_stale_path.py @@ -21,6 +21,7 @@ def _codex_backend() -> MagicMock: backend.capabilities = _CODEX_CAPABILITIES backend.conventions.skills_subdir = ClaudeDirectoryConventions.PLUGIN_DIR_SKILLS_SUBDIR backend.ensure_pre_launch.return_value = PreLaunchReadiness((), {}) + backend.setup_session_dir.return_value = None backend.validate_session_layout.return_value = [] backend.adapt_skill_semantics.side_effect = adapt_test_skill_semantics return backend