diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1b1a3e..2819fa94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- **SDK pre-write validation**: `PipefyClient.validate_ai_agent_behaviors` and `PipefyClient.validate_ai_automation_prompt` expose the two read-only validators as client methods, with the MCP tool names and parameters, so an agent that builds its tools from `PipefyClient` can validate before it writes. The MCP tools and CLI commands now call these methods. The `pipefy_sdk.ai_preflight` module functions stay. (#694) + ### Fixed - **Automation listings**: SDK, MCP, and CLI now return trigger IDs, event parameters, conditions, `actionEnabled`, and `disabledReason` for organization and pipe listings, avoiding a detail call per rule to audit its filters and whether the action is enabled. Listings are paged: the API caps a page at 50 rules, so `get_automations` / `pipefy automation list` accept `first` / `after` and report `totalCount` and `hasNextPage` instead of silently returning the first 50. `get_ai_automations` / `pipefy ai-automation list` expose the same page block for the mixed connection they filter. Human `pipefy automation list` prints a table of each row's scalar columns plus the page counts, leaving the nested `event_params` and `condition` to `--json`. Phase-delete preview follows every page of rules, reads their details under a concurrency bound instead of one simultaneous call per rule, and says when the dependents list is a lower bound because a page or a detail read failed. An empty pipe no longer fails `get_automation_logs_by_repo`. The last page of an audit names itself so `11 of 61` is not read as a shortfall. (#612) diff --git a/docs/sdk/README.md b/docs/sdk/README.md index d97e4cdb..00ef7f6d 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -48,6 +48,15 @@ Other exported error types sit outside this root. Catch these by name: Transport-level failures (connection refused, timeouts) surface as `gql`'s `TransportError`, which the SDK does not wrap. +## Pre-write validation + +Two read-only `PipefyClient` methods dry-run a write before you make it. They have the same names and parameters as the MCP tools, and they never persist anything: + +- **`validate_ai_agent_behaviors(pipe_id, behaviors, *, strict_unknown_action_types=True, data_source_ids=None)`** checks a behavior list against the pipe's fields, phases, relations, phase transitions, and knowledge bases. Call it before `create_ai_agent` / `update_ai_agent`. +- **`validate_ai_automation_prompt(pipe_id, prompt, field_ids, event_id=None)`** checks the prompt's `%{internal_id}` references, the output `field_ids`, the optional trigger, and whether AI is enabled for the pipe and the organization. Call it before `create_ai_automation`. + +Both return a dict. `valid` is true only when `problems` is empty, and `warnings` holds non-blocking notices. The agent result adds a `message`; the prompt result adds a `field_map` of the referenced field IDs to their labels. A failed pipe read sets `success` to false instead of raising: the agent result then gives the reason in `problems`, and the prompt result carries only `success`, `valid`, and `error`. + ## Configuration OAuth and endpoint variables are documented in **[`../config.md`](../config.md)** and **[`../../.env.example`](../../.env.example)**. Integration tests use `@pytest.mark.integration` and the same `PIPEFY_*` keys from local **`.env`** (e.g. `PIPEFY_PORTAL_ORG_UUID` for portal live tests). Unit tests use fictional ids in **[`../../packages/sdk/tests/_shared/fixture_ids.py`](../../packages/sdk/tests/_shared/fixture_ids.py)** — not production org UUIDs. diff --git a/packages/cli/src/pipefy_cli/commands/agent.py b/packages/cli/src/pipefy_cli/commands/agent.py index e0e03f8f..b1c8defd 100644 --- a/packages/cli/src/pipefy_cli/commands/agent.py +++ b/packages/cli/src/pipefy_cli/commands/agent.py @@ -11,7 +11,6 @@ PipefyClient, UpdateAiAgentInput, ) -from pipefy_sdk.ai_preflight import validate_ai_agent_behaviors_sdk from pipefy_sdk.behavior_placeholders import ( expand_behaviors_placeholders, normalize_pipefy_ai_instruction_tokens, @@ -160,8 +159,7 @@ def agent_create( raise typer.BadParameter(str(exc)) from exc async def factory(client: PipefyClient): - pre = await validate_ai_agent_behaviors_sdk( - client, + pre = await client.validate_ai_agent_behaviors( pipe.strip(), [b.model_dump(by_alias=True) for b in validated.behaviors], strict_unknown_action_types=strict_unknown, @@ -267,8 +265,7 @@ def agent_update( raise typer.BadParameter(str(exc)) from exc async def factory(client: PipefyClient): - pre = await validate_ai_agent_behaviors_sdk( - client, + pre = await client.validate_ai_agent_behaviors( pipe.strip(), [b.model_dump(by_alias=True) for b in validated.behaviors], strict_unknown_action_types=strict_unknown, @@ -394,8 +391,7 @@ def agent_validate_behaviors( behavior_list = _parse_behaviors_json(behaviors) async def factory(client: PipefyClient): - return await validate_ai_agent_behaviors_sdk( - client, + return await client.validate_ai_agent_behaviors( pipe.strip(), behavior_list, strict_unknown_action_types=strict_unknown, diff --git a/packages/cli/src/pipefy_cli/commands/ai_automation.py b/packages/cli/src/pipefy_cli/commands/ai_automation.py index bd304c0c..e819bf8a 100644 --- a/packages/cli/src/pipefy_cli/commands/ai_automation.py +++ b/packages/cli/src/pipefy_cli/commands/ai_automation.py @@ -11,10 +11,7 @@ PipefyClient, UpdateAiAutomationInput, ) -from pipefy_sdk.ai_preflight import ( - filter_ai_automation_summaries, - validate_ai_automation_prompt_sdk, -) +from pipefy_sdk.ai_preflight import filter_ai_automation_summaries from pydantic import ValidationError from pipefy_cli.commands._common import ( @@ -166,8 +163,8 @@ def ai_automation_validate_prompt( fids = _parse_field_ids(field_ids) async def factory(client: PipefyClient): - return await validate_ai_automation_prompt_sdk( - client, pipe.strip(), prompt, fids, event_id + return await client.validate_ai_automation_prompt( + pipe.strip(), prompt, fids, event_id ) run_cli_command(ctx, json_out, factory) @@ -213,8 +210,8 @@ def ai_automation_create( skills = list(skills_raw) async def factory(client: PipefyClient): - pre = await validate_ai_automation_prompt_sdk( - client, pipe.strip(), prompt, fids, event_id + pre = await client.validate_ai_automation_prompt( + pipe.strip(), prompt, fids, event_id ) _raise_if_prompt_preflight_blocks(pre) try: @@ -304,8 +301,7 @@ async def factory(client: PipefyClient): "Pass --prompt and --field-ids explicitly." ) ev = str(row.get("event_id") or "") - pre = await validate_ai_automation_prompt_sdk( - client, + pre = await client.validate_ai_automation_prompt( pipe.strip(), effective_prompt, effective_fids, diff --git a/packages/cli/tests/test_cli_agent_automation_smoke.py b/packages/cli/tests/test_cli_agent_automation_smoke.py index 9df9dfe0..5de33f9f 100644 --- a/packages/cli/tests/test_cli_agent_automation_smoke.py +++ b/packages/cli/tests/test_cli_agent_automation_smoke.py @@ -3,10 +3,12 @@ from __future__ import annotations import json +from types import MethodType from unittest.mock import AsyncMock, MagicMock, patch import pytest from _shared.ai_agent_test_payloads import minimal_behavior_dict +from pipefy_sdk import PipefyClient from typer.testing import CliRunner from pipefy_cli.main import app @@ -17,6 +19,9 @@ def test_agent_validate_behaviors_json( ): oauth_env("ag-val") mock_client = MagicMock() + mock_client.validate_ai_agent_behaviors = MethodType( + PipefyClient.validate_ai_agent_behaviors, mock_client + ) mock_client.get_pipe = AsyncMock( return_value={"pipe": {"phases": [], "start_form_fields": []}} ) @@ -131,6 +136,9 @@ def test_ai_automation_validate_prompt_json( ): oauth_env("ai-val") mock_client = MagicMock() + mock_client.validate_ai_automation_prompt = MethodType( + PipefyClient.validate_ai_automation_prompt, mock_client + ) mock_client.get_pipe_with_preferences = AsyncMock( return_value={ "pipe": { @@ -390,8 +398,9 @@ def test_agent_create_happy_path_chains_create_then_update( "pipefy_cli.commands._common.get_authenticated_client", return_value=mock_client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + mock_client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=preflight_ok), ), patch( @@ -474,8 +483,9 @@ def test_agent_update_invokes_field_ref_resolution_via_facade( "pipefy_cli.commands._common.get_authenticated_client", return_value=client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=preflight_ok), ), patch( @@ -531,8 +541,9 @@ def test_agent_create_blocks_when_preflight_invalid( "pipefy_cli.commands._common.get_authenticated_client", return_value=mock_client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + mock_client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=preflight_block), ), ): @@ -629,6 +640,9 @@ def test_ai_automation_create_succeeds_without_service_account( """ oauth_env("ai-create-public") mock_client = MagicMock() + mock_client.validate_ai_automation_prompt = MethodType( + PipefyClient.validate_ai_automation_prompt, mock_client + ) # Prompt references field 9 as input; output field 88 is distinct so overlap preflight passes. mock_client.get_pipe_with_preferences = AsyncMock( return_value={ @@ -935,6 +949,9 @@ def test_ai_automation_update_auto_fetches_prompt_when_omitted( # Prompt references field 9 as input; output field 88 is distinct so overlap preflight passes. existing = _ai_automation_row("Summarize: %{9}", ["88"]) mock_client = MagicMock() + mock_client.validate_ai_automation_prompt = MethodType( + PipefyClient.validate_ai_automation_prompt, mock_client + ) mock_client.get_automation = AsyncMock(return_value=existing) mock_client.get_pipe_with_preferences = AsyncMock( return_value={ diff --git a/packages/cli/tests/test_cli_agent_lifecycle.py b/packages/cli/tests/test_cli_agent_lifecycle.py index 3e4b7e46..132ba8bd 100644 --- a/packages/cli/tests/test_cli_agent_lifecycle.py +++ b/packages/cli/tests/test_cli_agent_lifecycle.py @@ -62,8 +62,9 @@ def test_agent_create_default_sets_preserve_disabled_at_false_on_update_chain( "pipefy_cli.commands._common.get_authenticated_client", return_value=mock_client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + mock_client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=_PREFLIGHT_OK), ), patch( @@ -130,8 +131,9 @@ def test_agent_create_inactive_sets_disabled_at_on_create_and_update_chain( "pipefy_cli.commands._common.get_authenticated_client", return_value=mock_client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + mock_client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=_PREFLIGHT_OK), ), patch( @@ -193,8 +195,9 @@ def test_agent_update_json_exposes_active_when_disabled_at_null( "pipefy_cli.commands._common.get_authenticated_client", return_value=mock_client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + mock_client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=_PREFLIGHT_OK), ), patch( @@ -250,8 +253,9 @@ def test_agent_update_json_exposes_active_false_when_disabled( "pipefy_cli.commands._common.get_authenticated_client", return_value=mock_client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + mock_client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=_PREFLIGHT_OK), ), patch( @@ -307,8 +311,9 @@ def test_agent_update_passes_disabled_at_when_provided( "pipefy_cli.commands._common.get_authenticated_client", return_value=mock_client, ), - patch( - "pipefy_cli.commands.agent.validate_ai_agent_behaviors_sdk", + patch.object( + mock_client, + "validate_ai_agent_behaviors", new=AsyncMock(return_value=_PREFLIGHT_OK), ), patch( diff --git a/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py b/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py index 98ccb7f5..51d2739f 100644 --- a/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py +++ b/packages/mcp/src/pipefy_mcp/tools/ai_agent_tools.py @@ -17,7 +17,6 @@ collect_ai_behavior_move_transition_problems, ) from pipefy_sdk.ai_pipe_validation import resolve_and_populate_field_refs -from pipefy_sdk.ai_preflight import validate_ai_agent_behaviors_sdk from pydantic import ValidationError from pipefy_mcp.tools.ai_tool_helpers import ( @@ -739,8 +738,7 @@ async def validate_ai_agent_behaviors( if not pid: return build_ai_tool_error("pipe_id must not be blank") - result = await validate_ai_agent_behaviors_sdk( - client, + result = await client.validate_ai_agent_behaviors( pid, behaviors, strict_unknown_action_types=strict_unknown_action_types, diff --git a/packages/mcp/src/pipefy_mcp/tools/ai_automation_tools.py b/packages/mcp/src/pipefy_mcp/tools/ai_automation_tools.py index 761f08d4..225b6d6f 100644 --- a/packages/mcp/src/pipefy_mcp/tools/ai_automation_tools.py +++ b/packages/mcp/src/pipefy_mcp/tools/ai_automation_tools.py @@ -10,10 +10,7 @@ PipefyId, UpdateAiAutomationInput, ) -from pipefy_sdk.ai_preflight import ( - filter_ai_automation_summaries, - validate_ai_automation_prompt_sdk, -) +from pipefy_sdk.ai_preflight import filter_ai_automation_summaries from pydantic import ValidationError from pipefy_mcp.core.tool_error_envelope import tool_error_message @@ -116,8 +113,7 @@ async def validate_ai_automation_prompt( } eid = eid_validated or None - result = await validate_ai_automation_prompt_sdk( - client, + result = await client.validate_ai_automation_prompt( pid, prompt, [str(f) for f in field_ids], diff --git a/packages/mcp/tests/tools/test_ai_agent_tools.py b/packages/mcp/tests/tools/test_ai_agent_tools.py index 6bdb4dca..fc85cb6a 100644 --- a/packages/mcp/tests/tools/test_ai_agent_tools.py +++ b/packages/mcp/tests/tools/test_ai_agent_tools.py @@ -3,6 +3,7 @@ import asyncio import copy from datetime import timedelta +from types import MethodType from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -16,7 +17,7 @@ make_field_id, make_pipe_id, ) -from pipefy_sdk import PipefyGraphQLError +from pipefy_sdk import PipefyClient, PipefyGraphQLError from pipefy_sdk.models.ai_agent import CreateAiAgentInput, UpdateAiAgentInput from pipefy_mcp.core.tool_error_envelope import tool_error_message @@ -42,6 +43,9 @@ def mock_pipefy_client(): client.get_pipe_members = AsyncMock(return_value={"pipe": {"members": []}}) client.get_phase_allowed_move_targets = AsyncMock() client.get_phase_fields = AsyncMock(return_value={"fields": []}) + client.validate_ai_agent_behaviors = MethodType( + PipefyClient.validate_ai_agent_behaviors, client + ) return client diff --git a/packages/mcp/tests/tools/test_ai_automation_tools.py b/packages/mcp/tests/tools/test_ai_automation_tools.py index 0f1e7767..3859bd4c 100644 --- a/packages/mcp/tests/tools/test_ai_automation_tools.py +++ b/packages/mcp/tests/tools/test_ai_automation_tools.py @@ -1,6 +1,7 @@ """Tests for AI Automation MCP tools.""" from datetime import timedelta +from types import MethodType from unittest.mock import AsyncMock, MagicMock import pytest @@ -29,6 +30,9 @@ def mock_pipefy_client(): client.get_pipe_with_preferences = AsyncMock() client.get_automation_events = AsyncMock() client.get_ai_credit_usage = AsyncMock() + client.validate_ai_automation_prompt = MethodType( + PipefyClient.validate_ai_automation_prompt, client + ) return client diff --git a/packages/sdk/src/pipefy_sdk/ai_phase_transition_validation.py b/packages/sdk/src/pipefy_sdk/ai_phase_transition_validation.py index 2c5a8eb9..d6cbfad2 100644 --- a/packages/sdk/src/pipefy_sdk/ai_phase_transition_validation.py +++ b/packages/sdk/src/pipefy_sdk/ai_phase_transition_validation.py @@ -3,17 +3,19 @@ from __future__ import annotations import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import ValidationError -from pipefy_sdk.client import PipefyClient from pipefy_sdk.models import BehaviorPayload from pipefy_sdk.transition_hints import ( TRANSITION_RULES_HINT, format_allowed_destinations_phrase, ) +if TYPE_CHECKING: + from pipefy_sdk.client import PipefyClient + logger = logging.getLogger(__name__) diff --git a/packages/sdk/src/pipefy_sdk/ai_preflight.py b/packages/sdk/src/pipefy_sdk/ai_preflight.py index 3c70b47b..3d0818f9 100644 --- a/packages/sdk/src/pipefy_sdk/ai_preflight.py +++ b/packages/sdk/src/pipefy_sdk/ai_preflight.py @@ -5,7 +5,7 @@ import asyncio import logging import re -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import ValidationError @@ -20,9 +20,11 @@ validate_behaviors_against_pipe, ) from pipefy_sdk.behavior_placeholders import expand_behaviors_placeholders -from pipefy_sdk.client import PipefyClient from pipefy_sdk.models import BehaviorInput +if TYPE_CHECKING: + from pipefy_sdk.client import PipefyClient + logger = logging.getLogger(__name__) _PROMPT_FIELD_TOKEN_RE = re.compile(r"%\{(\d+)\}") diff --git a/packages/sdk/src/pipefy_sdk/client.py b/packages/sdk/src/pipefy_sdk/client.py index e8c1b6de..eb8cef8f 100644 --- a/packages/sdk/src/pipefy_sdk/client.py +++ b/packages/sdk/src/pipefy_sdk/client.py @@ -10,6 +10,10 @@ from pipefy_sdk import __version__ from pipefy_sdk.ai_pipe_validation import resolve_and_populate_field_refs +from pipefy_sdk.ai_preflight import ( + validate_ai_agent_behaviors_sdk, + validate_ai_automation_prompt_sdk, +) from pipefy_sdk.automation_input import normalize_automation_input_keys from pipefy_sdk.automation_preflight import ( validate_automation_field_map_field_ids, @@ -1439,6 +1443,46 @@ async def update_ai_automation( """Update an existing AI Automation via the public ``updateAutomation``.""" return await self._automation_service.update_ai_automation(automation_input) + async def validate_ai_agent_behaviors( + self, + pipe_id: str, + behaviors: list[dict[str, Any]], + *, + strict_unknown_action_types: bool = True, + data_source_ids: list[str] | None = None, + ) -> dict[str, Any]: + """Dry-run AI Agent behaviors against a pipe's fields, phases, and relations (read-only). + + Call before :meth:`create_ai_agent` / :meth:`update_ai_agent`. Delegates to + :func:`pipefy_sdk.ai_preflight.validate_ai_agent_behaviors_sdk`; see it for the + checks and the ``{success, valid, problems, warnings, message}`` result. + """ + return await validate_ai_agent_behaviors_sdk( + self, + pipe_id, + behaviors, + strict_unknown_action_types=strict_unknown_action_types, + data_source_ids=data_source_ids, + ) + + async def validate_ai_automation_prompt( + self, + pipe_id: str, + prompt: str, + field_ids: list[str], + event_id: str | None = None, + ) -> dict[str, Any]: + """Pre-flight an AI Automation prompt, output fields, and trigger (read-only). + + Call before :meth:`create_ai_automation`. Delegates to + :func:`pipefy_sdk.ai_preflight.validate_ai_automation_prompt_sdk`; see it for the + checks and the ``{success, valid, problems, warnings, field_map}`` result. A failed + pipe read returns only ``{success, valid, error}``, with ``success`` false. + """ + return await validate_ai_automation_prompt_sdk( + self, pipe_id, prompt, field_ids, event_id + ) + async def get_pipe_members(self, pipe_id: str | int) -> dict: """Get the members of a pipe.""" return await self._pipe_service.get_pipe_members(pipe_id) diff --git a/packages/sdk/tests/test_ai_preflight_facade.py b/packages/sdk/tests/test_ai_preflight_facade.py new file mode 100644 index 00000000..16353acc --- /dev/null +++ b/packages/sdk/tests/test_ai_preflight_facade.py @@ -0,0 +1,145 @@ +"""Facade-level tests: ``PipefyClient`` exposes the read-only AI pre-flight validators.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from _shared.ai_agent_test_payloads import minimal_behavior_dict +from _shared.fixture_ids import EXAMPLE_PIPE_ID + +from pipefy_sdk import PipefyClient + +FIELD_ID = "900000001" + + +@pytest.fixture +def facade_client() -> PipefyClient: + client = PipefyClient.__new__(PipefyClient) + client.get_pipe = AsyncMock( + return_value={ + "pipe": { + "uuid": "pipe-uuid-1", + "phases": [], + "start_form_fields": [{"id": "slug", "internal_id": FIELD_ID}], + } + } + ) + client.get_pipe_relations = AsyncMock(return_value={"children": [], "parents": []}) + client.get_phase_fields = AsyncMock(return_value={"fields": []}) + client.get_ai_knowledge_bases = AsyncMock(return_value=[{"id": "kb-known"}]) + client.get_pipe_with_preferences = AsyncMock( + return_value={ + "pipe": { + "phases": [], + "start_form_fields": [ + {"internal_id": FIELD_ID, "label": "Input", "editable": True}, + {"internal_id": "900000002", "label": "Output", "editable": True}, + ], + "preferences": {"aiAgentsEnabled": True}, + } + } + ) + client.get_automation_events = AsyncMock(return_value=[{"id": "card_created"}]) + client.get_ai_credit_usage = AsyncMock( + return_value={"aiCreditUsageStats": {"active": True}} + ) + return client + + +@pytest.mark.anyio +async def test_validate_ai_agent_behaviors_runs_pipe_checks( + facade_client: PipefyClient, +): + behavior = minimal_behavior_dict(pipe_id=EXAMPLE_PIPE_ID, field_id=FIELD_ID) + + result = await facade_client.validate_ai_agent_behaviors( + EXAMPLE_PIPE_ID, [behavior], data_source_ids=["kb-missing"] + ) + + assert result["success"] is True + assert result["valid"] is True + assert any("kb-missing" in w for w in result["warnings"]) + facade_client.get_ai_knowledge_bases.assert_awaited_once_with("pipe-uuid-1") + + +@pytest.mark.anyio +async def test_validate_ai_agent_behaviors_reports_unknown_field( + facade_client: PipefyClient, +): + behavior = minimal_behavior_dict(pipe_id=EXAMPLE_PIPE_ID, field_id="999999999") + + result = await facade_client.validate_ai_agent_behaviors( + EXAMPLE_PIPE_ID, [behavior] + ) + + assert result["success"] is True + assert result["valid"] is False + assert any("999999999" in p for p in result["problems"]) + + +@pytest.mark.anyio +async def test_validate_ai_automation_prompt_runs_pipe_checks( + facade_client: PipefyClient, +): + result = await facade_client.validate_ai_automation_prompt( + "1", f"Summarize %{{{FIELD_ID}}}", ["900000002"], event_id="card_created" + ) + + assert result == { + "success": True, + "valid": True, + "problems": [], + "warnings": [], + "field_map": {FIELD_ID: "Input", "900000002": "Output"}, + } + facade_client.get_automation_events.assert_awaited_once_with("1") + + +@pytest.mark.anyio +async def test_validate_ai_automation_prompt_rejects_unknown_event( + facade_client: PipefyClient, +): + result = await facade_client.validate_ai_automation_prompt( + "1", f"Summarize %{{{FIELD_ID}}}", ["900000002"], event_id="card_moved" + ) + + assert result["valid"] is False + assert any("card_moved" in p for p in result["problems"]) + + +@pytest.mark.anyio +async def test_validate_ai_agent_behaviors_pipe_read_failure_reports_in_problems( + facade_client: PipefyClient, +): + facade_client.get_pipe.side_effect = RuntimeError("denied") + behavior = minimal_behavior_dict(pipe_id=EXAMPLE_PIPE_ID, field_id=FIELD_ID) + + result = await facade_client.validate_ai_agent_behaviors( + EXAMPLE_PIPE_ID, [behavior] + ) + + assert result == { + "success": False, + "valid": False, + "problems": [f"Failed to fetch pipe {EXAMPLE_PIPE_ID}: denied"], + "warnings": [], + "message": "Pipe fetch failed.", + } + + +@pytest.mark.anyio +async def test_validate_ai_automation_prompt_pipe_read_failure_returns_error_only( + facade_client: PipefyClient, +): + facade_client.get_pipe_with_preferences.side_effect = RuntimeError("denied") + + result = await facade_client.validate_ai_automation_prompt( + "1", f"Summarize %{{{FIELD_ID}}}", ["900000002"] + ) + + assert result == { + "success": False, + "valid": False, + "error": "Failed to fetch pipe 1: denied", + }