Step-2 PR A: the approval loop end to end with a stub agent, no LLM and no network. - transport.py: Transport interface per A1 (send_message, send_approval_request, ask_question, on_message) with an InMemoryTransport; no platform SDK outside an implementation - gates.py: PlanRevision with monotonic rev-N tags; Gate.evaluate enforces revision binding (stale tags refused with a notice), operator allowlisting, and approve-word-only semantics; any other reply is rejection feedback. Channel seam ready for Gitea/Vikunja approval sources - control.py: B1 state machine subset with structural gating — the agent's only actions are SubmitPlan (ends its turn) and AskHuman; no proceed tool exists. Gate state, revision counters, task_id, and pending questions checkpoint through StateStore, and boot resumes an open gate or gates to AWAIT_INPUT, never blind-resumes - gate.open/approve/reject A9 events complete the log taxonomy - CLAUDE.md: record the plan-first working convention - Tests: 15 unit (gates + orchestrator flows), 2 integration (mid-gate restart with revisions intact; unresolved intents gate the whole boot) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
49 lines
1.7 KiB
Python
49 lines
1.7 KiB
Python
from nisse_dev.gates import Approved, Gate, Ignored, PlanRevision, Rejected
|
|
|
|
REVISION = PlanRevision(gate="arch", rev=2, content="the plan")
|
|
OPERATORS = {"don"}
|
|
|
|
|
|
def gate() -> Gate:
|
|
return Gate(REVISION, OPERATORS)
|
|
|
|
|
|
def test_bare_approve_binds_to_current_revision() -> None:
|
|
result = gate().evaluate("don", "approve", channel="transport")
|
|
assert isinstance(result, Approved)
|
|
assert result.revision.tag == "rev-2"
|
|
assert result.approver == "don"
|
|
|
|
|
|
def test_tagged_approve_matching_current_revision() -> None:
|
|
result = gate().evaluate("don", "approve rev-2", channel="gitea")
|
|
assert isinstance(result, Approved)
|
|
assert result.channel == "gitea"
|
|
|
|
|
|
def test_stale_revision_tag_is_ignored_with_notice() -> None:
|
|
result = gate().evaluate("don", "approve rev-1", channel="transport")
|
|
assert isinstance(result, Ignored)
|
|
assert result.notice is not None
|
|
assert "rev-1" in result.notice
|
|
assert "rev-2" in result.notice
|
|
|
|
|
|
def test_any_other_reply_is_rejection_with_feedback() -> None:
|
|
result = gate().evaluate("don", "too vague, split milestone 1", "transport")
|
|
assert isinstance(result, Rejected)
|
|
assert result.feedback == "too vague, split milestone 1"
|
|
|
|
|
|
def test_unknown_sender_is_silently_ignored() -> None:
|
|
result = gate().evaluate("mallory", "approve", channel="transport")
|
|
assert isinstance(result, Ignored)
|
|
assert result.notice is None
|
|
|
|
|
|
def test_approve_with_trailing_words_is_rejection() -> None:
|
|
# "approve, but rename the module" is feedback, not approval (A4: the
|
|
# typed word approve, nothing else).
|
|
result = gate().evaluate("don", "approve, but rename the module", "transport")
|
|
assert isinstance(result, Rejected)
|