This is an automated email from the ASF dual-hosted git repository.

jason810496 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 2f5a3762d1c Evaluate AGENTS.md and skills with Codex (#70120)
2f5a3762d1c is described below

commit 2f5a3762d1c2ba5dcb151b54d6566f259254e021
Author: LI,JHE-CHEN <[email protected]>
AuthorDate: Wed Aug 12 16:45:59 2026 +0800

    Evaluate AGENTS.md and skills with Codex (#70120)
    
    * Evaluate AGENTS.md and skills with Codex
    
    * Skip Codex skill eval in bulk manual prek runs
---
 .pre-commit-config.yaml                     |  21 ++++
 AGENTS.md                                   |   8 +-
 contributing-docs/08_static_code_checks.rst |   2 +-
 dev/skill-evals/README.md                   |  49 ++++++++-
 dev/skill-evals/eval.py                     | 120 +++++++++++++++++----
 dev/skill-evals/last-eval-hash.txt          |   2 +-
 scripts/tests/ci/prek/test_skill_eval.py    | 158 ++++++++++++++++++++++++++++
 7 files changed, 326 insertions(+), 34 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 4c24bcc2b1d..7f8a908bb8e 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1035,6 +1035,27 @@ repos:
         pass_filenames: false
         require_serial: true
         verbose: true
+      - id: run-skill-eval-codex
+        name: Run skill-eval against AGENTS.md and cases (Codex runtime)
+        # Kept separate from run-skill-eval so only contributors who actually 
run
+        # the Codex arm build an env carrying the Codex CLI binary. promptfoo
+        # bundles its own @openai/codex-sdk, but that build ships a binary 
whose
+        # Developer ID certificate Apple has revoked — macOS kills it on exec —
+        # so a notarized version has to be installed explicitly.
+        entry: env AGENT_RUNTIME=codex ./dev/skill-evals/eval.py
+        language: node
+        language_version: '22.22.0'
+        additional_dependencies:
+          - '[email protected]'
+          - '@openai/[email protected]'
+        stages: ['manual']
+        files: >
+          (?x)
+          ^AGENTS\.md$|
+          ^dev/skill-evals/
+        pass_filenames: false
+        require_serial: true
+        verbose: true
       - id: view-skill-eval
         name: View skill-eval results in browser (manual)
         entry: env PROMPTFOO_CONFIG_DIR=.build/promptfoo promptfoo view
diff --git a/AGENTS.md b/AGENTS.md
index 2086b4040ad..c414057ac1f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -52,7 +52,7 @@ Don't spell out **Directed Acyclic Graph** except for 
historical context.
 - **Lint with ruff only:** `prek run ruff --from-ref <target_branch>`
 - **Format with ruff only:** `prek run ruff-format --from-ref <target_branch>`
 - **Run regular (fast) static checks:** `prek run --from-ref <target_branch> 
--stage pre-commit`
-- **Run manual (slower) checks:** `prek run --from-ref <target_branch> --stage 
manual --skip compile-ui-assets-dev --skip view-skill-eval` (the skipped hooks 
start long-running local servers rather than checks that complete)
+- **Run manual (slower) checks:** `prek run --from-ref <target_branch> --stage 
manual --skip compile-ui-assets-dev --skip view-skill-eval --skip 
run-skill-eval-codex` (the skipped hooks start long-running local servers or 
provision the opt-in Codex environment rather than run checks that complete)
 - **Build docs:** `breeze build-docs`
 - **Determine which tests to run based on changed files:** `breeze ci 
selective-check --commit-ref <commit_with_squashed_changes>`
 <!-- END generated-commands, please keep comment here to allow auto update -->
@@ -295,9 +295,9 @@ code review checklist in 
[`.github/instructions/code-review.instructions.md`](.g
 4. Run regular (fast) static checks (`prek run --from-ref <target_branch> 
--stage pre-commit`)
    and fix any failures. This includes mypy checks for non-provider projects 
(airflow-core, task-sdk, airflow-ctl, dev, scripts, devel-common).
 5. Run manual (slower) checks
-   (`prek run --from-ref <target_branch> --stage manual --skip 
compile-ui-assets-dev --skip view-skill-eval`)
-   and fix any failures. The skipped hooks start long-running local servers 
rather than checks
-   that complete.
+   (`prek run --from-ref <target_branch> --stage manual --skip 
compile-ui-assets-dev --skip view-skill-eval --skip run-skill-eval-codex`)
+   and fix any failures. The skipped hooks start long-running local servers or 
provision the
+   opt-in Codex environment rather than run checks that complete.
 6. Run relevant individual tests and confirm they pass.
 7. Find which tests to run for the changes with selective-checks and run those 
tests in parallel to confirm they pass and check for CI-specific issues.
 8. Check for security issues — no secrets, no injection vulnerabilities, no 
unsafe patterns.
diff --git a/contributing-docs/08_static_code_checks.rst 
b/contributing-docs/08_static_code_checks.rst
index 161bd7902b8..9bae6b9248f 100644
--- a/contributing-docs/08_static_code_checks.rst
+++ b/contributing-docs/08_static_code_checks.rst
@@ -234,7 +234,7 @@ To check other usage types of the pre-commit framework, see 
`Pre-commit website
      - "- **Lint with ruff only:** `prek run ruff --from-ref <target_branch>`"
      - "- **Format with ruff only:** `prek run ruff-format --from-ref 
<target_branch>`"
      - "- **Run regular (fast) static checks:** `prek run --from-ref 
<target_branch> --stage pre-commit`"
-     - "- **Run manual (slower) checks:** `prek run --from-ref <target_branch> 
--stage manual --skip compile-ui-assets-dev --skip view-skill-eval` (the 
skipped hooks start long-running local servers rather than checks that 
complete)"
+     - "- **Run manual (slower) checks:** `prek run --from-ref <target_branch> 
--stage manual --skip compile-ui-assets-dev --skip view-skill-eval --skip 
run-skill-eval-codex` (the skipped hooks start long-running local servers or 
provision the opt-in Codex environment rather than run checks that complete)"
      - "- **Build docs:** `breeze build-docs`"
      - "- **Determine which tests to run based on changed files:** `breeze ci 
selective-check --commit-ref <commit_with_squashed_changes>`"
 .. AGENT-SKILL-END
diff --git a/dev/skill-evals/README.md b/dev/skill-evals/README.md
index 57748b96c2e..e25333ff89a 100644
--- a/dev/skill-evals/README.md
+++ b/dev/skill-evals/README.md
@@ -23,6 +23,7 @@
 
 - [Skill-Eval Harness](#skill-eval-harness)
   - [Prerequisites](#prerequisites)
+  - [Agent runtimes](#agent-runtimes)
   - [Usage](#usage)
   - [Cleanup](#cleanup)
   - [Adding cases](#adding-cases)
@@ -48,12 +49,49 @@ regular-file CLAUDE.md would make every arm read identical 
guidance.
 
 ## Prerequisites
 
-- **Authentication** (one of):
+- **Claude authentication** (one of):
   - Claude Code session (`claude /login`) — Pro/Max subscription
   - `ANTHROPIC_API_KEY` environment variable — API credits
+- **Codex authentication** (when using the Codex runtime): a Codex CLI
+  session (`codex login`)
 
-That's it — prek provisions Node, promptfoo, and the Claude Agent SDK
-automatically.
+That's it — prek provisions Node, promptfoo, and the agent SDKs automatically.
+
+## Agent runtimes
+
+Claude is the default runtime. The `run-skill-eval-codex` hook runs the same
+arms and cases through the official Codex SDK instead. The Codex provider uses
+a fresh thread for every prompt, read-only sandboxing, disabled network access,
+disabled session-history persistence, and the SDK's structured-output support.
+
+The two runtimes are separate hooks, not one hook with a flag, because the
+Codex env carries a ~300 MB Codex CLI binary. Keeping it separate means prek
+only builds that env for contributors who actually run the Codex arm. promptfoo
+bundles its own older `@openai/codex-sdk`, but that build ships a binary whose
+Developer ID certificate Apple has revoked — macOS kills it on exec and reports
+it as malware — so the Codex hook installs a notarized version explicitly.
+
+Codex caps the project docs it reads at `project_doc_max_bytes` and truncates
+past it, and the 32,768-byte default is smaller than `AGENTS.md`. The harness
+raises that cap so the whole file reaches the model — otherwise guidance
+appended at the end would be invisible to every arm and the eval would compare
+identical prefixes.
+
+When `SKILL_NAME` is set, both runtimes verify skill usage with promptfoo's
+`skill-used` assertion. The Codex SDK does not expose a first-class skill-use
+event, so promptfoo infers usage from successful reads of the skill's
+`SKILL.md` file.
+
+```bash
+# Default: Claude Agent SDK
+prek run run-skill-eval --hook-stage manual --all-files
+
+# Codex SDK, using its configured default model
+prek run run-skill-eval-codex --hook-stage manual --all-files
+
+# Codex SDK with an explicit model
+MODEL=gpt-5.4 prek run run-skill-eval-codex --hook-stage manual --all-files
+```
 
 ## Usage
 
@@ -125,8 +163,9 @@ Use `output.should_create` directly in assertions.
 1. Creates git worktrees — one with `main`'s AGENTS.md, one with
    your working tree version. Both are full repo checkouts.
 2. Generates a [promptfoo](https://github.com/promptfoo/promptfoo)
-   config with `anthropic:claude-agent-sdk` provider and
-   `output_format: json_schema` for structured output.
+   config for the selected runtime. Claude uses promptfoo's
+   `anthropic:claude-agent-sdk` provider; Codex uses its `openai:codex-sdk`
+   provider. Both request schema-constrained structured output.
 3. Runs each case against all arms in parallel.
 4. Reports pass/fail diff. Worktrees cleaned up on exit.
 
diff --git a/dev/skill-evals/eval.py b/dev/skill-evals/eval.py
index 20eb766862a..19184629994 100755
--- a/dev/skill-evals/eval.py
+++ b/dev/skill-evals/eval.py
@@ -26,12 +26,15 @@ real repo — the agent sees actual source files.
 
 Usage:
     prek run run-skill-eval --hook-stage manual --all-files
+    prek run run-skill-eval-codex --hook-stage manual --all-files
 
-Env knobs: MODEL, SKILL_NAME, EVAL_REPEAT, EVAL_FULL (baseline arm).
+Env knobs: AGENT_RUNTIME (set by the Codex hook), MODEL, SKILL_NAME, 
EVAL_REPEAT,
+EVAL_FULL (baseline arm).
 Promptfoo flags like --filter* are argv-only — wire them as fixed entry
 args on a hook variant when needed.
 
-Authentication: Claude Code session (claude /login) or ANTHROPIC_API_KEY.
+Authentication: Claude Code session (claude /login), ANTHROPIC_API_KEY,
+or a Codex CLI session (codex login), depending on the selected runtime.
 """
 
 from __future__ import annotations
@@ -45,6 +48,16 @@ import tempfile
 from pathlib import Path
 
 PROMPTFOO_VERSION = "0.121.17"
+SDK_PACKAGES = {
+    "claude": "@anthropic-ai/claude-agent-sdk",
+    "codex": "@openai/codex-sdk",
+}
+SUPPORTED_RUNTIMES = tuple(SDK_PACKAGES)
+
+# Codex prefix-truncates project docs past project_doc_max_bytes, and the 
32,768-byte
+# default is already smaller than AGENTS.md — guidance appended at the end 
would be
+# invisible to every arm while the eval still recorded a hash as proof it ran.
+CODEX_PROJECT_DOC_MAX_BYTES = 1_048_576
 
 REPO_ROOT = Path(__file__).resolve().parent.parent.parent
 SCRIPT_DIR = Path(__file__).resolve().parent
@@ -65,7 +78,7 @@ OUTPUT_FORMAT = {
     "type": "json_schema",
     "schema": {
         "type": "object",
-        "required": ["should_create", "rationale"],
+        "required": ["should_create", "type", "rationale"],
         "additionalProperties": False,
         "properties": {
             "should_create": {"type": "boolean"},
@@ -83,12 +96,20 @@ def run(cmd: list[str], **kwargs) -> 
subprocess.CompletedProcess[str]:
     return subprocess.run(cmd, capture_output=True, text=True, check=False, 
**kwargs)
 
 
-def find_sdk_modules() -> Path:
-    """Locate the node_modules dir (in the prek env) that contains the Claude 
Agent SDK.
+def find_sdk_modules(runtime: str) -> Path:
+    """Locate the prek node_modules dir containing the selected agent SDK.
 
-    promptfoo resolves the SDK from the eval config's directory, not from its
-    own install tree — the caller symlinks this dir next to the config.
+    promptfoo resolves providers from the eval config's directory. The caller
+    symlinks this directory next to the generated config.
+
+    Only the hook's own install root counts, never promptfoo's nested
+    node_modules: promptfoo declares both SDKs as optional dependencies, so a
+    prek env holds two copies of each, and its bundled Codex build ships a
+    binary whose Developer ID certificate Apple has revoked — macOS kills it on
+    exec. Resolving by location rather than by version keeps the pins in
+    .pre-commit-config.yaml the single source of truth.
     """
+    sdk_package = SDK_PACKAGES[runtime]
     promptfoo_bin = shutil.which("promptfoo")
     if promptfoo_bin:
         # PROMPTFOO_DISABLE_UPDATE avoids an outdated version banner on stdout 
when a newer
@@ -102,12 +123,13 @@ def find_sdk_modules() -> Path:
                 if pf_pkg.parent == pf_pkg:
                     break
                 pf_pkg = pf_pkg.parent
-            for candidate in (pf_pkg / "node_modules", pf_pkg.parent):
-                if (candidate / "@anthropic-ai" / "claude-agent-sdk").is_dir():
-                    return candidate
+            install_root = pf_pkg.parent
+            if install_root.joinpath(*sdk_package.split("/"), 
"package.json").is_file():
+                return install_root
+    hook = "run-skill-eval-codex" if runtime == "codex" else "run-skill-eval"
     print(
-        "Error: promptfoo with the Claude Agent SDK not found. Run the eval 
via:\n"
-        "  prek run run-skill-eval --hook-stage manual --all-files",
+        f"Error: promptfoo {PROMPTFOO_VERSION} with {sdk_package} not found. 
Run the eval via:\n"
+        f"  prek run {hook} --hook-stage manual --all-files",
         file=sys.stderr,
     )
     sys.exit(1)
@@ -207,20 +229,56 @@ def remove_worktree(wt_dir: Path) -> None:
     run(["git", "-C", str(REPO_ROOT), "worktree", "remove", "--force", 
str(wt_dir)])
 
 
-def build_provider(label: str, working_dir: Path, model: str, skill_name: str 
| None = None) -> dict:
+def build_provider(label: str, working_dir: Path, model: str | None, 
skill_name: str | None = None) -> dict:
     config: dict = {
-        "model": model,
         "apiKeyRequired": False,
         "setting_sources": ["project"],
         "append_allowed_tools": ["Read", "Grep", "Glob"],
         "working_dir": str(working_dir),
         "output_format": OUTPUT_FORMAT,
     }
+    if model:
+        config["model"] = model
     if skill_name:
         config["skills"] = [skill_name]
     return {"id": "anthropic:claude-agent-sdk", "label": label, "config": 
config}
 
 
+def build_codex_provider(
+    label: str, working_dir: Path, model: str | None, detect_skill_usage: bool 
= False
+) -> dict:
+    config = {
+        "approval_policy": "never",
+        "cli_config": {
+            "history": {"persistence": "none"},
+            "project_doc_max_bytes": CODEX_PROJECT_DOC_MAX_BYTES,
+        },
+        "network_access_enabled": False,
+        "output_schema": OUTPUT_FORMAT["schema"],
+        "sandbox_mode": "read-only",
+        "web_search_mode": "disabled",
+        "working_dir": str(working_dir),
+    }
+    if model:
+        config["model"] = model
+    if detect_skill_usage:
+        config["enable_streaming"] = True
+    return {
+        "id": "openai:codex-sdk",
+        "label": label,
+        "config": config,
+        "transform": "JSON.parse(output)",
+    }
+
+
+def get_runtime() -> str:
+    runtime = os.environ.get("AGENT_RUNTIME", "claude").lower()
+    if runtime not in SUPPORTED_RUNTIMES:
+        choices = ", ".join(SUPPORTED_RUNTIMES)
+        raise ValueError(f"AGENT_RUNTIME must be one of: {choices}; got 
{runtime!r}")
+    return runtime
+
+
 def count_provider_errors(results_file: Path) -> int:
     """Count results whose provider call errored (as opposed to failing an 
assertion)."""
     try:
@@ -231,9 +289,16 @@ def count_provider_errors(results_file: Path) -> int:
 
 
 def main() -> int:
-    sdk_modules = find_sdk_modules()
+    try:
+        runtime = get_runtime()
+    except ValueError as error:
+        print(f"Error: {error}", file=sys.stderr)
+        return 1
+    sdk_modules = find_sdk_modules(runtime)
 
-    model = os.environ.get("MODEL", "claude-sonnet-4-6")
+    model = os.environ.get("MODEL")
+    if not model and runtime == "claude":
+        model = "claude-sonnet-4-6"
     skill_name = os.environ.get("SKILL_NAME")
     skill_src = None
     if skill_name:
@@ -263,7 +328,8 @@ def main() -> int:
         return 1
 
     base_branch = resolve_base_branch()
-    check_claude_md_symlink(base_branch)
+    if runtime == "claude":
+        check_claude_md_symlink(base_branch)
 
     # Hash before building arms so edits made mid-run aren't recorded as 
tested.
     guidance_hash = compute_guidance_hash(AGENTS_SRC, CASES_DIR)
@@ -273,7 +339,7 @@ def main() -> int:
     worktrees: list[Path] = []
 
     try:
-        # promptfoo resolves the Claude Agent SDK from the config directory
+        # promptfoo resolves the selected agent SDK from the config directory
         (work_dir / "node_modules").symlink_to(sdk_modules)
 
         # Extract main-branch AGENTS.md
@@ -319,11 +385,16 @@ def main() -> int:
             arm_baseline = create_worktree(work_dir, "baseline", base_branch, 
None, worktrees)
 
         # Generate config (JSON — valid promptfoo config, keeps the script 
stdlib-only)
-        providers = [build_provider("main", arm_main, model, skill_name)]
+        def provider(label: str, arm: Path, selected_skill: str | None = None) 
-> dict:
+            if runtime == "codex":
+                return build_codex_provider(label, arm, model, 
detect_skill_usage=bool(selected_skill))
+            return build_provider(label, arm, model, selected_skill)
+
+        providers = [provider("main", arm_main, skill_name)]
         if arm_working:
-            providers.append(build_provider("working", arm_working, model, 
skill_name))
+            providers.append(provider("working", arm_working, skill_name))
         if full_mode and arm_baseline:
-            providers.append(build_provider("baseline", arm_baseline, model))
+            providers.append(provider("baseline", arm_baseline))
 
         default_test: dict = {"options": {"disableVarExpansion": True}}
         if skill_name:
@@ -339,10 +410,13 @@ def main() -> int:
         config_path.write_text(json.dumps(config, indent=2))
 
         # Report
+        model_label = model or "runtime default"
         if skill_name:
-            print(f"Mode: {len(providers)} arms, skill '{skill_name}', model: 
{model}")
+            print(
+                f"Mode: {len(providers)} arms, skill '{skill_name}', runtime: 
{runtime}, model: {model_label}"
+            )
         else:
-            print(f"Mode: {len(providers)} arms, AGENTS.md only, model: 
{model}")
+            print(f"Mode: {len(providers)} arms, AGENTS.md only, runtime: 
{runtime}, model: {model_label}")
 
         print()
         print(f"Changes detected (vs {base_branch}):")
diff --git a/dev/skill-evals/last-eval-hash.txt 
b/dev/skill-evals/last-eval-hash.txt
index 23452e4e6f3..4e72dc42b31 100644
--- a/dev/skill-evals/last-eval-hash.txt
+++ b/dev/skill-evals/last-eval-hash.txt
@@ -1,3 +1,3 @@
 # Generated by dev/skill-evals/eval.py — do not edit or resolve conflicts by 
hand.
 # Run `prek run run-skill-eval --hook-stage manual --all-files` to regenerate.
-725f2a0640c5b6fec7f6666484a0577dde5c87f19206b1d0bdf191a7094cd98d
+8cfe725b810c4c2d364d99f63b27ec739545e40032195624c218b0eb23d75859
diff --git a/scripts/tests/ci/prek/test_skill_eval.py 
b/scripts/tests/ci/prek/test_skill_eval.py
new file mode 100644
index 00000000000..8bac034debb
--- /dev/null
+++ b/scripts/tests/ci/prek/test_skill_eval.py
@@ -0,0 +1,158 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+from __future__ import annotations
+
+import importlib.util
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+REPO_ROOT = Path(__file__).resolve().parents[4]
+MODULE_PATH = REPO_ROOT / "dev" / "skill-evals" / "eval.py"
+
+
[email protected](scope="module")
+def skill_eval_module():
+    spec = importlib.util.spec_from_file_location("skill_eval", MODULE_PATH)
+    assert spec and spec.loader
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
[email protected](
+    ("configured", "expected"),
+    [
+        pytest.param(None, "claude", id="default"),
+        pytest.param("Claude", "claude", id="case-insensitive"),
+        pytest.param("codex", "codex", id="codex"),
+    ],
+)
+def test_get_runtime(monkeypatch, skill_eval_module, configured, expected):
+    if configured is None:
+        monkeypatch.delenv("AGENT_RUNTIME", raising=False)
+    else:
+        monkeypatch.setenv("AGENT_RUNTIME", configured)
+
+    assert skill_eval_module.get_runtime() == expected
+
+
+def test_get_runtime_rejects_unknown_runtime(monkeypatch, skill_eval_module):
+    monkeypatch.setenv("AGENT_RUNTIME", "unknown")
+
+    with pytest.raises(ValueError, match="claude, codex"):
+        skill_eval_module.get_runtime()
+
+
+def test_build_codex_provider(skill_eval_module, tmp_path):
+    working_dir = tmp_path / "worktree"
+
+    provider = skill_eval_module.build_codex_provider(
+        "working", working_dir, "gpt-test", detect_skill_usage=True
+    )
+
+    assert provider == {
+        "id": "openai:codex-sdk",
+        "label": "working",
+        "config": {
+            "approval_policy": "never",
+            "cli_config": {
+                "history": {"persistence": "none"},
+                "project_doc_max_bytes": 
skill_eval_module.CODEX_PROJECT_DOC_MAX_BYTES,
+            },
+            "enable_streaming": True,
+            "model": "gpt-test",
+            "network_access_enabled": False,
+            "output_schema": skill_eval_module.OUTPUT_FORMAT["schema"],
+            "sandbox_mode": "read-only",
+            "web_search_mode": "disabled",
+            "working_dir": str(working_dir),
+        },
+        "transform": "JSON.parse(output)",
+    }
+
+
+def test_output_schema_requires_every_property(skill_eval_module):
+    schema = skill_eval_module.OUTPUT_FORMAT["schema"]
+
+    assert set(schema["required"]) == set(schema["properties"])
+
+
+def test_build_codex_provider_uses_runtime_default_model(skill_eval_module, 
tmp_path):
+    provider = skill_eval_module.build_codex_provider("main", tmp_path / 
"worktree", None)
+
+    assert "model" not in provider["config"]
+    assert "enable_streaming" not in provider["config"]
+
+
[email protected]
+def promptfoo_layout(monkeypatch, skill_eval_module, tmp_path):
+    """Build a prek-style node env and return an SDK installer for either 
location.
+
+    ``install_root`` mirrors a package listed in the hook's
+    additional_dependencies; ``bundled`` mirrors promptfoo's own optional
+    dependency, nested inside its package.
+    """
+    install_root = tmp_path / "node_modules"
+    promptfoo = install_root / "promptfoo"
+    bundled = promptfoo / "node_modules"
+    promptfoo.mkdir(parents=True)
+    (promptfoo / "package.json").write_text("{}")
+    promptfoo_bin = promptfoo / "promptfoo"
+    promptfoo_bin.touch()
+    monkeypatch.setattr(skill_eval_module.shutil, "which", lambda _: 
str(promptfoo_bin))
+    monkeypatch.setattr(
+        skill_eval_module,
+        "run",
+        lambda *args, **kwargs: 
SimpleNamespace(stdout=f"{skill_eval_module.PROMPTFOO_VERSION}\n"),
+    )
+
+    def install(location: Path, package: str) -> None:
+        sdk = location.joinpath(*package.split("/"))
+        sdk.mkdir(parents=True)
+        (sdk / "package.json").write_text('{"version": "1.2.3"}')
+
+    return SimpleNamespace(install_root=install_root, bundled=bundled, 
install=install)
+
+
[email protected]("runtime", ["claude", "codex"])
+def test_find_sdk_modules_resolves_the_hook_installed_copy(skill_eval_module, 
promptfoo_layout, runtime):
+    promptfoo_layout.install(promptfoo_layout.install_root, 
skill_eval_module.SDK_PACKAGES[runtime])
+
+    assert skill_eval_module.find_sdk_modules(runtime) == 
promptfoo_layout.install_root
+
+
[email protected]("runtime", ["claude", "codex"])
+def test_find_sdk_modules_ignores_the_copy_bundled_with_promptfoo(
+    skill_eval_module, promptfoo_layout, runtime
+):
+    promptfoo_layout.install(promptfoo_layout.bundled, 
skill_eval_module.SDK_PACKAGES[runtime])
+
+    with pytest.raises(SystemExit) as exit_info:
+        skill_eval_module.find_sdk_modules(runtime)
+
+    assert exit_info.value.code == 1
+
+
+def 
test_find_sdk_modules_requires_the_selected_runtimes_sdk(skill_eval_module, 
promptfoo_layout):
+    promptfoo_layout.install(promptfoo_layout.install_root, 
skill_eval_module.SDK_PACKAGES["claude"])
+
+    with pytest.raises(SystemExit) as exit_info:
+        skill_eval_module.find_sdk_modules("codex")
+
+    assert exit_info.value.code == 1

Reply via email to