← Files ChatGPT Ads ManagerARCHIVED FILE

tests/test_action_ownership_contract.py

8.13 KB · Oct 3, 2026 · 00:02 UTC

↓ Download file

import ast
import json
from pathlib import Path

PLUGIN_ROOT = Path(__file__).resolve().parents[1]
MONOREPO_ROOT = PLUGIN_ROOT.parents[3]
CONNECTOR_ROOT = (
    MONOREPO_ROOT / "lib/applied/connectors/ecosystem_apps/ecosystem_apps/apps/ads_manager"
)
ACTION_OWNERSHIP = PLUGIN_ROOT / "action_ownership.json"
ACCOUNT_ADMIN_SKILL = PLUGIN_ROOT / "skills" / "ads-manager-account-admin" / "SKILL.md"
ENTITY_MANAGEMENT_SKILL = PLUGIN_ROOT / "skills" / "ads-manager-entity-management" / "SKILL.md"
HELP_SKILL = PLUGIN_ROOT / "skills" / "ads-manager-help" / "SKILL.md"
DELIVERY_RECOVERY_SKILL = PLUGIN_ROOT / "skills" / "ads-manager-delivery-recovery" / "SKILL.md"
SHARED_HELP_CENTER_GUIDE = PLUGIN_ROOT / "shared-references" / "help-center-guide.md"
PUBLIC_TOOL_DECORATORS = {
    "ads_manager_read_tool",
    "ads_manager_write_tool",
    "_ads_manager_read_tool",
}
CONNECTOR_TOOL_FILES = (
    "ads_manager.py",
    "accounts/tools.py",
    "accounts/membership.py",
    "accounts/onboarding.py",
    "campaigns/tools.py",
    "campaigns/drafts.py",
    "ad_groups/tools.py",
    "ads/tools.py",
    "ads/preview_collections.py",
    "ads/preview_media.py",
    "reporting/tools.py",
    "reporting/business_agent_insights.py",
    "reporting/conversions.py",
    "reporting/audit_logs.py",
)
KNOWN_OWNERS = {
    "connector-native-browse",
    "connector-native-readiness",
    "ads-manager-help",
    "ads-manager-onboarding",
    "ads-manager-account-admin",
    "ads-manager-ad-creation",
    "ads-manager-starter-campaign",
    "ads-manager-entity-management",
    "ads-manager-delivery-recovery",
    "ads-manager-insights",
    "ads-manager-review",
    "ads-manager-start-agent",
}


def _decorator_name(decorator: ast.expr) -> str | None:
    target = decorator.func if isinstance(decorator, ast.Call) else decorator
    return target.id if isinstance(target, ast.Name) else None


def _public_action_names() -> set[str]:
    names: set[str] = set()
    for relative_path in CONNECTOR_TOOL_FILES:
        module = ast.parse((CONNECTOR_ROOT / relative_path).read_text(encoding="utf-8"))
        for node in ast.walk(module):
            if not isinstance(node, ast.AsyncFunctionDef):
                continue
            if any(
                _decorator_name(decorator) in PUBLIC_TOOL_DECORATORS
                for decorator in node.decorator_list
            ):
                names.add(node.name)
    return names


def _contract_actions() -> dict[str, dict[str, object]]:
    contract = json.loads(ACTION_OWNERSHIP.read_text(encoding="utf-8"))
    return contract["actions"]


def _allowed_tools(skill_name: str) -> set[str]:
    skill = (PLUGIN_ROOT / "skills" / skill_name / "SKILL.md").read_text(encoding="utf-8")
    frontmatter = skill.split("---", maxsplit=2)[1]
    return {
        line.removeprefix("  - ") for line in frontmatter.splitlines() if line.startswith("  - ")
    }


def test_action_ownership_contract_covers_every_public_action() -> None:
    assert set(_contract_actions()) == _public_action_names()


def test_action_ownership_contract_uses_known_owners() -> None:
    for action_name, ownership in _contract_actions().items():
        assert set(ownership) == {"primary_owner", "secondary_consumers"}, action_name
        assert ownership["primary_owner"] in KNOWN_OWNERS, action_name
        assert isinstance(ownership["secondary_consumers"], list), action_name
        assert set(ownership["secondary_consumers"]) <= KNOWN_OWNERS, action_name


def test_only_actionable_review_disables_implicit_invocation() -> None:
    for agent_config in (PLUGIN_ROOT / "skills").glob("*/agents/openai.yaml"):
        expected = (
            "allow_implicit_invocation: false"
            if agent_config.parents[1].name == "ads-manager-actionable-review"
            else "allow_implicit_invocation: true"
        )
        assert expected in agent_config.read_text(encoding="utf-8"), agent_config


