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()