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,215 @@
|
||||
"""CLI smoke tests for the standalone ``deeptutor-cli`` package."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from deeptutor.app import DeepTutorApp, TurnRequest
|
||||
from deeptutor.runtime.bootstrap.builtin_capabilities import BUILTIN_CAPABILITY_CLASSES
|
||||
from deeptutor_cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _install_fake_runtime(monkeypatch, captured_requests: list[TurnRequest]) -> None:
|
||||
async def _start_turn(self, request): # noqa: ANN001
|
||||
if isinstance(request, dict):
|
||||
request = TurnRequest(**request)
|
||||
captured_requests.append(request)
|
||||
return {"id": request.session_id or "session-1"}, {"id": "turn-1"}
|
||||
|
||||
async def _stream_turn(self, turn_id: str, after_seq: int = 0): # noqa: ANN001
|
||||
yield {"type": "session", "turn_id": turn_id, "seq": after_seq}
|
||||
yield {"type": "stage_start", "stage": "responding"}
|
||||
yield {"type": "content", "content": "response body"}
|
||||
yield {"type": "result", "metadata": {"response": "response body"}}
|
||||
yield {"type": "done"}
|
||||
|
||||
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.start_turn", _start_turn)
|
||||
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.stream_turn", _stream_turn)
|
||||
|
||||
|
||||
def test_run_command_json_mode(monkeypatch) -> None:
|
||||
captured_requests: list[TurnRequest] = []
|
||||
_install_fake_runtime(monkeypatch, captured_requests)
|
||||
|
||||
capabilities = list(BUILTIN_CAPABILITY_CLASSES)
|
||||
|
||||
for cap in capabilities:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"run",
|
||||
cap,
|
||||
"hello world",
|
||||
"--format",
|
||||
"json",
|
||||
"--tool",
|
||||
"rag",
|
||||
"--kb",
|
||||
"demo-kb",
|
||||
"--history-ref",
|
||||
"session-old",
|
||||
"--notebook-ref",
|
||||
"nb1:rec1,rec2",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
lines = [json.loads(line) for line in result.output.splitlines() if line.strip()]
|
||||
assert any(line["type"] == "result" for line in lines)
|
||||
|
||||
assert len(captured_requests) == len(capabilities)
|
||||
assert captured_requests[0].capability == "chat"
|
||||
assert captured_requests[0].tools == ["rag"]
|
||||
assert captured_requests[0].knowledge_bases == ["demo-kb"]
|
||||
assert captured_requests[0].history_references == ["session-old"]
|
||||
assert captured_requests[0].notebook_references == [
|
||||
{"notebook_id": "nb1", "record_ids": ["rec1", "rec2"]}
|
||||
]
|
||||
assert {request.capability for request in captured_requests} == set(capabilities)
|
||||
|
||||
|
||||
def test_builtin_capability_aliases_resolve_to_canonical_names() -> None:
|
||||
runtime = DeepTutorApp()
|
||||
|
||||
assert runtime.resolve_capability("solve") == "deep_solve"
|
||||
assert runtime.resolve_capability("quiz") == "deep_question"
|
||||
assert runtime.resolve_capability("research") == "deep_research"
|
||||
assert runtime.resolve_capability("viz") == "visualize"
|
||||
assert runtime.resolve_capability("animate") == "math_animator"
|
||||
assert runtime.resolve_capability("mastery") == "mastery_path"
|
||||
with pytest.raises(ValueError, match="Unknown capability `auto`"):
|
||||
runtime.resolve_capability("auto")
|
||||
|
||||
|
||||
def test_run_command_rejects_removed_auto_capability() -> None:
|
||||
result = runner.invoke(app, ["run", "auto", "hello"])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert isinstance(result.exception, ValueError)
|
||||
assert "Unknown capability `auto`" in str(result.exception)
|
||||
|
||||
|
||||
def test_run_command_rich_mode(monkeypatch) -> None:
|
||||
captured_requests: list[TurnRequest] = []
|
||||
_install_fake_runtime(monkeypatch, captured_requests)
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello rich"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "response body" in result.output
|
||||
assert captured_requests[0].capability == "chat"
|
||||
|
||||
|
||||
def test_run_command_with_config(monkeypatch) -> None:
|
||||
captured_requests: list[TurnRequest] = []
|
||||
_install_fake_runtime(monkeypatch, captured_requests)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"run",
|
||||
"deep_research",
|
||||
"compare retrieval stacks",
|
||||
"--config-json",
|
||||
'{"mode":"report","depth":"deep"}',
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
request = captured_requests[0]
|
||||
assert request.capability == "deep_research"
|
||||
assert request.config == {
|
||||
"mode": "report",
|
||||
"depth": "deep",
|
||||
}
|
||||
|
||||
|
||||
def test_chat_repl_config_commands_match_docs_syntax(monkeypatch) -> None:
|
||||
captured_requests: list[TurnRequest] = []
|
||||
_install_fake_runtime(monkeypatch, captured_requests)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["chat", "--config", "initial=true"],
|
||||
input=(
|
||||
"/config set num_questions 5\n"
|
||||
'/config set question_types \'["short_answer","mcq"]\'\n'
|
||||
"/refs\n"
|
||||
"Generate questions\n"
|
||||
"/quit\n"
|
||||
),
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert '"num_questions": 5' in result.output
|
||||
assert '"question_types"' in result.output
|
||||
assert captured_requests[0].config == {
|
||||
"initial": True,
|
||||
"num_questions": 5,
|
||||
"question_types": ["short_answer", "mcq"],
|
||||
}
|
||||
|
||||
|
||||
def test_chat_repl_backslash_continuation_sends_single_message(monkeypatch) -> None:
|
||||
captured_requests: list[TurnRequest] = []
|
||||
_install_fake_runtime(monkeypatch, captured_requests)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["chat"],
|
||||
input="Please review this code:\\\ndef fib(n): return n\n/quit\n",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured_requests[0].content == "Please review this code:\ndef fib(n): return n"
|
||||
|
||||
|
||||
def test_plugin_info_includes_capability_aliases_and_availability() -> None:
|
||||
result = runner.invoke(app, ["plugin", "info", "deep_solve"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload["name"] == "deep_solve"
|
||||
assert payload["cli_aliases"] == ["solve"]
|
||||
assert payload["availability"]["available"] is True
|
||||
|
||||
|
||||
def test_session_list_command_uses_shared_store(monkeypatch) -> None:
|
||||
async def _list_sessions(self, limit: int = 50, offset: int = 0): # noqa: ANN001
|
||||
return [
|
||||
{
|
||||
"id": "session-1",
|
||||
"title": "Algebra",
|
||||
"capability": "chat",
|
||||
"status": "completed",
|
||||
"message_count": 4,
|
||||
}
|
||||
]
|
||||
|
||||
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.list_sessions", _list_sessions)
|
||||
|
||||
result = runner.invoke(app, ["session", "list"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "session-1" in result.output
|
||||
assert "Algebra" in result.output
|
||||
|
||||
|
||||
def test_start_command_delegates_to_runtime_launcher(monkeypatch) -> None:
|
||||
calls: list[object] = []
|
||||
|
||||
def _fake_start(home=None): # noqa: ANN001
|
||||
calls.append(home)
|
||||
|
||||
monkeypatch.setattr("deeptutor.runtime.launcher.start", _fake_start)
|
||||
|
||||
result = runner.invoke(app, ["start"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert calls == [None]
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Unit tests for shared CLI helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from deeptutor_cli.common import parse_json_object
|
||||
|
||||
|
||||
def test_parse_json_object_whitespace_is_empty_dict() -> None:
|
||||
assert parse_json_object(" ") == {}
|
||||
|
||||
|
||||
def test_parse_json_object_invalid_json_still_errors() -> None:
|
||||
with pytest.raises(ValueError, match="Invalid JSON config"):
|
||||
parse_json_object("{")
|
||||
@@ -0,0 +1,65 @@
|
||||
"""CLI config command tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from deeptutor_cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_config_show_handles_missing_embedding(monkeypatch) -> None:
|
||||
"""CLI-only defaults skip embedding setup, so config show must not traceback."""
|
||||
import deeptutor.services.config as config
|
||||
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"load_system_settings",
|
||||
lambda: {"backend_port": 8001, "frontend_port": 3782},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"resolve_llm_runtime_config",
|
||||
lambda: SimpleNamespace(
|
||||
binding_hint="openai",
|
||||
provider_name="openai",
|
||||
provider_mode="standard",
|
||||
model="gpt-4o-mini",
|
||||
effective_url="https://api.openai.com/v1",
|
||||
api_version=None,
|
||||
extra_headers={},
|
||||
api_key="",
|
||||
),
|
||||
)
|
||||
|
||||
def _missing_embedding() -> None:
|
||||
raise ValueError("No active embedding model is configured.")
|
||||
|
||||
monkeypatch.setattr(config, "resolve_embedding_runtime_config", _missing_embedding)
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"resolve_search_runtime_config",
|
||||
lambda: SimpleNamespace(
|
||||
provider="duckduckgo",
|
||||
requested_provider="duckduckgo",
|
||||
status="ok",
|
||||
fallback_reason=None,
|
||||
base_url="",
|
||||
proxy=None,
|
||||
api_key="",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
config,
|
||||
"load_config_with_main",
|
||||
lambda _name: {"system": {"language": "en"}, "tools": {}},
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["config", "show"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert '"status": "not_configured"' in result.output
|
||||
assert "No active embedding model is configured." in result.output
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Contracts that keep the public docs aligned with the CLI surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DOCS_ROOT = ROOT / "site" / "src" / "content" / "docs"
|
||||
PUBLIC_DOCS = (
|
||||
ROOT / "README.md",
|
||||
ROOT / "deeptutor_cli" / "README.md",
|
||||
ROOT / "SKILL.md",
|
||||
)
|
||||
|
||||
|
||||
def _command_doc_paths() -> list[Path]:
|
||||
paths: list[Path] = []
|
||||
if DOCS_ROOT.exists():
|
||||
paths.extend(DOCS_ROOT.rglob("*.md"))
|
||||
paths.extend(path for path in PUBLIC_DOCS if path.exists())
|
||||
return paths
|
||||
|
||||
|
||||
def _docs_text() -> str:
|
||||
return "\n".join(path.read_text(encoding="utf-8") for path in _command_doc_paths())
|
||||
|
||||
|
||||
def _doc_ids() -> set[str]:
|
||||
ids: set[str] = set()
|
||||
for path in DOCS_ROOT.rglob("*.md"):
|
||||
slug = path.relative_to(DOCS_ROOT).with_suffix("").as_posix()
|
||||
ids.add(f"/{slug}")
|
||||
ids.add(f"/{slug}/")
|
||||
if slug.endswith("/index"):
|
||||
base = slug[: -len("/index")]
|
||||
ids.add(f"/{base}")
|
||||
ids.add(f"/{base}/")
|
||||
return ids
|
||||
|
||||
|
||||
def _deeptutor_commands() -> list[str]:
|
||||
commands: list[str] = []
|
||||
pending = ""
|
||||
for path in _command_doc_paths():
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
stripped = line.strip()
|
||||
if not pending and not stripped.startswith("deeptutor "):
|
||||
continue
|
||||
continued = stripped.endswith("\\")
|
||||
line_part = stripped[:-1].strip() if continued else stripped
|
||||
pending = f"{pending} {line_part}".strip()
|
||||
if continued:
|
||||
continue
|
||||
commands.append(pending)
|
||||
pending = ""
|
||||
return commands
|
||||
|
||||
|
||||
def test_internal_docs_links_point_to_existing_pages() -> None:
|
||||
ids = _doc_ids()
|
||||
missing: list[tuple[str, str]] = []
|
||||
|
||||
for path in DOCS_ROOT.rglob("*.md"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for match in re.finditer(r"\[[^\]]+\]\((/docs/[^)\s#]+)(?:#[^)]+)?\)", text):
|
||||
href = match.group(1)
|
||||
if href not in ids:
|
||||
missing.append((str(path.relative_to(ROOT)), href))
|
||||
|
||||
assert missing == []
|
||||
|
||||
|
||||
def test_documented_deeptutor_subcommands_exist() -> None:
|
||||
top_level = {
|
||||
"book",
|
||||
"chat",
|
||||
"config",
|
||||
"init",
|
||||
"kb",
|
||||
"memory",
|
||||
"notebook",
|
||||
"partner",
|
||||
"plugin",
|
||||
"provider",
|
||||
"run",
|
||||
"serve",
|
||||
"session",
|
||||
"skill",
|
||||
"start",
|
||||
}
|
||||
provider_subcommands = {"login"}
|
||||
|
||||
for command in _deeptutor_commands():
|
||||
first_segment = command.split("|", 1)[0].split("#", 1)[0].strip()
|
||||
if "<" in first_segment or "[" in first_segment:
|
||||
continue
|
||||
tokens = shlex.split(first_segment)
|
||||
if len(tokens) < 2:
|
||||
continue
|
||||
assert tokens[1] in top_level, command
|
||||
if tokens[1] == "provider" and len(tokens) >= 3:
|
||||
assert tokens[2] in provider_subcommands, command
|
||||
|
||||
|
||||
def test_deep_research_examples_include_required_config() -> None:
|
||||
examples = [
|
||||
command for command in _deeptutor_commands() if "deeptutor run deep_research" in command
|
||||
]
|
||||
|
||||
assert examples, "docs should include at least one deep_research example"
|
||||
for command in examples:
|
||||
has_json_config = "--config-json" in command
|
||||
has_pair_config = "--config mode=" in command and "--config depth=" in command
|
||||
assert has_json_config or has_pair_config, command
|
||||
|
||||
|
||||
def test_docs_do_not_advertise_removed_cli_forms() -> None:
|
||||
text = _docs_text()
|
||||
|
||||
assert "deeptutor provider logout" not in text
|
||||
assert "deeptutor memory show summary" not in text
|
||||
assert "WS /api/v1/turns" not in text
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
captured: list[dict] = []
|
||||
|
||||
def __init__(self, *, timeout: float):
|
||||
self.timeout = timeout
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def post(self, url: str, *, headers: dict, json: dict):
|
||||
self.captured.append({"url": url, "headers": headers, "json": json})
|
||||
return SimpleNamespace(status_code=200, text="")
|
||||
|
||||
|
||||
def test_probe_llm_uses_max_completion_tokens_for_gpt5(monkeypatch) -> None:
|
||||
from deeptutor_cli import init_wizard
|
||||
|
||||
_FakeClient.captured = []
|
||||
monkeypatch.setattr(init_wizard.httpx, "Client", _FakeClient)
|
||||
|
||||
ok, _elapsed_ms, error = init_wizard.probe_llm(
|
||||
base_url="https://example.test/v1",
|
||||
api_key="sk-test",
|
||||
binding="openai",
|
||||
model="gpt-5-mini",
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert error == ""
|
||||
body = _FakeClient.captured[0]["json"]
|
||||
assert body["max_completion_tokens"] == 1
|
||||
assert "max_tokens" not in body
|
||||
|
||||
|
||||
def test_probe_llm_keeps_max_tokens_for_legacy_chat_models(monkeypatch) -> None:
|
||||
from deeptutor_cli import init_wizard
|
||||
|
||||
_FakeClient.captured = []
|
||||
monkeypatch.setattr(init_wizard.httpx, "Client", _FakeClient)
|
||||
|
||||
init_wizard.probe_llm(
|
||||
base_url="https://example.test/v1",
|
||||
api_key="sk-test",
|
||||
binding="openai",
|
||||
model="gpt-3.5-turbo",
|
||||
)
|
||||
|
||||
body = _FakeClient.captured[0]["json"]
|
||||
assert body["max_tokens"] == 1
|
||||
assert "max_completion_tokens" not in body
|
||||
|
||||
|
||||
def test_probe_llm_keeps_anthropic_native_max_tokens(monkeypatch) -> None:
|
||||
from deeptutor_cli import init_wizard
|
||||
|
||||
_FakeClient.captured = []
|
||||
monkeypatch.setattr(init_wizard.httpx, "Client", _FakeClient)
|
||||
|
||||
init_wizard.probe_llm(
|
||||
base_url="https://api.anthropic.test/v1",
|
||||
api_key="sk-test",
|
||||
binding="anthropic",
|
||||
model="claude-sonnet-4",
|
||||
)
|
||||
|
||||
body = _FakeClient.captured[0]["json"]
|
||||
assert body["max_tokens"] == 1
|
||||
assert "max_completion_tokens" not in body
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from deeptutor_cli.kb import _collect_documents
|
||||
|
||||
|
||||
def test_collect_documents_from_directory_matches_uppercase_extensions(tmp_path: Path) -> None:
|
||||
docs_dir = tmp_path / "资料"
|
||||
docs_dir.mkdir()
|
||||
upper_pdf = docs_dir / "报告.PDF"
|
||||
upper_pdf.write_bytes(b"%PDF-1.4")
|
||||
nested = docs_dir / "nested"
|
||||
nested.mkdir()
|
||||
upper_md = nested / "README.MD"
|
||||
upper_md.write_text("hello", encoding="utf-8")
|
||||
|
||||
collected = [Path(path).name for path in _collect_documents([], str(docs_dir))]
|
||||
|
||||
assert collected == ["README.MD", "报告.PDF"]
|
||||
@@ -0,0 +1,236 @@
|
||||
"""CLI smoke tests for ``deeptutor notebook`` commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from deeptutor_cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class FakeNotebookManager:
|
||||
"""Fake NotebookManager for CLI testing."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.notebooks: dict[str, dict] = {}
|
||||
|
||||
def create_notebook(self, name: str, description: str = "") -> dict:
|
||||
import time
|
||||
import uuid
|
||||
|
||||
nb = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"name": name,
|
||||
"description": description,
|
||||
"created_at": time.time(),
|
||||
"updated_at": time.time(),
|
||||
"records": [],
|
||||
}
|
||||
self.notebooks[nb["id"]] = nb
|
||||
return nb
|
||||
|
||||
def get_notebook(self, notebook_id: str) -> dict | None:
|
||||
return self.notebooks.get(notebook_id)
|
||||
|
||||
def add_record(
|
||||
self,
|
||||
notebook_ids: list[str],
|
||||
record_type: str,
|
||||
title: str,
|
||||
user_query: str,
|
||||
output: str,
|
||||
summary: str = "",
|
||||
metadata: dict | None = None,
|
||||
kb_name: str | None = None,
|
||||
) -> dict:
|
||||
import time
|
||||
import uuid
|
||||
|
||||
record = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"type": record_type,
|
||||
"title": title,
|
||||
"summary": summary,
|
||||
"user_query": user_query,
|
||||
"output": output,
|
||||
"metadata": metadata or {},
|
||||
"created_at": time.time(),
|
||||
"kb_name": kb_name,
|
||||
}
|
||||
for nb_id in notebook_ids:
|
||||
if nb_id in self.notebooks:
|
||||
self.notebooks[nb_id]["records"].append(record)
|
||||
return {"record": record, "added_to_notebooks": notebook_ids}
|
||||
|
||||
def update_record(self, notebook_id: str, record_id: str, **kwargs) -> dict | None:
|
||||
nb = self.notebooks.get(notebook_id)
|
||||
if not nb:
|
||||
return None
|
||||
for rec in nb["records"]:
|
||||
if rec["id"] == record_id:
|
||||
rec.update(kwargs)
|
||||
return rec
|
||||
return None
|
||||
|
||||
def remove_record(self, notebook_id: str, record_id: str) -> bool:
|
||||
nb = self.notebooks.get(notebook_id)
|
||||
if not nb:
|
||||
return False
|
||||
for i, rec in enumerate(nb["records"]):
|
||||
if rec["id"] == record_id:
|
||||
nb["records"].pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_fake_manager = FakeNotebookManager()
|
||||
|
||||
|
||||
def _patch_facade(monkeypatch) -> None:
|
||||
"""Patch DeepTutorApp methods to use the shared fake manager."""
|
||||
|
||||
def _create_notebook(self, **kwargs):
|
||||
return _fake_manager.create_notebook(**kwargs)
|
||||
|
||||
def _get_notebook(self, notebook_id):
|
||||
return _fake_manager.get_notebook(notebook_id)
|
||||
|
||||
def _add_record(self, **kwargs):
|
||||
return _fake_manager.add_record(**kwargs)
|
||||
|
||||
def _update_record(self, notebook_id, record_id, **kwargs):
|
||||
return _fake_manager.update_record(notebook_id, record_id, **kwargs)
|
||||
|
||||
def _remove_record(self, notebook_id, record_id) -> bool:
|
||||
return _fake_manager.remove_record(notebook_id, record_id)
|
||||
|
||||
from deeptutor.app import facade
|
||||
|
||||
monkeypatch.setattr(facade.DeepTutorApp, "create_notebook", _create_notebook)
|
||||
monkeypatch.setattr(facade.DeepTutorApp, "get_notebook", _get_notebook)
|
||||
monkeypatch.setattr(facade.DeepTutorApp, "add_record", _add_record)
|
||||
monkeypatch.setattr(facade.DeepTutorApp, "update_record", _update_record)
|
||||
monkeypatch.setattr(facade.DeepTutorApp, "remove_record", _remove_record)
|
||||
|
||||
|
||||
def test_notebook_add_md_creates_record(monkeypatch, tmp_path: Path) -> None:
|
||||
"""add-md should read a markdown file and add it as a record to a notebook."""
|
||||
_patch_facade(monkeypatch)
|
||||
_fake_manager.notebooks.clear()
|
||||
|
||||
notebook = _fake_manager.create_notebook("Test Notebook")
|
||||
|
||||
md_file = tmp_path / "sample.md"
|
||||
md_file.write_text("# Hello\n\nThis is a test.", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["notebook", "add-md", notebook["id"], str(md_file)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Added record" in result.output
|
||||
|
||||
stored = _fake_manager.get_notebook(notebook["id"])
|
||||
assert stored is not None
|
||||
assert len(stored["records"]) == 1
|
||||
assert stored["records"][0]["title"] == "sample"
|
||||
assert stored["records"][0]["type"] == "chat"
|
||||
assert stored["records"][0]["output"] == "# Hello\n\nThis is a test."
|
||||
|
||||
|
||||
def test_notebook_add_md_custom_title_and_type(monkeypatch, tmp_path: Path) -> None:
|
||||
"""add-md should respect --title and --type options."""
|
||||
_patch_facade(monkeypatch)
|
||||
_fake_manager.notebooks.clear()
|
||||
|
||||
notebook = _fake_manager.create_notebook("Test Notebook")
|
||||
|
||||
md_file = tmp_path / "notes.md"
|
||||
md_file.write_text("## Notes", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"notebook",
|
||||
"add-md",
|
||||
notebook["id"],
|
||||
str(md_file),
|
||||
"--title",
|
||||
"My Custom Title",
|
||||
"--type",
|
||||
"research",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
stored = _fake_manager.get_notebook(notebook["id"])
|
||||
assert stored["records"][0]["title"] == "My Custom Title"
|
||||
assert stored["records"][0]["type"] == "research"
|
||||
|
||||
|
||||
def test_notebook_add_md_file_not_found(monkeypatch) -> None:
|
||||
"""add-md should exit with an error when the file does not exist."""
|
||||
_patch_facade(monkeypatch)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["notebook", "add-md", "any-nb", "/path/to/missing.md"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "File not found" in result.output
|
||||
|
||||
|
||||
def test_notebook_replace_md_updates_record(monkeypatch, tmp_path: Path) -> None:
|
||||
"""replace-md should replace the output content of an existing record."""
|
||||
_patch_facade(monkeypatch)
|
||||
_fake_manager.notebooks.clear()
|
||||
|
||||
notebook = _fake_manager.create_notebook("Test Notebook")
|
||||
add_result = _fake_manager.add_record(
|
||||
notebook_ids=[notebook["id"]],
|
||||
record_type="chat",
|
||||
title="Original",
|
||||
user_query="",
|
||||
output="Old content",
|
||||
)
|
||||
record_id = add_result["record"]["id"]
|
||||
|
||||
new_md = tmp_path / "updated.md"
|
||||
new_md.write_text("# Updated\n\nNew content here.", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["notebook", "replace-md", notebook["id"], record_id, str(new_md)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Updated record" in result.output
|
||||
|
||||
stored = _fake_manager.get_notebook(notebook["id"])
|
||||
assert len(stored["records"]) == 1
|
||||
assert stored["records"][0]["output"] == "# Updated\n\nNew content here."
|
||||
|
||||
|
||||
def test_notebook_replace_md_record_not_found(monkeypatch, tmp_path: Path) -> None:
|
||||
"""replace-md should exit with an error when the record does not exist."""
|
||||
_patch_facade(monkeypatch)
|
||||
_fake_manager.notebooks.clear()
|
||||
|
||||
notebook = _fake_manager.create_notebook("Test Notebook")
|
||||
|
||||
md_file = tmp_path / "any.md"
|
||||
md_file.write_text("content", encoding="utf-8")
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["notebook", "replace-md", notebook["id"], "nonexistent-record", str(md_file)],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Record not found" in result.output
|
||||
@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROVIDER_CMD = (ROOT / "deeptutor_cli" / "provider_cmd.py").read_text(encoding="utf-8")
|
||||
CLI_README = (ROOT / "deeptutor_cli" / "README.md").read_text(encoding="utf-8")
|
||||
ROOT_README = (ROOT / "README.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class ProviderCliDocsContractTest(unittest.TestCase):
|
||||
def test_provider_contract_describes_copilot_as_validation_not_oauth_login(self) -> None:
|
||||
self.assertIn(
|
||||
'help="Provider: openai-codex (OAuth login) | github-copilot (validate existing Copilot auth)"',
|
||||
PROVIDER_CMD,
|
||||
)
|
||||
self.assertIn('"""Authenticate or validate provider access."""', PROVIDER_CMD)
|
||||
self.assertIn("GitHub Copilot auth validation succeeded.", PROVIDER_CMD)
|
||||
self.assertIn("GitHub Copilot auth validation failed:", PROVIDER_CMD)
|
||||
self.assertNotIn("OAuth provider: openai-codex | github-copilot", PROVIDER_CMD)
|
||||
self.assertNotIn("GitHub Copilot OAuth authentication succeeded.", PROVIDER_CMD)
|
||||
|
||||
def test_readmes_match_the_cli_contract(self) -> None:
|
||||
self.assertIn(
|
||||
"Provider auth (`openai-codex` OAuth login; `github-copilot` validates an existing Copilot auth session)",
|
||||
ROOT_README,
|
||||
)
|
||||
self.assertIn(
|
||||
"deeptutor provider login github-copilot # 校验现有 GitHub Copilot 认证是否可用",
|
||||
CLI_README,
|
||||
)
|
||||
self.assertNotIn("OAuth login (`openai-codex`, `github-copilot`)", ROOT_README)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,351 @@
|
||||
"""Tests for the CLI turn-stream renderer against the chat-loop protocol.
|
||||
|
||||
The chat agent loop (deeptutor/agents/chat/agent_loop.py) streams every
|
||||
round's text as ``content`` chunks with ``trace_kind=llm_chunk`` and labels
|
||||
the round afterwards via a ``call_status`` marker carrying ``call_role``
|
||||
(``narration`` | ``finish``). These tests feed that exact event shape into
|
||||
the renderer and assert the terminal output (narration demoted to dim text
|
||||
before its tool calls, finish rendered as the answer, no stray blank
|
||||
progress lines) plus the ``ask_user`` pause/resume flow.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from deeptutor.app import TurnRequest
|
||||
from deeptutor_cli import common as cli_common
|
||||
from deeptutor_cli.common import _resolve_answer
|
||||
from deeptutor_cli.main import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _chunk(call_id: str, text: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "content",
|
||||
"content": text,
|
||||
"metadata": {
|
||||
"call_id": call_id,
|
||||
"call_kind": "agent_loop_round",
|
||||
"trace_kind": "llm_chunk",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _running(call_id: str, label: str = "Exploring") -> dict[str, Any]:
|
||||
return {
|
||||
"type": "progress",
|
||||
"content": label,
|
||||
"metadata": {
|
||||
"call_id": call_id,
|
||||
"call_kind": "agent_loop_round",
|
||||
"trace_kind": "call_status",
|
||||
"call_state": "running",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _marker(call_id: str, role: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "progress",
|
||||
"content": "",
|
||||
"metadata": {
|
||||
"call_id": call_id,
|
||||
"call_kind": "agent_loop_round",
|
||||
"trace_kind": "call_status",
|
||||
"call_state": "complete",
|
||||
"call_role": role,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _agent_loop_turn_events() -> list[dict[str, Any]]:
|
||||
"""One narration round (with a tool call) followed by a finish round."""
|
||||
return [
|
||||
{"type": "stage_start", "stage": "responding", "source": "chat"},
|
||||
_running("r1"),
|
||||
_chunk("r1", "Let me check the knowledge base."),
|
||||
_marker("r1", "narration"),
|
||||
{
|
||||
"type": "tool_call",
|
||||
"content": "rag",
|
||||
"metadata": {"args": {"query": "spectral clustering"}},
|
||||
},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "retrieved passage",
|
||||
"metadata": {"tool": "rag"},
|
||||
},
|
||||
_running("r2"),
|
||||
_chunk("r2", "The answer is **42**."),
|
||||
_marker("r2", "finish"),
|
||||
{"type": "stage_end", "stage": "responding", "source": "chat"},
|
||||
{
|
||||
"type": "result",
|
||||
"metadata": {
|
||||
"response": "The answer is **42**.",
|
||||
"engine": "agent_loop",
|
||||
"rounds": 2,
|
||||
"tool_steps": 1,
|
||||
"metadata": {
|
||||
"cost_summary": {
|
||||
"total_cost_usd": 0.0042,
|
||||
"total_tokens": 12345,
|
||||
"total_calls": 2,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{"type": "done"},
|
||||
]
|
||||
|
||||
|
||||
def _install_fake_runtime(
|
||||
monkeypatch,
|
||||
events: list[dict[str, Any]],
|
||||
*,
|
||||
replies: list[dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
async def _start_turn(self, request): # noqa: ANN001
|
||||
if isinstance(request, dict):
|
||||
request = TurnRequest(**request)
|
||||
return {"id": request.session_id or "session-1"}, {"id": "turn-1"}
|
||||
|
||||
async def _stream_turn(self, turn_id: str, after_seq: int = 0): # noqa: ANN001
|
||||
for event in events:
|
||||
yield event
|
||||
|
||||
async def _submit_user_reply(self, turn_id, text=None, *, answers=None): # noqa: ANN001
|
||||
if replies is not None:
|
||||
replies.append({"turn_id": turn_id, "text": text, "answers": answers})
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.start_turn", _start_turn)
|
||||
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.stream_turn", _stream_turn)
|
||||
monkeypatch.setattr("deeptutor.app.facade.DeepTutorApp.submit_user_reply", _submit_user_reply)
|
||||
|
||||
|
||||
def test_narration_renders_before_tools_and_finish_is_answer(monkeypatch) -> None:
|
||||
_install_fake_runtime(monkeypatch, _agent_loop_turn_events())
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
narration_at = result.output.find("Let me check the knowledge base.")
|
||||
tool_at = result.output.find("rag(")
|
||||
answer_at = result.output.find("42")
|
||||
assert narration_at != -1 and tool_at != -1 and answer_at != -1
|
||||
# Narration belongs to the round BEFORE its tool calls; the finish
|
||||
# round's text is the answer and comes last.
|
||||
assert narration_at < tool_at < answer_at
|
||||
# The chat wrapper stage emits no banner.
|
||||
assert "▶ responding" not in result.output
|
||||
|
||||
|
||||
def test_done_summary_line_includes_rounds_tools_tokens_cost(monkeypatch) -> None:
|
||||
_install_fake_runtime(monkeypatch, _agent_loop_turn_events())
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "rounds=2" in result.output
|
||||
assert "tools=1" in result.output
|
||||
assert "tokens=12.3k" in result.output
|
||||
assert "cost=$0.0042" in result.output
|
||||
|
||||
|
||||
def test_empty_call_status_markers_print_nothing(monkeypatch) -> None:
|
||||
_install_fake_runtime(monkeypatch, _agent_loop_turn_events())
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
lines = result.output.splitlines()
|
||||
# The two call_status markers carry empty content; no bare dim lines
|
||||
# may leak out of them (a leaked line is whitespace-only).
|
||||
assert not any(line.strip() == "" and line != "" for line in lines)
|
||||
|
||||
|
||||
def test_thinking_chunks_collapse_to_single_indicator(monkeypatch) -> None:
|
||||
events = _agent_loop_turn_events()
|
||||
thinking = [
|
||||
{
|
||||
"type": "thinking",
|
||||
"content": piece,
|
||||
"metadata": {
|
||||
"call_id": "r1",
|
||||
"call_kind": "agent_loop_round",
|
||||
"trace_kind": "llm_chunk",
|
||||
},
|
||||
}
|
||||
for piece in ("First ", "I ", "should ", "look ", "things ", "up.")
|
||||
]
|
||||
events[2:2] = thinking # before r1's content chunk
|
||||
|
||||
_install_fake_runtime(monkeypatch, events)
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert result.output.count("thinking…") == 1
|
||||
# Raw reasoning text must not splatter into the transcript.
|
||||
assert "should" not in result.output
|
||||
|
||||
|
||||
def test_legacy_capability_content_still_renders(monkeypatch) -> None:
|
||||
events = [
|
||||
{"type": "stage_start", "stage": "planning", "source": "deep_research"},
|
||||
{"type": "content", "content": "Plan text here."},
|
||||
{"type": "stage_end", "stage": "planning", "source": "deep_research"},
|
||||
{"type": "done"},
|
||||
]
|
||||
_install_fake_runtime(monkeypatch, events)
|
||||
|
||||
result = runner.invoke(app, ["run", "deep_research", "question"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "▶ planning" in result.output
|
||||
assert "Plan text here." in result.output
|
||||
|
||||
|
||||
def _ask_user_events() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"type": "stage_start", "stage": "responding", "source": "chat"},
|
||||
{
|
||||
"type": "tool_result",
|
||||
"content": "[awaiting user reply to: Which topic?]",
|
||||
"metadata": {
|
||||
"tool": "ask_user",
|
||||
"tool_metadata": {
|
||||
"ask_user": {
|
||||
"intro": "Quick check",
|
||||
"questions": [
|
||||
{
|
||||
"id": "q1",
|
||||
"prompt": "Which topic?",
|
||||
"options": [
|
||||
{"label": "Algebra", "description": "Linear systems"},
|
||||
{"label": "Geometry", "description": None},
|
||||
],
|
||||
"multi_select": False,
|
||||
"allow_free_text": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
_running("r2"),
|
||||
_chunk("r2", "Algebra it is."),
|
||||
_marker("r2", "finish"),
|
||||
{"type": "stage_end", "stage": "responding", "source": "chat"},
|
||||
{"type": "done"},
|
||||
]
|
||||
|
||||
|
||||
def test_ask_user_interactive_submits_selected_option(monkeypatch) -> None:
|
||||
replies: list[dict[str, Any]] = []
|
||||
_install_fake_runtime(monkeypatch, _ask_user_events(), replies=replies)
|
||||
monkeypatch.setattr(cli_common, "_stdin_interactive", lambda: True)
|
||||
monkeypatch.setattr(cli_common.console, "input", lambda prompt="": "1")
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Which topic?" in result.output
|
||||
assert "Algebra" in result.output
|
||||
assert replies == [
|
||||
{
|
||||
"turn_id": "turn-1",
|
||||
"text": None,
|
||||
"answers": [{"questionId": "q1", "text": "Algebra"}],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_ask_user_non_interactive_sends_empty_reply(monkeypatch) -> None:
|
||||
replies: list[dict[str, Any]] = []
|
||||
_install_fake_runtime(monkeypatch, _ask_user_events(), replies=replies)
|
||||
monkeypatch.setattr(cli_common, "_stdin_interactive", lambda: False)
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert replies == [{"turn_id": "turn-1", "text": "", "answers": None}]
|
||||
|
||||
|
||||
def test_ask_user_json_mode_sends_empty_reply_and_streams_events(monkeypatch) -> None:
|
||||
replies: list[dict[str, Any]] = []
|
||||
_install_fake_runtime(monkeypatch, _ask_user_events(), replies=replies)
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello", "--format", "json"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
lines = [json.loads(line) for line in result.output.splitlines() if line.strip()]
|
||||
assert any(line.get("type") == "tool_result" for line in lines)
|
||||
assert replies == [{"turn_id": "turn-1", "text": "", "answers": None}]
|
||||
|
||||
|
||||
def test_terminator_llm_output_renders_after_buffered_chunks(monkeypatch) -> None:
|
||||
events = [
|
||||
{"type": "stage_start", "stage": "responding", "source": "chat"},
|
||||
_chunk("r1", "Buffered narration."),
|
||||
{
|
||||
"type": "content",
|
||||
"content": "Terminator final text.",
|
||||
"metadata": {
|
||||
"call_id": "chat-final-response-1",
|
||||
"call_kind": "llm_final_response",
|
||||
"trace_kind": "llm_output",
|
||||
},
|
||||
},
|
||||
{"type": "stage_end", "stage": "responding", "source": "chat"},
|
||||
{"type": "done"},
|
||||
]
|
||||
_install_fake_runtime(monkeypatch, events)
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
buffered_at = result.output.find("Buffered narration.")
|
||||
final_at = result.output.find("Terminator final text.")
|
||||
assert buffered_at != -1 and final_at != -1
|
||||
assert buffered_at < final_at
|
||||
|
||||
|
||||
def test_resolve_answer_maps_numbers_to_labels() -> None:
|
||||
labels = ["Algebra", "Geometry", "Calculus"]
|
||||
assert _resolve_answer("2", labels, multi=False) == "Geometry"
|
||||
assert _resolve_answer("free text", labels, multi=False) == "free text"
|
||||
assert _resolve_answer("", labels, multi=False) == ""
|
||||
assert _resolve_answer("1, 3", labels, multi=True) == "Algebra, Calculus"
|
||||
assert _resolve_answer("1, custom", labels, multi=True) == "Algebra, custom"
|
||||
# Out-of-range numbers fall through as text rather than crashing.
|
||||
assert _resolve_answer("9", labels, multi=False) == "9"
|
||||
|
||||
|
||||
def test_sources_render_compact_list(monkeypatch) -> None:
|
||||
events = _agent_loop_turn_events()
|
||||
events.insert(
|
||||
-2,
|
||||
{
|
||||
"type": "sources",
|
||||
"metadata": {
|
||||
"sources": [
|
||||
{"title": "Paper A", "url": "https://example.com/a"},
|
||||
{"title": "Paper A", "url": "https://example.com/a"}, # dedupe
|
||||
{"filename": "notes.pdf", "file_path": "/tmp/notes.pdf"},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
_install_fake_runtime(monkeypatch, events)
|
||||
|
||||
result = runner.invoke(app, ["run", "chat", "hello"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "sources (2):" in result.output
|
||||
assert "Paper A" in result.output
|
||||
assert "notes.pdf" in result.output
|
||||
Reference in New Issue
Block a user