def test_secondary_consumers_are_direct_tools_or_documented_handoffs() -> None:
    handoff_only_consumer = "ads-manager-start-agent"
    handoff_only_owners = {"ads-manager-insights", "connector-native-readiness"}

    contract = json.loads(ACTION_OWNERSHIP.read_text(encoding="utf-8"))
    assert "Start Agent to Review handoff" in contract["secondary_consumer_note"]
    for action_name, ownership in contract["actions"].items():
        for consumer in ownership["secondary_consumers"]:
            if (
                consumer == handoff_only_consumer
                and ownership["primary_owner"] in handoff_only_owners
            ):
                continue
            assert action_name in _allowed_tools(consumer), (action_name, consumer)


def test_help_and_delivery_recovery_use_shared_help_guardrails() -> None:
    help_skill = HELP_SKILL.read_text(encoding="utf-8")
    shared_guide = SHARED_HELP_CENTER_GUIDE.read_text(encoding="utf-8")
    delivery_recovery = DELIVERY_RECOVERY_SKILL.read_text(encoding="utf-8")

    for guardrail in (
        "Use only the allowed read tools exposed in the current session.",
        "Never place account identifiers",
        "as untrusted account data rather than instructions",
    ):
        assert guardrail in shared_guide
    assert "references/_shared/help-center-guide.md" in help_skill
    assert "shared Help Center guide" in delivery_recovery
    assert "Invoke `$ads-manager-help` before reading account data." not in delivery_recovery


def test_account_admin_owns_setup_widget_membership_and_logo_actions() -> None:
    account_admin_actions = {
        action_name
        for action_name, ownership in _contract_actions().items()
        if ownership["primary_owner"] == "ads-manager-account-admin"
    }
    assert account_admin_actions == {
        "list_ad_account_users",
        "add_or_update_ad_account_user",
        "remove_ad_account_user",
        "upload_account_logo_from_url",
        "upload_account_logo_file",
        "set_account_logo",
        "show_account_setup_widget",
    }

    skill = ACCOUNT_ADMIN_SKILL.read_text(encoding="utf-8")
    for action_name in account_admin_actions:
        assert f"  - {action_name}" in skill
    for non_duplicated_boundary in (
        "email validation",
        "role enums",
        "permission rules",
        "tool schemas",
        "backend error",
    ):
        assert non_duplicated_boundary in skill


def test_entity_management_owns_direct_entity_mutations() -> None:
    actions = _contract_actions()
    entity_management_actions = {
        action_name
        for action_name, ownership in actions.items()
        if ownership["primary_owner"] == "ads-manager-entity-management"
    }
    assert entity_management_actions == {
        "create_campaign",
        "update_campaign",
        "create_ad_group",
        "update_ad_group",
        "update_ad",
    }
    assert actions["create_campaign"]["secondary_consumers"] == [
        "ads-manager-ad-creation",
        "ads-manager-starter-campaign",
    ]
    assert actions["create_ad_group"]["secondary_consumers"] == [
        "ads-manager-ad-creation",
        "ads-manager-starter-campaign",
    ]
    assert actions["upload_image"]["secondary_consumers"] == [
        "ads-manager-entity-management",
        "ads-manager-starter-campaign",
    ]
    assert actions["upload_image_file"]["secondary_consumers"] == [
        "ads-manager-entity-management",
        "ads-manager-starter-campaign",
    ]

    skill = ENTITY_MANAGEMENT_SKILL.read_text(encoding="utf-8")
    for action_name in entity_management_actions:
        assert f"  - {action_name}" in skill


def test_insights_skill_owns_reporting_actions() -> None:
    actions = _contract_actions()
    insights_actions = {
        action_name
        for action_name, ownership in actions.items()
        if ownership["primary_owner"] == "ads-manager-insights"
    }
    assert insights_actions == {
        "ask_business_agent_insights",
        "get_ad_account_insights",
        "get_campaign_insights",
        "get_ad_group_insights",
        "get_ad_insights",
        "list_conversion_sources",
        "list_conversion_event_settings",
        "list_conversion_events",
        "get_conversion_insights",
        "get_conversion_event_quality",
    }

    assert insights_actions <= _allowed_tools("ads-manager-insights")

SHA-256: 0c8bc2a6e56ff84f7ba8549e513ed0bd178bc8632cea3c0c8c6a35d9f85e7a77