Files
hkuds--deeptutor/tests/api/test_subagents_router.py
wehub-resource-sync e4dcfc49aa
Tests / Lint and Format (push) Waiting to run
Tests / Web Node Tests (push) Waiting to run
Tests / Import Check (Python 3.11) (push) Waiting to run
Tests / Import Check (Python 3.12) (push) Waiting to run
Tests / Import Check (Python 3.13) (push) Waiting to run
Tests / Import Check (Python 3.14) (push) Waiting to run
Tests / Python Tests (Python 3.11) (push) Blocked by required conditions
Tests / Python Tests (Python 3.12) (push) Blocked by required conditions
Tests / Python Tests (Python 3.13) (push) Blocked by required conditions
Tests / Python Tests (Python 3.14) (push) Blocked by required conditions
Tests / Test Summary (push) Blocked by required conditions
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

357 lines
13 KiB
Python

"""API tests for the subagents router (detect / connect / list / disconnect).
Built on a standalone app with a fake KB manager and a stubbed detector, so it
exercises the HTTP contract without spawning a real CLI or touching the data
tree (mirrors test_knowledge_router's isolation).
"""
from __future__ import annotations
import importlib
import json
from pathlib import Path
import pytest
try:
from fastapi import FastAPI
from fastapi.testclient import TestClient
except Exception: # pragma: no cover - optional dependency in lightweight envs
FastAPI = None
TestClient = None
pytestmark = pytest.mark.skipif(
FastAPI is None or TestClient is None, reason="fastapi not installed"
)
if FastAPI is not None and TestClient is not None:
subagents_module = importlib.import_module("deeptutor.api.routers.subagents")
else: # pragma: no cover
subagents_module = None
class _FakeKBManager:
def __init__(self) -> None:
self.kbs: dict[str, dict] = {}
def list_knowledge_bases(self) -> list[str]:
return sorted(self.kbs)
def get_metadata(self, name: str | None = None) -> dict:
return dict(self.kbs.get(name or "", {}))
def register_subagent_connection(
self, name, agent_kind, *, cwd="", partner_id="", description=""
):
if name in self.kbs:
raise ValueError(f"A knowledge base named '{name}' already exists.")
entry = {
"path": name,
"type": "subagent",
"agent_kind": agent_kind,
"cwd": cwd,
"partner_id": partner_id,
"description": description or f"Connected subagent: {name}",
}
self.kbs[name] = entry
return entry
def delete_knowledge_base(self, name, confirm=False):
self.kbs.pop(name, None)
return True
@pytest.fixture
def client(monkeypatch, tmp_path):
manager = _FakeKBManager()
monkeypatch.setattr(subagents_module, "current_kb_manager", lambda: manager)
monkeypatch.setattr(
subagents_module, "list_backend_kinds", lambda: ["claude_code", "codex", "partner"]
)
monkeypatch.setattr(subagents_module, "assert_path_allowed", lambda p: Path(p))
# Isolate settings persistence to a temp file — the PUT path otherwise
# writes the developer's real data/user/settings/subagent.json.
monkeypatch.setattr(
"deeptutor.services.subagent.config._settings_path",
lambda: tmp_path / "subagent.json",
)
async def fake_detect():
from deeptutor.services.subagent.types import DetectResult
return [
DetectResult("claude_code", "Claude Code", available=True, version="2.x"),
DetectResult("codex", "Codex", available=False, detail="not installed"),
]
monkeypatch.setattr(subagents_module, "detect_all", fake_detect)
app = FastAPI()
app.include_router(subagents_module.router, prefix="/api/v1/subagents")
# Settings PUT is admin-gated; bypass the auth dependency for the contract test.
app.dependency_overrides[subagents_module.require_admin] = lambda: None
return TestClient(app)
def test_detect_reports_backends(client):
res = client.get("/api/v1/subagents/detect")
assert res.status_code == 200
backends = {b["kind"]: b for b in res.json()["backends"]}
assert backends["claude_code"]["available"] is True
assert backends["codex"]["available"] is False
def test_connect_list_and_disconnect_roundtrip(client):
created = client.post(
"/api/v1/subagents/connections",
json={"name": "MyClaude", "agent_kind": "claude_code", "cwd": "/tmp"},
)
assert created.status_code == 200
assert created.json()["agent_kind"] == "claude_code"
listed = client.get("/api/v1/subagents/connections").json()["connections"]
assert len(listed) == 1
assert listed[0]["name"] == "MyClaude"
assert listed[0]["agent_kind"] == "claude_code"
assert listed[0]["cwd"] == "/tmp"
gone = client.delete("/api/v1/subagents/connections/MyClaude")
assert gone.status_code == 200
assert client.get("/api/v1/subagents/connections").json()["connections"] == []
def test_connect_rejects_unknown_kind(client):
res = client.post(
"/api/v1/subagents/connections",
json={"name": "X", "agent_kind": "bogus"},
)
assert res.status_code == 400
class _FakePartnerManagerForConnect:
def __init__(self, known: set[str]) -> None:
self._known = known
def partner_exists(self, pid: str) -> bool:
return pid in self._known
def _patch_partner_existence(monkeypatch, known: set[str]) -> None:
import deeptutor.services.partners as partners_pkg
monkeypatch.setattr(
partners_pkg, "get_partner_manager", lambda: _FakePartnerManagerForConnect(known)
)
def test_connect_partner_binds_partner_id(client, monkeypatch):
_patch_partner_existence(monkeypatch, {"paul"})
created = client.post(
"/api/v1/subagents/connections",
json={"name": "Paul", "agent_kind": "partner", "partner_id": "paul"},
)
assert created.status_code == 200
body = created.json()
assert body["agent_kind"] == "partner"
assert body["partner_id"] == "paul"
assert body["cwd"] == ""
listed = client.get("/api/v1/subagents/connections").json()["connections"]
assert listed[0]["agent_kind"] == "partner"
assert listed[0]["partner_id"] == "paul"
def test_list_visible_partners(client, monkeypatch):
monkeypatch.setattr(
subagents_module,
"visible_partner_cards",
lambda: [{"partner_id": "p1", "name": "P1", "emoji": "🤖"}],
)
res = client.get("/api/v1/subagents/partners")
assert res.status_code == 200
partners = res.json()["partners"]
assert partners == [{"partner_id": "p1", "name": "P1", "emoji": "🤖"}]
def test_connect_partner_denied_when_not_assigned(client, monkeypatch):
# A non-admin connecting an unassigned partner is rejected by the
# assignment guard before the connection is created.
from fastapi import HTTPException
_patch_partner_existence(monkeypatch, {"paul"})
def deny(_pid):
raise HTTPException(status_code=403, detail="Partner is not assigned to you")
monkeypatch.setattr(subagents_module, "assert_partner_allowed", deny)
res = client.post(
"/api/v1/subagents/connections",
json={"name": "Paul", "agent_kind": "partner", "partner_id": "paul"},
)
assert res.status_code == 403
# Nothing was connected.
assert client.get("/api/v1/subagents/connections").json()["connections"] == []
def test_connect_partner_requires_partner_id(client, monkeypatch):
_patch_partner_existence(monkeypatch, {"paul"})
res = client.post(
"/api/v1/subagents/connections",
json={"name": "Paul", "agent_kind": "partner"},
)
assert res.status_code == 400
def test_connect_partner_rejects_unknown_partner(client, monkeypatch):
_patch_partner_existence(monkeypatch, set())
res = client.post(
"/api/v1/subagents/connections",
json={"name": "Ghost", "agent_kind": "partner", "partner_id": "ghost"},
)
assert res.status_code == 400
def test_disconnect_unknown_is_404(client):
res = client.delete("/api/v1/subagents/connections/nope")
assert res.status_code == 404
def test_backend_options_endpoint_shape(client, monkeypatch):
from deeptutor.services.subagent import models as models_mod
from deeptutor.services.subagent.models import BackendOptions, ModelOption
async def fake_options():
return [
BackendOptions(
kind="codex",
display_name="Codex",
available=True,
version="0.x",
default_model="gpt-5.5",
models=[ModelOption("gpt-5.5", "GPT-5.5", "medium", ["low", "medium", "high"])],
efforts=["low", "medium", "high"],
allow_custom_model=True,
synced_at="2026-06-17T00:00:00Z",
)
]
# The route imports list_backend_options lazily from the models module.
monkeypatch.setattr(models_mod, "list_backend_options", fake_options)
res = client.get("/api/v1/subagents/backends/options")
assert res.status_code == 200
backends = res.json()["backends"]
assert backends[0]["kind"] == "codex"
assert backends[0]["default_model"] == "gpt-5.5"
assert backends[0]["models"][0]["efforts"] == ["low", "medium", "high"]
assert backends[0]["synced_at"] == "2026-06-17T00:00:00Z"
def test_message_connection_streams_and_persists(client, monkeypatch, tmp_path):
# Connect, then message the agent directly: the run streams as NDJSON and the
# backend session id is remembered (keyed by chat session + connection).
client.post(
"/api/v1/subagents/connections",
json={"name": "MyClaude", "agent_kind": "claude_code"},
)
from deeptutor.services.subagent import sessions as sess
from deeptutor.services.subagent.types import ConsultResult, SubagentEvent
monkeypatch.setattr(sess, "_path", lambda: tmp_path / "sessions.json")
class _FakeBackend:
kind = "claude_code"
async def consult(
self, message, *, on_event, cwd, session_id, config, images=None, partner_id=None
):
await on_event(SubagentEvent(kind="text", text="hi", meta={"merge_id": "txt:m:0"}))
return ConsultResult(final_text="hi", session_id="sess-9", success=True, event_count=1)
monkeypatch.setattr("deeptutor.services.subagent.get_backend", lambda kind: _FakeBackend())
res = client.post(
"/api/v1/subagents/connections/MyClaude/message",
json={"chat_session_id": "chatA", "message": "hello"},
)
assert res.status_code == 200
lines = [json.loads(line) for line in res.text.splitlines() if line.strip()]
# The user's own message heads the exchange.
assert lines[0] == {"channel": "user_question", "text": "hello"}
# The agent's event carries a sidebar-namespaced merge id.
assert any(
line.get("channel") == "text" and line.get("merge_id") == "side:txt:m:0" for line in lines
)
# The final line reports the session id, now persisted for the next turn.
assert lines[-1]["done"] is True and lines[-1]["session_id"] == "sess-9"
assert sess.get_session(sess.session_key("chatA", "MyClaude")) == "sess-9"
def test_message_connection_unknown_is_404(client):
res = client.post(
"/api/v1/subagents/connections/nope/message",
json={"chat_session_id": "x", "message": "hi"},
)
assert res.status_code == 404
def test_backend_sync_endpoint(client, monkeypatch):
from deeptutor.services.subagent import models as models_mod
from deeptutor.services.subagent.models import BackendOptions, ModelOption
async def fake_sync(kind):
return BackendOptions(
kind=kind,
display_name="Claude Code",
available=True,
models=[ModelOption("opus", "Opus 4.8 · 1M context", efforts=["high"])],
efforts=["high"],
allow_custom_model=True,
synced_at="2026-06-17T00:00:00Z",
)
monkeypatch.setattr(models_mod, "sync_backend_options", fake_sync)
res = client.post("/api/v1/subagents/backends/claude_code/sync")
assert res.status_code == 200
body = res.json()
assert body["kind"] == "claude_code"
assert body["models"][0]["slug"] == "opus"
assert body["synced_at"] == "2026-06-17T00:00:00Z"
# Unknown backend is rejected.
assert client.post("/api/v1/subagents/backends/bogus/sync").status_code == 400
def test_settings_put_merges_per_field_and_backend(client):
# Save one field for claude_code …
r1 = client.put(
"/api/v1/subagents/settings",
json={"backends": {"claude_code": {"model": "opus"}}},
)
assert r1.status_code == 200
# … then another field — the first must survive the merge.
r2 = client.put(
"/api/v1/subagents/settings",
json={"backends": {"claude_code": {"effort": "high"}}},
)
cc = r2.json()["backends"]["claude_code"]
assert cc["model"] == "opus" and cc["effort"] == "high"
# Saving the OTHER backend must not clobber claude_code.
r3 = client.put(
"/api/v1/subagents/settings",
json={"backends": {"codex": {"sandbox": "read-only"}}},
)
body = r3.json()
assert body["backends"]["claude_code"]["model"] == "opus"
assert body["backends"]["codex"]["sandbox"] == "read-only"
# Updating only the budget must leave the backends intact.
r4 = client.put("/api/v1/subagents/settings", json={"consult_budget": 7})
body = r4.json()
assert body["consult_budget"] == 7
assert body["backends"]["claude_code"]["model"] == "opus"