Files
nisse/nisse-dev/tests/test_planner.py
T
dongarandClaude Opus 5 8cfb45aed3
CI / checks (push) Skipped
CI / checks (pull_request) Successful in 51s
Enforce the token cap, and treat a spent budget as an interruption
Exceeding a LiteLLM spend limit takes external models away for the
rest of the day, which makes three existing gaps consequential rather
than untidy.

- A13 calls the delegation caps hard on "turns, tokens, and wall
  clock", and the module docstring repeated it, but the token cap was
  decoration: nothing counted, and result.tokens was always zero. It
  is now metered from the usage the provider reports, so a confused
  delegate cannot spend a day's allowance. A5 keeps the *money* cap on
  the LiteLLM key, which is a different thing; this is a scope cap
- The normalized route sent no ceiling at all, so truncation detection
  there had nothing to detect against. Both routes now send one
- A budget refusal is recognised distinctly. It is not a rate limit:
  retrying cannot help, so the planner no longer burns a second call
  to fail identically

And a delegation cut off by an exhausted budget is now "interrupted"
rather than "failed", which preserves its branch and leaves its intent
pending. The work on that branch may be perfectly good; discarding it
because the budget ran out would throw away work for a reason that has
nothing to do with the work. A3's resume rule already knows how to
pick such a delegation back up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:54:57 +00:00

300 lines
10 KiB
Python

