Found while chasing a flaky live test, but the flakiness was a real bug: only the ask_human path recorded a pending item, so AWAIT_INPUT reached any other way — planning failure, stopped milestone, boot reconciliation — refused every operator reply with "no pending question to answer" and the agent could never be recovered. The operator had no way back short of wiping state. Every path into AWAIT_INPUT now records what a reply should resume, and answering dispatches on it: planning failures and questions resume planning, a stopped milestone returns to its still-approved gate, and the fallback says what commands work instead of dead-ending. Two regression tests cover the recovery paths. The live test also scripted a fixed approve/approve/start sequence, which a real model breaks by legitimately calling ask_human at a gate. It now plays the operator adaptively, responding to whatever the agent actually did. Five consecutive live runs pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
282 lines
9.8 KiB
Python
282 lines
9.8 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 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
|