514 lines
16 KiB
Python
514 lines
16 KiB
Python
|
|
"""Unit tests for agent_team.operator_cli (§3.3.1, §6.6).
|
||
|
|
|
||
|
|
Covers the two design-mandated invariants — every destructive action is
|
||
|
|
audit-logged, and destructive actions require an explicit confirmation flag —
|
||
|
|
plus the ledger lifecycle effects (which delegate to the committed
|
||
|
|
compare-and-set helpers) and the argparse entrypoint.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import stat
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from agent_team.db.schema import connect, init_db
|
||
|
|
from agent_team.operator_cli import (
|
||
|
|
DESTRUCTIVE_ACTIONS,
|
||
|
|
AuditEntry,
|
||
|
|
AuditLog,
|
||
|
|
ConfirmationRequired,
|
||
|
|
OperatorCli,
|
||
|
|
QuestionNotFound,
|
||
|
|
build_parser,
|
||
|
|
main,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# fixtures / helpers
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
def _insert_open_question(
|
||
|
|
db_path: Path,
|
||
|
|
qid: str,
|
||
|
|
*,
|
||
|
|
thread_id: str = "thread-1",
|
||
|
|
turn: int = 0,
|
||
|
|
status: str = "open",
|
||
|
|
transport: str = "slack",
|
||
|
|
channel_ref: str | None = "slack-ts-1",
|
||
|
|
) -> None:
|
||
|
|
conn = connect(db_path)
|
||
|
|
try:
|
||
|
|
conn.execute(
|
||
|
|
"INSERT INTO pending_questions "
|
||
|
|
"(question_id, thread_id, turn, status, transport, channel_ref, "
|
||
|
|
" posted_at) "
|
||
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||
|
|
(
|
||
|
|
qid,
|
||
|
|
thread_id,
|
||
|
|
turn,
|
||
|
|
status,
|
||
|
|
transport,
|
||
|
|
channel_ref,
|
||
|
|
"2026-06-17T00:00:00",
|
||
|
|
),
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture()
|
||
|
|
def db_path(tmp_path: Path) -> Path:
|
||
|
|
db = tmp_path / "agent_team.sqlite"
|
||
|
|
init_db(db)
|
||
|
|
return db
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture()
|
||
|
|
def audit_path(tmp_path: Path) -> Path:
|
||
|
|
return tmp_path / "audit" / "operator.jsonl"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture()
|
||
|
|
def cli(db_path: Path, audit_path: Path) -> OperatorCli:
|
||
|
|
operator = OperatorCli(db_path, audit_path, actor="tester")
|
||
|
|
yield operator
|
||
|
|
operator.close()
|
||
|
|
|
||
|
|
|
||
|
|
def _status(db_path: Path, qid: str) -> str:
|
||
|
|
conn = connect(db_path)
|
||
|
|
try:
|
||
|
|
return conn.execute(
|
||
|
|
"SELECT status FROM pending_questions WHERE question_id=?", (qid,)
|
||
|
|
).fetchone()["status"]
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# AuditEntry / AuditLog
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
def test_audit_entry_to_dict_is_json_safe() -> None:
|
||
|
|
entry = AuditEntry(
|
||
|
|
timestamp="2026-06-17T00:00:00+00:00",
|
||
|
|
actor="tester",
|
||
|
|
action="force-expire",
|
||
|
|
phase="attempt",
|
||
|
|
confirmed=True,
|
||
|
|
question_id="q1",
|
||
|
|
thread_id="t1",
|
||
|
|
detail={"k": "v"},
|
||
|
|
)
|
||
|
|
data = json.loads(entry.to_json())
|
||
|
|
assert data["action"] == "force-expire"
|
||
|
|
assert data["confirmed"] is True
|
||
|
|
assert data["detail"] == {"k": "v"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_audit_entry_is_frozen() -> None:
|
||
|
|
entry = AuditEntry(
|
||
|
|
timestamp="t", actor="a", action="x", phase="attempt", confirmed=False
|
||
|
|
)
|
||
|
|
with pytest.raises(Exception):
|
||
|
|
entry.actor = "other" # type: ignore[misc]
|
||
|
|
|
||
|
|
|
||
|
|
def test_audit_log_append_is_jsonl_and_appends(audit_path: Path) -> None:
|
||
|
|
log = AuditLog(audit_path)
|
||
|
|
log.append(
|
||
|
|
AuditEntry(
|
||
|
|
timestamp="t1", actor="a", action="x", phase="attempt", confirmed=True
|
||
|
|
)
|
||
|
|
)
|
||
|
|
log.append(
|
||
|
|
AuditEntry(
|
||
|
|
timestamp="t2", actor="a", action="y", phase="outcome", confirmed=True
|
||
|
|
)
|
||
|
|
)
|
||
|
|
records = log.read_all()
|
||
|
|
assert [r["action"] for r in records] == ["x", "y"]
|
||
|
|
# raw file is one JSON object per line
|
||
|
|
lines = audit_path.read_text().splitlines()
|
||
|
|
assert len(lines) == 2
|
||
|
|
assert json.loads(lines[0])["timestamp"] == "t1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_audit_log_file_is_mode_600(audit_path: Path) -> None:
|
||
|
|
log = AuditLog(audit_path)
|
||
|
|
log.append(
|
||
|
|
AuditEntry(
|
||
|
|
timestamp="t", actor="a", action="x", phase="attempt", confirmed=True
|
||
|
|
)
|
||
|
|
)
|
||
|
|
mode = stat.S_IMODE(audit_path.stat().st_mode)
|
||
|
|
assert mode == 0o600
|
||
|
|
|
||
|
|
|
||
|
|
def test_audit_log_read_all_empty_when_absent(audit_path: Path) -> None:
|
||
|
|
assert AuditLog(audit_path).read_all() == []
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# confirmation gating (§3.3.1: explicit confirmation flag)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.parametrize("action", sorted(DESTRUCTIVE_ACTIONS))
|
||
|
|
def test_destructive_actions_set_matches_design(action: str) -> None:
|
||
|
|
assert action in {"force-expire", "answer-on-behalf", "force-resume"}
|
||
|
|
|
||
|
|
|
||
|
|
def test_force_expire_without_confirm_raises_and_no_mutation(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
with pytest.raises(ConfirmationRequired):
|
||
|
|
cli.force_expire("q1")
|
||
|
|
assert _status(db_path, "q1") == "open" # not mutated
|
||
|
|
|
||
|
|
|
||
|
|
def test_answer_on_behalf_without_confirm_raises_and_no_mutation(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
with pytest.raises(ConfirmationRequired):
|
||
|
|
cli.answer_on_behalf("q1", {"approve": True})
|
||
|
|
assert _status(db_path, "q1") == "open"
|
||
|
|
|
||
|
|
|
||
|
|
def test_force_resume_without_confirm_raises_and_no_mutation(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", status="answered")
|
||
|
|
with pytest.raises(ConfirmationRequired):
|
||
|
|
cli.force_resume("q1")
|
||
|
|
assert _status(db_path, "q1") == "answered"
|
||
|
|
|
||
|
|
|
||
|
|
def test_refused_action_is_audit_logged(cli: OperatorCli, db_path: Path) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
with pytest.raises(ConfirmationRequired):
|
||
|
|
cli.force_expire("q1")
|
||
|
|
records = cli.audit_log.read_all()
|
||
|
|
phases = [(r["action"], r["phase"]) for r in records]
|
||
|
|
# attempt + refusal outcome both recorded
|
||
|
|
assert ("force-expire", "attempt") in phases
|
||
|
|
assert ("force-expire", "outcome") in phases
|
||
|
|
refusal = [r for r in records if r["phase"] == "outcome"][0]
|
||
|
|
assert refusal["confirmed"] is False
|
||
|
|
assert "refused" in refusal["detail"]
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# destructive actions: audit-logged on success (§3.3.1: audit-logged)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
def test_force_expire_confirmed_mutates_and_audits(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
result = cli.force_expire("q1", confirm=True)
|
||
|
|
assert result.ok is True
|
||
|
|
assert _status(db_path, "q1") == "expired"
|
||
|
|
records = cli.audit_log.read_all()
|
||
|
|
actions = [(r["action"], r["phase"], r["confirmed"]) for r in records]
|
||
|
|
assert ("force-expire", "attempt", True) in actions
|
||
|
|
assert ("force-expire", "outcome", True) in actions
|
||
|
|
outcome = [r for r in records if r["phase"] == "outcome"][0]
|
||
|
|
assert outcome["question_id"] == "q1"
|
||
|
|
assert outcome["thread_id"] == "thread-1"
|
||
|
|
assert outcome["detail"]["changed"] is True
|
||
|
|
|
||
|
|
|
||
|
|
def test_answer_on_behalf_confirmed_writes_first_answer_wins(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
result = cli.answer_on_behalf("q1", {"approve": True}, confirm=True)
|
||
|
|
assert result.ok is True
|
||
|
|
conn = connect(db_path)
|
||
|
|
try:
|
||
|
|
row = conn.execute(
|
||
|
|
"SELECT status, answer_json, answered_via FROM pending_questions "
|
||
|
|
"WHERE question_id='q1'"
|
||
|
|
).fetchone()
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
assert row["status"] == "answered"
|
||
|
|
assert json.loads(row["answer_json"]) == {"approve": True}
|
||
|
|
assert row["answered_via"] == "operator:tester"
|
||
|
|
# answer payload + via captured in audit attempt detail
|
||
|
|
attempt = [
|
||
|
|
r
|
||
|
|
for r in cli.audit_log.read_all()
|
||
|
|
if r["action"] == "answer-on-behalf" and r["phase"] == "attempt"
|
||
|
|
][0]
|
||
|
|
assert attempt["detail"]["answer"] == {"approve": True}
|
||
|
|
assert attempt["detail"]["via"] == "operator:tester"
|
||
|
|
|
||
|
|
|
||
|
|
def test_answer_on_behalf_late_loses_compare_and_set(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", status="expired")
|
||
|
|
result = cli.answer_on_behalf("q1", "yes", confirm=True)
|
||
|
|
assert result.ok is False # already closed -> ignored
|
||
|
|
assert _status(db_path, "q1") == "expired"
|
||
|
|
|
||
|
|
|
||
|
|
def test_force_resume_confirmed_supersedes_and_records_intent(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", status="answered")
|
||
|
|
result = cli.force_resume("q1", confirm=True)
|
||
|
|
assert result.ok is True
|
||
|
|
assert result.detail["resume_requested"] is True
|
||
|
|
assert result.detail["superseded"] is True
|
||
|
|
assert _status(db_path, "q1") == "superseded"
|
||
|
|
outcome = [
|
||
|
|
r
|
||
|
|
for r in cli.audit_log.read_all()
|
||
|
|
if r["action"] == "force-resume" and r["phase"] == "outcome"
|
||
|
|
][0]
|
||
|
|
assert outcome["detail"]["resume_requested"] is True
|
||
|
|
assert outcome["thread_id"] == "thread-1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_force_resume_with_no_open_question_still_records_intent(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", status="expired")
|
||
|
|
result = cli.force_resume("q1", confirm=True)
|
||
|
|
assert result.ok is True
|
||
|
|
assert result.detail["superseded"] is False
|
||
|
|
assert result.detail["resume_requested"] is True
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# redeliver (non-destructive, still audited)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
def test_redeliver_clears_channel_ref_and_audits(
|
||
|
|
cli: OperatorCli, db_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", channel_ref="slack-ts-99")
|
||
|
|
result = cli.redeliver("q1")
|
||
|
|
assert result.ok is True
|
||
|
|
assert result.detail["prior_channel_ref"] == "slack-ts-99"
|
||
|
|
conn = connect(db_path)
|
||
|
|
try:
|
||
|
|
ref = conn.execute(
|
||
|
|
"SELECT channel_ref FROM pending_questions WHERE question_id='q1'"
|
||
|
|
).fetchone()["channel_ref"]
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
assert ref is None
|
||
|
|
actions = [r["action"] for r in cli.audit_log.read_all()]
|
||
|
|
assert actions.count("redeliver") == 2 # attempt + outcome
|
||
|
|
|
||
|
|
|
||
|
|
def test_redeliver_non_open_is_noop(cli: OperatorCli, db_path: Path) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", status="answered")
|
||
|
|
result = cli.redeliver("q1")
|
||
|
|
assert result.ok is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_redeliver_does_not_require_confirm(cli: OperatorCli, db_path: Path) -> None:
|
||
|
|
# redeliver is NOT in the destructive set, so no confirm needed
|
||
|
|
assert "redeliver" not in DESTRUCTIVE_ACTIONS
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
cli.redeliver("q1") # must not raise
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# listing (read-only)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
def test_list_questions_defaults_and_filters(cli: OperatorCli, db_path: Path) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", thread_id="t1", status="open")
|
||
|
|
_insert_open_question(db_path, "q2", thread_id="t2", status="expired")
|
||
|
|
_insert_open_question(db_path, "q3", thread_id="t1", status="open")
|
||
|
|
|
||
|
|
open_rows = cli.list_questions(statuses=["open"])
|
||
|
|
assert {r["question_id"] for r in open_rows} == {"q1", "q3"}
|
||
|
|
|
||
|
|
t1_rows = cli.list_questions(statuses=["open", "expired"], thread_id="t1")
|
||
|
|
assert {r["question_id"] for r in t1_rows} == {"q1", "q3"}
|
||
|
|
|
||
|
|
all_rows = cli.list_questions()
|
||
|
|
assert len(all_rows) == 3
|
||
|
|
|
||
|
|
|
||
|
|
def test_list_is_not_audited_as_mutation(cli: OperatorCli, db_path: Path) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
cli.list_questions(statuses=["open"])
|
||
|
|
# listing does not write audit rows
|
||
|
|
assert cli.audit_log.read_all() == []
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# missing question
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
def test_force_expire_missing_question_raises_after_attempt_logged(
|
||
|
|
cli: OperatorCli,
|
||
|
|
) -> None:
|
||
|
|
with pytest.raises(QuestionNotFound):
|
||
|
|
cli.force_expire("nope", confirm=True)
|
||
|
|
# the attempt is logged even though the question doesn't exist
|
||
|
|
attempts = [r for r in cli.audit_log.read_all() if r["phase"] == "attempt"]
|
||
|
|
assert any(r["question_id"] == "nope" for r in attempts)
|
||
|
|
|
||
|
|
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
# argparse entrypoint (main)
|
||
|
|
# --------------------------------------------------------------------------- #
|
||
|
|
|
||
|
|
|
||
|
|
def test_build_parser_requires_db_and_audit_log() -> None:
|
||
|
|
parser = build_parser()
|
||
|
|
with pytest.raises(SystemExit):
|
||
|
|
parser.parse_args(["list"]) # missing --db/--audit-log
|
||
|
|
|
||
|
|
|
||
|
|
def test_main_list_returns_zero(db_path: Path, audit_path: Path, capsys) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
rc = main(
|
||
|
|
[
|
||
|
|
"--db",
|
||
|
|
str(db_path),
|
||
|
|
"--audit-log",
|
||
|
|
str(audit_path),
|
||
|
|
"list",
|
||
|
|
"--status",
|
||
|
|
"open",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
assert rc == 0
|
||
|
|
out = json.loads(capsys.readouterr().out)
|
||
|
|
assert out[0]["question_id"] == "q1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_main_force_expire_requires_confirm_flag(
|
||
|
|
db_path: Path, audit_path: Path, capsys
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
rc = main(
|
||
|
|
["--db", str(db_path), "--audit-log", str(audit_path), "force-expire", "q1"]
|
||
|
|
)
|
||
|
|
assert rc == 2 # ConfirmationRequired exit code
|
||
|
|
assert "refused" in capsys.readouterr().err
|
||
|
|
assert _status(db_path, "q1") == "open"
|
||
|
|
|
||
|
|
|
||
|
|
def test_main_force_expire_with_confirm(db_path: Path, audit_path: Path) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
rc = main(
|
||
|
|
[
|
||
|
|
"--db",
|
||
|
|
str(db_path),
|
||
|
|
"--audit-log",
|
||
|
|
str(audit_path),
|
||
|
|
"--actor",
|
||
|
|
"adam",
|
||
|
|
"force-expire",
|
||
|
|
"q1",
|
||
|
|
"--confirm",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
assert rc == 0
|
||
|
|
assert _status(db_path, "q1") == "expired"
|
||
|
|
records = AuditLog(audit_path).read_all()
|
||
|
|
assert any(r["actor"] == "adam" for r in records)
|
||
|
|
|
||
|
|
|
||
|
|
def test_main_answer_on_behalf_parses_json_answer(
|
||
|
|
db_path: Path, audit_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
rc = main(
|
||
|
|
[
|
||
|
|
"--db",
|
||
|
|
str(db_path),
|
||
|
|
"--audit-log",
|
||
|
|
str(audit_path),
|
||
|
|
"answer-on-behalf",
|
||
|
|
"q1",
|
||
|
|
'{"approve": true}',
|
||
|
|
"--confirm",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
assert rc == 0
|
||
|
|
conn = connect(db_path)
|
||
|
|
try:
|
||
|
|
answer_json = conn.execute(
|
||
|
|
"SELECT answer_json FROM pending_questions WHERE question_id='q1'"
|
||
|
|
).fetchone()["answer_json"]
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
assert json.loads(answer_json) == {"approve": True}
|
||
|
|
|
||
|
|
|
||
|
|
def test_main_answer_on_behalf_raw_string_answer(
|
||
|
|
db_path: Path, audit_path: Path
|
||
|
|
) -> None:
|
||
|
|
_insert_open_question(db_path, "q1")
|
||
|
|
rc = main(
|
||
|
|
[
|
||
|
|
"--db",
|
||
|
|
str(db_path),
|
||
|
|
"--audit-log",
|
||
|
|
str(audit_path),
|
||
|
|
"answer-on-behalf",
|
||
|
|
"q1",
|
||
|
|
"approve",
|
||
|
|
"--confirm",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
assert rc == 0
|
||
|
|
conn = connect(db_path)
|
||
|
|
try:
|
||
|
|
answer_json = conn.execute(
|
||
|
|
"SELECT answer_json FROM pending_questions WHERE question_id='q1'"
|
||
|
|
).fetchone()["answer_json"]
|
||
|
|
finally:
|
||
|
|
conn.close()
|
||
|
|
assert json.loads(answer_json) == "approve"
|
||
|
|
|
||
|
|
|
||
|
|
def test_main_missing_question_returns_error_code(
|
||
|
|
db_path: Path, audit_path: Path, capsys
|
||
|
|
) -> None:
|
||
|
|
rc = main(
|
||
|
|
[
|
||
|
|
"--db",
|
||
|
|
str(db_path),
|
||
|
|
"--audit-log",
|
||
|
|
str(audit_path),
|
||
|
|
"force-resume",
|
||
|
|
"ghost",
|
||
|
|
"--confirm",
|
||
|
|
]
|
||
|
|
)
|
||
|
|
assert rc == 3
|
||
|
|
assert "error" in capsys.readouterr().err
|
||
|
|
|
||
|
|
|
||
|
|
def test_main_redeliver_returns_one_when_noop(db_path: Path, audit_path: Path) -> None:
|
||
|
|
_insert_open_question(db_path, "q1", status="answered")
|
||
|
|
rc = main(["--db", str(db_path), "--audit-log", str(audit_path), "redeliver", "q1"])
|
||
|
|
assert rc == 1 # not open -> ok=False -> exit 1
|