"""Unit tests for navi.core.planning."""
import pytest
from navi.core.planning import PlanningEngine, _parse_plan_steps
from navi.llm.base import LLMResponse, Message
from tests.conftest_factory import make_profile
class RecordingLLM:
def __init__(self, responses):
self.responses = list(responses)
self.calls = []
async def complete(self, messages, **kwargs):
self.calls.append(messages)
return LLMResponse(
content=self.responses.pop(0),
tool_calls=None,
finish_reason="stop",
)
class FakeContextBuilder:
def build_system_prompt(self, profile):
return "base system prompt"
def _mcp_context_msg(self, profile=None):
if profile and profile.mcp_servers:
return Message(
role="system",
content="gnexus-book instructions with mcp_gnexus-book_search_docs",
)
return None
class TestParsePlanSteps:
def test_basic_numbered_list(self):
text = "**Steps:**\n1. First step\n2. Second step\n3. Third step"
assert _parse_plan_steps(text) == ["First step", "Second step", "Third step"]
def test_parenthesised_numbers(self):
text = "**Steps:**\n1) Step one\n2) Step two"
assert _parse_plan_steps(text) == ["Step one", "Step two"]
def test_ignores_bracket_prefixes(self):
text = "**Steps:**\n1. [TOOL] Do thing\n2. Normal step"
assert _parse_plan_steps(text) == ["Normal step"]
def test_empty_steps_section(self):
text = "**Steps:**\n\n**Notes:** nothing"
assert _parse_plan_steps(text) == []
def test_no_steps_section(self):
text = "Some random text without steps"
assert _parse_plan_steps(text) == []
class TestPlanningPrompt:
async def test_planning_prompt_includes_profile_mcp_and_persistence_rules(self):
profile = make_profile(
"server_admin",
planning_phase2_enabled=False,
mcp_servers={"gnexus-book": ["read", "write"]},
)
llm = RecordingLLM([
"TASK: document infra\n"
"GOAL: docs updated\n"
"UNKNOWNS: NONE\n"
"RESOURCES:\n"
"- mcp_gnexus-book_search_docs: search docs\n"
"- context sources: gnexus-book\n"
"KNOWLEDGE SOURCE ASSESSMENT:\n"
"- Domain: infrastructure\n"
"- Primary source: connected knowledge servers\n"
"- Fallback: docs\n"
"KNOWLEDGE CAPTURE:\n"
"- New information to save: stable infra facts\n"
"- Target: connected knowledge server\n"
"- Duplication check: search target\n"
"- Rationale: reusable\n"
"COMPLEXITY: medium\n"
"SUBTASKS:\n"
"1. Search docs\n"
"2. Persist facts\n"
"REFLECT: no\n"
"COMMITMENTS: checkpoint",
"## Plan\n\n"
"**Task:** document infra\n"
"**Goal:** docs updated\n\n"
"**Milestones:**\nA. Inspect\nB. Persist\nC. Report\n\n"
"**Steps:**\n"
"1. Search gnexus-book → TOOL: mcp_gnexus-book_search_docs\n"
"2. Knowledge persistence checkpoint → TOOL: mcp_gnexus-book_propose_doc_change\n"
"3. Final synthesis → SELF\n\n"
"**Parallel:** NONE\n"
"**Risks:** NONE",
])
engine = PlanningEngine(FakeContextBuilder())
context = [Message(role="user", content="update infra docs")]
events = []
async for event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
events.append(event)
phase1_prompt = llm.calls[0][0].content
phase3_prompt = llm.calls[1][0].content
assert "gnexus-book instructions" in phase1_prompt
assert "memory` is only for personal user facts and preferences" in phase1_prompt
assert "Never use memory for infrastructure inventory" in phase1_prompt
assert "knowledge persistence checkpoint" in phase3_prompt
assert "Do not plan unavailable MCP tool calls" in phase3_prompt
async def test_planning_prompt_omits_mcp_when_profile_has_no_mcp_servers(self):
profile = make_profile(
"developer",
planning_phase2_enabled=False,
mcp_servers={},
)
llm = RecordingLLM(["DIRECT"])
engine = PlanningEngine(FakeContextBuilder())
context = [Message(role="user", content="hello")]
async for _event in engine.run(context, profile, llm, mem=None, tool_schemas=[]):
pass
phase1_prompt = llm.calls[0][0].content
assert "gnexus-book instructions" not in phase1_prompt
assert "Connected MCP knowledge servers are authoritative only when the active profile exposes their tools" in phase1_prompt