From 5c22ee7d2b9043aa8289bbbd2a2f83136d477b90 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Tue, 22 Sep 2026 21:43:04 -0300 Subject: [PATCH 1/3] feat(sdk): expose the AI pre-write validators as PipefyClient methods Add PipefyClient.validate_ai_agent_behaviors and PipefyClient.validate_ai_automation_prompt. Both delegate to the pipefy_sdk.ai_preflight functions and use 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 the new methods. Move the PipefyClient imports in ai_preflight and ai_phase_transition_validation under TYPE_CHECKING, so client.py can import ai_preflight without an import cycle. Closes #694 Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- CHANGELOG.md | 4 + docs/sdk/README.md | 9 ++ packages/cli/src/pipefy_cli/commands/agent.py | 10 +- .../src/pipefy_cli/commands/ai_automation.py | 16 +-- .../tests/test_cli_agent_automation_smoke.py | 29 ++++- .../cli/tests/test_cli_agent_lifecycle.py | 25 ++-- .../src/pipefy_mcp/tools/ai_agent_tools.py | 4 +- .../pipefy_mcp/tools/ai_automation_tools.py | 8 +- .../mcp/tests/tools/test_ai_agent_tools.py | 6 +- .../tests/tools/test_ai_automation_tools.py | 4 + .../ai_phase_transition_validation.py | 6 +- packages/sdk/src/pipefy_sdk/ai_preflight.py | 6 +- packages/sdk/src/pipefy_sdk/client.py | 43 +++++++ .../sdk/tests/test_ai_preflight_facade.py | 108 ++++++++++++++++++ 14 files changed, 231 insertions(+), 47 deletions(-) create mode 100644 packages/sdk/tests/test_ai_preflight_facade.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e1b1a3e3..2819fa945 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 d97e4cdb2..00ef7f6d6 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 e0e03f8fe..b1c8defd0 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 bd304c0c5..e819bf8a5 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 9df9dfe0c..5de33f9f5 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 3e4b7e46c..132ba8bd8 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 98ccb7f58..51d2739f6 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 761f08d44..225b6d6f8 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 6bdb4dca4..fc85cb6aa 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 0f1e7767e..3859bd4c5 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 2c5a8eb92..d6cbfad23 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 3c70b47bd..3d0818f9a 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 e8c1b6de4..5fd7a024b 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,45 @@ 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. + """ + 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 000000000..73ff5eaab --- /dev/null +++ b/packages/sdk/tests/test_ai_preflight_facade.py @@ -0,0 +1,108 @@ +"""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"]) From 401b3cb9caf9d257c517c9bff7b3466b4bd8cbb6 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:42:41 -0300 Subject: [PATCH 2/3] docs(sdk): name the pipe-read failure shape of validate_ai_automation_prompt Answers the review comment on client.py: the docstring listed only the success keys. Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- packages/sdk/src/pipefy_sdk/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/pipefy_sdk/client.py b/packages/sdk/src/pipefy_sdk/client.py index 5fd7a024b..eb8cef8ff 100644 --- a/packages/sdk/src/pipefy_sdk/client.py +++ b/packages/sdk/src/pipefy_sdk/client.py @@ -1476,7 +1476,8 @@ async def validate_ai_automation_prompt( 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. + 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 From d4aeab772faf7fbc87c635810a810c6b95f102e4 Mon Sep 17 00:00:00 2001 From: mocha06 <52426811+mocha06@users.noreply.github.com> Date: Tue, 22 Sep 2026 22:42:41 -0300 Subject: [PATCH 3/3] test(sdk): lock the pipe-read failure results of both validators Answers the review note that the facade tests did not cover the two failure dicts. Signed-off-by: mocha06 <52426811+mocha06@users.noreply.github.com> --- .../sdk/tests/test_ai_preflight_facade.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/sdk/tests/test_ai_preflight_facade.py b/packages/sdk/tests/test_ai_preflight_facade.py index 73ff5eaab..16353accb 100644 --- a/packages/sdk/tests/test_ai_preflight_facade.py +++ b/packages/sdk/tests/test_ai_preflight_facade.py @@ -106,3 +106,40 @@ async def test_validate_ai_automation_prompt_rejects_unknown_event( 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", + }