Files
kamidso-back/tests/kamidso/test_native_connector.py
T
javadandCursor edbaa2acec Add native Didar connectors so chats can query CRM data reliably.
Connector calls, semantic rules, and DuckDB memory keep long runs stable, and stream retry stops chats from stalling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 19:26:47 +03:30

483 lines
16 KiB
Python

"""Native Didar connector: catalog, validation, HTTP boundary, and handoff."""
from __future__ import annotations
import json
import os
import subprocess
import sys
import httpx
import pytest
from kamidso.contracts.chat import KamidsoChatRequest
from kamidso.engine.app.openapi.api_v1.tools.code_interpreter import make_code_interpreter
from kamidso.engine.app.openapi.api_v1.tools.connector_call import make_connector_call
from kamidso.engine.core.agent.resource.connector.credential import CredentialStore
from kamidso.integrations.engine.engine import connector_selection
from kamidso.native_connectors.errors import CatalogError
from kamidso.native_connectors.executor import NativeConnectorExecutor
from kamidso.native_connectors.prompt import render_native_prompt
from kamidso.native_connectors.registry import (
NativeConnectorRegistry,
get_registry,
reset_registry,
)
from kamidso.native_connectors.resolve import partition_connector_ids
from kamidso.native_connectors.result_store import materialize_connector_result
def _enable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.setenv("KAMIDSO_DIDAR_CONNECTOR", "1")
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
reset_registry()
def _provider():
return get_registry().get("didar")
def test_didar_catalog_is_fixed_and_read_only(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
provider = _provider()
names = [item.name for item in provider.functions]
assert len(names) == 49
assert len(set(names)) == 49
assert provider.base_url == "https://app.didar.me"
for function in provider.functions:
assert function.path.startswith("/")
assert "://" not in function.path
assert function.method in {"GET", "POST", "PUT", "PATCH", "DELETE"}
if function.enabled:
assert function.effect == "read"
deals = provider.function("search_deals")
assert deals is not None
assert deals.input_schema["required"] == ["Criteria", "From", "Limit"]
assert deals.records_path == "Response.List"
def test_invalid_catalog_fails_fast_outside_production(tmp_path, monkeypatch):
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.delenv("KAMIDSO_ENV", raising=False)
_write_bad_provider(tmp_path)
with pytest.raises(CatalogError):
NativeConnectorRegistry.load(tmp_path)
def test_invalid_catalog_is_disabled_in_production(tmp_path, monkeypatch):
monkeypatch.setenv("KAMIDSO_NATIVE_CONNECTORS_V1", "1")
monkeypatch.setenv("KAMIDSO_ENV", "production")
_write_bad_provider(tmp_path)
registry = NativeConnectorRegistry.load(tmp_path)
assert registry.provider_ids() == []
def _write_bad_provider(root):
directory = root / "bad"
directory.mkdir()
(directory / "manifest.json").write_text(
json.dumps(
{
"id": "bad",
"version": "0",
"display_name": "Bad",
"base_url": "https://example.com",
"authentication": {
"type": "api_key_query",
"parameter_name": "apikey",
"credential_field": "api_key",
},
"mode": "read_only",
"health_check": {"function": "ping", "body": {}},
}
),
encoding="utf-8",
)
(directory / "functions.json").write_text(
json.dumps(
[
{
"name": "ping",
"description": "ping",
"entity": "ping",
"effect": "read",
"enabled": True,
"method": "GET",
"path": "/ping",
"input_schema": {"type": "object"},
},
{
"name": "ping",
"description": "duplicate",
"entity": "ping",
"effect": "read",
"enabled": True,
"method": "GET",
"path": "/ping",
"input_schema": {"type": "object"},
},
]
),
encoding="utf-8",
)
def _row(store: CredentialStore, secret: str = "super-secret-key") -> dict:
salt = store.generate_salt()
return {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
"status": "active",
"encrypted_credentials": store.encrypt({"api_key": secret}, salt),
"encryption_salt": salt,
"user_name": "usr_test",
}
def _executor(handler, row, store: CredentialStore):
transport = httpx.MockTransport(handler)
client = httpx.AsyncClient(transport=transport)
executor = NativeConnectorExecutor(
client=client,
credential_store=store,
row_loader=lambda connector_id: row if connector_id == row["connector_id"] else None,
)
return executor, client
def _state():
return {
"conv_id": "conv-native",
"user_name": "usr_test",
"native_connectors": {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
},
"didar-b": {
"connector_id": "didar-b",
"connector_type": "didar",
"display_name": "دیدار شیراز",
},
},
}
@pytest.mark.asyncio
async def test_search_deals_validation_and_one_http_call(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr(
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
)
calls = {"n": 0}
secret = "super-secret-key"
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
assert request.url.host == "app.didar.me"
assert request.url.path == "/api/deal/search_v2"
assert request.url.params["apikey"] == secret
assert secret not in (request.content.decode() or "")
return httpx.Response(
200,
json={
"Response": {
"TotalCount": 651,
"TotalPrice": 10,
"WonDealsCount": 651,
"LostDealsCount": 0,
"PendingDealsCount": 0,
"List": [{"Title": "Deal"}],
}
},
)
store = CredentialStore()
row = _row(store, secret)
executor, client = _executor(handler, row, store)
try:
missing = await executor.execute(
connector_id="didar-a",
function_name="search_deals",
body={"From": 0, "Limit": 100},
react_state={"conv_id": "conv-native"},
display_name="دیدار فروش",
)
assert missing["error_type"] == "validation_error"
assert missing["http_sent"] is False
assert calls["n"] == 0
nested = await executor.execute(
connector_id="didar-a",
function_name="search_deals",
body={"Criteria": {"From": 0, "Limit": 50}},
react_state={"conv_id": "conv-native"},
display_name="دیدار فروش",
)
assert nested["http_sent"] is False
assert any(issue["message"].startswith("Unexpected fields") for issue in nested["issues"])
assert calls["n"] == 0
unknown = None
try:
await executor.execute(
connector_id="didar-a",
function_name="get_leads",
body={},
react_state={"conv_id": "conv-native"},
)
except Exception as exc: # noqa: BLE001
unknown = exc
assert unknown is not None
payload = unknown.to_payload()
assert payload["error_type"] == "unknown_function"
assert payload["http_sent"] is False
assert "search_persons" in payload["suggestions"]
assert "search_deals" in payload["suggestions"]
assert calls["n"] == 0
try:
await executor.execute(
connector_id="didar-a",
function_name="create_deal",
body={"Deal": {}},
react_state={"conv_id": "conv-native"},
)
except Exception as exc: # noqa: BLE001
blocked = exc
else:
blocked = None
assert blocked is not None
blocked_payload = blocked.to_payload()
assert blocked_payload["error_type"] == "operation_not_allowed"
assert blocked_payload["http_sent"] is False
assert calls["n"] == 0
ok = await executor.execute(
connector_id="didar-a",
function_name="search_deals",
body={"Criteria": {}, "From": 0, "Limit": 100},
react_state={"conv_id": "conv-native"},
display_name="دیدار فروش",
)
assert ok["ok"] is True
assert calls["n"] == 1
saved = tmp_path / "tmp" / "conv-native" / ok["relative_path"].replace("/", os.sep)
raw = json.loads(saved.read_text(encoding="utf-8"))
assert raw["Response"]["TotalCount"] == 651
assert secret not in saved.read_text(encoding="utf-8")
assert secret not in ok["observation"]
finally:
await client.aclose()
@pytest.mark.asyncio
async def test_duplicate_connector_call_does_not_repeat_http(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
_enable(monkeypatch)
monkeypatch.setattr(
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
)
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(200, json={"Response": {"List": [], "TotalCount": 0}})
store = CredentialStore()
row = _row(store)
executor, client = _executor(handler, row, store)
state = {
"conv_id": "conv-native",
"native_connectors": {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
},
}
tool = make_connector_call(state, executor=executor)
body = {"Criteria": {}, "From": 0, "Limit": 1}
try:
first = await tool(name="get_users", body={})
second = await tool(name="get_users", body={})
third = await tool(name="get_users", body={})
changed = await tool(name="search_deals", body=body)
finally:
await client.aclose()
assert calls["n"] == 2
assert "Reuse previous result" in first or "Reuse previous result" in second
assert "already executed" in third
assert "super-secret-key" not in first + second + third + changed
assert "api_key" not in json.dumps(state["native_connectors"])
@pytest.mark.asyncio
async def test_validation_failure_loop_stops_repeating_the_schema(
monkeypatch: pytest.MonkeyPatch,
):
_enable(monkeypatch)
calls = {"n": 0}
def handler(request: httpx.Request) -> httpx.Response:
calls["n"] += 1
return httpx.Response(200, json={})
store = CredentialStore()
executor, client = _executor(handler, _row(store), store)
state = {
"conv_id": "conv-native",
"native_connectors": {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
},
}
tool = make_connector_call(state, executor=executor)
try:
first = await tool(name="search_deals", body={"From": 0})
second = await tool(name="search_deals", body={"From": 0})
third = await tool(name="search_deals", body={"From": 0})
finally:
await client.aclose()
assert calls["n"] == 0
assert "expected_schema" in first
assert "expected_schema" in second
assert "failed repeatedly" in third
assert "expected_schema" not in third
@pytest.mark.asyncio
async def test_unattached_connector_is_rejected(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
state = _state()
del state["native_connectors"]["didar-b"]
tool = make_connector_call(state, executor=NativeConnectorExecutor())
text = await tool(name="get_users", connection="دیدار شیراز")
assert "connector_not_attached" in text
assert "super-secret" not in text
@pytest.mark.asyncio
async def test_several_connectors_require_a_connection(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
tool = make_connector_call(_state(), executor=NativeConnectorExecutor())
text = await tool(name="get_users")
assert "connector_ambiguous" in text
assert "دیدار فروش" in text
assert "دیدار شیراز" in text
def test_prompt_lists_names_without_schema_or_secrets(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
text = render_native_prompt(
[
{
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
]
)
assert "search_deals" in text
assert "get_leads" in text
assert "additionalProperties" not in text
assert "/api/" not in text
assert "api_key" not in text
assert "apikey" not in text
def test_partition_keeps_native_ids_away_from_mcp(monkeypatch: pytest.MonkeyPatch):
_enable(monkeypatch)
rows = {
"didar-a": {
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
"status": "active",
"user_name": "usr_test",
"api_key": "must-not-leak",
},
"mcp-1": {
"connector_id": "mcp-1",
"connector_type": "github",
"display_name": "GitHub",
"status": "active",
"user_name": "usr_test",
},
}
monkeypatch.setattr(
"kamidso.native_connectors.resolve.default_row_loader",
lambda connector_id: rows.get(connector_id),
)
mcp_ids, native = partition_connector_ids(["didar-a", "mcp-1", "missing"])
assert mcp_ids == ["mcp-1", "missing"]
assert native == [
{
"connector_id": "didar-a",
"connector_type": "didar",
"display_name": "دیدار فروش",
}
]
assert "api_key" not in json.dumps(native)
def test_result_file_survives_a_fresh_python_process(
monkeypatch: pytest.MonkeyPatch, tmp_path
):
monkeypatch.setattr(
"kamidso.engine.core.configs.model_config.PILOT_PATH", str(tmp_path)
)
meta = materialize_connector_result(
{"conv_id": "conv-native"},
{"Response": {"TotalCount": 651, "List": [{"Title": "Deal"}]}},
)
work = tmp_path / "tmp" / "conv-native"
env = os.environ.copy()
env["PLOT_DIR"] = str(work)
script = (
"import json, os\n"
"path = os.path.join(os.environ['PLOT_DIR'], 'connector_results', 'c_001.json')\n"
"print(json.load(open(path, encoding='utf-8'))['Response']['TotalCount'])\n"
)
completed = subprocess.run(
[sys.executable, "-c", script],
env=env,
capture_output=True,
text=True,
check=False,
)
assert completed.returncode == 0, completed.stderr
assert completed.stdout.strip() == "651"
assert meta["relative_path"] == "connector_results/c_001.json"
def test_code_interpreter_mentions_connector_results_when_attached():
attached = make_code_interpreter({}, sql_enabled=False, native_connector=True)
plain = make_code_interpreter({}, sql_enabled=False, native_connector=False)
assert "connector_results" in attached._tool.description
assert "requests/httpx" in attached._tool.description
assert "connector_results" not in plain._tool.description
def test_chat_resources_map_onto_connector_ids():
request = KamidsoChatRequest.from_body(
{
"message": "چند معامله داریم؟",
"resources": [
{"id": "didar-a", "type": "connector", "name": "دیدار فروش"},
{"id": "file-1", "type": "file", "name": "budget.xlsx"},
],
}
)
selected = connector_selection(request.resources)
assert selected == {
"connector_ids": ["didar-a"],
"connector_labels": ["دیدار فروش"],
}