feat: implement channel runtime connectors

This commit is contained in:
2026-06-03 16:22:44 +08:00
parent ee972441f5
commit c3d84b904a
105 changed files with 15621 additions and 322 deletions

View File

@ -1,4 +1,5 @@
import json
import asyncio
from fastapi.testclient import TestClient
@ -46,6 +47,44 @@ def test_load_config_reads_current_instance_shape(tmp_path) -> None:
assert target["extra_headers"] == {"X-Test": "1"}
def test_config_loader_reads_channels(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"webhook-dev": {
"enabled": True,
"kind": "webhook",
"mode": "webhook",
"accountId": "local",
"displayName": "Webhook Dev",
"config": {
"responseTimeoutSeconds": 1800,
"dedupeRetentionHours": 48,
},
"secrets": {"ignored_for_status": "secret-value"},
}
},
}
),
encoding="utf-8",
)
config = load_config(config_path=config_path)
channel = config.channels["webhook-dev"]
assert channel.enabled is True
assert channel.kind == "webhook"
assert channel.mode == "webhook"
assert channel.account_id == "local"
assert channel.display_name == "Webhook Dev"
assert channel.config["response_timeout_seconds"] == 1800
assert channel.config["dedupe_retention_hours"] == 48
assert channel.secrets == {"ignored_for_status": "secret-value"}
def test_provider_resolution_ignores_custom_and_disabled_overrides(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
@ -163,6 +202,58 @@ def test_reload_agent_config_updates_booted_loop_config(tmp_path) -> None:
service.close()
def test_reload_agent_config_keeps_running_service_when_old_mcp_close_fails(tmp_path) -> None:
async def run_case() -> None:
workspace = tmp_path / "workspace"
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "old-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://old.example.com/v1"}},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
await service.start()
class FailingMCPManager:
async def close(self) -> None:
raise RuntimeError("Attempted to exit cancel scope in a different task than it was entered in")
loaded = service.create_loop().boot()
loaded.mcp_manager = FailingMCPManager()
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"workspace": str(workspace), "model": "new-model"}},
"providers": {"openai": {"apiKey": "sk-test", "apiBase": "https://new.example.com/v1"}},
}
),
encoding="utf-8",
)
loop = asyncio.get_running_loop()
unhandled: list[dict[str, object]] = []
previous_handler = loop.get_exception_handler()
loop.set_exception_handler(lambda _loop, context: unhandled.append(context))
try:
_reload_agent_config(service, config_path)
await asyncio.sleep(0)
target = service.create_loop().boot().config.resolve_provider_target()
assert service.is_running is True
assert target["model"] == "new-model"
assert target["api_base"] == "https://new.example.com/v1"
assert unhandled == []
finally:
loop.set_exception_handler(previous_handler)
await service.shutdown(force=True)
asyncio.run(run_case())
def test_agent_defaults_include_runtime_controls(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
@ -245,6 +336,67 @@ def test_agent_config_api_accepts_zero_temperature_and_iterations(tmp_path) -> N
service.close()
def test_channel_config_api_persists_and_masks_secrets(tmp_path) -> None:
config_path = tmp_path / "config.json"
config_path.write_text(
json.dumps(
{
"agents": {"defaults": {"model": "openai/gpt-5"}},
"channels": {
"telegram-main": {
"enabled": False,
"kind": "telegram",
"mode": "polling",
"accountId": "bot-main",
"displayName": "Telegram Main",
"secrets": {"botToken": "1234567890abcdef"},
"config": {"requireMentionInGroups": True},
}
},
}
),
encoding="utf-8",
)
service = AgentService(config_path=config_path)
app = create_app(service=service, manage_service_lifecycle=False)
with TestClient(app) as client:
before = client.get("/api/channels/telegram-main/config")
response = client.post(
"/api/channels/telegram-main/config",
json={
"enabled": True,
"kind": "telegram",
"mode": "polling",
"account_id": "bot-main",
"display_name": "Telegram Primary",
"secrets": {"botToken": ""},
"config": {
"requireMentionInGroups": False,
"allowFrom": ["1001", "1002"],
"maxMessageChars": 3000,
},
},
)
saved = json.loads(config_path.read_text(encoding="utf-8"))
channel = saved["channels"]["telegram-main"]
assert before.status_code == 200
assert before.json()["secrets"] == {"botToken": "1234••••cdef"}
assert response.status_code == 200
assert response.json()["ok"] is True
assert response.json()["restart_required"] is True
assert response.json()["channel"]["display_name"] == "Telegram Primary"
assert response.json()["channel"]["secrets"] == {"botToken": "1234••••cdef"}
assert channel["enabled"] is True
assert channel["displayName"] == "Telegram Primary"
assert channel["secrets"]["botToken"] == "1234567890abcdef"
assert channel["config"]["allowFrom"] == ["1001", "1002"]
assert load_config(config_path=config_path).channels["telegram-main"].enabled is True
service.close()
def test_openai_compatible_qwen_config_keeps_openai_provider() -> None:
bundle = make_provider_bundle(
model="qwen-plus",