chore: import upstream snapshot with attribution
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
"""Regression tests for #481 and the auth-dep refactor.
|
||||
|
||||
When ``require_auth`` was declared as a sync ``def``, FastAPI dispatched it
|
||||
through ``anyio.to_thread.run_sync``, which executes the function in a worker
|
||||
thread under a *copy* of the request context. Any ``ContextVar.set`` inside
|
||||
that thread is discarded when the thread returns, so the endpoint reads the
|
||||
unset default. The user-scoped path service then silently falls back to the
|
||||
admin workspace and non-admin users hit 404 on every session request.
|
||||
|
||||
These tests pin three invariants:
|
||||
|
||||
1. ``require_auth`` and ``require_admin`` are declared ``async``.
|
||||
2. ``_install_current_user`` is the single point of truth for the
|
||||
payload-to-CurrentUser mapping used by both HTTP and WebSocket entry
|
||||
points (``None`` → local admin, payload → ``user_from_token_payload``).
|
||||
3. With ``AUTH_ENABLED=true`` and a valid token, the user ContextVar set
|
||||
inside ``require_auth`` is visible from inside the endpoint, so
|
||||
``get_path_service()`` resolves to the per-user workspace.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_require_auth_is_async_def() -> None:
|
||||
from deeptutor.api.routers.auth import require_admin, require_auth
|
||||
|
||||
assert inspect.iscoroutinefunction(require_auth), (
|
||||
"require_auth must be async — a sync dep is run in a threadpool whose "
|
||||
"ContextVar mutations don't propagate back to the endpoint. See #481."
|
||||
)
|
||||
assert inspect.iscoroutinefunction(require_admin), (
|
||||
"require_admin must be async for the same reason."
|
||||
)
|
||||
|
||||
|
||||
def test_install_current_user_maps_none_to_local_admin() -> None:
|
||||
"""``_install_current_user(None)`` is the AUTH_ENABLED=false branch
|
||||
for both HTTP and WS deps. It must install the local admin user so
|
||||
that ``get_current_path_service()`` resolves to the admin workspace
|
||||
rather than silently falling back through the None path."""
|
||||
from deeptutor.api.routers.auth import _install_current_user
|
||||
from deeptutor.multi_user.context import get_current_user_or_none, reset_current_user
|
||||
from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME
|
||||
|
||||
token = _install_current_user(None)
|
||||
try:
|
||||
user = get_current_user_or_none()
|
||||
assert user is not None
|
||||
assert user.id == LOCAL_ADMIN_ID
|
||||
assert user.username == LOCAL_ADMIN_USERNAME
|
||||
assert user.role == "admin"
|
||||
assert user.scope.kind == "admin"
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
|
||||
def test_install_current_user_maps_payload_to_scoped_user() -> None:
|
||||
"""``_install_current_user(payload)`` must produce a user-scoped
|
||||
CurrentUser whose workspace root lives under USERS_ROOT — the
|
||||
same shape that ``ws_require_auth`` and HTTP deps need to install."""
|
||||
from deeptutor.api.routers.auth import _install_current_user
|
||||
from deeptutor.multi_user.context import get_current_user_or_none, reset_current_user
|
||||
from deeptutor.services.auth import TokenPayload
|
||||
|
||||
token = _install_current_user(TokenPayload(username="alice", role="user", user_id="u_alice"))
|
||||
try:
|
||||
user = get_current_user_or_none()
|
||||
assert user is not None
|
||||
assert user.id == "u_alice"
|
||||
assert user.username == "alice"
|
||||
assert user.role == "user"
|
||||
assert user.scope.kind == "user"
|
||||
finally:
|
||||
reset_current_user(token)
|
||||
|
||||
|
||||
def test_local_admin_token_payload_matches_local_admin_user() -> None:
|
||||
"""The synthetic admin TokenPayload returned by ``require_admin`` when
|
||||
AUTH_ENABLED=false must use the same identity constants as
|
||||
``local_admin_user()`` — drift between the two reintroduces the kind
|
||||
of dual-source-of-truth bug that #481 lived in."""
|
||||
from deeptutor.api.routers.auth import _local_admin_token_payload
|
||||
from deeptutor.multi_user.models import LOCAL_ADMIN_ID, LOCAL_ADMIN_USERNAME
|
||||
from deeptutor.multi_user.paths import local_admin_user
|
||||
|
||||
tp = _local_admin_token_payload()
|
||||
user = local_admin_user()
|
||||
assert tp.username == LOCAL_ADMIN_USERNAME == user.username
|
||||
assert tp.user_id == LOCAL_ADMIN_ID == user.id
|
||||
assert tp.role == "admin" == user.role
|
||||
|
||||
|
||||
def test_require_auth_propagates_user_contextvar_to_endpoint(monkeypatch) -> None:
|
||||
"""End-to-end: a valid token through require_auth makes the user
|
||||
ContextVar visible to the endpoint."""
|
||||
from deeptutor.api.routers import auth as auth_router
|
||||
from deeptutor.multi_user.context import get_current_user_or_none
|
||||
from deeptutor.services.auth import TokenPayload
|
||||
|
||||
monkeypatch.setattr(auth_router, "AUTH_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
auth_router,
|
||||
"decode_token",
|
||||
lambda _t: TokenPayload(username="alice", role="user", user_id="u_alice"),
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/whoami")
|
||||
async def whoami(_=Depends(auth_router.require_auth)) -> dict:
|
||||
user = get_current_user_or_none()
|
||||
if user is None:
|
||||
return {"seen": None}
|
||||
return {"seen": user.username, "role": user.role, "scope_kind": user.scope.kind}
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/whoami", headers={"Authorization": "Bearer test-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["seen"] == "alice", (
|
||||
"Endpoint should observe the user ContextVar set inside require_auth. "
|
||||
"If this returns None the dependency is being run in a threadpool and "
|
||||
"the ContextVar mutation is discarded — see #481."
|
||||
)
|
||||
assert body["role"] == "user"
|
||||
assert body["scope_kind"] == "user"
|
||||
|
||||
|
||||
def test_require_auth_propagates_admin_contextvar_to_endpoint(monkeypatch) -> None:
|
||||
from deeptutor.api.routers import auth as auth_router
|
||||
from deeptutor.multi_user.context import get_current_user_or_none
|
||||
from deeptutor.services.auth import TokenPayload
|
||||
|
||||
monkeypatch.setattr(auth_router, "AUTH_ENABLED", True)
|
||||
monkeypatch.setattr(
|
||||
auth_router,
|
||||
"decode_token",
|
||||
lambda _t: TokenPayload(username="root", role="admin", user_id="u_root"),
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/whoami")
|
||||
async def whoami(_=Depends(auth_router.require_auth)) -> dict:
|
||||
user = get_current_user_or_none()
|
||||
return {"role": None if user is None else user.role}
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/whoami", headers={"Authorization": "Bearer test-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"role": "admin"}
|
||||
|
||||
|
||||
def test_path_service_resolves_per_user_workspace_through_dependency(monkeypatch, tmp_path) -> None:
|
||||
"""The full chain that the reporter exercised in #481: a non-admin
|
||||
request lands on an endpoint that calls ``get_path_service()`` and
|
||||
that path service must point at ``data/users/<uid>/``, not the
|
||||
admin fallback."""
|
||||
from deeptutor.api.routers import auth as auth_router
|
||||
from deeptutor.multi_user import paths as mu_paths
|
||||
from deeptutor.services.auth import TokenPayload
|
||||
from deeptutor.services.path_service import get_path_service
|
||||
|
||||
monkeypatch.setattr(auth_router, "AUTH_ENABLED", True)
|
||||
monkeypatch.setattr(mu_paths, "USERS_ROOT", tmp_path / "data" / "users")
|
||||
monkeypatch.setattr(mu_paths, "_path_services", {})
|
||||
monkeypatch.setattr(
|
||||
auth_router,
|
||||
"decode_token",
|
||||
lambda _t: TokenPayload(username="alice", role="user", user_id="u_alice"),
|
||||
)
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
@app.get("/db-path")
|
||||
async def db_path(_=Depends(auth_router.require_auth)) -> dict:
|
||||
service = get_path_service()
|
||||
return {"chat_db": str(service.get_chat_history_db())}
|
||||
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/db-path", headers={"Authorization": "Bearer test-token"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
chat_db = resp.json()["chat_db"]
|
||||
expected_root = str((tmp_path / "data" / "users" / "u_alice").resolve())
|
||||
assert chat_db.startswith(expected_root), (
|
||||
"Per-user request should resolve under the user's USERS_ROOT scope. "
|
||||
f"Expected prefix {expected_root!r}, got: {chat_db!r}. If this fails, the "
|
||||
"ContextVar mutation in require_auth is not reaching the endpoint — "
|
||||
"see #481."
|
||||
)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Co-Writer backend tests: doc id validation, storage CRUD, history limits."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
|
||||
from deeptutor.api.routers.co_writer import _validate_doc_id
|
||||
from deeptutor.co_writer import edit_agent
|
||||
from deeptutor.co_writer.storage import CoWriterStorage
|
||||
|
||||
|
||||
class _StubPathService:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root
|
||||
|
||||
def get_co_writer_dir(self) -> Path:
|
||||
return self.root
|
||||
|
||||
def get_co_writer_history_file(self) -> Path:
|
||||
return self.root / "history.json"
|
||||
|
||||
def get_co_writer_tool_calls_dir(self) -> Path:
|
||||
return self.root / "tool_calls"
|
||||
|
||||
def get_co_writer_docs_dir(self) -> Path:
|
||||
return self.root / "documents"
|
||||
|
||||
def get_co_writer_doc_root(self, doc_id: str) -> Path:
|
||||
return self.get_co_writer_docs_dir() / f"doc_{doc_id}"
|
||||
|
||||
def get_co_writer_doc_manifest(self, doc_id: str) -> Path:
|
||||
return self.get_co_writer_doc_root(doc_id) / "manifest.json"
|
||||
|
||||
|
||||
# ── doc_id validation ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_validate_doc_id_accepts_generated_ids():
|
||||
assert _validate_doc_id("a1b2c3d4e5f6") == "a1b2c3d4e5f6"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
"../x",
|
||||
"a/../../etc",
|
||||
"a1b2c3d4e5f6/../../x",
|
||||
"doc_1; rm -rf",
|
||||
"A1B2C3D4E5F6",
|
||||
"",
|
||||
"a" * 40,
|
||||
],
|
||||
)
|
||||
def test_validate_doc_id_rejects_traversal_and_junk(bad):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
_validate_doc_id(bad)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ── storage CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_storage_crud_roundtrip(tmp_path):
|
||||
storage = CoWriterStorage(path_service=_StubPathService(tmp_path))
|
||||
|
||||
doc = storage.create_document(title=None, content="# Hello\nWorld")
|
||||
assert doc.title == "Hello"
|
||||
assert _validate_doc_id(doc.id) == doc.id
|
||||
|
||||
loaded = storage.load_document(doc.id)
|
||||
assert loaded is not None
|
||||
assert loaded.content.startswith("# Hello")
|
||||
|
||||
# Explicit titles stick across content updates.
|
||||
updated = storage.update_document(doc.id, content="# Renamed\nBody")
|
||||
assert updated is not None
|
||||
assert updated.title == "Hello"
|
||||
|
||||
assert storage.delete_document(doc.id) is True
|
||||
assert storage.load_document(doc.id) is None
|
||||
|
||||
|
||||
def test_storage_untitled_doc_follows_first_heading(tmp_path):
|
||||
storage = CoWriterStorage(path_service=_StubPathService(tmp_path))
|
||||
doc = storage.create_document(title=None, content="")
|
||||
assert doc.title == "Untitled draft"
|
||||
|
||||
updated = storage.update_document(doc.id, content="# Fresh title\nBody")
|
||||
assert updated is not None
|
||||
assert updated.title == "Fresh title"
|
||||
|
||||
|
||||
def test_storage_list_sorted_by_recency(tmp_path):
|
||||
storage = CoWriterStorage(path_service=_StubPathService(tmp_path))
|
||||
first = storage.create_document(title="first", content="a")
|
||||
second = storage.create_document(title="second", content="b")
|
||||
storage.update_document(first.id, content="a updated")
|
||||
|
||||
summaries = storage.list_documents()
|
||||
assert [s.id for s in summaries][0] == first.id
|
||||
assert {s.id for s in summaries} == {first.id, second.id}
|
||||
|
||||
|
||||
# ── history limits ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_append_history_caps_entries(tmp_path, monkeypatch):
|
||||
stub = _StubPathService(tmp_path)
|
||||
monkeypatch.setattr(edit_agent, "get_path_service", lambda: stub)
|
||||
|
||||
overflow = 5
|
||||
for i in range(edit_agent._HISTORY_MAX_ENTRIES + overflow):
|
||||
edit_agent.append_history({"id": str(i)})
|
||||
|
||||
history = edit_agent.load_history()
|
||||
assert len(history) == edit_agent._HISTORY_MAX_ENTRIES
|
||||
assert history[-1]["id"] == str(edit_agent._HISTORY_MAX_ENTRIES + overflow - 1)
|
||||
assert history[0]["id"] == str(overflow)
|
||||
|
||||
|
||||
def test_append_history_clips_long_texts(tmp_path, monkeypatch):
|
||||
stub = _StubPathService(tmp_path)
|
||||
monkeypatch.setattr(edit_agent, "get_path_service", lambda: stub)
|
||||
|
||||
long_text = "x" * (edit_agent._HISTORY_TEXT_LIMIT + 500)
|
||||
edit_agent.append_history({"id": "clip", "input": {"original_text": long_text}})
|
||||
|
||||
record = edit_agent.load_history()[-1]
|
||||
stored = record["input"]["original_text"]
|
||||
assert stored.endswith("…[truncated]")
|
||||
assert len(stored) < len(long_text)
|
||||
|
||||
|
||||
def test_load_history_survives_corrupt_file(tmp_path, monkeypatch):
|
||||
stub = _StubPathService(tmp_path)
|
||||
monkeypatch.setattr(edit_agent, "get_path_service", lambda: stub)
|
||||
|
||||
stub.get_co_writer_dir().mkdir(parents=True, exist_ok=True)
|
||||
stub.get_co_writer_history_file().write_text("{not json", encoding="utf-8")
|
||||
assert edit_agent.load_history() == []
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for FastAPI CORS settings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from deeptutor.api import main as api_main
|
||||
|
||||
|
||||
def test_cors_allows_remote_http_origins_when_auth_disabled(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("AUTH_ENABLED", raising=False)
|
||||
monkeypatch.delenv("CORS_ORIGIN", raising=False)
|
||||
monkeypatch.delenv("CORS_ORIGINS", raising=False)
|
||||
monkeypatch.setenv("FRONTEND_PORT", "3782")
|
||||
|
||||
settings = api_main._build_cors_settings()
|
||||
|
||||
assert settings["allow_origin_regex"] == r"https?://.*"
|
||||
assert "http://localhost:3782" in settings["allow_origins"]
|
||||
assert "http://127.0.0.1:3782" in settings["allow_origins"]
|
||||
|
||||
|
||||
def test_cors_requires_explicit_origins_when_auth_enabled(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv("CORS_ORIGIN", "https://app.example.com/")
|
||||
monkeypatch.setenv(
|
||||
"CORS_ORIGINS",
|
||||
"https://foo.example.com, https://bar.example.com\nhttps://foo.example.com",
|
||||
)
|
||||
|
||||
settings = api_main._build_cors_settings()
|
||||
|
||||
assert settings["allow_origin_regex"] is None
|
||||
assert "https://app.example.com" in settings["allow_origins"]
|
||||
assert "https://foo.example.com" in settings["allow_origins"]
|
||||
assert "https://bar.example.com" in settings["allow_origins"]
|
||||
assert settings["allow_origins"].count("https://foo.example.com") == 1
|
||||
|
||||
|
||||
def test_cors_normalizes_common_origin_input_mistakes(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
monkeypatch.setenv(
|
||||
"CORS_ORIGIN",
|
||||
"172.26.0.10:3782; https://learn.example.com/app/",
|
||||
)
|
||||
monkeypatch.setenv("CORS_ORIGINS", "http://localhost:3000;api.example.com")
|
||||
|
||||
settings = api_main._build_cors_settings()
|
||||
|
||||
assert settings["allow_origin_regex"] is None
|
||||
assert "http://172.26.0.10:3782" in settings["allow_origins"]
|
||||
assert "https://learn.example.com" in settings["allow_origins"]
|
||||
assert "http://api.example.com" in settings["allow_origins"]
|
||||
|
||||
|
||||
def test_cors_preflight_allows_partner_patch_save() -> None:
|
||||
client = TestClient(api_main.app)
|
||||
|
||||
response = client.options(
|
||||
"/api/v1/partners/partner",
|
||||
headers={
|
||||
"Origin": "http://localhost:3000",
|
||||
"Access-Control-Request-Method": "PATCH",
|
||||
"Access-Control-Request-Headers": "content-type",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["access-control-allow-origin"] == "http://localhost:3000"
|
||||
allowed_methods = {
|
||||
method.strip() for method in response.headers["access-control-allow-methods"].split(",")
|
||||
}
|
||||
assert "PATCH" in allowed_methods
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Tests for the chat-history import endpoints.
|
||||
|
||||
The handlers are exercised directly (no TestClient) with the per-user store
|
||||
factory monkeypatched to a tmp database, so nothing touches the real chat DB.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import ValidationError
|
||||
import pytest
|
||||
|
||||
from deeptutor.api.routers import imports as imports_router
|
||||
from deeptutor.api.routers.imports import (
|
||||
ChatHistoryImportRequest,
|
||||
import_chat_history,
|
||||
list_imported_chat_history,
|
||||
)
|
||||
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SQLiteSessionStore:
|
||||
instance = SQLiteSessionStore(db_path=tmp_path / "test.db")
|
||||
monkeypatch.setattr(imports_router, "get_sqlite_session_store", lambda: instance)
|
||||
return instance
|
||||
|
||||
|
||||
def _payload(
|
||||
external_id: str = "s1",
|
||||
messages=None,
|
||||
agent_id: str = "",
|
||||
agent_name: str = "",
|
||||
) -> ChatHistoryImportRequest:
|
||||
return ChatHistoryImportRequest(
|
||||
source="codex",
|
||||
agent_id=agent_id,
|
||||
agent_name=agent_name,
|
||||
sessions=[
|
||||
{
|
||||
"external_id": external_id,
|
||||
"title": "T",
|
||||
"source_cwd": "/p",
|
||||
"created_at": 1.0,
|
||||
"updated_at": 2.0,
|
||||
"messages": messages
|
||||
if messages is not None
|
||||
else [
|
||||
{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": "a"},
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def _imported_prefs(store: SQLiteSessionStore) -> dict:
|
||||
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
|
||||
return listed["sessions"][0]["preferences"]["import"]
|
||||
|
||||
|
||||
def test_source_validation_rejects_unknown() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ChatHistoryImportRequest(source="opencode", sessions=[])
|
||||
|
||||
|
||||
def test_source_is_normalized_lowercase() -> None:
|
||||
assert ChatHistoryImportRequest(source="Claude_Code", sessions=[]).source == ("claude_code")
|
||||
|
||||
|
||||
def test_import_endpoint_persists_and_dedups(store: SQLiteSessionStore) -> None:
|
||||
res = asyncio.run(import_chat_history(_payload()))
|
||||
assert res["imported"] == 1
|
||||
assert res["skipped"] == 0
|
||||
|
||||
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
|
||||
assert len(listed["sessions"]) == 1
|
||||
assert listed["sessions"][0]["message_count"] == 2
|
||||
|
||||
# Re-import the same session → deduped, not duplicated.
|
||||
again = asyncio.run(import_chat_history(_payload()))
|
||||
assert again["imported"] == 0
|
||||
assert again["skipped"] == 1
|
||||
assert len(asyncio.run(list_imported_chat_history(limit=50, offset=0))["sessions"]) == 1
|
||||
|
||||
|
||||
def test_empty_content_messages_are_dropped(store: SQLiteSessionStore) -> None:
|
||||
res = asyncio.run(
|
||||
import_chat_history(
|
||||
_payload(
|
||||
messages=[
|
||||
{"role": "user", "content": "q"},
|
||||
{"role": "assistant", "content": " "}, # whitespace only
|
||||
{"role": "assistant", "content": "a"},
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
assert res["imported"] == 1
|
||||
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
|
||||
assert listed["sessions"][0]["message_count"] == 2
|
||||
|
||||
|
||||
def test_session_with_only_empty_messages_is_skipped(
|
||||
store: SQLiteSessionStore,
|
||||
) -> None:
|
||||
res = asyncio.run(import_chat_history(_payload(messages=[{"role": "user", "content": " "}])))
|
||||
assert res["imported"] == 0
|
||||
assert res["skipped"] == 1
|
||||
|
||||
|
||||
def test_import_rejects_empty_request(store: SQLiteSessionStore) -> None:
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
asyncio.run(import_chat_history(ChatHistoryImportRequest(source="codex", sessions=[])))
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_agent_attribution_persisted(store: SQLiteSessionStore) -> None:
|
||||
asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="Research")))
|
||||
meta = _imported_prefs(store)
|
||||
assert meta["agent_id"] == "codex-a1"
|
||||
assert meta["agent_name"] == "Research"
|
||||
assert meta["source"] == "codex"
|
||||
|
||||
|
||||
def test_reimport_backfills_agent_attribution(store: SQLiteSessionStore) -> None:
|
||||
# First import had no agent (legacy client) — no attribution stored.
|
||||
asyncio.run(import_chat_history(_payload()))
|
||||
assert "agent_id" not in _imported_prefs(store)
|
||||
|
||||
# Re-syncing under an agent backfills attribution without duplicating the
|
||||
# session or re-adding messages.
|
||||
again = asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="Research")))
|
||||
assert again["imported"] == 0
|
||||
assert again["skipped"] == 1
|
||||
listed = asyncio.run(list_imported_chat_history(limit=50, offset=0))
|
||||
assert len(listed["sessions"]) == 1
|
||||
assert listed["sessions"][0]["message_count"] == 2
|
||||
meta = listed["sessions"][0]["preferences"]["import"]
|
||||
assert meta["agent_id"] == "codex-a1"
|
||||
assert meta["agent_name"] == "Research"
|
||||
|
||||
|
||||
def test_agent_rename_propagates_on_resync(store: SQLiteSessionStore) -> None:
|
||||
asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="Old name")))
|
||||
asyncio.run(import_chat_history(_payload(agent_id="codex-a1", agent_name="New name")))
|
||||
assert _imported_prefs(store)["agent_name"] == "New name"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
||||
"""Wiring tests for safe ``.zip`` upload handling in the knowledge router.
|
||||
|
||||
The deep security guards live in ``tests/utils/test_archive_extractor.py``;
|
||||
these check that ``_save_uploaded_files`` routes ``.zip`` uploads through the
|
||||
safe extractor, registers only supported members, and never persists the
|
||||
archive itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from deeptutor.api.routers.knowledge import _save_uploaded_files
|
||||
|
||||
ALLOWED = {".txt", ".md", ".pdf", ".zip"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _disable_pocketbase(monkeypatch):
|
||||
monkeypatch.setattr("deeptutor.services.pocketbase_client.is_pocketbase_enabled", lambda: False)
|
||||
|
||||
|
||||
def _zip_upload(filename: str, entries: list[tuple[str, bytes]]) -> UploadFile:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
for name, data in entries:
|
||||
zf.writestr(name, data)
|
||||
buf.seek(0)
|
||||
return UploadFile(filename=filename, file=buf)
|
||||
|
||||
|
||||
def test_zip_upload_extracts_only_supported_members(tmp_path: Path) -> None:
|
||||
upload = _zip_upload(
|
||||
"bundle.zip",
|
||||
[
|
||||
("notes.txt", b"hello"),
|
||||
("paper.md", b"# title"),
|
||||
("malware.exe", b"x"), # disallowed -> skipped
|
||||
("inner.zip", b"PK\x03\x04"), # nested archive -> skipped
|
||||
],
|
||||
)
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
|
||||
names, paths = _save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
|
||||
|
||||
assert sorted(names) == ["notes.txt", "paper.md"]
|
||||
assert (raw / "notes.txt").read_bytes() == b"hello"
|
||||
# The archive itself is never persisted or registered.
|
||||
assert not (raw / "bundle.zip").exists()
|
||||
assert all(not p.endswith(".zip") for p in paths)
|
||||
|
||||
|
||||
def test_zip_upload_with_zip_slip_member_stays_in_target(tmp_path: Path) -> None:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as zf:
|
||||
zf.writestr(zipfile.ZipInfo("../../escape.txt"), b"x")
|
||||
zf.writestr("safe.txt", b"y")
|
||||
buf.seek(0)
|
||||
upload = UploadFile(filename="evil.zip", file=buf)
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
|
||||
names, _ = _save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
|
||||
|
||||
assert sorted(names) == ["escape.txt", "safe.txt"]
|
||||
assert (raw / "escape.txt").exists()
|
||||
assert not (tmp_path / "escape.txt").exists() # did not escape
|
||||
|
||||
|
||||
def test_invalid_zip_is_rejected(tmp_path: Path) -> None:
|
||||
upload = UploadFile(filename="broken.zip", file=io.BytesIO(b"not a zip"))
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
def test_zip_with_no_supported_members_is_rejected(tmp_path: Path) -> None:
|
||||
upload = _zip_upload("only-junk.zip", [("a.exe", b"x"), ("b.sh", b"y")])
|
||||
raw = tmp_path / "raw"
|
||||
raw.mkdir()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_save_uploaded_files([upload], raw, allowed_extensions=ALLOWED)
|
||||
assert exc_info.value.status_code == 400
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Tests for the main notebook router (/api/v1/notebook).
|
||||
|
||||
Verifies that records can only be saved using real notebook UUIDs
|
||||
(from /api/v1/notebook/list), not question-notebook category integer IDs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
FastAPI = pytest.importorskip("fastapi").FastAPI
|
||||
TestClient = pytest.importorskip("fastapi.testclient").TestClient
|
||||
|
||||
notebook_router = importlib.import_module("deeptutor.api.routers.notebook").router
|
||||
|
||||
from deeptutor.services.notebook.service import NotebookManager
|
||||
|
||||
|
||||
def _build_app(manager: NotebookManager) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(notebook_router, prefix="/api/v1/notebook")
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def manager(tmp_path, monkeypatch) -> NotebookManager:
|
||||
instance = NotebookManager(base_dir=str(tmp_path / "notebooks"))
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.api.routers.notebook.notebook_manager",
|
||||
instance,
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
def test_list_notebooks_empty(manager: NotebookManager) -> None:
|
||||
with TestClient(_build_app(manager)) as client:
|
||||
resp = client.get("/api/v1/notebook/list")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["notebooks"] == []
|
||||
assert data["total"] == 0
|
||||
|
||||
|
||||
def test_create_and_list_notebook(manager: NotebookManager) -> None:
|
||||
with TestClient(_build_app(manager)) as client:
|
||||
create_resp = client.post(
|
||||
"/api/v1/notebook/create",
|
||||
json={"name": "Study Notes", "description": "Physics"},
|
||||
)
|
||||
assert create_resp.status_code == 200
|
||||
nb = create_resp.json()["notebook"]
|
||||
assert nb["name"] == "Study Notes"
|
||||
nb_id = nb["id"]
|
||||
|
||||
listing = client.get("/api/v1/notebook/list").json()
|
||||
assert listing["total"] == 1
|
||||
assert listing["notebooks"][0]["id"] == nb_id
|
||||
|
||||
|
||||
def test_add_record_with_valid_notebook_id(manager: NotebookManager) -> None:
|
||||
"""Records saved with a real notebook UUID must appear in that notebook."""
|
||||
nb = manager.create_notebook(name="My Notes")
|
||||
nb_id = nb["id"]
|
||||
|
||||
with TestClient(_build_app(manager)) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/notebook/add_record",
|
||||
json={
|
||||
"notebook_ids": [nb_id],
|
||||
"record_type": "chat",
|
||||
"title": "Draft on Fourier",
|
||||
"summary": "Existing summary",
|
||||
"user_query": "Explain Fourier",
|
||||
"output": "Fourier transform is...",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["success"] is True
|
||||
assert nb_id in body["added_to_notebooks"]
|
||||
|
||||
detail = client.get(f"/api/v1/notebook/{nb_id}").json()
|
||||
assert len(detail["records"]) == 1
|
||||
assert detail["records"][0]["title"] == "Draft on Fourier"
|
||||
|
||||
|
||||
def test_add_record_with_numeric_category_id_saves_nothing(manager: NotebookManager) -> None:
|
||||
"""Using a question-notebook integer category ID must NOT match any notebook.
|
||||
|
||||
This is the root cause of issue #301: the old SaveToNotebookModal sent
|
||||
numeric category IDs from /api/v1/question-notebook/categories instead of
|
||||
UUID notebook IDs from /api/v1/notebook/list.
|
||||
"""
|
||||
manager.create_notebook(name="My Notes")
|
||||
|
||||
with TestClient(_build_app(manager)) as client:
|
||||
resp = client.post(
|
||||
"/api/v1/notebook/add_record",
|
||||
json={
|
||||
"notebook_ids": ["1", "42"],
|
||||
"record_type": "chat",
|
||||
"title": "Lost draft",
|
||||
"summary": "This should not be saved anywhere",
|
||||
"user_query": "...",
|
||||
"output": "...",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["added_to_notebooks"] == []
|
||||
|
||||
|
||||
def test_stream_add_record_with_summary_strips_thinking_tags(
|
||||
manager: NotebookManager,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
class FakeSummarizeAgent:
|
||||
def __init__(self, language: str = "en") -> None:
|
||||
self.language = language
|
||||
|
||||
async def stream_summary(self, **_kwargs):
|
||||
yield "<thi"
|
||||
yield "nk>private reasoning</think>\n"
|
||||
yield "Final reusable summary."
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.api.routers.notebook.NotebookSummarizeAgent",
|
||||
FakeSummarizeAgent,
|
||||
)
|
||||
nb = manager.create_notebook(name="My Notes")
|
||||
|
||||
async def collect_events() -> list[dict]:
|
||||
request = importlib.import_module("deeptutor.api.routers.notebook").AddRecordRequest(
|
||||
notebook_ids=[nb["id"]],
|
||||
record_type="chat",
|
||||
title="Streaming save",
|
||||
user_query="Explain Fourier",
|
||||
output="Fourier transform is...",
|
||||
)
|
||||
events: list[dict] = []
|
||||
async for raw in importlib.import_module(
|
||||
"deeptutor.api.routers.notebook"
|
||||
)._stream_add_record_with_summary(request):
|
||||
assert "<think" not in raw.lower()
|
||||
assert "private reasoning" not in raw
|
||||
events.append(json.loads(raw.removeprefix("data: ").strip()))
|
||||
return events
|
||||
|
||||
events = asyncio.run(collect_events())
|
||||
assert events[-1]["type"] == "result"
|
||||
assert events[-1]["summary"] == "Final reusable summary."
|
||||
|
||||
detail = manager.get_notebook(nb["id"])
|
||||
assert detail is not None
|
||||
assert detail["records"][0]["summary"] == "Final reusable summary."
|
||||
@@ -0,0 +1,82 @@
|
||||
"""GET /api/v1/memory/resolve_entry/{id} → (layer, key).
|
||||
|
||||
L3 docs cite L2 entries by their ``m_<ULID>`` entry id. The resolver
|
||||
turns an L3 footnote click into "which L2 surface do I navigate to".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
FastAPI = pytest.importorskip("fastapi").FastAPI
|
||||
TestClient = pytest.importorskip("fastapi.testclient").TestClient
|
||||
|
||||
memory_router = importlib.import_module("deeptutor.api.routers.memory").router
|
||||
paths_mod = importlib.import_module("deeptutor.services.memory.paths")
|
||||
document_mod = importlib.import_module("deeptutor.services.memory.document")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path: Path, monkeypatch) -> TestClient:
|
||||
monkeypatch.setattr(paths_mod, "memory_root", lambda: tmp_path)
|
||||
(tmp_path / "L2").mkdir()
|
||||
(tmp_path / "L3").mkdir()
|
||||
app = FastAPI()
|
||||
app.include_router(memory_router, prefix="/api/v1/memory")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _seed_l2(tmp_path: Path, surface: str, entry_id: str) -> None:
|
||||
doc = document_mod.Document(
|
||||
title=f"{surface} memory",
|
||||
sections=[
|
||||
(
|
||||
"Themes",
|
||||
[
|
||||
document_mod.Entry(
|
||||
id=entry_id,
|
||||
section="Themes",
|
||||
text="some fact",
|
||||
refs=[f"{surface}:r1"],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
(tmp_path / "L2" / f"{surface}.md").write_text(document_mod.serialize(doc), encoding="utf-8")
|
||||
|
||||
|
||||
def test_resolve_entry_returns_owning_surface(client: TestClient, tmp_path: Path) -> None:
|
||||
entry_id = "m_01HZK1ABCDEFGHJKMNPQRSTVWX"
|
||||
_seed_l2(tmp_path, "notebook", entry_id)
|
||||
|
||||
res = client.get(f"/api/v1/memory/resolve_entry/{entry_id}")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body == {"layer": "L2", "key": "notebook", "entry_id": entry_id}
|
||||
|
||||
|
||||
def test_resolve_entry_404_when_missing(client: TestClient) -> None:
|
||||
res = client.get("/api/v1/memory/resolve_entry/m_01HZK1ABCDEFGHJKMNPQRSTVWX")
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
def test_resolve_entry_400_on_bad_id(client: TestClient) -> None:
|
||||
res = client.get("/api/v1/memory/resolve_entry/not-an-entry-id")
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_resolve_entry_first_hit_wins(client: TestClient, tmp_path: Path) -> None:
|
||||
"""Iteration order is the SURFACES tuple — chat before notebook."""
|
||||
entry_id = "m_01HZK1ABCDEFGHJKMNPQRSTVWX"
|
||||
_seed_l2(tmp_path, "chat", entry_id)
|
||||
_seed_l2(tmp_path, "notebook", entry_id) # duplicate (shouldn't happen IRL)
|
||||
|
||||
res = client.get(f"/api/v1/memory/resolve_entry/{entry_id}")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["key"] == "chat"
|
||||
@@ -0,0 +1,362 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
FastAPI = pytest.importorskip("fastapi").FastAPI
|
||||
TestClient = pytest.importorskip("fastapi.testclient").TestClient
|
||||
notebook_router = importlib.import_module("deeptutor.api.routers.question_notebook").router
|
||||
sessions_router = importlib.import_module("deeptutor.api.routers.sessions").router
|
||||
|
||||
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
|
||||
|
||||
|
||||
def _build_app(store: SQLiteSessionStore) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(notebook_router, prefix="/api/v1/question-notebook")
|
||||
app.include_router(sessions_router, prefix="/api/v1/sessions")
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path: Path, monkeypatch) -> SQLiteSessionStore:
|
||||
instance = SQLiteSessionStore(db_path=tmp_path / "router-test.db")
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.api.routers.question_notebook.get_sqlite_session_store",
|
||||
lambda: instance,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.api.routers.sessions.get_sqlite_session_store",
|
||||
lambda: instance,
|
||||
)
|
||||
return instance
|
||||
|
||||
|
||||
def _quiz_answers():
|
||||
return [
|
||||
{
|
||||
"question_id": "q1",
|
||||
"question": "Capital of France?",
|
||||
"question_type": "choice",
|
||||
"options": {"A": "Berlin", "B": "Paris"},
|
||||
"user_answer": "A",
|
||||
"correct_answer": "B",
|
||||
"explanation": "Paris is the capital.",
|
||||
"difficulty": "easy",
|
||||
"is_correct": False,
|
||||
},
|
||||
{
|
||||
"question_id": "q2",
|
||||
"question": "2+2?",
|
||||
"question_type": "choice",
|
||||
"options": {"A": "3", "B": "4"},
|
||||
"user_answer": "B",
|
||||
"correct_answer": "B",
|
||||
"is_correct": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_list_entries_empty(store: SQLiteSessionStore) -> None:
|
||||
with TestClient(_build_app(store)) as client:
|
||||
resp = client.get("/api/v1/question-notebook/entries")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"items": [], "total": 0}
|
||||
|
||||
|
||||
def test_quiz_results_populates_notebook(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session(title="Quiz Session"))
|
||||
sid = session["id"]
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
resp = client.post(
|
||||
f"/api/v1/sessions/{sid}/quiz-results",
|
||||
json={"answers": _quiz_answers()},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["recorded"] is True
|
||||
assert body["notebook_count"] == 2
|
||||
assert "[Quiz Performance]" in body["content"]
|
||||
|
||||
listing = client.get("/api/v1/question-notebook/entries")
|
||||
assert listing.status_code == 200
|
||||
items = listing.json()["items"]
|
||||
assert len(items) == 2
|
||||
|
||||
|
||||
def test_quiz_results_upserts_on_retry(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session())
|
||||
sid = session["id"]
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
client.post(f"/api/v1/sessions/{sid}/quiz-results", json={"answers": _quiz_answers()})
|
||||
updated = _quiz_answers()
|
||||
updated[0]["user_answer"] = "B"
|
||||
updated[0]["is_correct"] = True
|
||||
client.post(f"/api/v1/sessions/{sid}/quiz-results", json={"answers": updated})
|
||||
|
||||
listing = client.get("/api/v1/question-notebook/entries").json()
|
||||
assert listing["total"] == 2
|
||||
q1 = next(e for e in listing["items"] if e["question_id"] == "q1")
|
||||
assert q1["is_correct"] is True
|
||||
assert q1["user_answer"] == "B"
|
||||
|
||||
|
||||
def test_bookmark_toggle(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session())
|
||||
asyncio.run(
|
||||
store.upsert_notebook_entries(
|
||||
session["id"],
|
||||
[
|
||||
{
|
||||
"question_id": "q1",
|
||||
"question": "Q?",
|
||||
"is_correct": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
eid = asyncio.run(store.list_notebook_entries())["items"][0]["id"]
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
resp = client.patch(
|
||||
f"/api/v1/question-notebook/entries/{eid}",
|
||||
json={"bookmarked": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
bm = client.get("/api/v1/question-notebook/entries?bookmarked=true").json()
|
||||
assert bm["total"] == 1
|
||||
|
||||
client.patch(f"/api/v1/question-notebook/entries/{eid}", json={"bookmarked": False})
|
||||
bm2 = client.get("/api/v1/question-notebook/entries?bookmarked=true").json()
|
||||
assert bm2["total"] == 0
|
||||
|
||||
|
||||
def test_delete_entry(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session())
|
||||
asyncio.run(
|
||||
store.upsert_notebook_entries(
|
||||
session["id"],
|
||||
[
|
||||
{
|
||||
"question_id": "q1",
|
||||
"question": "Q?",
|
||||
"is_correct": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
eid = asyncio.run(store.list_notebook_entries())["items"][0]["id"]
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
assert client.delete(f"/api/v1/question-notebook/entries/{eid}").status_code == 200
|
||||
assert client.delete(f"/api/v1/question-notebook/entries/{eid}").status_code == 404
|
||||
|
||||
|
||||
def test_category_crud_and_association(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session())
|
||||
asyncio.run(
|
||||
store.upsert_notebook_entries(
|
||||
session["id"],
|
||||
[
|
||||
{
|
||||
"question_id": "q1",
|
||||
"question": "Q?",
|
||||
"is_correct": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
eid = asyncio.run(store.list_notebook_entries())["items"][0]["id"]
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
cat_resp = client.post(
|
||||
"/api/v1/question-notebook/categories",
|
||||
json={"name": "Math"},
|
||||
)
|
||||
assert cat_resp.status_code == 201
|
||||
cat_id = cat_resp.json()["id"]
|
||||
|
||||
cats = client.get("/api/v1/question-notebook/categories").json()
|
||||
assert len(cats) == 1
|
||||
assert cats[0]["name"] == "Math"
|
||||
|
||||
add_resp = client.post(
|
||||
f"/api/v1/question-notebook/entries/{eid}/categories",
|
||||
json={"category_id": cat_id},
|
||||
)
|
||||
assert add_resp.status_code == 200
|
||||
|
||||
by_cat = client.get(f"/api/v1/question-notebook/entries?category_id={cat_id}").json()
|
||||
assert by_cat["total"] == 1
|
||||
|
||||
rm_resp = client.delete(f"/api/v1/question-notebook/entries/{eid}/categories/{cat_id}")
|
||||
assert rm_resp.status_code == 200
|
||||
by_cat2 = client.get(f"/api/v1/question-notebook/entries?category_id={cat_id}").json()
|
||||
assert by_cat2["total"] == 0
|
||||
|
||||
client.patch(f"/api/v1/question-notebook/categories/{cat_id}", json={"name": "Algebra"})
|
||||
cats2 = client.get("/api/v1/question-notebook/categories").json()
|
||||
assert cats2[0]["name"] == "Algebra"
|
||||
|
||||
client.delete(f"/api/v1/question-notebook/categories/{cat_id}")
|
||||
assert client.get("/api/v1/question-notebook/categories").json() == []
|
||||
|
||||
|
||||
def test_lookup_entry_by_question(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session())
|
||||
asyncio.run(
|
||||
store.upsert_notebook_entries(
|
||||
session["id"],
|
||||
[
|
||||
{
|
||||
"question_id": "q1",
|
||||
"question": "Q?",
|
||||
"is_correct": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
resp = client.get(
|
||||
"/api/v1/question-notebook/entries/lookup/by-question",
|
||||
params={"session_id": session["id"], "question_id": "q1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["question_id"] == "q1"
|
||||
|
||||
resp404 = client.get(
|
||||
"/api/v1/question-notebook/entries/lookup/by-question",
|
||||
params={"session_id": session["id"], "question_id": "nope"},
|
||||
)
|
||||
assert resp404.status_code == 404
|
||||
|
||||
|
||||
def test_quiz_state_isolated_per_turn(store: SQLiteSessionStore) -> None:
|
||||
"""Regression test for #487 — two quizzes in the same chat session must
|
||||
not share answer state, even when the positional ``question_id`` (e.g.
|
||||
``q_1``) collides. The producing turn_id scopes notebook entries.
|
||||
"""
|
||||
session = asyncio.run(store.create_session())
|
||||
sid = session["id"]
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
first = _quiz_answers()
|
||||
resp1 = client.post(
|
||||
f"/api/v1/sessions/{sid}/quiz-results",
|
||||
json={"answers": first, "turn_id": "turn_A"},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
assert resp1.json()["notebook_count"] == 2
|
||||
|
||||
second = _quiz_answers()
|
||||
second[0]["user_answer"] = ""
|
||||
second[0]["is_correct"] = False
|
||||
resp2 = client.post(
|
||||
f"/api/v1/sessions/{sid}/quiz-results",
|
||||
json={"answers": second, "turn_id": "turn_B"},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
assert resp2.json()["notebook_count"] == 2
|
||||
|
||||
listing = client.get("/api/v1/question-notebook/entries").json()
|
||||
assert listing["total"] == 4
|
||||
|
||||
# Looking up q1 scoped to the first turn returns the first quiz's
|
||||
# answer, not the second.
|
||||
scoped_a = client.get(
|
||||
"/api/v1/question-notebook/entries/lookup/by-question",
|
||||
params={"session_id": sid, "question_id": "q1", "turn_id": "turn_A"},
|
||||
)
|
||||
assert scoped_a.status_code == 200
|
||||
assert scoped_a.json()["user_answer"] == "A"
|
||||
assert scoped_a.json()["turn_id"] == "turn_A"
|
||||
|
||||
# The second turn has no recorded answer for q1.
|
||||
scoped_b = client.get(
|
||||
"/api/v1/question-notebook/entries/lookup/by-question",
|
||||
params={"session_id": sid, "question_id": "q1", "turn_id": "turn_B"},
|
||||
)
|
||||
assert scoped_b.status_code == 200
|
||||
assert scoped_b.json()["user_answer"] == ""
|
||||
assert scoped_b.json()["turn_id"] == "turn_B"
|
||||
|
||||
|
||||
def test_lookup_without_turn_id_falls_back_to_latest(
|
||||
store: SQLiteSessionStore,
|
||||
) -> None:
|
||||
"""Callers that don't pass turn_id (legacy entries / external API) get the
|
||||
most recently updated matching entry — deterministic even when multiple
|
||||
turns share a question_id."""
|
||||
session = asyncio.run(store.create_session())
|
||||
sid = session["id"]
|
||||
|
||||
asyncio.run(
|
||||
store.upsert_notebook_entries(
|
||||
sid,
|
||||
[
|
||||
{
|
||||
"turn_id": "turn_old",
|
||||
"question_id": "q1",
|
||||
"question": "Q?",
|
||||
"user_answer": "A",
|
||||
"is_correct": False,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
asyncio.run(
|
||||
store.upsert_notebook_entries(
|
||||
sid,
|
||||
[
|
||||
{
|
||||
"turn_id": "turn_new",
|
||||
"question_id": "q1",
|
||||
"question": "Q?",
|
||||
"user_answer": "B",
|
||||
"is_correct": True,
|
||||
}
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
with TestClient(_build_app(store)) as client:
|
||||
resp = client.get(
|
||||
"/api/v1/question-notebook/entries/lookup/by-question",
|
||||
params={"session_id": sid, "question_id": "q1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["turn_id"] == "turn_new"
|
||||
assert body["user_answer"] == "B"
|
||||
|
||||
|
||||
def test_lookup_missing_entry_returns_404_by_default(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session())
|
||||
sid = session["id"]
|
||||
with TestClient(_build_app(store)) as client:
|
||||
resp = client.get(
|
||||
"/api/v1/question-notebook/entries/lookup/by-question",
|
||||
params={"session_id": sid, "question_id": "absent"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_lookup_missing_entry_returns_204_when_missing_ok(store: SQLiteSessionStore) -> None:
|
||||
session = asyncio.run(store.create_session())
|
||||
sid = session["id"]
|
||||
with TestClient(_build_app(store)) as client:
|
||||
resp = client.get(
|
||||
"/api/v1/question-notebook/entries/lookup/by-question",
|
||||
params={"session_id": sid, "question_id": "absent", "missing_ok": "true"},
|
||||
)
|
||||
assert resp.status_code == 204
|
||||
assert resp.content == b""
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for the schema-driven channels endpoint helpers.
|
||||
|
||||
Covers:
|
||||
* ``resolve_config_model``: maps each ``XxxChannel`` to its ``XxxConfig``.
|
||||
* ``inline_refs``: flattens nested model ``$ref``s (slack ``dm`` subtree).
|
||||
* ``collect_secret_fields``: only flags **string-typed** secret-looking keys
|
||||
(so e.g. ``user_token_read_only: bool`` is excluded).
|
||||
* ``GET /api/v1/partners/channels/schema`` integration: shape, snake_case
|
||||
property names, and that telegram/slack/discord schemas survive the trip.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from deeptutor.api.routers._partners_channel_schema import (
|
||||
all_channel_schemas,
|
||||
channel_schema_payload,
|
||||
collect_secret_fields,
|
||||
inline_refs,
|
||||
resolve_config_model,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveConfigModel:
|
||||
def test_telegram_pairs_with_telegram_config(self) -> None:
|
||||
from deeptutor.partners.channels.telegram import TelegramChannel, TelegramConfig
|
||||
|
||||
assert resolve_config_model(TelegramChannel) is TelegramConfig
|
||||
|
||||
def test_slack_pairs_with_slack_config(self) -> None:
|
||||
from deeptutor.partners.channels.slack import SlackChannel, SlackConfig
|
||||
|
||||
assert resolve_config_model(SlackChannel) is SlackConfig
|
||||
|
||||
def test_discord_pairs_with_discord_config(self) -> None:
|
||||
from deeptutor.partners.channels.discord import DiscordChannel, DiscordConfig
|
||||
|
||||
assert resolve_config_model(DiscordChannel) is DiscordConfig
|
||||
|
||||
|
||||
class TestInlineRefs:
|
||||
def test_inlines_simple_def(self) -> None:
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {"dm": {"$ref": "#/$defs/SlackDMConfig"}},
|
||||
"$defs": {
|
||||
"SlackDMConfig": {
|
||||
"type": "object",
|
||||
"properties": {"enabled": {"type": "boolean"}},
|
||||
}
|
||||
},
|
||||
}
|
||||
out = inline_refs(schema)
|
||||
assert "$defs" not in out
|
||||
assert out["properties"]["dm"]["type"] == "object"
|
||||
assert out["properties"]["dm"]["properties"]["enabled"]["type"] == "boolean"
|
||||
|
||||
def test_per_field_overrides_take_precedence(self) -> None:
|
||||
# Pydantic sometimes emits {"$ref": "...", "description": "..."}; the
|
||||
# description should override the referenced model's description.
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"child": {"$ref": "#/$defs/Foo", "description": "override"},
|
||||
},
|
||||
"$defs": {
|
||||
"Foo": {"type": "object", "description": "original"},
|
||||
},
|
||||
}
|
||||
out = inline_refs(schema)
|
||||
assert out["properties"]["child"]["description"] == "override"
|
||||
|
||||
|
||||
class TestCollectSecretFields:
|
||||
def test_flags_string_token_field(self) -> None:
|
||||
schema = {
|
||||
"properties": {
|
||||
"token": {"type": "string"},
|
||||
"enabled": {"type": "boolean"},
|
||||
}
|
||||
}
|
||||
assert collect_secret_fields(schema) == ["token"]
|
||||
|
||||
def test_skips_boolean_with_secret_substring(self) -> None:
|
||||
# Slack's ``user_token_read_only`` is a flag, not a secret.
|
||||
schema = {
|
||||
"properties": {
|
||||
"user_token_read_only": {"type": "boolean"},
|
||||
"bot_token": {"type": "string"},
|
||||
}
|
||||
}
|
||||
assert collect_secret_fields(schema) == ["bot_token"]
|
||||
|
||||
def test_walks_nested_objects(self) -> None:
|
||||
schema = {
|
||||
"properties": {
|
||||
"dm": {
|
||||
"type": "object",
|
||||
"properties": {"webhook_secret": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
assert collect_secret_fields(schema) == ["dm.webhook_secret"]
|
||||
|
||||
def test_handles_nullable_strings(self) -> None:
|
||||
# Pydantic's ``Optional[str]`` becomes ``anyOf: [{type: string}, {type: null}]``.
|
||||
schema = {
|
||||
"properties": {
|
||||
"encrypt_key": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
}
|
||||
}
|
||||
assert collect_secret_fields(schema) == ["encrypt_key"]
|
||||
|
||||
|
||||
class TestChannelSchemaPayload:
|
||||
def test_telegram_payload_shape(self) -> None:
|
||||
from deeptutor.partners.channels.telegram import TelegramChannel
|
||||
|
||||
payload = channel_schema_payload(TelegramChannel)
|
||||
assert payload is not None
|
||||
assert payload["name"] == "telegram"
|
||||
assert payload["display_name"] == "Telegram"
|
||||
assert payload["secret_fields"] == ["token"]
|
||||
# Snake_case wire format (matches the storage form).
|
||||
props = payload["json_schema"]["properties"]
|
||||
assert "allow_from" in props and "allowFrom" not in props
|
||||
assert payload["default_config"]["enabled"] is False
|
||||
|
||||
def test_slack_dm_subtree_inlined(self) -> None:
|
||||
from deeptutor.partners.channels.slack import SlackChannel
|
||||
|
||||
payload = channel_schema_payload(SlackChannel)
|
||||
assert payload is not None
|
||||
dm = payload["json_schema"]["properties"]["dm"]
|
||||
assert dm["type"] == "object"
|
||||
assert "enabled" in dm["properties"]
|
||||
# Bool flags whose names contain "token" must NOT be flagged secret.
|
||||
assert "user_token_read_only" not in payload["secret_fields"]
|
||||
assert "bot_token" in payload["secret_fields"]
|
||||
|
||||
|
||||
class TestEndpoint:
|
||||
@pytest.fixture
|
||||
def client(self) -> TestClient:
|
||||
# Build a minimal FastAPI app with just the partners router; the
|
||||
# ``/channels/schema`` endpoint doesn't touch the manager so no
|
||||
# fixturing of ``get_partner_manager`` is needed.
|
||||
from fastapi import FastAPI
|
||||
|
||||
from deeptutor.api.routers import partners as partners_router
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(partners_router.router, prefix="/api/v1/partners")
|
||||
return TestClient(app)
|
||||
|
||||
def test_returns_channels_only(self, client: TestClient) -> None:
|
||||
res = client.get("/api/v1/partners/channels/schema")
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert set(body.keys()) == {"channels"}
|
||||
# Telegram is always installed (no extra deps).
|
||||
assert "telegram" in body["channels"]
|
||||
|
||||
def test_telegram_entry_has_secret_fields(self, client: TestClient) -> None:
|
||||
res = client.get("/api/v1/partners/channels/schema")
|
||||
tg = res.json()["channels"]["telegram"]
|
||||
assert tg["secret_fields"] == ["token"]
|
||||
assert "token" in tg["json_schema"]["properties"]
|
||||
|
||||
def test_delivery_flags_are_per_channel(self, client: TestClient) -> None:
|
||||
res = client.get("/api/v1/partners/channels/schema")
|
||||
props = res.json()["channels"]["telegram"]["json_schema"]["properties"]
|
||||
assert "send_progress" in props
|
||||
assert "send_tool_hints" in props
|
||||
|
||||
|
||||
class TestAllChannelSchemas:
|
||||
def test_returns_at_least_telegram(self) -> None:
|
||||
out = all_channel_schemas()
|
||||
assert "telegram" in out
|
||||
# Every payload has the four documented keys.
|
||||
for entry in out.values():
|
||||
assert {
|
||||
"name",
|
||||
"display_name",
|
||||
"default_config",
|
||||
"secret_fields",
|
||||
"json_schema",
|
||||
} <= entry.keys()
|
||||
@@ -0,0 +1,384 @@
|
||||
"""API surface tests for /api/v1/partners (create / config / soul / assets)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
except Exception: # pragma: no cover
|
||||
FastAPI = None
|
||||
TestClient = None
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
FastAPI is None or TestClient is None, reason="fastapi not installed"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_root(tmp_path, monkeypatch) -> Path:
|
||||
from deeptutor.multi_user import paths
|
||||
|
||||
project_root = tmp_path
|
||||
admin_root = (project_root / "data").resolve()
|
||||
monkeypatch.setattr(paths, "PROJECT_ROOT", project_root)
|
||||
monkeypatch.setattr(paths, "ADMIN_WORKSPACE_ROOT", admin_root)
|
||||
monkeypatch.setattr(paths, "USERS_ROOT", admin_root / "users")
|
||||
monkeypatch.setattr(paths, "SYSTEM_ROOT", admin_root / "system")
|
||||
monkeypatch.setattr(paths, "_path_services", {})
|
||||
admin_root.mkdir(parents=True, exist_ok=True)
|
||||
return admin_root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(isolated_root, monkeypatch) -> TestClient:
|
||||
import deeptutor.api.routers.partners as partners_router_mod
|
||||
from deeptutor.services.partners.manager import PartnerManager
|
||||
|
||||
# Fresh manager per test so the module-level singleton can't leak
|
||||
# tmp-path state across tests.
|
||||
mgr = PartnerManager()
|
||||
monkeypatch.setattr(partners_router_mod, "get_partner_manager", lambda: mgr)
|
||||
partners_router_mod._start_locks.clear()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(partners_router_mod.router, prefix="/api/v1/partners")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _create(client: TestClient, **overrides):
|
||||
payload = {
|
||||
"name": "Ada",
|
||||
"description": "study partner",
|
||||
"soul": {"source": "custom", "content": "# Soul\nBe rigorous."},
|
||||
"start": False,
|
||||
**overrides,
|
||||
}
|
||||
return client.post("/api/v1/partners", json=payload)
|
||||
|
||||
|
||||
class TestCreate:
|
||||
def test_create_returns_masked_config(self, client):
|
||||
res = _create(
|
||||
client,
|
||||
channels={"telegram": {"enabled": True, "token": "123:ABC"}},
|
||||
enabled_tools=["web_search"],
|
||||
mcp_tools=[],
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = res.json()
|
||||
assert body["partner_id"] == "ada"
|
||||
assert body["channels"]["telegram"]["token"] == "***"
|
||||
assert body["enabled_tools"] == ["web_search"]
|
||||
assert body["mcp_tools"] == []
|
||||
assert body["soul_origin"] == {"type": "custom", "id": ""}
|
||||
assert body["provisioning"]["errors"] == []
|
||||
|
||||
def test_duplicate_id_conflicts(self, client):
|
||||
assert _create(client).status_code == 200
|
||||
assert _create(client).status_code == 409
|
||||
|
||||
def test_top_level_delivery_flags_rejected(self, client):
|
||||
res = _create(client, channels={"send_progress": False})
|
||||
assert res.status_code == 422
|
||||
|
||||
def test_create_from_library_soul(self, client):
|
||||
res = _create(
|
||||
client,
|
||||
partner_id="mathy",
|
||||
soul={"source": "library", "id": "math-tutor"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
soul = client.get("/api/v1/partners/mathy/soul").json()
|
||||
assert "math tutor" in soul["content"].lower()
|
||||
|
||||
def test_create_with_unknown_library_soul_404(self, client):
|
||||
res = _create(client, soul={"source": "library", "id": "ghost"})
|
||||
assert res.status_code == 404
|
||||
|
||||
|
||||
class TestConfigAndSoul:
|
||||
def test_get_masks_secrets_by_default(self, client):
|
||||
_create(client, channels={"telegram": {"enabled": True, "token": "raw"}})
|
||||
body = client.get("/api/v1/partners/ada").json()
|
||||
assert body["channels"]["telegram"]["token"] == "***"
|
||||
body = client.get("/api/v1/partners/ada?include_secrets=true").json()
|
||||
assert body["channels"]["telegram"]["token"] == "raw"
|
||||
|
||||
def test_patch_updates_tools_and_clears(self, client):
|
||||
_create(client, enabled_tools=["web_search", "paper_search"])
|
||||
res = client.patch(
|
||||
"/api/v1/partners/ada",
|
||||
json={"enabled_tools": [], "mcp_tools": ["mcp_x_y"]},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
body = client.get("/api/v1/partners/ada").json()
|
||||
assert body["enabled_tools"] == []
|
||||
assert body["mcp_tools"] == ["mcp_x_y"]
|
||||
|
||||
def test_builtin_tools_create_and_patch(self, client):
|
||||
res = _create(client, builtin_tools=["rag", "read_memory"])
|
||||
assert res.status_code == 200
|
||||
assert res.json()["builtin_tools"] == ["rag", "read_memory"]
|
||||
# Default (omitted) stays null = no gating; an explicit deny persists.
|
||||
_create(client, partner_id="bob", name="Bob")
|
||||
assert client.get("/api/v1/partners/bob").json()["builtin_tools"] is None
|
||||
res = client.patch("/api/v1/partners/ada", json={"builtin_tools": []})
|
||||
assert res.status_code == 200
|
||||
assert client.get("/api/v1/partners/ada").json()["builtin_tools"] == []
|
||||
|
||||
def test_tool_options_exposes_builtin_tools(self, client):
|
||||
body = client.get("/api/v1/partners/tool-options").json()
|
||||
assert {"tools", "builtin_tools", "mcp_tools"} <= set(body)
|
||||
builtin_names = {t["name"] for t in body["builtin_tools"]}
|
||||
# rag stays owner-configurable; the chat memory tools are NOT — partners
|
||||
# use the mandatory partner_read / partner_memorize / partner_search
|
||||
# instead, so they never surface in the partner config UI.
|
||||
assert "rag" in builtin_names
|
||||
assert "read_memory" not in builtin_names
|
||||
assert "write_memory" not in builtin_names
|
||||
|
||||
def test_avatar_roundtrip_and_validation(self, client):
|
||||
_create(client)
|
||||
avatar = "data:image/png;base64,iVBORw0KGgo="
|
||||
res = client.patch("/api/v1/partners/ada", json={"avatar": avatar})
|
||||
assert res.status_code == 200
|
||||
assert client.get("/api/v1/partners/ada").json()["avatar"] == avatar
|
||||
|
||||
# Clearing works; junk and oversized payloads are rejected.
|
||||
assert client.patch("/api/v1/partners/ada", json={"avatar": ""}).status_code == 200
|
||||
assert client.get("/api/v1/partners/ada").json()["avatar"] == ""
|
||||
res = client.patch("/api/v1/partners/ada", json={"avatar": "https://evil.example/x.png"})
|
||||
assert res.status_code == 422
|
||||
res = client.patch(
|
||||
"/api/v1/partners/ada",
|
||||
json={"avatar": "data:image/png;base64," + "A" * 200_001},
|
||||
)
|
||||
assert res.status_code == 422
|
||||
|
||||
def test_soul_roundtrip(self, client):
|
||||
_create(client)
|
||||
res = client.put("/api/v1/partners/ada/soul", json={"content": "# Soul\nUpdated."})
|
||||
assert res.status_code == 200
|
||||
assert client.get("/api/v1/partners/ada/soul").json()["content"] == "# Soul\nUpdated."
|
||||
|
||||
def test_404_for_unknown_partner(self, client):
|
||||
assert client.get("/api/v1/partners/ghost").status_code == 404
|
||||
assert client.get("/api/v1/partners/ghost/soul").status_code == 404
|
||||
|
||||
|
||||
class TestAssets:
|
||||
def _seed_skill(self, admin_root: Path, name="focus"):
|
||||
skill = admin_root / "user" / "workspace" / "skills" / name
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: d\n---\nBody", encoding="utf-8"
|
||||
)
|
||||
|
||||
def test_add_list_remove_assets(self, client, isolated_root):
|
||||
self._seed_skill(isolated_root)
|
||||
_create(client)
|
||||
|
||||
res = client.post("/api/v1/partners/ada/assets", json={"skills": ["focus"]})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["copied"]["skills"] == ["focus"]
|
||||
assert [s["name"] for s in res.json()["assets"]["skills"]] == ["focus"]
|
||||
|
||||
res = client.delete("/api/v1/partners/ada/assets/skill/focus")
|
||||
assert res.status_code == 200
|
||||
assert res.json()["assets"]["skills"] == []
|
||||
|
||||
def test_unknown_asset_reported_in_errors(self, client):
|
||||
_create(client)
|
||||
res = client.post("/api/v1/partners/ada/assets", json={"skills": ["ghost"]})
|
||||
assert res.status_code == 200
|
||||
assert res.json()["errors"][0]["type"] == "skill"
|
||||
|
||||
|
||||
class TestSoulLibraryEndpoints:
|
||||
def test_souls_crud(self, client):
|
||||
res = client.get("/api/v1/partners/souls")
|
||||
assert res.status_code == 200
|
||||
assert any(s["id"] == "math-tutor" for s in res.json())
|
||||
|
||||
res = client.post(
|
||||
"/api/v1/partners/souls",
|
||||
json={"id": "custom-soul", "name": "Custom", "content": "# Soul"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert client.get("/api/v1/partners/souls/custom-soul").status_code == 200
|
||||
assert (
|
||||
client.put("/api/v1/partners/souls/custom-soul", json={"name": "Renamed"}).json()[
|
||||
"name"
|
||||
]
|
||||
== "Renamed"
|
||||
)
|
||||
assert client.delete("/api/v1/partners/souls/custom-soul").status_code == 200
|
||||
assert client.get("/api/v1/partners/souls/custom-soul").status_code == 404
|
||||
|
||||
def test_soul_cjk_id_is_ascii_safe(self, client):
|
||||
# A pure-CJK soul name must not become a non-ASCII (unreachable) id: the
|
||||
# server slugs it authoritatively and the returned id is URL-safe.
|
||||
res = client.post(
|
||||
"/api/v1/partners/souls",
|
||||
json={"id": "我的灵魂", "name": "我的灵魂", "content": "# Soul"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
soul_id = res.json()["id"]
|
||||
assert soul_id.isascii() and soul_id.startswith("soul-")
|
||||
# …and the soul is reachable / deletable by that returned id.
|
||||
assert client.get(f"/api/v1/partners/souls/{soul_id}").status_code == 200
|
||||
assert client.delete(f"/api/v1/partners/souls/{soul_id}").status_code == 200
|
||||
|
||||
def test_soul_sources_shape(self, client):
|
||||
body = client.get("/api/v1/partners/soul-sources").json()
|
||||
assert "library" in body and "personas" in body
|
||||
|
||||
|
||||
class TestHistory:
|
||||
def test_history_reads_session_store(self, client, isolated_root):
|
||||
_create(client)
|
||||
sessions = isolated_root / "partners" / "ada" / "sessions"
|
||||
sessions.mkdir(parents=True, exist_ok=True)
|
||||
(sessions / "telegram_42.jsonl").write_text(
|
||||
json.dumps({"role": "user", "content": "hi", "timestamp": "2026-01-01T00:00:00"})
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
res = client.get("/api/v1/partners/ada/history")
|
||||
assert res.status_code == 200
|
||||
assert res.json()[0]["content"] == "hi"
|
||||
|
||||
def test_history_scoped_by_web_session_id(self, client, isolated_root):
|
||||
_create(client)
|
||||
sessions = isolated_root / "partners" / "ada" / "sessions"
|
||||
sessions.mkdir(parents=True, exist_ok=True)
|
||||
# Two distinct web sessions; the endpoint must scope to the one asked for.
|
||||
(sessions / "web_s1.jsonl").write_text(
|
||||
json.dumps({"role": "user", "content": "from s1", "timestamp": "t"}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(sessions / "web_s2.jsonl").write_text(
|
||||
json.dumps({"role": "user", "content": "from s2", "timestamp": "t"}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
res = client.get("/api/v1/partners/ada/history?session_id=s1")
|
||||
assert res.status_code == 200
|
||||
contents = [m["content"] for m in res.json()]
|
||||
assert contents == ["from s1"]
|
||||
|
||||
def test_sessions_list_carries_title(self, client, isolated_root):
|
||||
_create(client)
|
||||
sessions = isolated_root / "partners" / "ada" / "sessions"
|
||||
sessions.mkdir(parents=True, exist_ok=True)
|
||||
(sessions / "web_s1.jsonl").write_text(
|
||||
json.dumps({"role": "user", "content": "what is recursion?", "timestamp": "t"}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
res = client.get("/api/v1/partners/ada/sessions")
|
||||
assert res.status_code == 200
|
||||
assert res.json()[0]["title"] == "what is recursion?"
|
||||
|
||||
def _seed_session(self, isolated_root: Path, key: str, content: str) -> None:
|
||||
sessions = isolated_root / "partners" / "ada" / "sessions"
|
||||
sessions.mkdir(parents=True, exist_ok=True)
|
||||
(sessions / f"{key}.jsonl").write_text(
|
||||
json.dumps({"role": "user", "content": content, "timestamp": "t"}) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def test_archive_then_resume_roundtrip(self, client, isolated_root):
|
||||
_create(client)
|
||||
self._seed_session(isolated_root, "web-a", "hi")
|
||||
assert (
|
||||
client.post("/api/v1/partners/ada/sessions/archive", json={"session_key": "web-a"})
|
||||
).status_code == 200
|
||||
archived = {s["session_key"]: s for s in client.get("/api/v1/partners/ada/sessions").json()}
|
||||
assert archived["web-a"]["archived"] is True
|
||||
assert (
|
||||
client.post("/api/v1/partners/ada/sessions/resume", json={"session_key": "web-a"})
|
||||
).status_code == 200
|
||||
live = {s["session_key"]: s for s in client.get("/api/v1/partners/ada/sessions").json()}
|
||||
assert live["web-a"]["archived"] is False
|
||||
|
||||
def test_branch_copies_and_archives(self, client, isolated_root):
|
||||
_create(client)
|
||||
self._seed_session(isolated_root, "web-a", "carry me")
|
||||
res = client.post(
|
||||
"/api/v1/partners/ada/sessions/branch",
|
||||
json={"source_key": "web-a", "new_key": "web-b"},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert res.json()["session"]["session_key"] == "web-b"
|
||||
hist = client.get("/api/v1/partners/ada/history?session_key=web-b").json()
|
||||
assert [m["content"] for m in hist] == ["carry me"]
|
||||
sessions = {s["session_key"]: s for s in client.get("/api/v1/partners/ada/sessions").json()}
|
||||
assert sessions["web-a"]["archived"] is True
|
||||
|
||||
def test_delete_session_endpoint(self, client, isolated_root):
|
||||
_create(client)
|
||||
self._seed_session(isolated_root, "web-a", "bye")
|
||||
assert (
|
||||
client.post("/api/v1/partners/ada/sessions/delete", json={"session_key": "web-a"})
|
||||
).status_code == 200
|
||||
assert client.get("/api/v1/partners/ada/sessions").json() == []
|
||||
# Deleting a missing session is a 404.
|
||||
assert (
|
||||
client.post("/api/v1/partners/ada/sessions/delete", json={"session_key": "web-a"})
|
||||
).status_code == 404
|
||||
|
||||
|
||||
class TestChatAttachments:
|
||||
def test_chat_does_not_auto_start_stopped_partner(self, client):
|
||||
# ``start=True`` spawns a real PartnerRunner task; drive every request
|
||||
# through one shared event loop (context-managed TestClient) so the
|
||||
# runner started by create can be cancelled by stop — otherwise each
|
||||
# request runs on its own loop and the cancel raises a cross-loop error.
|
||||
with client:
|
||||
assert _create(client, start=True).status_code == 200
|
||||
assert client.post("/api/v1/partners/ada/stop").status_code == 200
|
||||
|
||||
res = client.post("/api/v1/partners/ada/chat", json={"content": "hello"})
|
||||
|
||||
assert res.status_code == 409
|
||||
from deeptutor.core.i18n import t
|
||||
|
||||
assert res.json()["detail"] == t("api.partner_stopped_start_required")
|
||||
|
||||
def test_create_start_false_disables_auto_start(self, client, isolated_root):
|
||||
assert _create(client, start=False).status_code == 200
|
||||
|
||||
data = yaml.safe_load(
|
||||
(isolated_root / "partners" / "ada" / "config.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
assert data["auto_start"] is False
|
||||
|
||||
def test_materialize_partner_attachment_writes_partner_media(self, isolated_root):
|
||||
from deeptutor.api.routers.partners import (
|
||||
ChatAttachmentRequest,
|
||||
_materialize_partner_attachments,
|
||||
)
|
||||
|
||||
paths = _materialize_partner_attachments(
|
||||
"ada",
|
||||
[
|
||||
ChatAttachmentRequest(
|
||||
type="file",
|
||||
filename="notes.txt",
|
||||
base64=base64.b64encode(b"hello").decode("ascii"),
|
||||
mime_type="text/plain",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert len(paths) == 1
|
||||
path = Path(paths[0])
|
||||
assert path.read_bytes() == b"hello"
|
||||
assert path.name.endswith("_notes.txt")
|
||||
assert path.parent == isolated_root / "partners" / "ada" / "media" / "web"
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Regression tests for partner-compatible plugin capability streaming."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
except Exception: # pragma: no cover
|
||||
FastAPI = None
|
||||
TestClient = None
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
FastAPI is None or TestClient is None, reason="fastapi not installed"
|
||||
)
|
||||
|
||||
|
||||
def _events(text: str) -> list[tuple[str, dict[str, Any]]]:
|
||||
events: list[tuple[str, dict[str, Any]]] = []
|
||||
for block in text.strip().split("\n\n"):
|
||||
event = ""
|
||||
data = "{}"
|
||||
for line in block.splitlines():
|
||||
if line.startswith("event: "):
|
||||
event = line[len("event: ") :]
|
||||
elif line.startswith("data: "):
|
||||
data = line[len("data: ") :]
|
||||
if event:
|
||||
events.append((event, json.loads(data)))
|
||||
return events
|
||||
|
||||
|
||||
def _client_with_fake_partner(monkeypatch):
|
||||
from deeptutor.core.stream import StreamEvent, StreamEventType
|
||||
from deeptutor.services.partners.manager import PartnerConfig
|
||||
|
||||
class FakeInstance:
|
||||
running = True
|
||||
|
||||
class FakeMgr:
|
||||
def __init__(self):
|
||||
self.calls: list[dict[str, Any]] = []
|
||||
|
||||
def get_partner(self, partner_id: str):
|
||||
return FakeInstance()
|
||||
|
||||
def load_config(self, partner_id: str):
|
||||
return PartnerConfig(name=partner_id)
|
||||
|
||||
async def start_partner(self, partner_id: str, config: PartnerConfig):
|
||||
return FakeInstance()
|
||||
|
||||
async def send_message(self, partner_id: str, content: str, **kwargs):
|
||||
self.calls.append({"partner_id": partner_id, "content": content, **kwargs})
|
||||
on_event = kwargs.get("on_event")
|
||||
if on_event:
|
||||
await on_event(StreamEvent(type=StreamEventType.THINKING, content="thinking"))
|
||||
return "streamed answer"
|
||||
|
||||
mgr = FakeMgr()
|
||||
partners_router_mod = importlib.import_module("deeptutor.api.routers.partners")
|
||||
plugins_router_mod = importlib.import_module("deeptutor.api.routers.plugins_api")
|
||||
monkeypatch.setattr(partners_router_mod, "get_partner_manager", lambda: mgr)
|
||||
partners_router_mod._start_locks.clear()
|
||||
|
||||
app = FastAPI()
|
||||
app.include_router(plugins_router_mod.router, prefix="/api/v1/plugins")
|
||||
return TestClient(app), mgr
|
||||
|
||||
|
||||
def test_plugin_chat_stream_routes_to_specified_partner_session(monkeypatch):
|
||||
client, mgr = _client_with_fake_partner(monkeypatch)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/plugins/capabilities/chat/execute-stream",
|
||||
json={
|
||||
"content": "hi",
|
||||
# Legacy TutorBot HTTP API field name still addresses a partner.
|
||||
"bot_id": "math-bot",
|
||||
"session_id": "lesson-1",
|
||||
"enabledTools": [],
|
||||
"knowledgeBases": [],
|
||||
"language": "zh",
|
||||
"llmSelection": {"profile_id": "p-alt", "model_id": "m-alt"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
events = _events(response.text)
|
||||
assert ("session", {"partner_id": "math-bot", "session_id": "lesson-1"}) in events
|
||||
assert ("thinking", {"content": "thinking"}) in events
|
||||
assert ("content", {"content": "streamed answer"}) in events
|
||||
assert ("done", {"partner_id": "math-bot", "session_id": "lesson-1"}) in events
|
||||
assert len(mgr.calls) == 1
|
||||
call = mgr.calls[0]
|
||||
assert call["partner_id"] == "math-bot"
|
||||
assert call["content"] == "hi"
|
||||
assert call["chat_id"] == "lesson-1"
|
||||
assert call["session_id"] == "lesson-1"
|
||||
assert callable(call["on_event"])
|
||||
|
||||
|
||||
def test_plugin_chat_stream_creates_session_when_missing(monkeypatch):
|
||||
client, mgr = _client_with_fake_partner(monkeypatch)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/plugins/capabilities/chat/execute-stream",
|
||||
json={"content": "first message", "bot_id": "math-bot"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "event: done" in response.text
|
||||
match = re.search(r'"session_id": "([^"]+)"', response.text)
|
||||
assert match is not None
|
||||
session_id = match.group(1)
|
||||
assert session_id != "web"
|
||||
assert mgr.calls[0]["session_id"] == session_id
|
||||
assert mgr.calls[0]["chat_id"] == session_id
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
FastAPI = pytest.importorskip("fastapi").FastAPI
|
||||
TestClient = pytest.importorskip("fastapi.testclient").TestClient
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _cleanup_question_router_module():
|
||||
yield
|
||||
sys.modules.pop("deeptutor.api.routers.question", None)
|
||||
|
||||
|
||||
class _DummyProcessLogEvent:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.data = {"type": "process_log", **kwargs}
|
||||
|
||||
def to_dict(self):
|
||||
return self.data
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _noop_context(*_args, **_kwargs):
|
||||
yield
|
||||
|
||||
|
||||
def _package(name: str) -> types.ModuleType:
|
||||
module = types.ModuleType(name)
|
||||
module.__path__ = []
|
||||
return module
|
||||
|
||||
|
||||
def _load_question_router_module(monkeypatch: pytest.MonkeyPatch):
|
||||
sys.modules.pop("deeptutor.api.routers.question", None)
|
||||
|
||||
fake_agents = _package("deeptutor.agents")
|
||||
fake_agents_question = types.ModuleType("deeptutor.agents.question")
|
||||
fake_agents_question.AgentCoordinator = object
|
||||
fake_agents.question = fake_agents_question
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.agents", fake_agents)
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.agents.question", fake_agents_question)
|
||||
|
||||
fake_logging = _package("deeptutor.logging")
|
||||
fake_logging.ProcessLogEvent = _DummyProcessLogEvent
|
||||
fake_logging.bind_log_context = _noop_context
|
||||
fake_logging.capture_process_logs = _noop_context
|
||||
fake_logging.current_log_context = lambda: {}
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.logging", fake_logging)
|
||||
|
||||
fake_config = types.ModuleType("deeptutor.services.config")
|
||||
fake_config.PROJECT_ROOT = Path.cwd()
|
||||
fake_config.load_config_with_main = lambda *_args, **_kwargs: {}
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.services.config", fake_config)
|
||||
|
||||
fake_llm_package = _package("deeptutor.services.llm")
|
||||
fake_llm_config = types.ModuleType("deeptutor.services.llm.config")
|
||||
fake_llm_config.get_llm_config = lambda: None
|
||||
fake_llm_package.config = fake_llm_config
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.services.llm", fake_llm_package)
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.services.llm.config", fake_llm_config)
|
||||
|
||||
fake_settings_package = _package("deeptutor.services.settings")
|
||||
fake_interface_settings = types.ModuleType("deeptutor.services.settings.interface_settings")
|
||||
fake_interface_settings.get_ui_language = lambda default="en": default
|
||||
fake_settings_package.interface_settings = fake_interface_settings
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.services.settings", fake_settings_package)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"deeptutor.services.settings.interface_settings",
|
||||
fake_interface_settings,
|
||||
)
|
||||
|
||||
fake_tools = _package("deeptutor.tools")
|
||||
fake_tools_question = types.ModuleType("deeptutor.tools.question")
|
||||
|
||||
async def _default_mimic_exam_questions(*_args, **_kwargs):
|
||||
return {"success": True}
|
||||
|
||||
fake_tools_question.mimic_exam_questions = _default_mimic_exam_questions
|
||||
fake_tools.question = fake_tools_question
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.tools", fake_tools)
|
||||
monkeypatch.setitem(sys.modules, "deeptutor.tools.question", fake_tools_question)
|
||||
|
||||
return importlib.import_module("deeptutor.api.routers.question")
|
||||
|
||||
|
||||
def _build_app(router_module) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router, prefix="/api/v1/question")
|
||||
return app
|
||||
|
||||
|
||||
def test_mimic_websocket_accepts_config_and_returns_messages(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
question_router_module = _load_question_router_module(monkeypatch)
|
||||
|
||||
async def _fake_mimic_exam_questions(*_args, **_kwargs):
|
||||
return {"success": False, "error": "stub mimic failure"}
|
||||
|
||||
monkeypatch.setattr(question_router_module, "mimic_exam_questions", _fake_mimic_exam_questions)
|
||||
# ``MIMIC_OUTPUT_DIR`` was a module-level constant resolved at import time
|
||||
# (which froze it to the admin path). It's now a per-call helper so the
|
||||
# path follows whichever user is running. Patch the helper instead.
|
||||
monkeypatch.setattr(
|
||||
question_router_module, "_mimic_output_dir", lambda: tmp_path / "mimic_papers"
|
||||
)
|
||||
|
||||
with TestClient(_build_app(question_router_module)) as client:
|
||||
with client.websocket_connect("/api/v1/question/mimic") as websocket:
|
||||
websocket.send_json(
|
||||
{
|
||||
"mode": "parsed",
|
||||
"paper_path": str(tmp_path / "paper"),
|
||||
"kb_name": "demo-kb",
|
||||
"max_questions": 3,
|
||||
}
|
||||
)
|
||||
messages = [websocket.receive_json() for _ in range(3)]
|
||||
|
||||
assert [message["type"] for message in messages] == ["status", "status", "error"]
|
||||
assert messages[0]["stage"] == "init"
|
||||
assert messages[1]["stage"] == "processing"
|
||||
assert messages[2]["content"] == "stub mimic failure"
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Tests for the selective_access_log middleware in main.py.
|
||||
|
||||
Verifies that non-200 responses are logged with the 5-element args tuple
|
||||
(client_addr, method, full_path, http_version, status_code) — the shape that
|
||||
was needed for uvicorn's AccessFormatter in #334 — while 200s stay silent.
|
||||
|
||||
The real middleware logs through the ``deeptutor.access`` logger, which carries
|
||||
its own stdout handler with ``propagate=False`` (uvicorn's own access log is
|
||||
disabled on every launch path). Because it does not propagate to root, we
|
||||
capture by attaching a handler directly to that logger rather than via caplog.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
ACCESS_LOGGER = "deeptutor.access"
|
||||
|
||||
|
||||
def _build_app_with_middleware():
|
||||
"""Build a minimal app that replicates the selective_access_log middleware."""
|
||||
test_app = FastAPI()
|
||||
_access_logger = logging.getLogger(ACCESS_LOGGER)
|
||||
|
||||
@test_app.middleware("http")
|
||||
async def selective_access_log(request: Request, call_next):
|
||||
response = await call_next(request)
|
||||
if response.status_code != 200:
|
||||
_access_logger.info(
|
||||
'%s - "%s %s HTTP/%s" %d',
|
||||
request.client.host if request.client else "-",
|
||||
request.method,
|
||||
request.url.path,
|
||||
request.scope.get("http_version", "1.1"),
|
||||
response.status_code,
|
||||
)
|
||||
return response
|
||||
|
||||
@test_app.get("/ok")
|
||||
def ok():
|
||||
return {"status": "ok"}
|
||||
|
||||
@test_app.get("/not-found")
|
||||
def not_found():
|
||||
return JSONResponse({"error": "not found"}, status_code=404)
|
||||
|
||||
return test_app
|
||||
|
||||
|
||||
class _RecordCollector(logging.Handler):
|
||||
"""Capture emitted records directly on the access logger (no propagation)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(level=logging.INFO)
|
||||
self.records: list[logging.LogRecord] = []
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
class _CaptureAccess:
|
||||
def __enter__(self) -> _RecordCollector:
|
||||
self._logger = logging.getLogger(ACCESS_LOGGER)
|
||||
self._handler = _RecordCollector()
|
||||
self._prev_level = self._logger.level
|
||||
self._logger.setLevel(logging.INFO)
|
||||
self._logger.addHandler(self._handler)
|
||||
return self._handler
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self._logger.removeHandler(self._handler)
|
||||
self._logger.setLevel(self._prev_level)
|
||||
|
||||
|
||||
class TestSelectiveAccessLog:
|
||||
"""selective_access_log middleware must emit 5-arg tuples for the formatter."""
|
||||
|
||||
def test_non_200_log_has_five_args(self):
|
||||
"""Non-200 response log record args must have 5 elements (#334)."""
|
||||
app = _build_app_with_middleware()
|
||||
with _CaptureAccess() as cap:
|
||||
with TestClient(app) as client:
|
||||
client.get("/not-found")
|
||||
|
||||
assert len(cap.records) >= 1
|
||||
record = cap.records[0]
|
||||
assert len(record.args) == 5, (
|
||||
f"Expected 5-element args for AccessFormatter, got {len(record.args)}"
|
||||
)
|
||||
client_addr, method, path, http_version, status_code = record.args
|
||||
assert method == "GET"
|
||||
assert path == "/not-found"
|
||||
assert http_version in ("1.0", "1.1", "2")
|
||||
assert status_code == 404
|
||||
|
||||
def test_200_not_logged(self):
|
||||
"""200 responses should not produce deeptutor.access log records."""
|
||||
app = _build_app_with_middleware()
|
||||
with _CaptureAccess() as cap:
|
||||
with TestClient(app) as client:
|
||||
client.get("/ok")
|
||||
|
||||
ok_records = [
|
||||
r for r in cap.records if r.args and len(r.args) >= 3 and "/ok" in str(r.args[2])
|
||||
]
|
||||
assert len(ok_records) == 0
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for oversized event-payload truncation in the session GET response.
|
||||
|
||||
Covers ``_truncate_oversized_events`` (introduced via PR #524 and refactored to
|
||||
operate directly on the store's parsed ``events`` list). The session store
|
||||
returns each message with an ``events`` list and no ``events_json`` key, so the
|
||||
helper mutates that list in place.
|
||||
"""
|
||||
|
||||
from deeptutor.api.routers.sessions import (
|
||||
_TRUNCATION_NOTICE,
|
||||
MAX_EVENT_PAYLOAD,
|
||||
_truncate_oversized_events,
|
||||
)
|
||||
|
||||
|
||||
def _big(n: int) -> str:
|
||||
return "x" * n
|
||||
|
||||
|
||||
def test_truncates_oversized_tool_result_content():
|
||||
big = _big(MAX_EVENT_PAYLOAD + 100)
|
||||
messages = [{"events": [{"type": "tool_result", "content": big}]}]
|
||||
|
||||
_truncate_oversized_events(messages)
|
||||
|
||||
event = messages[0]["events"][0]
|
||||
assert len(event["content"]) == MAX_EVENT_PAYLOAD + len(_TRUNCATION_NOTICE)
|
||||
assert event["content"].endswith(_TRUNCATION_NOTICE)
|
||||
assert event["_truncated"] is True
|
||||
|
||||
|
||||
def test_truncates_nested_tool_metadata_fields():
|
||||
big = _big(MAX_EVENT_PAYLOAD + 1)
|
||||
messages = [
|
||||
{
|
||||
"events": [
|
||||
{
|
||||
"type": "observation",
|
||||
"content": "short",
|
||||
"metadata": {"tool_metadata": {"content": big, "answer": big}},
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
_truncate_oversized_events(messages)
|
||||
|
||||
tm = messages[0]["events"][0]["metadata"]["tool_metadata"]
|
||||
assert tm["content"].endswith(_TRUNCATION_NOTICE)
|
||||
assert tm["answer"].endswith(_TRUNCATION_NOTICE)
|
||||
assert messages[0]["events"][0]["_truncated"] is True
|
||||
# Untouched short top-level content stays intact.
|
||||
assert messages[0]["events"][0]["content"] == "short"
|
||||
|
||||
|
||||
def test_small_payloads_are_left_untouched():
|
||||
messages = [
|
||||
{"events": [{"type": "tool_result", "content": "tiny"}]},
|
||||
{"events": [{"type": "thinking", "content": _big(MAX_EVENT_PAYLOAD + 5)}]},
|
||||
]
|
||||
|
||||
_truncate_oversized_events(messages)
|
||||
|
||||
# Small payload unchanged, no truncation marker added.
|
||||
assert messages[0]["events"][0]["content"] == "tiny"
|
||||
assert "_truncated" not in messages[0]["events"][0]
|
||||
# Non-truncatable event type left alone even when oversized.
|
||||
assert "_truncated" not in messages[1]["events"][0]
|
||||
assert len(messages[1]["events"][0]["content"]) == MAX_EVENT_PAYLOAD + 5
|
||||
|
||||
|
||||
def test_handles_messages_without_events_or_malformed_events():
|
||||
messages = [
|
||||
{"role": "user", "content": "hi"}, # no events key
|
||||
{"events": None},
|
||||
{"events": "not-a-list"},
|
||||
{"events": ["not-a-dict", {"type": "tool_result"}]}, # missing content
|
||||
]
|
||||
|
||||
# Must not raise.
|
||||
_truncate_oversized_events(messages)
|
||||
|
||||
# Event with no content gains no spurious content key.
|
||||
assert "content" not in messages[3]["events"][1]
|
||||
@@ -0,0 +1,653 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from deeptutor.api.routers import settings as settings_router
|
||||
from deeptutor.services.config.provider_runtime import (
|
||||
ResolvedEmbeddingConfig,
|
||||
ResolvedLLMConfig,
|
||||
)
|
||||
from deeptutor.services.config.runtime_settings import RuntimeSettingsService
|
||||
from deeptutor.services.embedding import client as embedding_client_module
|
||||
from deeptutor.services.embedding import config as embedding_config_module
|
||||
from deeptutor.services.llm import client as llm_client_module
|
||||
from deeptutor.services.llm import config as llm_config_module
|
||||
|
||||
|
||||
class _FakeEmbeddingAdapter:
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.config = config
|
||||
|
||||
async def embed(self, request):
|
||||
return type("EmbeddingResponse", (), {"embeddings": [[] for _ in request.texts]})()
|
||||
|
||||
|
||||
class _FakeCatalogService:
|
||||
def __init__(self, catalog: dict[str, Any]):
|
||||
self._catalog = deepcopy(catalog)
|
||||
|
||||
def save(self, catalog: dict[str, Any]) -> dict[str, Any]:
|
||||
self._catalog = deepcopy(catalog)
|
||||
return deepcopy(self._catalog)
|
||||
|
||||
def load(self) -> dict[str, Any]:
|
||||
return deepcopy(self._catalog)
|
||||
|
||||
def apply(self, catalog: dict[str, Any]) -> dict[str, Any]:
|
||||
current = self.save(catalog)
|
||||
return {
|
||||
"catalog_path": "memory://model_catalog.json",
|
||||
"services": list(current["services"]),
|
||||
}
|
||||
|
||||
|
||||
def _build_catalog(
|
||||
*,
|
||||
llm_model: str,
|
||||
llm_base_url: str,
|
||||
llm_api_key: str,
|
||||
embedding_model: str,
|
||||
embedding_base_url: str,
|
||||
embedding_api_key: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"version": 1,
|
||||
"services": {
|
||||
"llm": {
|
||||
"active_profile_id": "llm-profile-default",
|
||||
"active_model_id": "llm-model-default",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "llm-profile-default",
|
||||
"name": "Default LLM Endpoint",
|
||||
"binding": "openai",
|
||||
"base_url": llm_base_url,
|
||||
"api_key": llm_api_key,
|
||||
"api_version": "",
|
||||
"extra_headers": {},
|
||||
"models": [
|
||||
{
|
||||
"id": "llm-model-default",
|
||||
"name": llm_model,
|
||||
"model": llm_model,
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"embedding": {
|
||||
"active_profile_id": "embedding-profile-default",
|
||||
"active_model_id": "embedding-model-default",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "embedding-profile-default",
|
||||
"name": "Default Embedding Endpoint",
|
||||
"binding": "openai",
|
||||
"base_url": embedding_base_url,
|
||||
"api_key": embedding_api_key,
|
||||
"api_version": "",
|
||||
"extra_headers": {},
|
||||
"models": [
|
||||
{
|
||||
"id": "embedding-model-default",
|
||||
"name": embedding_model,
|
||||
"model": embedding_model,
|
||||
"dimension": "1536",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
"search": {
|
||||
"active_profile_id": None,
|
||||
"profiles": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _patch_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
service: _FakeCatalogService,
|
||||
) -> None:
|
||||
monkeypatch.setattr(settings_router, "get_model_catalog_service", lambda: service)
|
||||
monkeypatch.setattr(
|
||||
embedding_client_module,
|
||||
"_resolve_adapter_class",
|
||||
lambda _binding: _FakeEmbeddingAdapter,
|
||||
)
|
||||
|
||||
def _resolve_llm_runtime_config() -> ResolvedLLMConfig:
|
||||
catalog = service.load()
|
||||
profile = catalog["services"]["llm"]["profiles"][0]
|
||||
model = profile["models"][0]
|
||||
return ResolvedLLMConfig(
|
||||
model=model["model"],
|
||||
provider_name=profile["binding"],
|
||||
provider_mode="standard",
|
||||
binding_hint=profile["binding"],
|
||||
binding=profile["binding"],
|
||||
api_key=profile["api_key"],
|
||||
base_url=profile["base_url"],
|
||||
effective_url=profile["base_url"],
|
||||
api_version=None,
|
||||
extra_headers={},
|
||||
reasoning_effort=None,
|
||||
)
|
||||
|
||||
def _resolve_embedding_runtime_config() -> ResolvedEmbeddingConfig:
|
||||
catalog = service.load()
|
||||
profile = catalog["services"]["embedding"]["profiles"][0]
|
||||
model = profile["models"][0]
|
||||
return ResolvedEmbeddingConfig(
|
||||
model=model["model"],
|
||||
provider_name=profile["binding"],
|
||||
provider_mode="standard",
|
||||
binding_hint=profile["binding"],
|
||||
binding=profile["binding"],
|
||||
api_key=profile["api_key"],
|
||||
base_url=profile["base_url"],
|
||||
effective_url=profile["base_url"],
|
||||
api_version=None,
|
||||
extra_headers={},
|
||||
dimension=int(model["dimension"]),
|
||||
request_timeout=60,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
llm_config_module,
|
||||
"resolve_llm_runtime_config",
|
||||
_resolve_llm_runtime_config,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
embedding_config_module,
|
||||
"resolve_embedding_runtime_config",
|
||||
_resolve_embedding_runtime_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_network_settings_roundtrip_normalizes_cors_origins(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
|
||||
service.save_system({"backend_port": 8001, "frontend_port": 3782})
|
||||
service.save_auth({"enabled": True, "cookie_secure": True})
|
||||
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
|
||||
|
||||
payload = settings_router.NetworkSettingsUpdate(
|
||||
backend_port=8101,
|
||||
frontend_port=3882,
|
||||
public_api_base="https://api.example.com/deeptutor",
|
||||
cors_origins=["app.example.com; https://learn.example.com/path"],
|
||||
)
|
||||
|
||||
response = await settings_router.update_network_settings(payload)
|
||||
|
||||
assert response["settings"]["backend_port"] == 8101
|
||||
assert response["settings"]["public_api_base"] == "https://api.example.com/deeptutor"
|
||||
assert response["settings"]["cors_origins"] == [
|
||||
"http://app.example.com",
|
||||
"https://learn.example.com",
|
||||
]
|
||||
assert response["effective"]["cors_mode"] == "explicit"
|
||||
assert response["auth"]["cross_site_cookie_ready"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mineru_settings_roundtrip_redacts_token(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
|
||||
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
|
||||
|
||||
payload = settings_router.MinerUSettingsUpdate(
|
||||
mode="cloud",
|
||||
api_base_url="https://mineru.net/",
|
||||
api_token="secret-token",
|
||||
model_version="vlm",
|
||||
)
|
||||
response = await settings_router.update_mineru_settings(payload)
|
||||
|
||||
# The raw token never leaves the backend; only a boolean flag does.
|
||||
assert response["api_token_set"] is True
|
||||
assert "api_token" not in response["settings"]
|
||||
assert response["settings"]["mode"] == "cloud"
|
||||
assert response["settings"]["api_base_url"] == "https://mineru.net"
|
||||
assert response["settings"]["model_version"] == "vlm"
|
||||
# Persisted on disk under the canonical key.
|
||||
assert service.load_mineru()["api_token"] == "secret-token"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mineru_token_tristate_keep_then_clear(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
|
||||
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
|
||||
service.save_mineru({"mode": "cloud", "api_token": "keep-me"})
|
||||
|
||||
# api_token=None → keep the stored token.
|
||||
await settings_router.update_mineru_settings(
|
||||
settings_router.MinerUSettingsUpdate(mode="cloud", api_token=None)
|
||||
)
|
||||
assert service.load_mineru()["api_token"] == "keep-me"
|
||||
|
||||
# api_token="" → explicitly clear it.
|
||||
await settings_router.update_mineru_settings(
|
||||
settings_router.MinerUSettingsUpdate(mode="cloud", api_token="")
|
||||
)
|
||||
assert service.load_mineru()["api_token"] == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mineru_test_connection_reports_missing_token(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
|
||||
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
|
||||
|
||||
result = await settings_router.test_mineru_connection(
|
||||
settings_router.MinerUSettingsUpdate(mode="cloud", api_token="")
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "token" in result["message"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mineru_payload_includes_local_cli_probe(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
from deeptutor.services.parsing.engines.mineru import backend as mineru_backend
|
||||
|
||||
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
|
||||
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
|
||||
monkeypatch.setattr(
|
||||
mineru_backend,
|
||||
"local_cli_probe",
|
||||
lambda *a: {"found": True, "command": "mineru", "path": "/env/bin/mineru"},
|
||||
)
|
||||
|
||||
payload = await settings_router.get_mineru_settings()
|
||||
assert payload["local_cli"] == {
|
||||
"found": True,
|
||||
"command": "mineru",
|
||||
"path": "/env/bin/mineru",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mineru_test_connection_local_mode(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
from deeptutor.services.parsing.engines.mineru import backend as mineru_backend
|
||||
|
||||
service = RuntimeSettingsService(tmp_path / "settings", process_env={})
|
||||
monkeypatch.setattr(settings_router, "get_runtime_settings_service", lambda: service)
|
||||
|
||||
# CLI present → ok with version detail.
|
||||
monkeypatch.setattr(
|
||||
mineru_backend,
|
||||
"local_cli_probe",
|
||||
lambda *a: {"found": True, "command": "mineru", "path": "/env/bin/mineru"},
|
||||
)
|
||||
monkeypatch.setattr(mineru_backend, "local_cli_version", lambda cmd: "mineru, version 2.5.0")
|
||||
result = await settings_router.test_mineru_connection(
|
||||
settings_router.MinerUSettingsUpdate(mode="local")
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert "2.5.0" in result["message"]
|
||||
|
||||
# CLI absent → actionable failure message.
|
||||
monkeypatch.setattr(
|
||||
mineru_backend, "local_cli_probe", lambda *a: {"found": False, "command": "", "path": ""}
|
||||
)
|
||||
result = await settings_router.test_mineru_connection(
|
||||
settings_router.MinerUSettingsUpdate(mode="local")
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["message"].lower()
|
||||
|
||||
# Bad configured path → message points at the path, not at PATH install.
|
||||
monkeypatch.setattr(
|
||||
mineru_backend,
|
||||
"local_cli_probe",
|
||||
lambda *a: {"found": False, "command": "", "path": "/bad/mineru", "source": "configured"},
|
||||
)
|
||||
result = await settings_router.test_mineru_connection(
|
||||
settings_router.MinerUSettingsUpdate(mode="local", local_cli_path="/bad/mineru")
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "/bad/mineru" in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mineru_models_download_start_requires_downloader(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from deeptutor.services.parsing.engines.mineru import models as mineru_models
|
||||
|
||||
monkeypatch.setattr(
|
||||
mineru_models, "resolve_models_downloader", lambda p: {"found": False, "path": ""}
|
||||
)
|
||||
result = await settings_router.start_mineru_models_download(
|
||||
settings_router.MinerUModelDownloadPayload()
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "not found" in result["message"].lower()
|
||||
|
||||
# Configured CLI without a sibling downloader → message names the path.
|
||||
monkeypatch.setattr(
|
||||
mineru_models,
|
||||
"resolve_models_downloader",
|
||||
lambda p: {"found": False, "path": "/env/bin/mineru-models-download"},
|
||||
)
|
||||
result = await settings_router.start_mineru_models_download(
|
||||
settings_router.MinerUModelDownloadPayload(local_cli_path="/env/bin/mineru")
|
||||
)
|
||||
assert result["ok"] is False
|
||||
assert "/env/bin/mineru-models-download" in result["message"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mineru_models_download_start_and_status_passthrough(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from deeptutor.services.parsing.engines.mineru import models as mineru_models
|
||||
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class _FakeManager:
|
||||
def start(self, **kwargs):
|
||||
calls.update(kwargs)
|
||||
return {"ok": True, "message": ""}
|
||||
|
||||
def status(self, cursor=0):
|
||||
return {"state": "running", "lines": ["l1"], "next_cursor": 1, "message": ""}
|
||||
|
||||
def cancel(self):
|
||||
return {"ok": True, "message": ""}
|
||||
|
||||
monkeypatch.setattr(
|
||||
mineru_models,
|
||||
"resolve_models_downloader",
|
||||
lambda p: {"found": True, "path": "/env/bin/mineru-models-download"},
|
||||
)
|
||||
monkeypatch.setattr(mineru_models, "get_model_download_manager", lambda: _FakeManager())
|
||||
|
||||
result = await settings_router.start_mineru_models_download(
|
||||
settings_router.MinerUModelDownloadPayload(
|
||||
model_type="all", source="modelscope", endpoint="https://hf-mirror.com"
|
||||
)
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert calls["downloader"] == "/env/bin/mineru-models-download"
|
||||
assert calls["model_type"] == "all"
|
||||
assert calls["source"] == "modelscope"
|
||||
|
||||
status = await settings_router.mineru_models_download_status(cursor=0)
|
||||
assert status["lines"] == ["l1"]
|
||||
cancel = await settings_router.cancel_mineru_models_download()
|
||||
assert cancel["ok"] is True
|
||||
|
||||
|
||||
def test_embedding_provider_choices_use_full_endpoint_urls() -> None:
|
||||
embedding = {item["value"]: item for item in settings_router._provider_choices()["embedding"]}
|
||||
|
||||
assert embedding["openrouter"]["base_url"] == "https://openrouter.ai/api/v1/embeddings"
|
||||
assert embedding["ollama"]["base_url"] == "http://localhost:11434/api/embed"
|
||||
assert embedding["openai"]["base_url"] == "https://api.openai.com/v1/embeddings"
|
||||
assert "custom_openai_sdk" not in embedding
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_llm_options_returns_redacted_catalog(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
catalog = _build_catalog(
|
||||
llm_model="gpt-4o-mini",
|
||||
llm_base_url="https://llm.example/v1",
|
||||
llm_api_key="secret-key",
|
||||
embedding_model="text-embedding-3-small",
|
||||
embedding_base_url="https://emb.example/v1/embeddings",
|
||||
embedding_api_key="emb-key",
|
||||
)
|
||||
service = _FakeCatalogService(catalog)
|
||||
monkeypatch.setattr(settings_router, "get_model_catalog_service", lambda: service)
|
||||
|
||||
response = await settings_router.get_llm_options()
|
||||
|
||||
assert response["active"] == {
|
||||
"profile_id": "llm-profile-default",
|
||||
"model_id": "llm-model-default",
|
||||
}
|
||||
assert response["options"][0]["model"] == "gpt-4o-mini"
|
||||
assert "api_key" not in response["options"][0]
|
||||
assert "base_url" not in response["options"][0]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_runtime_state() -> None:
|
||||
llm_config_module.clear_llm_config_cache()
|
||||
llm_client_module.reset_llm_client()
|
||||
embedding_client_module.reset_embedding_client()
|
||||
yield
|
||||
llm_config_module.clear_llm_config_cache()
|
||||
llm_client_module.reset_llm_client()
|
||||
embedding_client_module.reset_embedding_client()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_catalog_invalidates_runtime_caches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
initial_catalog = _build_catalog(
|
||||
llm_model="gpt-old",
|
||||
llm_base_url="https://old-llm.example/v1",
|
||||
llm_api_key="old-llm-key",
|
||||
embedding_model="text-embedding-old",
|
||||
embedding_base_url="https://old-embedding.example/v1/embeddings",
|
||||
embedding_api_key="old-embedding-key",
|
||||
)
|
||||
updated_catalog = _build_catalog(
|
||||
llm_model="gpt-new",
|
||||
llm_base_url="https://new-llm.example/v1",
|
||||
llm_api_key="new-llm-key",
|
||||
embedding_model="text-embedding-new",
|
||||
embedding_base_url="https://new-embedding.example/v1/embeddings",
|
||||
embedding_api_key="new-embedding-key",
|
||||
)
|
||||
service = _FakeCatalogService(initial_catalog)
|
||||
_patch_runtime(monkeypatch, service)
|
||||
|
||||
old_llm_config = llm_config_module.get_llm_config()
|
||||
old_llm_client = llm_client_module.get_llm_client()
|
||||
old_embedding_client = embedding_client_module.get_embedding_client()
|
||||
|
||||
response = await settings_router.update_catalog(
|
||||
settings_router.CatalogPayload(catalog=updated_catalog)
|
||||
)
|
||||
|
||||
new_llm_config = llm_config_module.get_llm_config()
|
||||
new_llm_client = llm_client_module.get_llm_client()
|
||||
new_embedding_client = embedding_client_module.get_embedding_client()
|
||||
|
||||
assert response == {"catalog": updated_catalog}
|
||||
assert old_llm_config.model == "gpt-old"
|
||||
assert new_llm_config.model == "gpt-new"
|
||||
assert new_llm_config.base_url == "https://new-llm.example/v1"
|
||||
assert new_llm_config is not old_llm_config
|
||||
assert new_llm_client is not old_llm_client
|
||||
assert new_llm_client.config.model == "gpt-new"
|
||||
assert new_embedding_client is not old_embedding_client
|
||||
assert new_embedding_client.config.model == "text-embedding-new"
|
||||
assert new_embedding_client.config.base_url == "https://new-embedding.example/v1/embeddings"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_catalog_invalidates_runtime_caches(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
initial_catalog = _build_catalog(
|
||||
llm_model="gpt-before-apply",
|
||||
llm_base_url="https://before-apply-llm.example/v1",
|
||||
llm_api_key="before-apply-llm-key",
|
||||
embedding_model="text-embedding-before-apply",
|
||||
embedding_base_url="https://before-apply-embedding.example/v1/embeddings",
|
||||
embedding_api_key="before-apply-embedding-key",
|
||||
)
|
||||
applied_catalog = _build_catalog(
|
||||
llm_model="gpt-after-apply",
|
||||
llm_base_url="https://after-apply-llm.example/v1",
|
||||
llm_api_key="after-apply-llm-key",
|
||||
embedding_model="text-embedding-after-apply",
|
||||
embedding_base_url="https://after-apply-embedding.example/v1/embeddings",
|
||||
embedding_api_key="after-apply-embedding-key",
|
||||
)
|
||||
service = _FakeCatalogService(initial_catalog)
|
||||
_patch_runtime(monkeypatch, service)
|
||||
|
||||
llm_config_module.get_llm_config()
|
||||
old_llm_client = llm_client_module.get_llm_client()
|
||||
old_embedding_client = embedding_client_module.get_embedding_client()
|
||||
|
||||
response = await settings_router.apply_catalog(
|
||||
settings_router.CatalogPayload(catalog=applied_catalog)
|
||||
)
|
||||
|
||||
new_llm_config = llm_config_module.get_llm_config()
|
||||
new_llm_client = llm_client_module.get_llm_client()
|
||||
new_embedding_client = embedding_client_module.get_embedding_client()
|
||||
|
||||
assert response["catalog"] == applied_catalog
|
||||
assert response["runtime"]["catalog_path"]
|
||||
assert new_llm_config.model == "gpt-after-apply"
|
||||
assert new_llm_client is not old_llm_client
|
||||
assert new_llm_client.config.base_url == "https://after-apply-llm.example/v1"
|
||||
assert new_embedding_client is not old_embedding_client
|
||||
assert new_embedding_client.config.model == "text-embedding-after-apply"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_tools_roundtrip(monkeypatch: pytest.MonkeyPatch, tmp_path) -> None:
|
||||
settings_file = tmp_path / "interface.json"
|
||||
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
|
||||
|
||||
# Default state — no file yet, so the loader emits the full toggleable set.
|
||||
assert set(settings_router.get_enabled_optional_tools()) == set(
|
||||
settings_router.USER_TOGGLEABLE_TOOL_NAMES
|
||||
)
|
||||
|
||||
# PUT a partial set; unknown tool names get filtered out.
|
||||
update = settings_router.EnabledToolsUpdate(
|
||||
enabled_tools=["web_search", "reason", "not_a_real_tool"]
|
||||
)
|
||||
response = await settings_router.update_enabled_tools(update)
|
||||
assert response == {"enabled_optional_tools": ["web_search", "reason"]}
|
||||
assert settings_router.get_enabled_optional_tools() == ["web_search", "reason"]
|
||||
|
||||
# Empty selection is a valid "all off" state.
|
||||
response = await settings_router.update_enabled_tools(
|
||||
settings_router.EnabledToolsUpdate(enabled_tools=[])
|
||||
)
|
||||
assert response == {"enabled_optional_tools": []}
|
||||
assert settings_router.get_enabled_optional_tools() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_tour_invalidates_runtime_caches(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
initial_catalog = _build_catalog(
|
||||
llm_model="gpt-before-tour",
|
||||
llm_base_url="https://before-tour-llm.example/v1",
|
||||
llm_api_key="before-tour-llm-key",
|
||||
embedding_model="text-embedding-before-tour",
|
||||
embedding_base_url="https://before-tour-embedding.example/v1/embeddings",
|
||||
embedding_api_key="before-tour-embedding-key",
|
||||
)
|
||||
completed_catalog = _build_catalog(
|
||||
llm_model="gpt-after-tour",
|
||||
llm_base_url="https://after-tour-llm.example/v1",
|
||||
llm_api_key="after-tour-llm-key",
|
||||
embedding_model="text-embedding-after-tour",
|
||||
embedding_base_url="https://after-tour-embedding.example/v1/embeddings",
|
||||
embedding_api_key="after-tour-embedding-key",
|
||||
)
|
||||
service = _FakeCatalogService(initial_catalog)
|
||||
_patch_runtime(monkeypatch, service)
|
||||
|
||||
tour_cache = tmp_path / ".tour_cache.json"
|
||||
tour_cache.write_text('{"status": "running"}', encoding="utf-8")
|
||||
monkeypatch.setattr(settings_router, "TOUR_CACHE", tour_cache)
|
||||
|
||||
llm_config_module.get_llm_config()
|
||||
old_llm_client = llm_client_module.get_llm_client()
|
||||
old_embedding_client = embedding_client_module.get_embedding_client()
|
||||
|
||||
response = await settings_router.complete_tour(
|
||||
settings_router.TourCompletePayload(catalog=completed_catalog)
|
||||
)
|
||||
|
||||
new_llm_config = llm_config_module.get_llm_config()
|
||||
new_llm_client = llm_client_module.get_llm_client()
|
||||
new_embedding_client = embedding_client_module.get_embedding_client()
|
||||
cache = tour_cache.read_text(encoding="utf-8")
|
||||
|
||||
assert response["runtime"]["catalog_path"]
|
||||
assert response["status"] == "completed"
|
||||
assert new_llm_config.model == "gpt-after-tour"
|
||||
assert new_llm_client is not old_llm_client
|
||||
assert new_embedding_client is not old_embedding_client
|
||||
assert '"status": "completed"' in cache
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_returns_picker_options(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import deeptutor.services.llm.factory as factory_module
|
||||
|
||||
async def _fake_fetch(binding: str, base_url: str, api_key: str | None = None):
|
||||
assert binding == "openai" # "OpenAI" is normalized to lowercase
|
||||
assert base_url == "https://api.example.com/v1"
|
||||
assert api_key == "sk-x"
|
||||
return ["gpt-4o", "gpt-4o-mini"]
|
||||
|
||||
monkeypatch.setattr(factory_module, "fetch_models", _fake_fetch)
|
||||
|
||||
response = await settings_router.fetch_models_from_provider(
|
||||
settings_router.FetchModelsPayload(
|
||||
binding="OpenAI", base_url="https://api.example.com/v1", api_key="sk-x"
|
||||
)
|
||||
)
|
||||
|
||||
assert response == {
|
||||
"models": [
|
||||
{"id": "gpt-4o", "name": "gpt-4o"},
|
||||
{"id": "gpt-4o-mini", "name": "gpt-4o-mini"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_requires_base_url() -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await settings_router.fetch_models_from_provider(
|
||||
settings_router.FetchModelsPayload(base_url=" ")
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_models_maps_provider_error_to_502(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from fastapi import HTTPException
|
||||
|
||||
import deeptutor.services.llm.factory as factory_module
|
||||
|
||||
async def _boom(binding: str, base_url: str, api_key: str | None = None):
|
||||
raise RuntimeError("connection refused")
|
||||
|
||||
monkeypatch.setattr(factory_module, "fetch_models", _boom)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await settings_router.fetch_models_from_provider(
|
||||
settings_router.FetchModelsPayload(binding="custom", base_url="https://x/v1")
|
||||
)
|
||||
assert exc_info.value.status_code == 502
|
||||
@@ -0,0 +1,106 @@
|
||||
"""API tests for the in-app EduHub skill browser endpoints.
|
||||
|
||||
``GET /api/v1/skills/hub/catalog`` and ``/hub/detail`` proxy a hub's public
|
||||
catalog so the web panel can render it in DeepTutor's own UI (no iframe, no
|
||||
login). The hub provider is mocked over an ``httpx`` transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
|
||||
import httpx
|
||||
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"
|
||||
)
|
||||
|
||||
from deeptutor.services.skill import hub as hub_module
|
||||
from deeptutor.services.skill.hub import ClawHubProvider
|
||||
|
||||
|
||||
def _mock_provider() -> ClawHubProvider:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
path = request.url.path
|
||||
if path == "/api/v1/skills":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"skills": [
|
||||
{
|
||||
"slug": "socratic-tutor",
|
||||
"displayName": "Socratic Tutor",
|
||||
"summary": "Teach by asking.",
|
||||
"version": "1.0.0",
|
||||
"stats": {"downloads": 8, "stars": 2},
|
||||
"owner": {
|
||||
"displayName": "DeepTutor",
|
||||
"htmlUrl": "https://deeptutor.info",
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
if path == "/api/v1/skills/socratic-tutor":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"skill": {
|
||||
"slug": "socratic-tutor",
|
||||
"displayName": "Socratic Tutor",
|
||||
"summary": "Teach.",
|
||||
"description": "---\nname: socratic-tutor\n---\n\n# Body\n",
|
||||
"tags": ["tutor"],
|
||||
"stats": {"downloads": 8, "stars": 2},
|
||||
},
|
||||
"owner": {"displayName": "DeepTutor", "htmlUrl": "https://deeptutor.info"},
|
||||
"distTags": {"latest": "1.0.0"},
|
||||
},
|
||||
)
|
||||
return httpx.Response(404, text="nope")
|
||||
|
||||
return ClawHubProvider(
|
||||
"eduhub",
|
||||
base_url="https://eduhub.deeptutor.info/api/v1",
|
||||
client=httpx.Client(transport=httpx.MockTransport(handler)),
|
||||
)
|
||||
|
||||
|
||||
def _build_app() -> FastAPI:
|
||||
router = importlib.import_module("deeptutor.api.routers.skills").router
|
||||
app = FastAPI()
|
||||
app.include_router(router, prefix="/api/v1/skills")
|
||||
return app
|
||||
|
||||
|
||||
def test_hub_catalog_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(hub_module, "get_hub_provider", lambda name: _mock_provider())
|
||||
client = TestClient(_build_app())
|
||||
resp = client.get("/api/v1/skills/hub/catalog")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["hub"] == "eduhub"
|
||||
assert data["web_url"] == "https://eduhub.deeptutor.info"
|
||||
row = data["skills"][0]
|
||||
assert row["slug"] == "socratic-tutor"
|
||||
assert row["downloads"] == 8 and row["stars"] == 2
|
||||
assert row["owner"] == "DeepTutor"
|
||||
|
||||
|
||||
def test_hub_detail_endpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(hub_module, "get_hub_provider", lambda name: _mock_provider())
|
||||
client = TestClient(_build_app())
|
||||
resp = client.get("/api/v1/skills/hub/detail", params={"slug": "socratic-tutor"})
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["version"] == "1.0.0"
|
||||
assert "# Body" in data["content"]
|
||||
assert data["web_url"] == "https://eduhub.deeptutor.info/skills/socratic-tutor"
|
||||
@@ -0,0 +1,356 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deeptutor.api.routers import system as system_router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embeddings_connection_uses_batch_probe(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, list[str]] = {}
|
||||
|
||||
class _FakeClient:
|
||||
async def embed(self, texts: list[str]):
|
||||
captured["texts"] = texts
|
||||
return [[0.1, 0.2], [0.3, 0.4]]
|
||||
|
||||
monkeypatch.setattr(
|
||||
system_router,
|
||||
"get_embedding_config",
|
||||
lambda: SimpleNamespace(model="embed-test", binding="openai"),
|
||||
)
|
||||
monkeypatch.setattr(system_router, "get_embedding_client", lambda: _FakeClient())
|
||||
|
||||
response = await system_router.test_embeddings_connection()
|
||||
|
||||
assert response.success is True
|
||||
assert captured["texts"] == ["test", "retrieval batch probe"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embeddings_connection_rejects_partial_batch_response(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class _FakeClient:
|
||||
async def embed(self, texts: list[str]):
|
||||
return [[0.1, 0.2]]
|
||||
|
||||
monkeypatch.setattr(
|
||||
system_router,
|
||||
"get_embedding_config",
|
||||
lambda: SimpleNamespace(model="embed-test", binding="openai"),
|
||||
)
|
||||
monkeypatch.setattr(system_router, "get_embedding_client", lambda: _FakeClient())
|
||||
|
||||
response = await system_router.test_embeddings_connection()
|
||||
|
||||
assert response.success is False
|
||||
assert response.message == "Embeddings connection failed: Invalid response"
|
||||
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from deeptutor.api.routers import settings as settings_router
|
||||
from deeptutor.api.routers import tools as tools_router
|
||||
from deeptutor.tools.builtin import (
|
||||
BUILTIN_TOOL_NAMES,
|
||||
COMING_SOON_TOOL_NAMES,
|
||||
USER_TOGGLEABLE_TOOL_NAMES,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_builtin_tools_marks_toggleable_set(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""The /api/v1/tools response must clearly separate user-toggleable
|
||||
tools from locked-on (auto-mounted) tools so the /settings/tools UI
|
||||
can render the right control per row."""
|
||||
settings_file = tmp_path / "interface.json"
|
||||
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
|
||||
|
||||
response = await tools_router.list_builtin_tools()
|
||||
by_name = {tool.name: tool for tool in response.tools}
|
||||
|
||||
# Every registered tool + every coming-soon placeholder shows up.
|
||||
assert set(by_name) == set(BUILTIN_TOOL_NAMES) | set(COMING_SOON_TOOL_NAMES)
|
||||
|
||||
# Coming-soon entries are NOT toggleable and report enabled=False so
|
||||
# the UI can lock the toggle and badge them appropriately.
|
||||
for name in COMING_SOON_TOOL_NAMES:
|
||||
assert by_name[name].coming_soon is True, name
|
||||
assert by_name[name].toggleable is False, name
|
||||
assert by_name[name].enabled is False, name
|
||||
|
||||
# Exactly the documented set is toggleable. Pinning the literal here
|
||||
# (in addition to the constant) is intentional — this list is the
|
||||
# product contract for the /settings/tools "体验增强" section.
|
||||
toggleable_names = {name for name, tool in by_name.items() if tool.toggleable}
|
||||
assert toggleable_names == set(USER_TOGGLEABLE_TOOL_NAMES)
|
||||
assert toggleable_names == {
|
||||
"brainstorm",
|
||||
"web_search",
|
||||
"paper_search",
|
||||
"reason",
|
||||
"geogebra_analysis",
|
||||
"imagegen",
|
||||
"videogen",
|
||||
}
|
||||
|
||||
# Locked-on (non-toggleable, non-coming-soon) tools always report
|
||||
# enabled=True.
|
||||
for name, tool in by_name.items():
|
||||
if not tool.toggleable and not tool.coming_soon:
|
||||
assert tool.enabled is True, name
|
||||
|
||||
# On a fresh settings file the default toggleable set is "all on".
|
||||
assert set(response.enabled_optional_tools) == set(USER_TOGGLEABLE_TOOL_NAMES)
|
||||
for name in USER_TOGGLEABLE_TOOL_NAMES:
|
||||
assert by_name[name].enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_builtin_tools_marks_capability_owned_tools(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
"""Capability-owned tools (solve_*, mastery_*) report their owning
|
||||
capability so the /settings/tools UI groups them below the built-ins;
|
||||
plain system built-ins report ``capability=None``."""
|
||||
settings_file = tmp_path / "interface.json"
|
||||
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
|
||||
|
||||
response = await tools_router.list_builtin_tools()
|
||||
by_name = {tool.name: tool for tool in response.tools}
|
||||
|
||||
assert by_name["solve_plan"].capability == "solve"
|
||||
assert by_name["mastery_status"].capability == "mastery"
|
||||
assert by_name["web_fetch"].capability is None
|
||||
# Capability tools stay locked-on (not user-toggleable).
|
||||
assert by_name["solve_plan"].toggleable is False
|
||||
assert by_name["solve_plan"].enabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_builtin_tools_reflects_user_toggle(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path
|
||||
) -> None:
|
||||
settings_file = tmp_path / "interface.json"
|
||||
monkeypatch.setattr(settings_router, "_settings_file", lambda: settings_file)
|
||||
|
||||
# User disables a subset of toggleable tools.
|
||||
await settings_router.update_enabled_tools(
|
||||
settings_router.EnabledToolsUpdate(enabled_tools=["web_search", "reason"])
|
||||
)
|
||||
|
||||
response = await tools_router.list_builtin_tools()
|
||||
by_name = {tool.name: tool for tool in response.tools}
|
||||
|
||||
assert response.enabled_optional_tools == ["reason", "web_search"]
|
||||
assert by_name["web_search"].enabled is True
|
||||
assert by_name["reason"].enabled is True
|
||||
assert by_name["brainstorm"].enabled is False
|
||||
assert by_name["paper_search"].enabled is False
|
||||
# Locked-on tools stay on regardless (code_execution is now auto-mounted,
|
||||
# gated by sandbox availability rather than a user toggle).
|
||||
assert by_name["code_execution"].enabled is True
|
||||
assert by_name["rag"].enabled is True
|
||||
assert by_name["web_fetch"].enabled is True
|
||||
@@ -0,0 +1,913 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from deeptutor.core.stream import StreamEvent, StreamEventType
|
||||
from deeptutor.services.session.sqlite_store import SQLiteSessionStore
|
||||
from deeptutor.services.session.turn_runtime import TurnRuntimeManager
|
||||
|
||||
|
||||
async def _noop_async(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def _fake_skill_service() -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
summary_entries=lambda: [],
|
||||
load_always_for_context=lambda: "",
|
||||
load_for_context=lambda _skills: "",
|
||||
list_skills=lambda: [],
|
||||
)
|
||||
|
||||
|
||||
def _fake_persona_service() -> SimpleNamespace:
|
||||
# Non-empty render so the resolved persona is recorded in the snapshot.
|
||||
return SimpleNamespace(
|
||||
load_for_context=lambda name: (
|
||||
f"## Active Persona\n### Persona: {name}\n\nbody" if name else ""
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _model_catalog() -> dict:
|
||||
return {
|
||||
"version": 1,
|
||||
"services": {
|
||||
"llm": {
|
||||
"active_profile_id": "p-default",
|
||||
"active_model_id": "m-default",
|
||||
"profiles": [
|
||||
{
|
||||
"id": "p-default",
|
||||
"name": "Default",
|
||||
"binding": "openai",
|
||||
"base_url": "https://api.openai.com/v1",
|
||||
"api_key": "sk-test",
|
||||
"models": [
|
||||
{
|
||||
"id": "m-default",
|
||||
"name": "Default",
|
||||
"model": "gpt-4o-mini",
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "p-alt",
|
||||
"name": "Alt",
|
||||
"binding": "openrouter",
|
||||
"base_url": "https://openrouter.ai/api/v1",
|
||||
"api_key": "sk-alt",
|
||||
"models": [
|
||||
{
|
||||
"id": "m-alt",
|
||||
"name": "Alt Model",
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
}
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_replays_events_and_materializes_messages(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
captured: dict[str, object] = {}
|
||||
publish_order: list[str] = []
|
||||
original_publish = runtime._publish_live_event
|
||||
|
||||
async def publish_with_status_capture(execution, event):
|
||||
publish_order.append(event.type.value)
|
||||
if event.type == StreamEventType.DONE:
|
||||
persisted_turn = await store.get_turn(execution.turn_id)
|
||||
captured["turn_status_when_done_published"] = (persisted_turn or {}).get("status")
|
||||
return await original_publish(execution, event)
|
||||
|
||||
monkeypatch.setattr(runtime, "_publish_live_event", publish_with_status_capture)
|
||||
|
||||
async def title_after_done(*_args, **_kwargs):
|
||||
captured["title_started_after_done"] = "done" in publish_order
|
||||
|
||||
monkeypatch.setattr(runtime, "_maybe_generate_session_title", title_after_done)
|
||||
|
||||
class FakeContextBuilder:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def build(self, **kwargs):
|
||||
on_event = kwargs.get("on_event")
|
||||
if on_event is not None:
|
||||
await on_event(
|
||||
StreamEvent(
|
||||
type=StreamEventType.PROGRESS,
|
||||
source="context",
|
||||
stage="summarizing",
|
||||
content="summarize context",
|
||||
)
|
||||
)
|
||||
return SimpleNamespace(
|
||||
conversation_history=[],
|
||||
conversation_summary="",
|
||||
context_text="",
|
||||
token_count=0,
|
||||
budget=0,
|
||||
)
|
||||
|
||||
class FakeOrchestrator:
|
||||
async def handle(self, context):
|
||||
captured["user_message"] = context.user_message
|
||||
captured["metadata"] = context.metadata
|
||||
captured["source_manifest"] = context.source_manifest
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.CONTENT,
|
||||
source="chat",
|
||||
stage="responding",
|
||||
content="Hello Frank",
|
||||
metadata={"call_kind": "llm_final_response"},
|
||||
)
|
||||
yield StreamEvent(type=StreamEventType.DONE, source="chat")
|
||||
|
||||
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.book.context.build_book_context",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(
|
||||
text="## Page: Signal Basics\nA selected page.",
|
||||
references=[{"book_id": "book-1", "page_ids": ["page-1"]}],
|
||||
warnings=[],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.memory.get_memory_store",
|
||||
lambda: SimpleNamespace(
|
||||
read_l3_concat=lambda: "",
|
||||
emit=_noop_async,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.skill.get_skill_service",
|
||||
_fake_skill_service,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.persona.get_persona_service",
|
||||
_fake_persona_service,
|
||||
)
|
||||
|
||||
session, turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "hello, i'm frank",
|
||||
"session_id": None,
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"persona": "socratic",
|
||||
"memory_references": ["summary"],
|
||||
"book_references": [{"book_id": "book-1", "page_ids": ["page-1"]}],
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in runtime.subscribe_turn(turn["id"], after_seq=0):
|
||||
events.append(event)
|
||||
|
||||
# session_meta may arrive after `done` from the title generator —
|
||||
# filter it out so the timing race doesn't flake the assertion.
|
||||
assert [e["type"] for e in events if e["type"] != "session_meta"] == [
|
||||
"session",
|
||||
"content",
|
||||
"done",
|
||||
]
|
||||
done_event = next(e for e in events if e["type"] == "done")
|
||||
assert done_event["metadata"]["status"] == "completed"
|
||||
assert captured["turn_status_when_done_published"] == "completed"
|
||||
assert captured["title_started_after_done"] is True
|
||||
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert detail is not None
|
||||
assert [message["role"] for message in detail["messages"]] == ["user", "assistant"]
|
||||
assert detail["messages"][0]["metadata"]["request_snapshot"]["persona"] == "socratic"
|
||||
assert detail["messages"][0]["metadata"]["request_snapshot"]["memoryReferences"] == ["summary"]
|
||||
assert detail["messages"][0]["metadata"]["request_snapshot"]["bookReferences"] == [
|
||||
{"book_id": "book-1", "page_ids": ["page-1"]}
|
||||
]
|
||||
# Chat capability now routes attached sources through the manifest +
|
||||
# ``read_source`` tool instead of inlining ``[Book Context]`` into the
|
||||
# user message. The raw user message stays raw; the book payload
|
||||
# surfaces in ``context.source_manifest`` and ``metadata.source_index``.
|
||||
assert str(captured["user_message"]) == "hello, i'm frank"
|
||||
manifest = str(captured.get("source_manifest") or "")
|
||||
assert "[Attached Sources]" in manifest
|
||||
# Book source id is now per-book (``bk-{book_id}``) so multi-book
|
||||
# sessions can read_source each independently. The mocked book has id
|
||||
# "book-1".
|
||||
assert "bk-book-1" in manifest
|
||||
source_index = (captured.get("metadata") or {}).get("source_index") or {}
|
||||
assert "bk-book-1" in source_index
|
||||
assert "A selected page." in source_index["bk-book-1"]
|
||||
assert captured["metadata"] and captured["metadata"]["book_references"] == [
|
||||
{"book_id": "book-1", "page_ids": ["page-1"]}
|
||||
]
|
||||
assert detail["messages"][1]["content"] == "Hello Frank"
|
||||
assert detail["preferences"] == {
|
||||
"capability": "chat",
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"language": "en",
|
||||
# Explicit persona in the payload is persisted as a session-level
|
||||
# preference (survives reloads; later turns fall back to it).
|
||||
"persona": "socratic",
|
||||
}
|
||||
|
||||
persisted_turn = await store.get_turn(turn["id"])
|
||||
assert persisted_turn is not None
|
||||
assert persisted_turn["status"] == "completed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_persists_llm_selection_in_turn_snapshot(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeContextBuilder:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def build(self, **kwargs):
|
||||
captured["builder_llm_config"] = kwargs["llm_config"]
|
||||
return SimpleNamespace(
|
||||
conversation_history=[],
|
||||
conversation_summary="",
|
||||
context_text="",
|
||||
token_count=0,
|
||||
budget=0,
|
||||
)
|
||||
|
||||
class FakeOrchestrator:
|
||||
async def handle(self, context):
|
||||
captured["metadata"] = context.metadata
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.CONTENT,
|
||||
source="chat",
|
||||
stage="responding",
|
||||
content="Alt reply",
|
||||
metadata={"call_kind": "llm_final_response"},
|
||||
)
|
||||
yield StreamEvent(type=StreamEventType.DONE, source="chat")
|
||||
|
||||
def fake_activate(selection):
|
||||
captured["activated_selection"] = selection
|
||||
return SimpleNamespace(
|
||||
model="anthropic/claude-sonnet-4", provider_name="openrouter"
|
||||
), object()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.config.get_model_catalog_service",
|
||||
lambda: SimpleNamespace(load=_model_catalog),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.model_selection.runtime.activate_llm_selection",
|
||||
fake_activate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.model_selection.runtime.reset_llm_selection",
|
||||
lambda _token: captured.setdefault("reset_called", True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.memory.get_memory_store",
|
||||
lambda: SimpleNamespace(
|
||||
read_l3_concat=lambda: "",
|
||||
emit=_noop_async,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
|
||||
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
|
||||
|
||||
selection = {"profile_id": "p-alt", "model_id": "m-alt"}
|
||||
session, turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "use the alt model",
|
||||
"session_id": None,
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {},
|
||||
"llm_selection": selection,
|
||||
}
|
||||
)
|
||||
|
||||
async for _event in runtime.subscribe_turn(turn["id"], after_seq=0):
|
||||
pass
|
||||
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert detail is not None
|
||||
assert detail["preferences"]["llm_selection"] == selection
|
||||
assert detail["messages"][0]["metadata"]["request_snapshot"]["llmSelection"] == selection
|
||||
assert captured["activated_selection"] == selection
|
||||
assert captured["builder_llm_config"].model == "anthropic/claude-sonnet-4"
|
||||
assert captured["metadata"]["llm_selection"] == selection
|
||||
assert captured["metadata"]["llm_model"] == "anthropic/claude-sonnet-4"
|
||||
assert captured["metadata"]["llm_provider"] == "openrouter"
|
||||
assert captured["reset_called"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_session_persona_persists_falls_back_and_clears(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
"""Persona is a session preference: explicit key persists (incl. ""),
|
||||
absent key falls back to the stored preference."""
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
|
||||
class FakeContextBuilder:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def build(self, **_kwargs):
|
||||
return SimpleNamespace(
|
||||
conversation_history=[],
|
||||
conversation_summary="",
|
||||
context_text="",
|
||||
token_count=0,
|
||||
budget=0,
|
||||
)
|
||||
|
||||
class FakeOrchestrator:
|
||||
async def handle(self, context):
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.CONTENT,
|
||||
source="chat",
|
||||
stage="responding",
|
||||
content="ok",
|
||||
metadata={"call_kind": "llm_final_response"},
|
||||
)
|
||||
yield StreamEvent(type=StreamEventType.DONE, source="chat")
|
||||
|
||||
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.memory.get_memory_store",
|
||||
lambda: SimpleNamespace(read_l3_concat=lambda: "", emit=_noop_async),
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
|
||||
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
|
||||
|
||||
async def run_turn(session_id, extra):
|
||||
session, turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "hi",
|
||||
"session_id": session_id,
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {},
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
async for _event in runtime.subscribe_turn(turn["id"], after_seq=0):
|
||||
pass
|
||||
return session
|
||||
|
||||
# Turn 1 — explicit persona: applied to the turn AND persisted.
|
||||
session = await run_turn(None, {"persona": "socratic"})
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert detail["preferences"]["persona"] == "socratic"
|
||||
assert detail["messages"][0]["metadata"]["request_snapshot"]["persona"] == "socratic"
|
||||
|
||||
# Turn 2 — persona key ABSENT: falls back to the stored preference, so
|
||||
# the persona keeps applying to follow-up questions in the session.
|
||||
await run_turn(session["id"], {})
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert detail["preferences"]["persona"] == "socratic"
|
||||
assert detail["messages"][2]["metadata"]["request_snapshot"]["persona"] == "socratic"
|
||||
|
||||
# Turn 3 — explicit "" (Default): clears the stored preference and the
|
||||
# turn runs without a persona.
|
||||
await run_turn(session["id"], {"persona": ""})
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert detail["preferences"]["persona"] == ""
|
||||
assert "persona" not in detail["messages"][4]["metadata"]["request_snapshot"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_rejects_invalid_llm_selection(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.config.get_model_catalog_service",
|
||||
lambda: SimpleNamespace(load=_model_catalog),
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Invalid LLM selection"):
|
||||
await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "bad model",
|
||||
"session_id": None,
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {},
|
||||
"llm_selection": {"profile_id": "p-alt", "model_id": "m-default"},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_allows_model_switching_within_same_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
activated: list[dict] = []
|
||||
metadata_seen: list[dict] = []
|
||||
|
||||
class FakeContextBuilder:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def build(self, **_kwargs):
|
||||
return SimpleNamespace(
|
||||
conversation_history=[],
|
||||
conversation_summary="",
|
||||
context_text="",
|
||||
token_count=0,
|
||||
budget=0,
|
||||
)
|
||||
|
||||
class FakeOrchestrator:
|
||||
async def handle(self, context):
|
||||
metadata_seen.append(context.metadata)
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.CONTENT,
|
||||
source="chat",
|
||||
stage="responding",
|
||||
content=f"Reply from {context.metadata['llm_model']}",
|
||||
metadata={"call_kind": "llm_final_response"},
|
||||
)
|
||||
yield StreamEvent(type=StreamEventType.DONE, source="chat")
|
||||
|
||||
def fake_activate(selection):
|
||||
activated.append(dict(selection or {}))
|
||||
is_alt = (selection or {}).get("profile_id") == "p-alt"
|
||||
return (
|
||||
SimpleNamespace(
|
||||
model="anthropic/claude-sonnet-4" if is_alt else "gpt-4o-mini",
|
||||
provider_name="openrouter" if is_alt else "openai",
|
||||
),
|
||||
object(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.config.get_model_catalog_service",
|
||||
lambda: SimpleNamespace(load=_model_catalog),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.model_selection.runtime.activate_llm_selection",
|
||||
fake_activate,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.model_selection.runtime.reset_llm_selection",
|
||||
lambda _token: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.memory.get_memory_store",
|
||||
lambda: SimpleNamespace(
|
||||
read_l3_concat=lambda: "",
|
||||
emit=_noop_async,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
|
||||
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
|
||||
|
||||
first_selection = {"profile_id": "p-default", "model_id": "m-default"}
|
||||
second_selection = {"profile_id": "p-alt", "model_id": "m-alt"}
|
||||
|
||||
session, first_turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "first model",
|
||||
"session_id": None,
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {},
|
||||
"llm_selection": first_selection,
|
||||
}
|
||||
)
|
||||
async for _event in runtime.subscribe_turn(first_turn["id"], after_seq=0):
|
||||
pass
|
||||
|
||||
same_session, second_turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "second model",
|
||||
"session_id": session["id"],
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {},
|
||||
"llm_selection": second_selection,
|
||||
}
|
||||
)
|
||||
async for _event in runtime.subscribe_turn(second_turn["id"], after_seq=0):
|
||||
pass
|
||||
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert same_session["id"] == session["id"]
|
||||
assert detail is not None
|
||||
assert detail["preferences"]["llm_selection"] == second_selection
|
||||
assert activated == [first_selection, second_selection]
|
||||
assert metadata_seen[0]["llm_model"] == "gpt-4o-mini"
|
||||
assert metadata_seen[1]["llm_model"] == "anthropic/claude-sonnet-4"
|
||||
user_messages = [message for message in detail["messages"] if message["role"] == "user"]
|
||||
assert user_messages[0]["metadata"]["request_snapshot"]["llmSelection"] == first_selection
|
||||
assert user_messages[1]["metadata"]["request_snapshot"]["llmSelection"] == second_selection
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regenerate_reuses_snapshot_or_override_llm_selection(tmp_path) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
captured_payloads: list[dict] = []
|
||||
|
||||
class CapturingRuntime(TurnRuntimeManager):
|
||||
async def start_turn(self, payload: dict):
|
||||
captured_payloads.append(payload)
|
||||
return {"id": payload["session_id"]}, {"id": "turn-test"}
|
||||
|
||||
runtime = CapturingRuntime(store)
|
||||
session = await store.create_session(session_id="session-with-snapshot")
|
||||
await store.update_session_preferences(
|
||||
session["id"],
|
||||
{"llm_selection": {"profile_id": "p-default", "model_id": "m-default"}},
|
||||
)
|
||||
await store.add_message(
|
||||
session_id=session["id"],
|
||||
role="user",
|
||||
content="again",
|
||||
capability="chat",
|
||||
metadata={
|
||||
"request_snapshot": {
|
||||
"content": "again",
|
||||
"llmSelection": {"profile_id": "p-alt", "model_id": "m-alt"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
await runtime.regenerate_last_turn(session["id"])
|
||||
assert captured_payloads[-1]["llm_selection"] == {
|
||||
"profile_id": "p-alt",
|
||||
"model_id": "m-alt",
|
||||
}
|
||||
|
||||
await runtime.regenerate_last_turn(
|
||||
session["id"],
|
||||
overrides={"llm_selection": {"profile_id": "p-default", "model_id": "m-default"}},
|
||||
)
|
||||
assert captured_payloads[-1]["llm_selection"] == {
|
||||
"profile_id": "p-default",
|
||||
"model_id": "m-default",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_bootstraps_question_followup_context_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeContextBuilder:
|
||||
def __init__(self, session_store, *_args, **_kwargs) -> None:
|
||||
self.store = session_store
|
||||
|
||||
async def build(self, **kwargs):
|
||||
messages = await self.store.get_messages_for_context(kwargs["session_id"])
|
||||
captured["history_messages"] = messages
|
||||
return SimpleNamespace(
|
||||
conversation_history=[
|
||||
{"role": item["role"], "content": item["content"]} for item in messages
|
||||
],
|
||||
conversation_summary="",
|
||||
context_text="",
|
||||
token_count=0,
|
||||
budget=0,
|
||||
)
|
||||
|
||||
class FakeOrchestrator:
|
||||
async def handle(self, context):
|
||||
captured["conversation_history"] = context.conversation_history
|
||||
captured["config_overrides"] = context.config_overrides
|
||||
captured["metadata"] = context.metadata
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.CONTENT,
|
||||
source="chat",
|
||||
stage="responding",
|
||||
content="Let's discuss this question.",
|
||||
metadata={"call_kind": "llm_final_response"},
|
||||
)
|
||||
yield StreamEvent(type=StreamEventType.DONE, source="chat")
|
||||
|
||||
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.memory.get_memory_store",
|
||||
lambda: SimpleNamespace(
|
||||
read_l3_concat=lambda: "",
|
||||
emit=_noop_async,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
|
||||
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
|
||||
|
||||
session, turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "Why is my answer wrong?",
|
||||
"session_id": None,
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {
|
||||
"followup_question_context": {
|
||||
"parent_quiz_session_id": "quiz_session_1",
|
||||
"question_id": "q_2",
|
||||
"question_type": "choice",
|
||||
"difficulty": "hard",
|
||||
"concentration": "win-rate comparison",
|
||||
"question": "Which criterion best describes density?",
|
||||
"options": {
|
||||
"A": "Coverage",
|
||||
"B": "Informative value",
|
||||
"C": "Relevant content without redundancy",
|
||||
"D": "Credibility",
|
||||
},
|
||||
"user_answer": "B",
|
||||
"correct_answer": "C",
|
||||
"explanation": "Density focuses on including relevant content without redundancy.",
|
||||
"knowledge_context": "Density measures whether content is relevant and non-redundant.",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in runtime.subscribe_turn(turn["id"], after_seq=0):
|
||||
events.append(event)
|
||||
|
||||
# session_meta may arrive after `done` from the title generator —
|
||||
# filter it out so the timing race doesn't flake the assertion.
|
||||
assert [e["type"] for e in events if e["type"] != "session_meta"] == [
|
||||
"session",
|
||||
"content",
|
||||
"done",
|
||||
]
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert detail is not None
|
||||
assert [message["role"] for message in detail["messages"]] == ["system", "user", "assistant"]
|
||||
assert "Question Follow-up Context" in detail["messages"][0]["content"]
|
||||
assert "Which criterion best describes density?" in detail["messages"][0]["content"]
|
||||
assert "User answer: B" in detail["messages"][0]["content"]
|
||||
assert captured["conversation_history"][0]["role"] == "system"
|
||||
assert "followup_question_context" not in captured["config_overrides"]
|
||||
assert captured["metadata"]["question_followup_context"]["question_id"] == "q_2"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_rejects_deep_research_without_explicit_config(
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Invalid deep research config"):
|
||||
await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "research transformers",
|
||||
"session_id": None,
|
||||
"capability": "deep_research",
|
||||
"tools": ["rag"],
|
||||
"knowledge_bases": ["research-kb"],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_persists_deep_research_session_preference(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
|
||||
class FakeContextBuilder:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def build(self, **_kwargs):
|
||||
return SimpleNamespace(
|
||||
conversation_history=[],
|
||||
conversation_summary="",
|
||||
context_text="",
|
||||
token_count=0,
|
||||
budget=0,
|
||||
)
|
||||
|
||||
class FakeOrchestrator:
|
||||
async def handle(self, _context):
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.CONTENT,
|
||||
source="deep_research",
|
||||
stage="reporting",
|
||||
content="Research report ready.",
|
||||
metadata={"call_kind": "llm_final_response"},
|
||||
)
|
||||
yield StreamEvent(type=StreamEventType.DONE, source="deep_research")
|
||||
|
||||
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.memory.get_memory_store",
|
||||
lambda: SimpleNamespace(
|
||||
read_l3_concat=lambda: "",
|
||||
emit=_noop_async,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
|
||||
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
|
||||
|
||||
session, turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "research transformers",
|
||||
"session_id": None,
|
||||
"capability": "deep_research",
|
||||
"tools": ["rag", "web_search"],
|
||||
"knowledge_bases": ["research-kb"],
|
||||
"attachments": [],
|
||||
"language": "en",
|
||||
"config": {
|
||||
"mode": "report",
|
||||
"depth": "standard",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
events = []
|
||||
async for event in runtime.subscribe_turn(turn["id"], after_seq=0):
|
||||
events.append(event)
|
||||
|
||||
# session_meta may arrive after `done` from the title generator —
|
||||
# filter it out so the timing race doesn't flake the assertion.
|
||||
assert [e["type"] for e in events if e["type"] != "session_meta"] == [
|
||||
"session",
|
||||
"content",
|
||||
"done",
|
||||
]
|
||||
detail = await store.get_session_with_messages(session["id"])
|
||||
assert detail is not None
|
||||
assert detail["preferences"]["capability"] == "deep_research"
|
||||
assert detail["preferences"]["tools"] == ["rag", "web_search"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_turn_runtime_injects_memory_and_refreshes_after_completion(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
store = SQLiteSessionStore(tmp_path / "chat_history.db")
|
||||
runtime = TurnRuntimeManager(store)
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeContextBuilder:
|
||||
def __init__(self, *_args, **_kwargs) -> None:
|
||||
pass
|
||||
|
||||
async def build(self, **_kwargs):
|
||||
return SimpleNamespace(
|
||||
conversation_history=[],
|
||||
conversation_summary="",
|
||||
context_text="Recent chat summary",
|
||||
token_count=0,
|
||||
budget=0,
|
||||
)
|
||||
|
||||
class FakeOrchestrator:
|
||||
async def handle(self, context):
|
||||
captured["conversation_history"] = context.conversation_history
|
||||
captured["memory_context"] = context.memory_context
|
||||
captured["conversation_context_text"] = context.metadata.get(
|
||||
"conversation_context_text"
|
||||
)
|
||||
yield StreamEvent(
|
||||
type=StreamEventType.CONTENT,
|
||||
source="chat",
|
||||
stage="responding",
|
||||
content="Stored reply",
|
||||
metadata={"call_kind": "llm_final_response"},
|
||||
)
|
||||
yield StreamEvent(type=StreamEventType.DONE, source="chat")
|
||||
|
||||
emit_calls: list[object] = []
|
||||
|
||||
async def fake_emit(event):
|
||||
emit_calls.append(event)
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("deeptutor.services.llm.config.get_llm_config", lambda: SimpleNamespace())
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.session.context_builder.ContextBuilder", FakeContextBuilder
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.runtime.orchestrator.ChatOrchestrator", FakeOrchestrator)
|
||||
monkeypatch.setattr(
|
||||
"deeptutor.services.memory.get_memory_store",
|
||||
lambda: SimpleNamespace(
|
||||
read_l3_concat=lambda: "## Memory\n## Preferences\n- Prefer concise answers.",
|
||||
emit=fake_emit,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr("deeptutor.services.skill.get_skill_service", _fake_skill_service)
|
||||
monkeypatch.setattr("deeptutor.services.persona.get_persona_service", _fake_persona_service)
|
||||
|
||||
_session, turn = await runtime.start_turn(
|
||||
{
|
||||
"type": "start_turn",
|
||||
"content": "hello, i'm frank",
|
||||
"session_id": None,
|
||||
"capability": None,
|
||||
"tools": [],
|
||||
"knowledge_bases": [],
|
||||
"attachments": [],
|
||||
"memory_references": ["preferences"],
|
||||
"language": "en",
|
||||
"config": {},
|
||||
}
|
||||
)
|
||||
|
||||
async for _event in runtime.subscribe_turn(turn["id"], after_seq=0):
|
||||
pass
|
||||
|
||||
assert captured["memory_context"] == "## Memory\n## Preferences\n- Prefer concise answers."
|
||||
assert captured["conversation_history"] == []
|
||||
assert captured["conversation_context_text"] == "Recent chat summary"
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Voice router tests — /tts and /stt request/response contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
import wave
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from deeptutor.api.routers import voice as voice_router
|
||||
from deeptutor.services.voice import VoiceProviderError
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(voice_router.router, prefix="/api/v1/voice")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_tts_returns_audio_bytes(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_synth(text: str, *, voice=None, response_format=None, **_: Any):
|
||||
captured["text"] = text
|
||||
captured["voice"] = voice
|
||||
captured["format"] = response_format
|
||||
return b"audio-bytes", "audio/mpeg"
|
||||
|
||||
monkeypatch.setattr(voice_router, "synthesize_speech", fake_synth)
|
||||
resp = client.post("/api/v1/voice/tts", json={"text": "hello", "voice": "nova"})
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == b"audio-bytes"
|
||||
assert resp.headers["content-type"] == "audio/mpeg"
|
||||
assert captured == {"text": "hello", "voice": "nova", "format": None}
|
||||
|
||||
|
||||
def test_tts_wraps_pcm_bytes_as_browser_playable_wav(
|
||||
client: TestClient,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
pcm = b"\x00\x00\x01\x00" * 12
|
||||
|
||||
async def fake_synth(text: str, *, voice=None, response_format=None, **_: Any):
|
||||
return pcm, "audio/pcm;rate=24000;channels=1"
|
||||
|
||||
monkeypatch.setattr(voice_router, "synthesize_speech", fake_synth)
|
||||
resp = client.post("/api/v1/voice/tts", json={"text": "hello"})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"] == "audio/wav"
|
||||
assert resp.content.startswith(b"RIFF")
|
||||
with wave.open(io.BytesIO(resp.content), "rb") as wav:
|
||||
assert wav.getframerate() == 24000
|
||||
assert wav.getnchannels() == 1
|
||||
assert wav.getsampwidth() == 2
|
||||
assert wav.readframes(wav.getnframes()) == pcm
|
||||
|
||||
|
||||
def test_tts_rejects_empty_text(client: TestClient) -> None:
|
||||
resp = client.post("/api/v1/voice/tts", json={"text": ""})
|
||||
assert resp.status_code == 422 # pydantic min_length
|
||||
|
||||
|
||||
def test_tts_provider_error_is_502(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def boom(*_: Any, **__: Any):
|
||||
raise VoiceProviderError("upstream down")
|
||||
|
||||
monkeypatch.setattr(voice_router, "synthesize_speech", boom)
|
||||
resp = client.post("/api/v1/voice/tts", json={"text": "hi"})
|
||||
assert resp.status_code == 502
|
||||
assert "upstream down" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_tts_missing_config_is_400(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
async def no_config(*_: Any, **__: Any):
|
||||
raise ValueError("No active TTS model is configured.")
|
||||
|
||||
monkeypatch.setattr(voice_router, "synthesize_speech", no_config)
|
||||
resp = client.post("/api/v1/voice/tts", json={"text": "hi"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_stt_returns_text(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def fake_transcribe(audio: bytes, *, filename: str, content_type: str, language=None):
|
||||
captured["bytes"] = len(audio)
|
||||
captured["filename"] = filename
|
||||
return "hello world"
|
||||
|
||||
monkeypatch.setattr(voice_router, "transcribe_audio", fake_transcribe)
|
||||
resp = client.post(
|
||||
"/api/v1/voice/stt",
|
||||
files={"file": ("clip.webm", b"audiobytes", "audio/webm")},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"text": "hello world"}
|
||||
assert captured["filename"] == "clip.webm"
|
||||
assert captured["bytes"] == 10
|
||||
|
||||
|
||||
def test_stt_rejects_empty_upload(client: TestClient) -> None:
|
||||
resp = client.post(
|
||||
"/api/v1/voice/stt",
|
||||
files={"file": ("empty.webm", b"", "audio/webm")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
Reference in New Issue
Block a user