chore: nothing special, subagent enhanced

This commit is contained in:
qixinbo
2026-03-28 08:58:02 +08:00
parent bdf67e9132
commit 00e5587e75
11 changed files with 517 additions and 22 deletions
+100
View File
@@ -0,0 +1,100 @@
import asyncio
import sys
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = BACKEND_ROOT.parent
NANOBOT_ROOT = REPO_ROOT / "nanobot"
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
if str(NANOBOT_ROOT) not in sys.path:
sys.path.insert(0, str(NANOBOT_ROOT))
import main
def test_nanobot_chat_syncs_project_id(monkeypatch) -> None:
calls: list[dict[str, object]] = []
process_kwargs: list[dict[str, object]] = []
def fake_update_alias_meta(**kwargs):
calls.append(kwargs)
return kwargs
async def fake_process_message(*args, **kwargs):
process_kwargs.append(kwargs)
return "ok"
monkeypatch.setattr(main.session_alias_store, "update_alias_meta", fake_update_alias_meta)
monkeypatch.setattr(main.nanobot_service, "process_message", fake_process_message)
monkeypatch.setattr(main.nanobot_service, "agent", None)
request = main.ChatRequest(message="hello", session_id="api:test-1", project_id=101)
response = asyncio.run(main.nanobot_chat(request))
assert response["response"] == "ok"
assert calls == [{"session_key": "api:test-1", "project_id": 101}]
assert process_kwargs and process_kwargs[0]["project_id"] == 101
def test_nanobot_chat_without_project_id_does_not_sync(monkeypatch) -> None:
calls: list[dict[str, object]] = []
process_kwargs: list[dict[str, object]] = []
def fake_update_alias_meta(**kwargs):
calls.append(kwargs)
return kwargs
async def fake_process_message(*args, **kwargs):
process_kwargs.append(kwargs)
return "ok"
monkeypatch.setattr(main.session_alias_store, "update_alias_meta", fake_update_alias_meta)
monkeypatch.setattr(main.nanobot_service, "process_message", fake_process_message)
monkeypatch.setattr(main.nanobot_service, "agent", None)
request = main.ChatRequest(message="hello", session_id="api:test-2")
response = asyncio.run(main.nanobot_chat(request))
assert response["response"] == "ok"
assert calls == []
assert process_kwargs and process_kwargs[0]["project_id"] is None
def test_nanobot_chat_stream_syncs_project_id(monkeypatch) -> None:
calls: list[dict[str, object]] = []
process_kwargs: list[dict[str, object]] = []
def fake_update_alias_meta(**kwargs):
calls.append(kwargs)
return kwargs
async def fake_process_message(*args, **kwargs):
process_kwargs.append(kwargs)
on_stream = kwargs.get("on_stream")
if on_stream:
await on_stream("stream-token")
return "stream-complete"
async def collect_stream_chunks(response) -> list[str]:
chunks: list[str] = []
async for chunk in response.body_iterator:
if isinstance(chunk, bytes):
chunks.append(chunk.decode("utf-8"))
else:
chunks.append(chunk)
return chunks
monkeypatch.setattr(main.session_alias_store, "update_alias_meta", fake_update_alias_meta)
monkeypatch.setattr(main.nanobot_service, "process_message", fake_process_message)
monkeypatch.setattr(main.nanobot_service, "agent", None)
request = main.ChatRequest(message="hello", session_id="api:test-3", project_id=202)
response = asyncio.run(main.nanobot_chat_stream(request))
chunks = asyncio.run(collect_stream_chunks(response))
content = "".join(chunks)
assert "stream-token" in content
assert "stream-complete" in content
assert calls == [{"session_key": "api:test-3", "project_id": 202}]
assert process_kwargs and process_kwargs[0]["project_id"] == 202
@@ -0,0 +1,110 @@
import asyncio
from types import SimpleNamespace
from app.core.nanobot import NanobotIntegration
from app.context import current_session_id
class _DummySessions:
def __init__(self) -> None:
self.saved = []
self._session = SimpleNamespace(messages=[])
def get_or_create(self, _session_id: str):
return self._session
def save(self, session) -> None:
self.saved.append(session)
class _DummyAgent:
def __init__(self) -> None:
self.sessions = _DummySessions()
self.provider = SimpleNamespace(default_model="demo-model")
self.model = "demo-model"
async def process_direct(self, *_args, **_kwargs):
return "ok"
def test_process_message_project_id_fallback_from_session_alias(monkeypatch) -> None:
service = NanobotIntegration()
base_agent = _DummyAgent()
custom_agent = _DummyAgent()
service.agent = base_agent
service._started = True
captured: dict[str, object] = {}
async def fake_get_or_create_model_agent(model_id, target_config, project_id):
captured["project_id"] = project_id
return custom_agent
monkeypatch.setattr(service, "_get_or_create_model_agent", fake_get_or_create_model_agent)
monkeypatch.setattr("app.core.nanobot.get_llm_configs", lambda: [])
monkeypatch.setattr("app.core.nanobot.get_active_llm_config", lambda: None)
monkeypatch.setattr(
"app.core.session_alias_store.session_alias_store.get_alias_meta",
lambda _session_id: {"project_id": 77},
)
response = asyncio.run(service.process_message("hello", session_id="api:s1"))
assert response == "ok"
assert captured["project_id"] == 77
def test_process_message_project_id_prefers_request_value(monkeypatch) -> None:
service = NanobotIntegration()
base_agent = _DummyAgent()
custom_agent = _DummyAgent()
service.agent = base_agent
service._started = True
captured: dict[str, object] = {}
async def fake_get_or_create_model_agent(model_id, target_config, project_id):
captured["project_id"] = project_id
return custom_agent
monkeypatch.setattr(service, "_get_or_create_model_agent", fake_get_or_create_model_agent)
monkeypatch.setattr("app.core.nanobot.get_llm_configs", lambda: [])
monkeypatch.setattr("app.core.nanobot.get_active_llm_config", lambda: None)
monkeypatch.setattr(
"app.core.session_alias_store.session_alias_store.get_alias_meta",
lambda _session_id: {"project_id": 88},
)
response = asyncio.run(service.process_message("hello", session_id="api:s2", project_id=9))
assert response == "ok"
assert captured["project_id"] == 9
def test_register_custom_tools_always_contains_subagent_tools() -> None:
service = NanobotIntegration()
names: list[str] = []
class _ToolRegistry:
def register(self, tool) -> None:
names.append(tool.name)
fake_agent = SimpleNamespace(tools=_ToolRegistry())
service._register_custom_tools(fake_agent, project_id=None)
assert "list_subagents" in names
assert "invoke_subagent" in names
def test_subagent_tool_resolves_project_from_session_alias(monkeypatch) -> None:
from app.tools.subagent import _resolve_project_id
token = current_session_id.set("api:subagent-test")
try:
monkeypatch.setattr(
"app.tools.subagent.session_alias_store.get_alias_meta",
lambda _session_id: {"project_id": 66},
)
assert _resolve_project_id(None) == 66
finally:
current_session_id.reset(token)
+102
View File
@@ -0,0 +1,102 @@
import sys
from collections.abc import Generator
from pathlib import Path
BACKEND_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = BACKEND_ROOT.parent
NANOBOT_ROOT = REPO_ROOT / "nanobot"
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
if str(NANOBOT_ROOT) not in sys.path:
sys.path.insert(0, str(NANOBOT_ROOT))
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.security import CurrentUser, get_current_user
from app.database import Base, get_db
from app.models.project import Project
from app.models.subagent import Subagent
from app.models.user import User
from main import app
def _seed_subagent(db: Session) -> tuple[User, Project, Subagent]:
user = User(
username="task3-owner",
email="task3-owner@example.com",
hashed_password="test",
is_admin=False,
)
db.add(user)
db.commit()
db.refresh(user)
project = Project(
name="task3-project",
description="task3",
owner_id=user.id,
)
db.add(project)
db.commit()
db.refresh(project)
subagent = Subagent(
project_id=project.id,
name="task3-subagent",
description="task3",
instructions="do task3",
model="gpt",
)
db.add(subagent)
db.commit()
db.refresh(subagent)
return user, project, subagent
def test_subagent_detail_route_is_global_and_project_scoped_route_is_invalid() -> None:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = testing_session_local()
user, project, subagent = _seed_subagent(db)
user_id = user.id
username = user.username
project_id = project.id
subagent_id = subagent.id
db.close()
def override_get_db() -> Generator[Session, None, None]:
override_db = testing_session_local()
try:
yield override_db
finally:
override_db.close()
def override_current_user() -> CurrentUser:
return CurrentUser(id=user_id, username=username, is_admin=False)
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_current_user
try:
client = TestClient(app)
response = client.get(f"/api/v1/subagents/{subagent_id}")
assert response.status_code == 200
body = response.json()
assert body["id"] == subagent_id
assert body["project_id"] == project_id
legacy_path_response = client.get(f"/api/v1/projects/{project_id}/subagents/{subagent_id}")
assert legacy_path_response.status_code == 404
finally:
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
engine.dispose()
@@ -0,0 +1,136 @@
import asyncio
import json
import sys
from collections.abc import Generator
from pathlib import Path
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
BACKEND_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = BACKEND_ROOT.parent
NANOBOT_ROOT = REPO_ROOT / "nanobot"
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
if str(NANOBOT_ROOT) not in sys.path:
sys.path.insert(0, str(NANOBOT_ROOT))
from app.context import current_session_id
from app.core.security import CurrentUser, get_current_user
from app.database import Base, get_db
from app.models.project import Project
from app.models.user import User
from app.tools.subagent import InvokeSubagentTool, ListSubagentsTool
from main import app
def _seed_owner_and_project(db: Session) -> tuple[User, Project]:
user = User(
username="task4-owner",
email="task4-owner@example.com",
hashed_password="test",
is_admin=False,
)
db.add(user)
db.commit()
db.refresh(user)
project = Project(
name="task4-project",
description="task4",
owner_id=user.id,
)
db.add(project)
db.commit()
db.refresh(project)
return user, project
def test_create_subagent_then_list_and_invoke_success(monkeypatch) -> None:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
testing_session_local = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base.metadata.create_all(bind=engine)
db = testing_session_local()
user, project = _seed_owner_and_project(db)
user_id = user.id
username = user.username
project_id = project.id
db.close()
def override_get_db() -> Generator[Session, None, None]:
override_db = testing_session_local()
try:
yield override_db
finally:
override_db.close()
def override_current_user() -> CurrentUser:
return CurrentUser(id=user_id, username=username, is_admin=False)
app.dependency_overrides[get_db] = override_get_db
app.dependency_overrides[get_current_user] = override_current_user
token = current_session_id.set("api:task4-regression")
captured: dict[str, object] = {}
async def fake_process_message(message, session_id, project_id, model_id):
captured["message"] = message
captured["session_id"] = session_id
captured["project_id"] = project_id
captured["model_id"] = model_id
return "invoke-ok"
try:
monkeypatch.setattr("app.tools.subagent.SessionLocal", testing_session_local)
monkeypatch.setattr(
"app.tools.subagent.session_alias_store.get_alias_meta",
lambda _session_id: {"project_id": project_id},
)
monkeypatch.setattr("app.tools.subagent.get_llm_configs", lambda: [])
monkeypatch.setattr("app.tools.subagent.get_active_llm_config", lambda: None)
monkeypatch.setattr("app.tools.subagent.nanobot_service.process_message", fake_process_message)
client = TestClient(app)
create_response = client.post(
f"/api/v1/projects/{project_id}/subagents",
json={
"name": "task4-subagent",
"description": "task4-desc",
"instructions": "focus on regression",
"model": "gpt-x",
},
)
assert create_response.status_code == 200
created = create_response.json()
assert created["project_id"] == project_id
assert created["name"] == "task4-subagent"
listed = asyncio.run(ListSubagentsTool().execute())
listed_payload = json.loads(listed)
assert len(listed_payload) == 1
assert listed_payload[0]["name"] == "task4-subagent"
assert listed_payload[0]["description"] == "task4-desc"
invoke_result = asyncio.run(
InvokeSubagentTool().execute(
subagent_name="task4-subagent",
task="run regression task",
)
)
assert "completed the task" in invoke_result
assert "invoke-ok" in invoke_result
assert captured["project_id"] == project_id
assert captured["session_id"] == f"api:task4-regression:subagent:{created['id']}"
assert "focus on regression" in str(captured["message"])
finally:
current_session_id.reset(token)
app.dependency_overrides.clear()
Base.metadata.drop_all(bind=engine)
engine.dispose()