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

davidzollo pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 59ada4ec02 [Improve][Core] Support transform and value constraint 
option rules in CLI (#11109)
59ada4ec02 is described below

commit 59ada4ec02f821d97491507c20ab3575be4aa86b
Author: yzeng1618 <[email protected]>
AuthorDate: Fri Jun 19 18:25:52 2026 +0800

    [Improve][Core] Support transform and value constraint option rules in CLI 
(#11109)
    
    Co-authored-by: zengyi <[email protected]>
---
 seatunnel-cli/README.md                        |   9 +-
 seatunnel-cli/README.zh-CN.md                  |   9 +-
 seatunnel-cli/seatunnel_cli/agents.py          |  13 +-
 seatunnel-cli/seatunnel_cli/cli.py             |   3 +-
 seatunnel-cli/seatunnel_cli/connectors.py      |  99 +++++++-
 seatunnel-cli/seatunnel_cli/skills.py          |  32 ++-
 seatunnel-cli/tests/test_connector_metadata.py | 304 +++++++++++++++++++++++++
 7 files changed, 446 insertions(+), 23 deletions(-)

diff --git a/seatunnel-cli/README.md b/seatunnel-cli/README.md
index f268e3ff12..17f81c02af 100644
--- a/seatunnel-cli/README.md
+++ b/seatunnel-cli/README.md
@@ -10,6 +10,7 @@ Describe your data synchronization task in English or 
Chinese, and the CLI gener
 - **Multi-Provider LLM** -- AWS Bedrock, Anthropic API, OpenAI (and compatible 
APIs like Azure OpenAI)
 - **Multi-Agent Pipeline** -- Planner -> Generator -> Validator -> Auto-fix, 
up to 3 correction rounds
 - **100+ Connectors** -- Full coverage of SeaTunnel's connector ecosystem with 
runtime metadata reflection
+- **Transform Metadata** -- Source, sink, and transform plugins use full 
option rules and value constraints during generation
 - **Skill Framework** -- Three-layer generation: Skill SOP -> Golden Example 
-> Connector Metadata
 - **Auto-Save** -- Generated configs automatically saved to 
`.data/last_job.conf` (co-located with CLI)
 - **Auto-Fix** -- `/check` and `/run` failures trigger automatic LLM-powered 
diagnosis and config repair
@@ -241,7 +242,7 @@ Options:
 | `/save <path>` | Save config to custom path (auto-saved to 
`.data/last_job.conf` on generation) |
 | `/check` | Dry-run validate last config; auto-diagnoses and fixes on failure 
|
 | `/run` | Execute last config via REST API or `seatunnel.sh`; auto-diagnoses 
on failure |
-| `/connectors` | List all available sources, sinks, and transforms |
+| `/connectors` | List all available sources, sinks, and transforms; transform 
option rules and constraints are supported during generation |
 | `/sessions` | List recent conversation sessions |
 | `/resume [id]` | Resume a previous session |
 | `/new` | Start a fresh session |
@@ -371,7 +372,9 @@ User Input (natural language)
 Two-tier resolution with intelligent fallback:
 
 1. **Runtime API** -- Live metadata from running SeaTunnel engine 
(`/option-rules` endpoint). Always accurate, zero maintenance.
-2. **Bundled Metadata** -- `connector_metadata.json` ships with the CLI 
package. 150 connectors with full option rules, exported from SeaTunnel engine 
via reflection. Zero LLM token cost.
+2. **Bundled Metadata** -- `connector_metadata.json` ships with the CLI 
package. Source, sink, and transform plugins include full option rules and 
value constraints exported from SeaTunnel engine via reflection. Zero LLM token 
cost.
+
+Transform metadata is resolved through the same path as source and sink 
metadata, so prompts can include transform-specific required options and 
constraints such as non-blank SQL queries.
 
 ### Memory System
 
@@ -394,7 +397,7 @@ Memory is stored locally at `.data/memory.json` (co-located 
with the CLI package
 
 ## Connector Metadata
 
-The CLI ships with `connector_metadata.json` (150 connectors), exported from 
the SeaTunnel engine via runtime reflection. No extra steps needed.
+The CLI ships with `connector_metadata.json`, exported from the SeaTunnel 
engine via runtime reflection. It includes source, sink, and transform plugin 
option rules, conditional options, and value constraints. No extra steps needed.
 
 To re-export for a different SeaTunnel version (requires a running engine):
 
diff --git a/seatunnel-cli/README.zh-CN.md b/seatunnel-cli/README.zh-CN.md
index f59ea644ff..0b0f97f57b 100644
--- a/seatunnel-cli/README.zh-CN.md
+++ b/seatunnel-cli/README.zh-CN.md
@@ -10,6 +10,7 @@
 - **多 LLM 提供商** -- 支持 AWS Bedrock、Anthropic API、OpenAI(及兼容 API,如 Azure OpenAI)
 - **多智能体流水线** -- 规划器 -> 生成器 -> 校验器 -> 自动修复,最多 3 轮纠错
 - **100+ 连接器** -- 全面覆盖 SeaTunnel 连接器生态,支持运行时元数据反射
+- **Transform 元数据** -- Source、Sink 和 Transform 插件在生成配置时都支持完整选项规则和值约束
 - **技能框架** -- 三层生成:技能 SOP -> 黄金示例 -> 连接器元数据
 - **自动保存** -- 生成的配置自动保存到 `.data/last_job.conf`(与 CLI 同目录)
 - **自动修复** -- `/check` 和 `/run` 失败时自动触发 LLM 诊断和配置修复
@@ -241,7 +242,7 @@ seatunnel [request] [options]
 | `/save <path>` | 将配置保存到自定义路径(生成时自动保存到 `.data/last_job.conf`) |
 | `/check` | 试运行校验最近的配置;失败时自动诊断并修复 |
 | `/run` | 通过 REST API 或 `seatunnel.sh` 执行最近的配置;失败时自动诊断 |
-| `/connectors` | 列出所有可用的 Source、Sink 和 Transform |
+| `/connectors` | 列出所有可用的 Source、Sink 和 Transform;生成配置时支持 Transform 选项规则和值约束 |
 | `/sessions` | 列出最近的对话会话 |
 | `/resume [id]` | 恢复之前的会话 |
 | `/new` | 开始新会话 |
@@ -371,7 +372,9 @@ seatunnel [request] [options]
 两级解析,智能回退:
 
 1. **运行时 API** -- 从运行中的 SeaTunnel 引擎获取实时元数据(`/option-rules` 端点)。始终准确,零维护成本。
-2. **内置元数据** -- `connector_metadata.json` 随 CLI 包分发。包含 150 个连接器的完整选项规则,通过 
SeaTunnel 引擎反射导出。零 LLM Token 消耗。
+2. **内置元数据** -- `connector_metadata.json` 随 CLI 包分发。Source、Sink 和 Transform 
插件都包含通过 SeaTunnel 引擎反射导出的完整选项规则和值约束。零 LLM Token 消耗。
+
+Transform 元数据与 Source、Sink 元数据走同一条解析路径,因此提示词可以包含 Transform 专属必填项和值约束,例如 SQL 
查询不能为空。
 
 ### 记忆系统
 
@@ -394,7 +397,7 @@ CLI 跨会话记忆信息,以提高配置准确性:
 
 ## 连接器元数据
 
-CLI 内置 `connector_metadata.json`(150 个连接器),通过运行时反射从 SeaTunnel 引擎导出,无需额外步骤。
+CLI 内置 `connector_metadata.json`,通过运行时反射从 SeaTunnel 引擎导出。它包含 Source、Sink 和 
Transform 插件的选项规则、条件选项和值约束,无需额外步骤。
 
 如需为不同 SeaTunnel 版本重新导出(需要运行中的引擎):
 
diff --git a/seatunnel-cli/seatunnel_cli/agents.py 
b/seatunnel-cli/seatunnel_cli/agents.py
index 833f2d0f22..89c5010a4c 100644
--- a/seatunnel-cli/seatunnel_cli/agents.py
+++ b/seatunnel-cli/seatunnel_cli/agents.py
@@ -68,8 +68,10 @@ TOOLS = [
         "toolSpec": {
             "name": "get_connector_info",
             "description": "Get detailed info about a specific connector 
including parameters and examples. "
-                           "IMPORTANT: Always specify connector_type ('source' 
or 'sink') to get the correct "
-                           "type-specific options. Source and sink connectors 
have different required/optional parameters.",
+                           "IMPORTANT: Always specify connector_type "
+                           "('source', 'sink', or 'transform') to get the 
correct "
+                           "type-specific options. Source, sink, and transform 
plugins can "
+                           "have different required/optional parameters.",
             "inputSchema": {
                 "json": {
                     "type": "object",
@@ -80,9 +82,10 @@ TOOLS = [
                         },
                         "connector_type": {
                             "type": "string",
-                            "enum": ["source", "sink"],
-                            "description": "Whether this connector is used as 
'source' or 'sink'. "
-                                           "Source and sink have different 
options — always specify this.",
+                            "enum": ["source", "sink", "transform"],
+                            "description": "Whether this plugin is used as 
'source', 'sink', "
+                                           "or 'transform'. These plugin types 
have different "
+                                           "options — always specify this.",
                         },
                     },
                     "required": ["connector_name"],
diff --git a/seatunnel-cli/seatunnel_cli/cli.py 
b/seatunnel-cli/seatunnel_cli/cli.py
index e357d99783..6be82c7f70 100644
--- a/seatunnel-cli/seatunnel_cli/cli.py
+++ b/seatunnel-cli/seatunnel_cli/cli.py
@@ -70,7 +70,7 @@ Generate Apache SeaTunnel configs with natural language.
   [bold]/save <path>[/bold]     — Save config to custom path (auto-saved to 
.data/last_job.conf)
   [bold]/check[/bold]           — Dry-run validate last config (auto-fixes on 
failure)
   [bold]/run[/bold]             — Execute last config with SeaTunnel
-  [bold]/connectors[/bold]      — List available connectors
+  [bold]/connectors[/bold]      — List available sources, sinks, and transforms
   [bold]/config[/bold]          — Show/change LLM provider settings
   [bold]/sessions[/bold]        — List recent sessions
   [bold]/resume [id][/bold]     — Resume a previous session
@@ -1066,6 +1066,7 @@ class SeaTunnelCLI:
             self.console.print(f"  [bold]Sources:[/bold] {', 
'.join(names['sources'])}")
             self.console.print(f"  [bold]Sinks:[/bold]   {', 
'.join(names['sinks'])}")
             self.console.print(f"  [bold]Transforms:[/bold] {', 
'.join(names['transforms'])}")
+            self.console.print("  Transform option rules and value constraints 
are supported during generation.")
 
         elif command == "/config":
             self._cmd_config(arg.strip())
diff --git a/seatunnel-cli/seatunnel_cli/connectors.py 
b/seatunnel-cli/seatunnel_cli/connectors.py
index 614df5d91c..1fa2e73769 100644
--- a/seatunnel-cli/seatunnel_cli/connectors.py
+++ b/seatunnel-cli/seatunnel_cli/connectors.py
@@ -180,6 +180,11 @@ def _api_response_to_detail(resp: dict) -> dict:
                 "then_options": nested_opts,
             })
 
+    value_constraints = [
+        _value_constraint_to_dict(constraint)
+        for constraint in rule.get("valueConstraints", [])
+    ]
+
     return {
         "name": resp.get("pluginName", ""),
         "types": [resp.get("pluginType", "unknown")],
@@ -187,6 +192,7 @@ def _api_response_to_detail(resp: dict) -> dict:
         "optional": optional,
         "exclusive": [],
         "conditional": conditional,
+        "value_constraints": value_constraints,
         "examples": [],
         "source": "runtime_api",
     }
@@ -242,6 +248,43 @@ def _api_opt_to_dict(opt: dict, rule_type: str = "") -> 
dict:
     return result
 
 
+def _value_constraint_to_dict(constraint: dict) -> dict:
+    """Convert one value constraint to a compact prompt-friendly form."""
+    result = {"expression": constraint.get("expression", "")}
+    tree = constraint.get("conditionTree") or {}
+    if not isinstance(tree, dict):
+        return result
+
+    option = tree.get("option") or {}
+    key = tree.get("key") or option.get("key")
+    if key:
+        result["key"] = key
+
+    expect_value = tree.get("expectValue")
+    if expect_value is not None:
+        result["expect"] = _constraint_value_to_text(expect_value)
+
+    operator = tree.get("compareOperator")
+    if operator:
+        result["operator"] = str(operator)
+
+    condition_operator = tree.get("conditionOperator")
+    if condition_operator:
+        result["condition_operator"] = str(condition_operator)
+
+    return result
+
+
+def _constraint_value_to_text(value) -> str:
+    """Return a stable, prompt-friendly string for a constraint expected 
value."""
+    if isinstance(value, str):
+        return value
+    try:
+        return json.dumps(value, ensure_ascii=False)
+    except TypeError:
+        return str(value)
+
+
 # ─── Runtime JSON metadata (from Java exporter) ───
 
 _RUNTIME_METADATA: dict | None = None  # Parsed connector_metadata.json
@@ -371,6 +414,11 @@ def _runtime_metadata_to_detail(entry: dict) -> dict:
                         "then_options": [_runtime_opt_to_dict(opt)],
                     })
 
+    value_constraints = [
+        _value_constraint_to_dict(constraint)
+        for constraint in entry.get("valueConstraints", [])
+    ]
+
     detail = {
         "name": entry.get("name", ""),
         "types": [entry.get("type", "unknown")],
@@ -378,6 +426,7 @@ def _runtime_metadata_to_detail(entry: dict) -> dict:
         "optional": optional,
         "exclusive": [],
         "conditional": conditional,
+        "value_constraints": value_constraints,
         "examples": [],
         "source": "runtime_json",
     }
@@ -585,6 +634,11 @@ def format_metadata_for_prompt(metadata: dict, 
plugin_name: str, plugin_type: st
             opt_list = ", ".join(f"`{o['key']}`" for o in unique_opts)
             lines.append(f"  - When {cond_label} → {opt_list}")
 
+    if metadata.get("value_constraints"):
+        lines.append("**Value Constraints (must satisfy):**")
+        for constraint in metadata["value_constraints"]:
+            lines.append(_format_value_constraint(constraint, prefix="  - "))
+
     # 3. Optional: show important ones with type, rest as key list
     important_keys = {"username", "password", "query", "table_path", 
"table_list",
                       "user", "bucket", "path", "topic", "database", "table",
@@ -609,6 +663,29 @@ def format_metadata_for_prompt(metadata: dict, 
plugin_name: str, plugin_type: st
     return "\n".join(lines)
 
 
+def _format_value_constraint(constraint: dict, prefix: str = "- ") -> str:
+    """Format one value constraint without attempting to evaluate it 
locally."""
+    key = constraint.get("key")
+    operator = constraint.get("operator")
+    expect = constraint.get("expect")
+    expression = constraint.get("expression", "")
+
+    if key and operator and expect and str(operator).lower() == "extension":
+        line = f"{prefix}`{key}` {expect}"
+    elif key and operator and expect:
+        line = f"{prefix}`{key}` {operator} {expect}"
+    elif key and expect:
+        line = f"{prefix}`{key}` {expect}"
+    elif expression:
+        line = f"{prefix}{expression}"
+    else:
+        line = f"{prefix}<unspecified constraint>"
+
+    if expression and expression not in line.replace("`", ""):
+        line += f" ({expression})"
+    return line
+
+
 def _format_opt_detail(opt: dict) -> str:
     """Format one option with full detail for LLM prompt."""
     line = f"  - `{opt['key']}` ({opt.get('type', '?')})"
@@ -904,7 +981,10 @@ def get_connector_catalog() -> str:
 
     count = sum(len(types) for types in connectors.values())
     lines = [f"## Available Connectors ({len(connectors)} connectors, {count} 
endpoints) [engine: {engine_status}]\n"]
-    lines.append("Use `get_connector_info` tool with `connector_type` 
('source' or 'sink') to get type-specific details.")
+    lines.append(
+        "Use `get_connector_info` tool with `connector_type` "
+        "('source', 'sink', or 'transform') to get type-specific details."
+    )
     if engine_status == "connected":
         lines.append("(Details fetched live from running engine — always 
up-to-date)\n")
     elif runtime_meta:
@@ -940,8 +1020,8 @@ def get_connector_detail(name: str, connector_type: str | 
None = None) -> str |
 
     Args:
         name: Connector name (e.g., 'Jdbc', 'Kafka', 'S3File').
-        connector_type: 'source' or 'sink' for type-specific options.
-                        None returns both types with separate sections.
+        connector_type: 'source', 'sink', or 'transform' for type-specific 
options.
+                        None returns all available types with separate 
sections.
 
     Resolution order:
       1. Runtime API (live /option-rules endpoint — always accurate)
@@ -952,7 +1032,7 @@ def get_connector_detail(name: str, connector_type: str | 
None = None) -> str |
     Returns a formatted string with all options, defaults, conditions, and 
examples.
     """
     # ── 1. Try runtime API ──
-    query_types = [connector_type] if connector_type else ["source", "sink"]
+    query_types = [connector_type] if connector_type else ["source", "sink", 
"transform"]
     api_details: dict[str, dict] = {}
     for ptype in query_types:
         api_resp = _fetch_option_rules(ptype, name)
@@ -1044,6 +1124,11 @@ def _format_connector_detail_typed(
                 deps = ", ".join(cond["then_require"])
                 lines.append(f"- When `{cond['when']}` = `{cond['equals']}` → 
also require: {deps}")
 
+        if detail.get("value_constraints"):
+            lines.append(f"\n## {type_label} — Value Constraints")
+            for constraint in detail["value_constraints"]:
+                lines.append(_format_value_constraint(constraint))
+
         if len(show_types) > 1:
             lines.append("")  # separator between types
 
@@ -1126,6 +1211,7 @@ def list_connector_names() -> dict:
     runtime_meta = _load_runtime_metadata()
     sources = set()
     sinks = set()
+    transforms = set(TRANSFORMS.keys())
     if runtime_meta:
         for key in runtime_meta:
             parts = key.split(":", 1)
@@ -1135,8 +1221,9 @@ def list_connector_names() -> dict:
                     sources.add(name)
                 elif ctype == "sink":
                     sinks.add(name)
-    transforms = list(TRANSFORMS.keys())
-    return {"sources": sorted(sources), "sinks": sorted(sinks), "transforms": 
transforms}
+                elif ctype == "transform":
+                    transforms.add(name)
+    return {"sources": sorted(sources), "sinks": sorted(sinks), "transforms": 
sorted(transforms)}
 
 
 def validate_connector_options(
diff --git a/seatunnel-cli/seatunnel_cli/skills.py 
b/seatunnel-cli/seatunnel_cli/skills.py
index 60fcdf9d58..21442e7e92 100644
--- a/seatunnel-cli/seatunnel_cli/skills.py
+++ b/seatunnel-cli/seatunnel_cli/skills.py
@@ -273,6 +273,31 @@ def _collect_required_options(
     return result
 
 
+def _transform_connector_name(transform: object) -> str:
+    """Return the transform plugin name from a structured or string plan 
field."""
+    if not transform:
+        return ""
+    if isinstance(transform, dict):
+        for key in ("connector", "name", "type"):
+            value = transform.get(key)
+            if value:
+                return str(value)
+        return ""
+    return str(transform)
+
+
+def _metadata_targets_for_pipeline(pipeline: PipelineSlot) -> list[tuple[str, 
str]]:
+    """Return source, sink, and transform metadata targets for a pipeline."""
+    targets = [
+        (pipeline.source_connector, "source"),
+        (pipeline.sink_connector, "sink"),
+    ]
+    transform_name = _transform_connector_name(pipeline.transform)
+    if transform_name:
+        targets.append((transform_name, "transform"))
+    return [(name, ctype) for name, ctype in targets if name]
+
+
 def llm_check_missing_info(
     client,
     user_request: str,
@@ -516,10 +541,7 @@ class SkillExecutor:
         all_required: list[dict] = []
         seen_keys: set[str] = set()
         for p in self.plan.pipelines:
-            for connector, ctype in [
-                (p.source_connector, "source"),
-                (p.sink_connector, "sink"),
-            ]:
+            for connector, ctype in _metadata_targets_for_pipeline(p):
                 for opt in _collect_required_options(connector, ctype):
                     if opt["key"] not in seen_keys:
                         seen_keys.add(opt["key"])
@@ -539,7 +561,7 @@ class SkillExecutor:
         seen: set[tuple[str, str]] = set()
         sections: list[str] = []
         for p in self.plan.pipelines:
-            for name, ctype in [(p.source_connector, "source"), 
(p.sink_connector, "sink")]:
+            for name, ctype in _metadata_targets_for_pipeline(p):
                 if (name, ctype) in seen:
                     continue
                 seen.add((name, ctype))
diff --git a/seatunnel-cli/tests/test_connector_metadata.py 
b/seatunnel-cli/tests/test_connector_metadata.py
new file mode 100644
index 0000000000..0be5be8405
--- /dev/null
+++ b/seatunnel-cli/tests/test_connector_metadata.py
@@ -0,0 +1,304 @@
+#
+# 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.
+#
+
+import unittest
+from unittest.mock import patch
+
+from seatunnel_cli import connectors
+from seatunnel_cli.skills import PipelineSlot, SkillExecutor, StructuredPlan
+
+
+def _runtime_transform_entry():
+    return {
+        "name": "Sql",
+        "type": "transform",
+        "required": [
+            {
+                "key": "query",
+                "type": "string",
+                "defaultValue": None,
+                "description": "SQL query",
+                "category": "absolutely_required",
+            }
+        ],
+        "optional": [],
+        "conditionRules": [],
+        "valueConstraints": [
+            {
+                "expression": "query must not be blank",
+                "conditionTree": {
+                    "key": "query",
+                    "expectValue": "must not be blank",
+                    "compareOperator": "extension",
+                    "conditionOperator": "EXTENSION",
+                    "conditionOperatorCategory": "EXTENSION",
+                },
+            }
+        ],
+    }
+
+
+def _api_transform_response():
+    return {
+        "pluginName": "Sql",
+        "pluginType": "transform",
+        "optionRule": {
+            "requiredOptions": [
+                {
+                    "ruleType": "absolutely_required",
+                    "options": [
+                        {
+                            "key": "query",
+                            "type": "java.lang.String",
+                            "defaultValue": None,
+                            "description": "SQL query",
+                        }
+                    ],
+                }
+            ],
+            "optionalOptions": [],
+            "conditionRules": [],
+            "valueConstraints": [
+                {
+                    "expression": "query must not be blank",
+                    "conditionTree": {
+                        "option": {"key": "query"},
+                        "expectValue": "must not be blank",
+                        "compareOperator": "extension",
+                        "conditionOperator": "EXTENSION",
+                        "conditionOperatorCategory": "EXTENSION",
+                    },
+                }
+            ],
+        },
+    }
+
+
+class ConnectorMetadataSyncTest(unittest.TestCase):
+    def test_api_response_preserves_value_constraints(self):
+        detail = connectors._api_response_to_detail(_api_transform_response())
+
+        self.assertEqual(
+            detail["value_constraints"],
+            [
+                {
+                    "expression": "query must not be blank",
+                    "key": "query",
+                    "expect": "must not be blank",
+                    "operator": "extension",
+                    "condition_operator": "EXTENSION",
+                }
+            ],
+        )
+
+    def test_runtime_metadata_preserves_value_constraints(self):
+        detail = 
connectors._runtime_metadata_to_detail(_runtime_transform_entry())
+
+        self.assertEqual(detail["value_constraints"][0]["key"], "query")
+        self.assertEqual(detail["value_constraints"][0]["operator"], 
"extension")
+        self.assertEqual(detail["value_constraints"][0]["expect"], "must not 
be blank")
+
+    def test_prompt_includes_value_constraints(self):
+        detail = 
connectors._runtime_metadata_to_detail(_runtime_transform_entry())
+
+        prompt = connectors.format_metadata_for_prompt(detail, "Sql", 
"transform")
+
+        self.assertIn("Value Constraints", prompt)
+        self.assertIn("`query` must not be blank", prompt)
+        self.assertNotIn("`query` extension must not be blank", prompt)
+
+    def 
test_value_constraint_tolerates_missing_or_malformed_condition_tree(self):
+        constraints = [
+            {"expression": "query must not be blank"},
+            {"expression": "query must not be blank", "conditionTree": None},
+            {
+                "expression": "query must not be blank",
+                "conditionTree": ["not", "a", "dict"],
+            },
+        ]
+
+        for constraint in constraints:
+            with self.subTest(constraint=constraint):
+                self.assertEqual(
+                    connectors._value_constraint_to_dict(constraint),
+                    {"expression": "query must not be blank"},
+                )
+
+    def test_value_constraint_formats_complex_expect_value_as_json(self):
+        constraint = connectors._value_constraint_to_dict(
+            {
+                "expression": "mode should be in the allowed list",
+                "conditionTree": {
+                    "key": "mode",
+                    "expectValue": ["batch", "streaming"],
+                    "compareOperator": "in",
+                },
+            }
+        )
+
+        self.assertEqual(
+            connectors._format_value_constraint(constraint),
+            '- `mode` in ["batch", "streaming"] (mode should be in the allowed 
list)',
+        )
+
+    def 
test_connector_detail_can_fetch_transform_metadata_from_runtime_api(self):
+        calls = []
+
+        def fake_fetch(plugin_type, plugin_name):
+            calls.append((plugin_type, plugin_name))
+            if plugin_type == "transform" and plugin_name == "Sql":
+                return _api_transform_response()
+            return None
+
+        with patch.object(connectors, "_fetch_option_rules", 
side_effect=fake_fetch):
+            with patch.object(connectors, "_load_runtime_metadata", 
return_value={}):
+                detail = connectors.get_connector_detail(
+                    "Sql", connector_type="transform"
+                )
+
+        self.assertEqual(calls, [("transform", "Sql")])
+        self.assertIn("[source: runtime API]", detail)
+        self.assertIn("Types: transform", detail)
+        self.assertIn("Value Constraints", detail)
+
+    def 
test_connector_detail_queries_transform_when_type_is_not_specified(self):
+        calls = []
+
+        def fake_fetch(plugin_type, plugin_name):
+            calls.append((plugin_type, plugin_name))
+            if plugin_type == "transform" and plugin_name == "Sql":
+                return _api_transform_response()
+            return None
+
+        with patch.object(connectors, "_fetch_option_rules", 
side_effect=fake_fetch):
+            detail = connectors.get_connector_detail("Sql")
+
+        self.assertEqual(
+            calls,
+            [("source", "Sql"), ("sink", "Sql"), ("transform", "Sql")],
+        )
+        self.assertIn("Types: transform", detail)
+
+    def test_list_connector_names_includes_runtime_transforms(self):
+        runtime_meta = {
+            "source:FakeSource": {"name": "FakeSource", "type": "source"},
+            "sink:Console": {"name": "Console", "type": "sink"},
+            "transform:DataValidator": {"name": "DataValidator", "type": 
"transform"},
+        }
+
+        with patch.object(
+            connectors, "_load_runtime_metadata", return_value=runtime_meta
+        ):
+            names = connectors.list_connector_names()
+
+        self.assertIn("DataValidator", names["transforms"])
+        self.assertIn("Sql", names["transforms"])
+
+    def test_skill_executor_fetches_transform_metadata_from_plan(self):
+        plan = StructuredPlan(
+            pipelines=[
+                PipelineSlot(
+                    "pipeline_1",
+                    source_connector="FakeSource",
+                    sink_connector="Console",
+                    transform="Sql",
+                )
+            ]
+        )
+        executor = SkillExecutor(plan, skills=[])
+        calls = []
+
+        def fake_fetch(name, connector_type):
+            calls.append((name, connector_type))
+            return {
+                "name": name,
+                "required": [],
+                "optional": [],
+                "conditional": [],
+                "source": "test",
+            }
+
+        with patch(
+            "seatunnel_cli.connectors.fetch_connector_metadata", 
side_effect=fake_fetch
+        ):
+            metadata = executor.fetch_all_metadata(lambda *_args: None)
+
+        self.assertEqual(
+            calls,
+            [
+                ("FakeSource", "source"),
+                ("Console", "sink"),
+                ("Sql", "transform"),
+            ],
+        )
+        self.assertIn("### Sql (TRANSFORM)", metadata)
+
+    def 
test_skill_executor_fill_and_check_includes_transform_required_options(self):
+        plan = StructuredPlan(
+            pipelines=[
+                PipelineSlot(
+                    "pipeline_1",
+                    source_connector="FakeSource",
+                    sink_connector="Console",
+                    transform={"connector": "Sql"},
+                )
+            ]
+        )
+        executor = SkillExecutor(plan, skills=[])
+        collect_calls = []
+
+        def fake_collect(connector, connector_type):
+            collect_calls.append((connector, connector_type))
+            if connector_type == "transform":
+                return [
+                    {
+                        "key": "query",
+                        "type": "string",
+                        "description": "SQL query",
+                        "connector": connector,
+                        "connector_type": connector_type,
+                    }
+                ]
+            return []
+
+        with patch(
+            "seatunnel_cli.skills._collect_required_options", 
side_effect=fake_collect
+        ):
+            with patch(
+                "seatunnel_cli.skills.llm_check_missing_info",
+                return_value=["Please provide query"],
+            ) as check_missing:
+                missing = executor.fill_and_check(
+                    "Filter rows with a SQL transform", client=object()
+                )
+
+        self.assertEqual(
+            collect_calls,
+            [
+                ("FakeSource", "source"),
+                ("Console", "sink"),
+                ("Sql", "transform"),
+            ],
+        )
+        self.assertEqual(missing, ["Please provide query"])
+        required_options = check_missing.call_args[0][2]
+        self.assertEqual(required_options[0]["key"], "query")
+        self.assertEqual(required_options[0]["connector_type"], "transform")
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to