Files
nisse/nisse-dev/tests/test_stop.py
T
dongarandClaude Opus 5.5 3cf4cefacd Rename the delegate and consultant roles to helper and expert
Architecture L9's role names, for the two roles with a direct
counterpart: the delegation role is now 'helper' and the ask_expert role
'expert', in RoleMap, configuration (roles.helper, roles.expert),
recorded events and tests. 'planner' and 'executor' stay distinct for
now; they become 'driver' when the new loop merges them (Development
Plan M2.3).

Configurations must rename their keys: nisse-dev.example.yaml and
tests/integration/nisse-test.yaml are updated here.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
2026-10-01 23:04:37 +00:00

364 lines
12 KiB
Python

"""The operator's kill switch, `stop` (architecture L2)."""
import io
import threading
from pathlib import Path
from typing import Any
import pytest
from nisse_dev.cancel import STOPPED_RESULT, Cancel
from nisse_dev.canonical import AssistantText, ToolCall, ToolResult, UserMessage
from nisse_dev.control import (
AWAIT_ARCH_SIGNOFF,
AWAIT_INPUT,
AWAIT_MILESTONE_SIGNOFF,
Orchestrator,
PlanningStopped,
StubAgent,
SubmitPlan,
)
from nisse_dev.delegation import DelegationResult
from nisse_dev.executor import ExecutorOutcome, LLMExecutor
from nisse_dev.llm import RoleMap
from nisse_dev.oplog import OpLog
from nisse_dev.planner import LLMPlanningAgent
from nisse_dev.state import StateStore
from nisse_dev.tools import ToolOutcome
from nisse_dev.transport import InMemoryTransport
from nisse_dev.worker import SerialWorker
ROLES = RoleMap(
executor="llama3.2",
planner="claude-haiku-4-5",
helper="claude-haiku-4-5",
expert="claude-haiku-4-5",
)
def new_store(path: Path) -> StateStore:
return StateStore(path / "state.db", oplog=OpLog("nisse-dev", io.StringIO()))
def user_messages(store: StateStore) -> list[str]:
return [
e.event.text for e in store.read_events() if isinstance(e.event, UserMessage)
]
# --- the orchestrator ------------------------------------------------------
class OperatorDuringWork:
"""An executor that hears from the operator mid-milestone, then does what
the real loop does at its next tool boundary."""
def __init__(self, cancel: Cancel, says: str) -> None:
self.cancel = cancel
self.says = says
self.transport: InMemoryTransport | None = None
def run(self, milestone: int) -> ExecutorOutcome:
assert self.transport is not None
self.transport.receive("don", self.says)
if self.cancel.requested:
return ExecutorOutcome("stopped", "during turn 3")
return ExecutorOutcome("completed", "done")
def at_start_of_milestone(
tmp_path: Path, says: str
) -> tuple[StateStore, InMemoryTransport, Cancel]:
store = new_store(tmp_path)
transport = InMemoryTransport()
cancel = Cancel()
executor = OperatorDuringWork(cancel, says)
executor.transport = transport
orch = Orchestrator(
store,
transport,
StubAgent(),
operators={"don"},
executor=executor,
cancel=cancel,
)
orch.start()
for text in ("use demo", "approve", "approve"):
transport.receive("don", text)
return store, transport, cancel
def test_stop_halts_a_running_milestone_and_asks_why(tmp_path: Path) -> None:
store, transport, cancel = at_start_of_milestone(tmp_path, "stop")
transport.receive("don", "start milestone 1")
assert store.get_state().state == AWAIT_INPUT
prompt = transport.outbound[-1]
assert prompt.kind == "question"
assert "stopped milestone 1 (during turn 3)" in prompt.text
assert "Why did you stop it?" in prompt.text
assert user_messages(store)[-1] == "stop"
assert not cancel.requested # the next piece of work is not pre-stopped
def test_the_reason_is_recorded_and_the_plan_stays_approved(tmp_path: Path) -> None:
store, transport, _ = at_start_of_milestone(tmp_path, "stop")
transport.receive("don", "start milestone 1")
transport.receive("don", "it was editing the wrong module")
assert store.get_state().state == AWAIT_MILESTONE_SIGNOFF
# The next run renders this, so the model hears why it was stopped.
assert user_messages(store)[-1] == "it was editing the wrong module"
assert "start milestone 1" in transport.outbound[-1].text
def test_stop_with_nothing_running_says_so_and_changes_nothing(
tmp_path: Path,
) -> None:
store = new_store(tmp_path)
transport = InMemoryTransport()
orch = Orchestrator(store, transport, StubAgent(), operators={"don"})
orch.start()
transport.receive("don", "use demo")
transport.receive("don", "stop")
# At a gate, "stop" is the command, not rejection feedback.
assert store.get_state().state == AWAIT_ARCH_SIGNOFF
assert orch._data["revs"]["arch"] == 1
assert (
transport.outbound[-1].text == f"nothing to stop (state={AWAIT_ARCH_SIGNOFF})"
)
def test_stop_from_a_non_operator_is_ignored(tmp_path: Path) -> None:
_, transport, cancel = at_start_of_milestone(tmp_path, "approve")
before = len(transport.outbound)
transport.receive("mallory", "stop")
assert len(transport.outbound) == before
assert not cancel.requested
def test_other_messages_during_work_are_answered_not_acted_on(
tmp_path: Path,
) -> None:
store, transport, _ = at_start_of_milestone(tmp_path, "approve")
transport.receive("don", "start milestone 1")
texts = [m.text for m in transport.outbound]
assert "busy (EXECUTING); say 'stop' to halt" in texts
# Recorded, as every operator message is, but not applied to anything.
assert user_messages(store)[-1] == "approve"
class StopsBeforePlanning:
def __init__(self, cancel: Cancel) -> None:
self.cancel = cancel
self.transport: InMemoryTransport | None = None
self.answers: list[str | None] = []
def plan(
self,
gate: str,
project: str,
feedback: str | None = None,
answer: str | None = None,
) -> SubmitPlan:
self.answers.append(answer)
if len(self.answers) == 1:
assert self.transport is not None
self.transport.receive("don", "stop")
if self.cancel.requested:
raise PlanningStopped("stopped by the operator")
return SubmitPlan(f"{gate} plan, after: {answer}")
def test_stop_during_planning_resumes_planning_with_the_reason(
tmp_path: Path,
) -> None:
store = new_store(tmp_path)
transport = InMemoryTransport()
cancel = Cancel()
planner = StopsBeforePlanning(cancel)
planner.transport = transport
orch = Orchestrator(store, transport, planner, operators={"don"}, cancel=cancel)
orch.start()
transport.receive("don", "use demo")
assert store.get_state().state == AWAIT_INPUT
assert "stopped planning arch" in transport.outbound[-1].text
transport.receive("don", "keep it to one module")
assert store.get_state().state == AWAIT_ARCH_SIGNOFF
assert planner.answers[-1] == "keep it to one module"
# --- on real threads -------------------------------------------------------
class BlocksUntilStopped:
def __init__(self, cancel: Cancel) -> None:
self.cancel = cancel
self.running = threading.Event()
def run(self, milestone: int) -> ExecutorOutcome:
self.running.set()
for _ in range(500):
if self.cancel.requested:
return ExecutorOutcome("stopped", "at a tool boundary")
threading.Event().wait(0.01)
return ExecutorOutcome("completed", "never stopped")
@pytest.mark.parametrize("word", ["stop", "Stop!", " STOP "])
def test_stop_reaches_work_running_on_the_worker(tmp_path: Path, word: str) -> None:
# The bug this design fixes: the work used to run on the transport's own
# thread, so no message, stop included, was read until it finished.
worker = SerialWorker()
worker.start()
store = worker.call(lambda: new_store(tmp_path))
transport = InMemoryTransport()
cancel = Cancel()
executor = BlocksUntilStopped(cancel)
orch = Orchestrator(
store,
transport,
StubAgent(),
operators={"don"},
executor=executor,
worker=worker,
cancel=cancel,
)
worker.call(orch.start)
for text in ("use demo", "approve", "approve"):
transport.receive("don", text)
# Reply only once the plan is up: a reply stamped with an earlier
# revision is not applied to a later one.
worker.call(lambda: None)
transport.receive("don", "start milestone 1")
assert executor.running.wait(5)
transport.receive("don", "status") # delivered while the milestone runs
transport.receive("don", word) # a phone may capitalise it (#64)
worker.call(lambda: None) # wait for the stop to be recorded
texts = [m.text for m in transport.outbound]
assert "busy (EXECUTING); say 'stop' to halt" in texts
assert worker.call(store.get_state).state == AWAIT_INPUT
assert "stopped milestone 1 (at a tool boundary)" in texts[-1]
worker.call(store.close)
worker.stop(timeout=5)
# --- the loops -------------------------------------------------------------
class ScriptedClient:
def __init__(self, turns: list[list[Any]], on_call: Any = None) -> None:
self.turns = turns
self.on_call = on_call
self.calls = 0
def complete(self, events: Any, tools: Any, model: str, system: Any = None):
self.calls += 1
if self.on_call:
self.on_call()
return self.turns.pop(0) if self.turns else [AssistantText("done", model)]
class StopsAfterFirstCall:
"""A workspace whose first tool call is where the operator says stop."""
def __init__(self, cancel: Cancel) -> None:
self.cancel = cancel
self.ran: list[str] = []
def describe(self) -> str:
return "workspace"
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
self.ran.append(tool)
self.cancel.set()
return ToolOutcome("ok", success=True)
def test_executor_stops_before_a_turn_without_calling_the_model(
tmp_path: Path,
) -> None:
cancel = Cancel()
cancel.set()
client = ScriptedClient([])
executor = LLMExecutor(
client, new_store(tmp_path), ROLES, StopsAfterFirstCall(cancel), cancel=cancel
)
outcome = executor.run(1)
assert outcome == ExecutorOutcome("stopped", "before turn 1")
assert client.calls == 0
def test_executor_stops_between_tool_calls_and_records_the_skipped_ones(
tmp_path: Path,
) -> None:
cancel = Cancel()
store = new_store(tmp_path)
ws = StopsAfterFirstCall(cancel)
calls = [
ToolCall(call_id="a", tool="run_command", arguments={"command": "make"}),
ToolCall(call_id="b", tool="write_file", arguments={"path": "x"}),
]
executor = LLMExecutor(ScriptedClient([calls]), store, ROLES, ws, cancel=cancel)
outcome = executor.run(1)
assert outcome == ExecutorOutcome("stopped", "during turn 1")
assert ws.ran == ["run_command"]
results = {
e.event.call_id: e.event.result
for e in store.read_events()
if isinstance(e.event, ToolResult)
}
# Every call has a result, so the transcript still renders.
assert results == {"a": "ok", "b": STOPPED_RESULT}
class StoppedDelegate:
def run(self, *, brief: str, branch: str) -> DelegationResult:
return DelegationResult("stopped", "stopped by the operator during turn 2")
def test_a_stopped_delegation_stops_the_milestone(tmp_path: Path) -> None:
failing = [
[ToolCall(call_id=f"c{n}", tool="run_command", arguments={"command": "x"})]
for n in range(2)
]
class Failing:
def describe(self) -> str:
return "workspace"
def dispatch(self, tool: str, arguments: dict[str, Any]) -> ToolOutcome:
return ToolOutcome("exit 1", success=False)
executor = LLMExecutor(
ScriptedClient(failing),
new_store(tmp_path),
ROLES,
Failing(),
delegate=StoppedDelegate(),
)
outcome = executor.run(1)
assert outcome.status == "stopped"
assert "during a delegation" in outcome.note
def test_planner_does_not_start_a_turn_once_stopped(tmp_path: Path) -> None:
cancel = Cancel()
cancel.set()
client = ScriptedClient([])
planner = LLMPlanningAgent(client, new_store(tmp_path), ROLES, cancel=cancel)
try:
planner.plan("arch", "demo")
except PlanningStopped:
pass
else:
raise AssertionError("planning ran after stop")
assert client.calls == 0