Files
obsidian-wiki/tests/test_mcp_query.py
T
Codex 3d0203fb9e feat(mcp): configure OpenCode models per workflow
Add server-side model and variant settings for MCP ingest and query OpenCode jobs. Ingest defaults to openai/gpt-5.5 high, while query defaults to openai/gpt-5.5 minimal, and readiness/status surfaces the active settings.

Persist the settings through the managed service env file and preserve local overrides across start, tools, and token rotations. Remote MCP clients still cannot choose arbitrary models per call.

Validation: python3 -m unittest discover -v; python3 -m py_compile obsidian_wiki/mcp_ingest.py obsidian_wiki/mcp_query.py obsidian_wiki/mcp_server.py scripts/manage_mcp_service.py tests/test_mcp_ingest.py tests/test_mcp_query.py tests/test_mcp_server_remote.py tests/test_mcp_service.py; git diff --check.
2026-07-05 18:20:06 +00:00

218 lines
9.2 KiB
Python

import subprocess
import tempfile
import time
import unittest
from pathlib import Path
from unittest.mock import patch
from obsidian_wiki.mcp_query import QueryError, QueryManager
class McpQueryTests(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.TemporaryDirectory()
self.root = Path(self.tempdir.name)
self.vault = self.root / "vault"
self.project = self.root / "project"
self.runtime = self.project / ".runtime" / "mcp-query"
self.vault.mkdir()
self.project.mkdir()
self._write_opencode_skill_fixture(self.project)
self.manager = QueryManager(
vault_path=self.vault,
project_root=self.project,
runtime_root=self.runtime,
opencode_path=Path("/usr/bin/opencode"),
max_query_chars=80,
)
def tearDown(self):
self.tempdir.cleanup()
def test_check_opencode_readiness_static_success(self):
with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run) as run_mock:
result = self.manager.check_opencode_readiness()
self.assertTrue(result["ok"])
self.assertTrue(result["static_preflight"])
self.assertEqual(result["runtime_smoke_test"], "skipped")
self.assertEqual(result["opencode_version"], "1.17.13")
self.assertFalse(result["errors"])
self.assertTrue(any(path.endswith(".agents/skills/wiki-query/SKILL.md") for path in result["checked_paths"]))
self.assertEqual(run_mock.call_count, 2)
def test_check_opencode_readiness_reports_missing_skill_files(self):
missing_project = self.root / "missing-project"
missing_project.mkdir()
(missing_project / "AGENTS.md").write_text("# Agent rules\n", encoding="utf-8")
manager = QueryManager(
vault_path=self.vault,
project_root=missing_project,
runtime_root=self.root / "runtime-missing",
opencode_path=Path("/usr/bin/opencode"),
)
with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run):
result = manager.check_opencode_readiness()
self.assertFalse(result["ok"])
self.assertFalse(result["static_preflight"])
self.assertIn(".agents/skills/wiki-query/SKILL.md", "\n".join(result["errors"]))
self.assertIn(".agents/skills/llm-wiki/SKILL.md", "\n".join(result["errors"]))
def test_check_opencode_readiness_smoke_test_success(self):
self.manager.opencode_model = "openai/gpt-5.5"
self.manager.opencode_variant = "minimal"
with patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_smoke_success_run) as run_mock:
result = self.manager.check_opencode_readiness(run_smoke_test=True)
self.assertTrue(result["ok"])
self.assertEqual(result["runtime_smoke_test"], "passed")
self.assertEqual(run_mock.call_count, 3)
smoke_args = run_mock.call_args_list[-1].args[0]
self.assertEqual(smoke_args[:4], ["/usr/bin/opencode", "run", "--format", "default"])
self.assertEqual(smoke_args[smoke_args.index("--model") + 1], "openai/gpt-5.5")
self.assertEqual(smoke_args[smoke_args.index("--variant") + 1], "minimal")
self.assertIn("--auto", smoke_args)
self.assertIn("--dir", smoke_args)
self.assertIn("MCP query readiness smoke test", smoke_args)
def test_query_rejects_empty_oversized_and_invalid_mode(self):
with self.assertRaises(QueryError):
self.manager.start_query_job("")
with self.assertRaises(QueryError):
self.manager.start_query_job("x" * 81)
with self.assertRaises(QueryError):
self.manager.start_query_job("what do I know?", mode="full")
def test_start_query_job_constructs_restricted_opencode_command(self):
self.manager.opencode_model = "openai/gpt-5.5"
self.manager.opencode_variant = "minimal"
popen = FakePopen()
with (
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen) as popen_mock,
):
job = self.manager.start_query_job(
"what do I know about Mini FAKRA?",
mode="index_only",
public_only=True,
note="remote agent asked",
)
args = popen_mock.call_args.args[0]
self.assertEqual(args[:5], ["/usr/bin/opencode", "run", "--format", "default", "--auto"])
self.assertEqual(args[args.index("--model") + 1], "openai/gpt-5.5")
self.assertEqual(args[args.index("--variant") + 1], "minimal")
self.assertIn("--dir", args)
self.assertEqual(args[args.index("--dir") + 1], str(self.project))
self.assertIn("--title", args)
self.assertIn(f"MCP query {job['job_id']}", args)
prompt = args[-1]
self.assertIn("/wiki-query", prompt)
self.assertIn("what do I know about Mini FAKRA?", prompt)
self.assertIn("index-only", prompt)
self.assertIn("public only", prompt)
self.assertIn("normal wiki-query log entry is allowed", prompt)
self.assertIn("Do not run wiki-ingest, wiki-update, wiki-rebuild", prompt)
self.assertNotIn("rm -rf", prompt)
def test_query_result_returns_opencode_output_tail(self):
popen = FakePopen(returncode=None)
with (
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
):
job = self.manager.start_query_job("what do I know?")
log_path = Path(self.manager.get_query_job_status(job["job_id"])["log_path"])
log_path.write_text("Based on the wiki:\nAnswer body\n", encoding="utf-8")
popen.returncode = 0
result = self.manager.get_query_job_result(job["job_id"])
self.assertEqual(result["state"], "succeeded")
self.assertIn("Based on the wiki", result["answer"])
self.assertIn("Answer body", result["opencode_output_tail"])
def test_jobs_queue_one_at_a_time_and_cancel_queued_job(self):
popen = FakePopen(returncode=None)
with (
patch("obsidian_wiki.mcp_query.subprocess.run", side_effect=self._fake_static_readiness_run),
patch("obsidian_wiki.mcp_query.subprocess.Popen", return_value=popen),
):
first = self.manager.start_query_job("first query")
second = self.manager.start_query_job("second query")
self.assertEqual(self.manager.get_query_job_status(first["job_id"])["state"], "running")
self.assertEqual(self.manager.get_query_job_status(second["job_id"])["state"], "queued")
cancelled = self.manager.cancel_query_job(second["job_id"])
self.assertEqual(cancelled["state"], "cancelled")
def _write_opencode_skill_fixture(self, root: Path) -> None:
(root / "AGENTS.md").write_text("# Agent rules\n", encoding="utf-8")
wiki_query = root / ".agents" / "skills" / "wiki-query"
llm_wiki = root / ".agents" / "skills" / "llm-wiki"
wiki_query.mkdir(parents=True)
llm_wiki.mkdir(parents=True)
(wiki_query / "SKILL.md").write_text(
"---\nname: wiki-query\ndescription: Answer questions from the wiki.\n---\n",
encoding="utf-8",
)
(llm_wiki / "SKILL.md").write_text(
"---\nname: llm-wiki\ndescription: Shared wiki framework instructions.\n---\n",
encoding="utf-8",
)
def _fake_static_readiness_run(self, args, **_kwargs):
if args == ["/usr/bin/opencode", "--version"]:
return subprocess.CompletedProcess(args, 0, stdout="1.17.13\n", stderr="")
if args == ["/usr/bin/opencode", "run", "--help"]:
return subprocess.CompletedProcess(
args,
0,
stdout="--format\n--auto\n--dir\n--title\n",
stderr="",
)
raise AssertionError(f"unexpected subprocess.run args: {args}")
def _fake_smoke_success_run(self, args, **kwargs):
if args in (["/usr/bin/opencode", "--version"], ["/usr/bin/opencode", "run", "--help"]):
return self._fake_static_readiness_run(args, **kwargs)
if args[:3] == ["/usr/bin/opencode", "run", "--format"]:
return subprocess.CompletedProcess(args, 0, stdout="WIKI_QUERY_SKILL_LOADED\n", stderr="")
raise AssertionError(f"unexpected subprocess.run args: {args}")
class FakePopen:
def __init__(self, returncode=None):
self.returncode = returncode
self.pid = 12345
self.terminated = False
def poll(self):
return self.returncode
def wait(self, timeout=None):
deadline = time.monotonic() + (timeout or 0)
while self.returncode is None and (timeout is None or time.monotonic() < deadline):
time.sleep(0.01)
if self.returncode is None:
raise subprocess.TimeoutExpired("fake", timeout)
return self.returncode
def terminate(self):
self.terminated = True
self.returncode = -15
def kill(self):
self.returncode = -9
if __name__ == "__main__":
unittest.main()