import io
from pathlib import Path
from typing import Any
import pytest
from nisse_dev.canonical import (
AssistantText,
CanonicalEvent,
PlanSubmitted,
StoredEvent,
ToolCall,
UserMessage,
)
from nisse_dev.control import (
AWAIT_ARCH_SIGNOFF,
AWAIT_MILESTONE_SIGNOFF,
AskHuman,
Orchestrator,
PlanningError,
SubmitPlan,
)
from nisse_dev.llm import BudgetExhausted, RoleMap
from nisse_dev.oplog import OpLog
from nisse_dev.planner import PLANNER_TOOLS, LLMPlanningAgent
from nisse_dev.state import StateStore
from nisse_dev.transport import InMemoryTransport
ROLES = RoleMap(
executor="local/qwen",
planner="anthropic/claude-opus-5",
delegate="anthropic/claude-opus-5",
consultant="anthropic/claude-opus-5",
)
class ScriptedClient:
"""Returns canned canonical events per call, recording what it was asked."""
def __init__(self, turns: list[list[CanonicalEvent]]) -> None:
self._turns = turns
self.calls: list[dict[str, Any]] = []
def complete(
self,
events: list[StoredEvent],
tools: list[dict[str, Any]] | None,
model: str,
system: str | None = None,
) -> list[CanonicalEvent]:
self.calls.append(
{"events": events, "tools": tools, "model": model, "system": system}
)
if not self._turns:
return []
return self._turns.pop(0)
def plan_call(content: str, gate: str = "arch") -> ToolCall:
return ToolCall(
call_id="call-1",
tool="submit_plan",
arguments={"type": gate, "content": content},
)
@pytest.fixture
def store(tmp_path: Path) -> StateStore:
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
def test_submit_plan_tool_call_becomes_submit_plan(store: StateStore) -> None:
client = ScriptedClient([[plan_call("step 1: create src/app.py")]])
agent = LLMPlanningAgent(client, store, ROLES)
action = agent.plan("arch", "demo")
assert action == SubmitPlan("step 1: create src/app.py")
def test_ask_human_tool_call_becomes_ask_human(store: StateStore) -> None:
client = ScriptedClient(
[
[
ToolCall(
call_id="call-1",
tool="ask_human",
arguments={"question": "postgres or sqlite?"},
)
]
]
)
action = LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
assert action == AskHuman("postgres or sqlite?")
def test_planner_role_and_tools_are_used(store: StateStore) -> None:
client = ScriptedClient([[plan_call("plan")]])
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
call = client.calls[0]
assert call["model"] == "anthropic/claude-opus-5" # never the executor
assert [t["function"]["name"] for t in call["tools"]] == [
"submit_plan",
"ask_human",
]
assert "demo" in call["system"] and "arch" in call["system"]
def test_no_proceed_tool_is_offered() -> None:
names = {tool["function"]["name"] for tool in PLANNER_TOOLS}
assert names == {"submit_plan", "ask_human"} # no self-advancement path
def test_model_output_is_appended_to_the_log(store: StateStore) -> None:
client = ScriptedClient(
[[AssistantText(text="thinking out loud", model="m"), plan_call("the plan")]]
)
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
kinds = [type(e.event).__name__ for e in store.read_events()]
assert kinds == ["AssistantText", "ToolCall", "ToolResult"]
def test_gate_tool_call_is_answered_in_the_log(store: StateStore) -> None:
# submit_plan ends the turn and has no natural result, but a tool call
# left dangling makes the next render invalid for the provider.
client = ScriptedClient([[plan_call("the plan")]])
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
events = [e.event for e in store.read_events()]
assert [type(e).__name__ for e in events] == ["ToolCall", "ToolResult"]
assert events[1].call_id == events[0].call_id
assert "posted for approval" in events[1].result
def test_prose_only_turn_retries_once_then_raises(store: StateStore) -> None:
client = ScriptedClient(
[
[AssistantText(text="here's what I think", model="m")],
[AssistantText(text="still prose", model="m")],
]
)
with pytest.raises(PlanningError, match="no submit_plan or ask_human"):
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
assert len(client.calls) == 2
assert "did not call a tool" in client.calls[1]["system"]
def test_retry_succeeds_after_prose(store: StateStore) -> None:
client = ScriptedClient(
[[AssistantText(text="prose", model="m")], [plan_call("the real plan")]]
)
action = LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
assert action == SubmitPlan("the real plan")
def test_malformed_tool_arguments_are_rejected(store: StateStore) -> None:
blank = ToolCall(call_id="c", tool="submit_plan", arguments={"content": " "})
client = ScriptedClient([[blank], [blank]])
with pytest.raises(PlanningError):
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
def test_planner_sees_prior_transcript(store: StateStore) -> None:
store.append_event(UserMessage(text="use demo", source="transport:@don"))
client = ScriptedClient([[plan_call("plan")]])
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
seen = [e.event for e in client.calls[0]["events"]]
assert UserMessage(text="use demo", source="transport:@don") in seen
# --- orchestrator integration (still no network) -------------------------
def build(tmp_path: Path, client: ScriptedClient) -> tuple[Orchestrator, StateStore]:
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
transport = InMemoryTransport()
agent = LLMPlanningAgent(client, store, ROLES)
orch = Orchestrator(store, transport, agent, operators={"don"}, roles=ROLES)
orch.start()
return orch, store, transport
def test_use_project_produces_llm_plan_and_transcript(tmp_path: Path) -> None:
client = ScriptedClient([[plan_call("1. create app.py\n2. add a test")]])
_, store, transport = build(tmp_path, client)
transport.receive("don", "use demo")
request = transport.outbound[-1]
assert request.kind == "approval_request"
assert "create app.py" in request.text
events = [e.event for e in store.read_events()]
kinds = [type(e).__name__ for e in events]
assert kinds == [
"UserMessage",
"ToolCall",
"ToolResult",
"PlanSubmitted",
"GateOpened",
]
submitted = next(e for e in events if isinstance(e, PlanSubmitted))
assert submitted.model == "anthropic/claude-opus-5" # pinned planner
def test_roles_pinned_at_task_start(tmp_path: Path) -> None:
client = ScriptedClient([[plan_call("plan")]])
_, store, transport = build(tmp_path, client)
transport.receive("don", "use demo")
state = store.get_state()
assert state is not None
assert state.data["roles"]["planner"] == "anthropic/claude-opus-5"
def test_planning_failure_gates_to_await_input(tmp_path: Path) -> None:
prose = [AssistantText(text="prose", model="m")]
client = ScriptedClient([prose, prose])
_, store, transport = build(tmp_path, client)
transport.receive("don", "use demo")
state = store.get_state()
assert state is not None
assert state.state == "AWAIT_INPUT"
assert transport.outbound[-1].kind == "question"
def test_operator_can_recover_from_a_planning_failure(tmp_path: Path) -> None:
# Regression: AWAIT_INPUT reached by any path other than ask_human used to
# record no pending item, so every operator reply was refused with "no
# pending question" and the agent was stuck for good.
prose = [AssistantText(text="prose", model="m")]
client = ScriptedClient([prose, prose, [plan_call("recovered plan")]])
_, store, transport = build(tmp_path, client)
transport.receive("don", "use demo")
assert store.get_state().state == "AWAIT_INPUT"
transport.receive("don", "try again, keep it short")
assert store.get_state().state == AWAIT_ARCH_SIGNOFF
assert "recovered plan" in transport.outbound[-1].text
def test_operator_can_recover_after_a_stopped_milestone(tmp_path: Path) -> None:
class StoppedExecutor:
def run(self, milestone: int) -> Any:
from nisse_dev.executor import ExecutorOutcome
return ExecutorOutcome("escalate", "tests failed twice")
store = StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
transport = InMemoryTransport()
client = ScriptedClient([[plan_call("p1")], [plan_call("p2")]])
orch = Orchestrator(
store,
transport,
LLMPlanningAgent(client, store, ROLES),
operators={"don"},
roles=ROLES,
executor=StoppedExecutor(),
)
orch.start()
transport.receive("don", "use demo")
transport.receive("don", "approve")
transport.receive("don", "approve")
transport.receive("don", "start milestone 1")
assert store.get_state().state == "AWAIT_INPUT"
transport.receive("don", "ok, I fixed the dependency")
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF
assert "start milestone 1" in transport.outbound[-1].text
def test_rejection_feedback_reaches_the_planner_via_the_log(
tmp_path: Path,
) -> None:
client = ScriptedClient([[plan_call("v1")], [plan_call("v2")]])
_, _store, transport = build(tmp_path, client)
transport.receive("don", "use demo")
transport.receive("don", "too vague, name the files")
replan_context = [e.event for e in client.calls[1]["events"]]
assert any(
isinstance(e, UserMessage) and "too vague" in e.text for e in replan_context
)
assert "v2" in transport.outbound[-1].text
def test_budget_exhaustion_is_not_retried(store: StateStore) -> None:
"""A budget will not reappear within a turn, so a retry buys nothing and
delays telling the operator."""
class BrokeClient:
def __init__(self) -> None:
self.calls = 0
def complete(self, *args: Any, **kwargs: Any):
self.calls += 1
raise BudgetExhausted("daily budget exhausted")
client = BrokeClient()
with pytest.raises(BudgetExhausted):
LLMPlanningAgent(client, store, ROLES).plan("arch", "demo")
assert client.calls == 1 # not two