From a2f23ff455705e4c618fbbf1ea9a6a9513133308 Mon Sep 17 00:00:00 2001 From: JA VI <127710324+JaDi03@users.noreply.github.com> Date: Tue, 13 Oct 2026 18:54:31 -0600 Subject: [PATCH] fix: the agent is not woken by what it did itself --- agent/memory/sessions.py | 6 +- agent/models.py | 1 + agent/prompt.py | 3 +- agent/reflexes/wake.py | 21 +++-- agent/think.py | 16 +++- agent/tools/definitions.py | 4 +- agent/tools/toolbox.py | 9 +- docs/architecture.md | 2 +- services/shops/activity.py | 2 + services/shops/agent.py | 15 ++- tests/test_agent_in_cycle.py | 17 ++-- tests/test_no_self_wake.py | 171 +++++++++++++++++++++++++++++++++++ 12 files changed, 238 insertions(+), 29 deletions(-) create mode 100644 tests/test_no_self_wake.py diff --git a/agent/memory/sessions.py b/agent/memory/sessions.py index fca7fe1..2cf2225 100644 --- a/agent/memory/sessions.py +++ b/agent/memory/sessions.py @@ -17,7 +17,8 @@ def session_body(session: Session) -> dict[str, Any]: "wakes": [{"kind": w.kind, "text": w.text, "invoice": w.invoice, "key": w.key} for w in session.wakes], "decided": [d.invoice for d in session.decisions], - "alarms": [{"at": a.at.isoformat(), "why": a.why} for a in session.alarms], + "alarms": [{"at": a.at.isoformat(), "why": a.why, "invoice": a.invoice} + for a in session.alarms], "notes": [{"about": n.about, "text": n.text} for n in session.notes], "steps": session.steps, "model": session.model, @@ -55,7 +56,8 @@ def session_from(at: datetime, body: dict[str, Any], summary=body.get("summary", ""), decisions=tuple(decisions[i] for i in body.get("decided", []) if decisions and i in decisions), - alarms=tuple(Alarm(datetime.fromisoformat(a["at"]), a["why"], set_at=at) + alarms=tuple(Alarm(datetime.fromisoformat(a["at"]), a["why"], set_at=at, + invoice=a.get("invoice")) for a in body.get("alarms", [])), notes=tuple(Note(n["about"], n["text"], at=at) for n in body.get("notes", [])), steps=int(body.get("steps", 0)), diff --git a/agent/models.py b/agent/models.py index 3b56b48..0b70fa0 100644 --- a/agent/models.py +++ b/agent/models.py @@ -41,6 +41,7 @@ class Alarm: at: datetime why: str set_at: datetime | None = None + invoice: str | None = None # the invoice it is about: once paid, the alarm does not ring @dataclass(frozen=True) diff --git a/agent/prompt.py b/agent/prompt.py index 8145328..a5432db 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -50,7 +50,8 @@ Talking to the owner. One message, one topic: only what it is about, in one or two short sentences, plain words. After a payment say what you paid (amount, supplier, invoice) and -nothing more. Never add other invoices, other dates or what is still missing elsewhere: the +nothing more; a payment you make on your own is reported to the owner by itself, so send no +notice about it. Never add other invoices, other dates or what is still missing elsewhere: the dashboard's activity shows all of that. No transaction hashes or wallet addresses: the dashboard links them. Your ask_owner questions reach the owner by themselves (with yes/no buttons when only their yes is missing, or a reminder of what to do in the dashboard), so never diff --git a/agent/reflexes/wake.py b/agent/reflexes/wake.py index 46721ac..cd429a4 100644 --- a/agent/reflexes/wake.py +++ b/agent/reflexes/wake.py @@ -3,8 +3,10 @@ Every cycle (every 15 minutes) the reflexes look for a reason: an invoice that is new, changed or still without the agent's decision, a payment that failed or was blocked, the day the agent planned to pay something, a due date close by with no payment planned before it, an alarm the -agent set, the daily round, or the heartbeat when the agent has not looked for a while. No -reason, no session: quiet cycles cost nothing. +agent set, or the daily round. No reason, no session: quiet cycles cost nothing. + +What the agent itself did is not a reason: an invoice it already paid or sent does not wake it +when it changes, and an alarm about it does not ring. A one-time reason (an alarm, a due date, the round) fires until a session that finished saw it. """ @@ -18,7 +20,6 @@ from agent.models import AgentDecision, Alarm, Choice, Note, Session, Wake, payment_seen_key from services.payments import PaymentEvent, PaymentStatus -HEARTBEAT = timedelta(hours=4) RETRY_AFTER_FAILURE = timedelta(minutes=30) DUE_SOON_DAYS = (3, 1, 0) # 0: due today or overdue RECENT_NOTES = 20 @@ -48,6 +49,8 @@ def wakes_for( told = set() for change in changes: d = change.decision + if d.invoice in done: + continue # paid or sent: paying it is what changed it if change.kind is ChangeKind.NEW: wakes.append(Wake("new", f"New invoice {d.invoice} from {d.supplier}, " f"{fmt(d.amount)} USDC.", d.invoice)) @@ -73,14 +76,11 @@ def wakes_for( wakes.extend(_due_soon(d, standing.get(d.invoice), local.date(), fired)) for alarm in due_alarms(sessions, now): key = _alarm_key(alarm) - if key not in fired: + if key not in fired and alarm.invoice not in done: wakes.append(Wake("alarm", f"Your alarm: {alarm.why}", key=key)) round_key = f"round:{local.date().isoformat()}" if local.hour >= round_hour and round_key not in fired: wakes.append(Wake("round", "Daily round: look over everything.", key=round_key)) - if open_ and last and not wakes and now - last.at >= HEARTBEAT: - wakes.append(Wake("heartbeat", f"Heartbeat: you have not looked for " - f"{int((now - last.at).total_seconds() // 3600)} hours.")) return wakes @@ -94,8 +94,11 @@ def due_alarms(sessions: Sequence[Session], now: datetime) -> list[Alarm]: return [a for s in sessions if s.finished for a in s.alarms if a.at <= now] -def pending_alarms(sessions: Sequence[Session], now: datetime) -> list[Alarm]: - return [a for s in sessions if s.finished for a in s.alarms if a.at > now] +def pending_alarms(sessions: Sequence[Session], now: datetime, + done: set[str] = frozenset()) -> list[Alarm]: + """Alarms still to ring; one about an invoice already paid will not.""" + return [a for s in sessions if s.finished for a in s.alarms + if a.at > now and a.invoice not in done] def recent_notes(sessions: Sequence[Session]) -> list[Note]: diff --git a/agent/think.py b/agent/think.py index c6d6d00..fea6af3 100644 --- a/agent/think.py +++ b/agent/think.py @@ -119,7 +119,7 @@ def think( past_notes=tuple(recent_notes(sessions)), messages=tuple(messages), payer=payer, can_pay=setup.autonomy is Autonomy.ACT) - text = briefing(toolbox, wakes, pending_alarms(sessions, now), recent_notes(sessions), + text = briefing(toolbox, wakes, pending_alarms(sessions, now, done), recent_notes(sessions), conversation, already_told(sessions, setup.zone, now)) try: run = setup.brain.run(toolbox, text) @@ -129,7 +129,7 @@ def think( finished, error, steps, usage = False, "the session crashed", 0, None session = Session( at=now, wakes=tuple(wakes), finished=finished, summary=toolbox.summary or "", - money=money, + money=money_after(setup, toolbox, money), decisions=tuple(toolbox.chosen.values()) if finished else (), alarms=tuple(toolbox.alarms) if finished else (), notes=tuple(toolbox.notes) if finished else (), @@ -180,7 +180,7 @@ def converse( past_notes=tuple(recent_notes(sessions)), messages=tuple(messages), require_all=False, payer=payer, can_pay=setup.autonomy is Autonomy.ACT) - text = chat_briefing(toolbox, pending_alarms(sessions, now), recent_notes(sessions), + text = chat_briefing(toolbox, pending_alarms(sessions, now, done), recent_notes(sessions), conversation, already_told(sessions, setup.zone, now)) try: run = brain.run(toolbox, text) @@ -191,6 +191,7 @@ def converse( session = Session( at=now, wakes=(Wake("message", f"The owner wrote to you ({len(messages)} message(s))."),), finished=finished, summary=toolbox.summary or "", + money=money_after(setup, toolbox, None), decisions=tuple(toolbox.chosen.values()) if finished else (), alarms=tuple(toolbox.alarms) if finished else (), notes=tuple(toolbox.notes) if finished else (), @@ -206,6 +207,15 @@ def converse( session=session, last=session, payments=tuple(toolbox.payments)) +def money_after(setup: BrainSetup, toolbox: Toolbox, + before: dict[str, str] | None) -> dict[str, str] | None: + """The money the agent leaves the session knowing: read again when it paid, so its own + payments are not a change of money that wakes it in the next check.""" + if not toolbox.payments: + return before + return setup.funds() or before + + def emails_today(sessions: Sequence[Session], zone: tzinfo, now: datetime) -> int: """Emails to suppliers already sent today (shop time), for the daily cap.""" today = now.astimezone(zone).date() diff --git a/agent/tools/definitions.py b/agent/tools/definitions.py index f681849..2309340 100644 --- a/agent/tools/definitions.py +++ b/agent/tools/definitions.py @@ -88,7 +88,9 @@ def _text(description: str) -> dict[str, Any]: _tool("set_alarm", "Wake yourself at a given time to look at something again.", at=_text("Shop's local date and time, YYYY-MM-DDTHH:MM."), - why=_text("What to look at then.")), + why=_text("What to look at then."), + invoice=_text("The invoice it is about, exactly as listed, or empty. An alarm about " + "an invoice does not ring once that invoice is paid.")), _tool("note", "Keep a short note for your future sessions (about a supplier, an invoice or the shop).", about=_text("A supplier name, an invoice name, or 'shop'."), diff --git a/agent/tools/toolbox.py b/agent/tools/toolbox.py index f7dcd62..ae24be2 100644 --- a/agent/tools/toolbox.py +++ b/agent/tools/toolbox.py @@ -301,7 +301,7 @@ def _ask_owner(self, invoice: str, question: str, recommendation: str) -> str: recommendation=recommendation.strip()) return f"{invoice}: the owner will be asked" - def _set_alarm(self, at: str, why: str) -> str: + def _set_alarm(self, at: str, why: str, invoice: str = "") -> str: if len(self.alarms) >= MAX_ALARMS: raise ToolError(f"at most {MAX_ALARMS} alarms per session") try: @@ -313,7 +313,12 @@ def _set_alarm(self, at: str, why: str) -> str: raise ToolError("that time has already passed") if when > self.now + timedelta(days=MAX_ALARM_DAYS): raise ToolError(f"set alarms at most {MAX_ALARM_DAYS} days ahead") - self.alarms.append(Alarm(at=when, why=why.strip(), set_at=self.now)) + invoice = invoice.strip() + if invoice and invoice not in {d.invoice for d in self.decisions}: + raise ToolError(f"{invoice} is not an open invoice; leave it empty or use the name " + "exactly as listed") + self.alarms.append(Alarm(at=when, why=why.strip(), set_at=self.now, + invoice=invoice or None)) return f"alarm set for {when.astimezone(self.zone):%Y-%m-%d %H:%M}" def _note(self, about: str, text: str) -> str: diff --git a/docs/architecture.md b/docs/architecture.md index 5ac5768..9cfc4f6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,7 @@ The contracts live in `contracts/`. | `services/telegram` | The agent on Telegram, a process of its own (`embco telegram`). A chat is linked with a one-time code from the dashboard (hashed, 10 minutes, once); any other chat can do nothing. `/stop` turns the whole agent off and pauses the contract from the agent's wallet, `/pause` pauses payments while the agent keeps watching and answering, `/status` tells its state: none of them go through the model, and only the dashboard turns it on again. Any other text is a message to the agent, whose answers come back to the chat. The agent's questions about invoices arrive once: with two buttons (yes, pay it / no, hold it) when only the owner's yes is missing, where a press from the linked chat, while the question is still open, is only a message to the agent; as a reminder to use the dashboard when the owner's wallet is needed (approve a wallet, raise a limit, add money, authorize or resume payments). What the agent writes to the owner is one topic, short and without full hashes: a longer text goes back to the model to be rewritten. The bot token never reaches a log or an error | | `agent/tools` | What the agent can call. To read: the open invoices (with the last state of each payment), a search over them (supplier, due date, what the checks say), one invoice in full, a supplier's profile, an item's price history, the cash position, the state of a payment, and its own wallet (balance for network fees, whether the contract has it as its agent, whether the contract is paused). To act: pay now (sent at once, with the result back to the agent), schedule (on that day the agent is woken to pay), hold, ask the owner, set an alarm, keep a note, finish. To talk: reply to the owner's message, notify the owner of something to act on (at most 3 per session), and email a supplier from the shop's ERPNext, only after the owner agreed | | `agent/guardrails/gate.py` | Refuses a payment the rules do not allow (HOLD, ASK, already paid, over this week's room in the contract) before anything is sent, and the refusal goes back to the agent | -| `agent/reflexes/wake.py` | When to wake the agent: an invoice new, changed or without its decision, a payment failed or blocked (not again for a result it already saw), the day it planned to pay something, a due date close with nothing planned, an owner answer or supplier signature, a limit change, an alarm it set, the daily round, a heartbeat after 4 hours. No reason, no model call | +| `agent/reflexes/wake.py` | When to wake the agent: an invoice new, changed or without its decision, a payment failed or blocked (not again for a result it already saw), the day it planned to pay something, a due date close with nothing planned, an owner answer or supplier signature, a limit change, an alarm it set, the daily round. What the agent did itself is not a reason: an invoice it paid does not wake it when it changes, an alarm about it does not ring, and its own payments are not a change of money. No reason, no model call | | `services/erp` | Domain models, `LedgerAdapter` protocol, `erpnext/` adapter (client, mappers, adapter). Reads, plus one write through `PaymentWriter`: a payment final on chain becomes a submitted Payment Entry (on testnet, a draft marked TESTNET, so test USDC never closes a real invoice), drafted by ERPNext from the invoice and checked against what was paid (party, amount, invoice) before it is written, with the transaction hash and payee wallet in its custom fields. The same transaction is never written twice | | `agent/guardrails/controls` | Context builder and one small module per control: three-way match, payee wallet, duplicates, price anomaly, payment limit, supplier status. Each returns PASS, HOLD (an unproven wallet, a duplicate bill), ASK (a first payment to a new wallet) or NOTE (evidence for the agent to weigh: receipt and quantities, price, limit, disabled supplier). A wallet never paid before is held until the supplier signs a challenge with it (proof the address is right and theirs), then the owner is asked to approve, since only a person knows whether the supplier really asked for the change. The question says who set the wallet in the ERP and when, from the supplier's change history | | `services/signing` | EIP-712 wallet ownership message bound to embco and Arc testnet, and signer recovery (ordinary wallets; smart contract wallets need EIP-1271 on chain) | diff --git a/services/shops/activity.py b/services/shops/activity.py index b41362c..9112d45 100644 --- a/services/shops/activity.py +++ b/services/shops/activity.py @@ -187,6 +187,8 @@ def cycle_events(report: CycleReport, interval_minutes: int) -> list[dict[str, A events = [] for change in report.changes: d = change.decision + if d.invoice in report.already_paid: + continue # paid or sent: its payment lines say what happened why = _why(d.action, d.reasons) if change.kind is ChangeKind.NEW: events.append(_event(at, NEW, f"New invoice {d.invoice} from {d.supplier}, " diff --git a/services/shops/agent.py b/services/shops/agent.py index 0e578eb..2ce05a5 100644 --- a/services/shops/agent.py +++ b/services/shops/agent.py @@ -13,6 +13,7 @@ from typing import Any from agent.cost import estimate, shown +from agent.guardrails.controls.base import fmt from agent.guardrails.rules import PolicyConfig from agent.memory import JournalError, SqliteJournal from agent.models import Autonomy, Choice, Wake @@ -25,7 +26,7 @@ from services.erp import ErpnextAdapter, LedgerError from services.erp.erpnext.cache import CachedLedger from services.hub.chain import DEFAULT_FACTORY, ShopChain, ShopLimits -from services.payments import ArcRpc, ChainError, Payer, PaymentEvent +from services.payments import ArcRpc, ChainError, Payer, PaymentEvent, PaymentStatus from services.payments.encoding import ref_scope from services.settings import Settings from services.shops import activity, chat @@ -39,6 +40,7 @@ log = logging.getLogger("embco") MESSAGE_POLL = 3 # seconds between looks at the inbox while waiting for the next check +SENT = (PaymentStatus.SUBMITTED, PaymentStatus.COMPLETE, PaymentStatus.RECORDED) def erp_for(settings: Settings) -> ErpnextAdapter: @@ -122,6 +124,16 @@ def chat_replies(session, owner_waiting: bool) -> list[dict[str, Any]]: return [] +def paid_notices(thought, owner_waiting: bool) -> list[dict[str, Any]]: + """One short line per payment the agent made on its own. When the owner wrote, its answer + already says what it paid.""" + if thought is None or owner_waiting: + return [] + return [{"from": chat.AGENT, "at": e.at.isoformat(), + "text": f"Paid {fmt(e.amount)} USDC to {e.supplier} ({e.invoice})."} + for e in thought.payments if e.status in SENT] + + def not_woken(folder, thought, owner_waiting: bool, at: datetime) -> list[dict[str, Any]]: """A word from the service when the agent had a reason to wake but was not woken: always when the owner is waiting for an answer, and for the daily budget even when not. Said once @@ -318,6 +330,7 @@ def cycle() -> None: chat.append_chat(folder, on_telegram(folder, [ *chat_replies(report.thought.session if report.thought else None, bool(waiting)), *not_woken(folder, report.thought, bool(waiting), report.at), + *paid_notices(report.thought, bool(waiting)), *(new_questions(folder, report.decisions, report.thought.agent, report.at, policy=policy["now"], funds=funds, room=room, paused=store.payments_paused(shop)) diff --git a/tests/test_agent_in_cycle.py b/tests/test_agent_in_cycle.py index 38bab7d..f338f20 100644 --- a/tests/test_agent_in_cycle.py +++ b/tests/test_agent_in_cycle.py @@ -7,7 +7,7 @@ from datetime import timedelta, timezone from agent.agent import SessionRun -from agent.models import Autonomy, Choice, Usage +from agent.models import Autonomy, Choice, Usage, Wake from agent.reflexes.cycle import run_cycle from agent.think import BrainSetup from agent.tools import ToolError @@ -35,10 +35,13 @@ def run(self, toolbox, briefing): return SessionRun(finished=True, steps=1, usage=Usage()) -def cycle(journal, payer, brain, at, autonomy=Autonomy.ACT): # noqa: F811 +def cycle(journal, payer, brain, at, autonomy=Autonomy.ACT, wakes=()): # noqa: F811 setup = BrainSetup(brain=brain, zone=LAGOS, autonomy=autonomy) return run_cycle(payer.ledger, journal, POLICY, "TEST Shop", at=at, payments=payer, - brain=setup) + brain=setup, wakes=list(wakes)) + + +AGAIN = (Wake("limits", "The owner changed the limits in the shop contract."),) # wakes it again def test_what_the_agent_chooses_to_pay_now_is_paid_through_the_contract(journal): # noqa: F811 @@ -219,11 +222,9 @@ def run(self, toolbox, briefing): def woken_twice(journal, brain): # noqa: F811 - from agent.reflexes.wake import HEARTBEAT - payer, _, _ = make_payer(matching_ledger()) first = cycle(journal, payer, brain, NOW) - second = cycle(journal, payer, brain, NOW + HEARTBEAT) # the heartbeat would wake it + second = cycle(journal, payer, brain, NOW + timedelta(hours=1), wakes=AGAIN) return first, second @@ -292,12 +293,10 @@ def test_the_agent_sees_what_stops_an_invoice_apart_from_what_to_weigh(journal): def test_the_agent_is_told_what_it_already_said_to_the_owner(journal): # noqa: F811 - from agent.reflexes.wake import HEARTBEAT - payer, _, _ = make_payer(matching_ledger()) brain = Reads() cycle(journal, payer, brain, NOW) - cycle(journal, payer, brain, NOW + HEARTBEAT) + cycle(journal, payer, brain, NOW + timedelta(hours=1), wakes=AGAIN) assert brain.briefings[0]["already_told_the_owner"] == [] [told] = brain.briefings[1]["already_told_the_owner"] assert told["summary"] == "Summary 1." diff --git a/tests/test_no_self_wake.py b/tests/test_no_self_wake.py new file mode 100644 index 0000000..ecdd85d --- /dev/null +++ b/tests/test_no_self_wake.py @@ -0,0 +1,171 @@ +"""What the agent did itself never wakes it: its own payments are not a change of money, an +invoice it paid does not wake it when it changes, and an alarm about that invoice does not ring. +Nothing wakes it only because time passed. A payment it makes on its own is told in one line.""" + +import secrets +from dataclasses import replace +from datetime import UTC, date, datetime, timedelta, timezone +from decimal import Decimal + +import pytest + +from agent.agent import SessionRun +from agent.guardrails.controls.base import Finding, Outcome +from agent.guardrails.rules import Action, Decision, PolicyConfig, fingerprint +from agent.memory import Change, ChangeKind, SqliteJournal +from agent.models import AgentDecision, Alarm, Choice, Session, Wake +from agent.reflexes.cycle import run_cycle +from agent.reflexes.funds import money_wake +from agent.reflexes.wake import pending_alarms, wakes_for +from agent.think import BrainSetup, Thought, think +from agent.tools import Toolbox, ToolError +from services.payments import PaymentEvent, PaymentStatus +from services.shops import activity +from services.shops.agent import paid_notices +from support import WALLET_A, FakeLedger + +NOW = datetime(2026, 10, 13, 19, 49, tzinfo=UTC) +LAGOS = timezone(timedelta(hours=1)) +POLICY = PolicyConfig(max_per_payment=Decimal(250), weekly_budget=Decimal(2000)) +PAID, OPEN = "ACC-PINV-2026-00012", "ACC-PINV-2026-00017" + + +def decision(invoice, amount="184"): + return Decision(invoice=invoice, supplier="Northern Grains", amount=Decimal(amount), + due_date=date(2026, 10, 19), action=Action.PAY, reasons=("all passed",), + findings=(Finding("three_way_match", Outcome.PASS, "ok"),)) + + +def payment(invoice=PAID, status=PaymentStatus.COMPLETE): + return PaymentEvent(invoice=invoice, status=status, at=NOW, supplier="Northern Grains", + payee=WALLET_A, amount=Decimal("184.0"), + invoice_ref="0x" + secrets.token_hex(32)) + + +def wakes(changes=(), sessions=(), done=frozenset({PAID}), decisions=None, now=NOW): + decisions = decisions or [decision(PAID), decision(OPEN, "476")] + standing = {} + return wakes_for(now=now, zone=LAGOS, decisions=decisions, changes=changes, + sessions=list(sessions), standing=standing, done=set(done), payments=[], + round_hour=25) # no round in these tests + + +# ---- its own payment is not a change of money + + +class PaysOnce: + """Pays during its session: the money it can see drops while it runs.""" + + model = "scripted" + + def __init__(self, money): + self.money = money + + def run(self, toolbox, briefing): + self.money["available_to_pay"] = "165.945618" + toolbox.payments.append(payment()) + for invoice in toolbox.undecided(): + toolbox.call("hold", {"invoice": invoice, "reason": "waiting"}) + toolbox.call("finish", {"summary": "Paid one."}) + return SessionRun(finished=True, steps=1, usage=None) + + +def test_after_paying_the_session_keeps_the_money_it_left_and_is_not_woken_by_it(tmp_path): + money = {"owner_balance": "349.945618", "available_to_pay": "349.945618", + "payments_authorized": "yes"} + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + setup = BrainSetup(brain=PaysOnce(money), zone=LAGOS, funds=lambda: dict(money)) + think(setup, journal=journal, ledger=None, decisions=[decision(OPEN, "476")], + changes=(), policy=POLICY, done=set(), now=NOW) + sessions = journal.sessions() + assert sessions[-1].money["available_to_pay"] == "165.945618" + assert money_wake(sessions, dict(money), [decision(OPEN, "476")], set()) is None + added = {**money, "available_to_pay": "665.945618"} # the owner adds 500: worth a look + assert money_wake(sessions, added, [decision(OPEN, "476")], set()).kind == "money" + + +# ---- an invoice it paid does not wake it + + +def test_a_paid_invoice_that_changes_does_not_wake_the_agent(): + paid = Change(decision(PAID), ChangeKind.CHANGED, None, "the reasons changed") + other = Change(decision(OPEN, "476"), ChangeKind.CHANGED, None, "the wallet was proven") + found = wakes(changes=[paid, other]) + assert [(w.kind, w.invoice) for w in found if w.kind == "changed"] == [("changed", OPEN)] + + +def test_a_paid_invoice_that_changes_leaves_no_line_in_the_activity(tmp_path): + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + report = run_cycle(FakeLedger(), journal, POLICY, "TEST Shop", at=NOW) + invoices = [c.decision.invoice for c in report.changes] + assert invoices, "the fake ERP has open invoices" + shown = {e.get("invoice") for e in activity.cycle_events(report, 15)} + assert set(invoices) <= shown + paid = replace(report, already_paid=tuple(invoices)) + assert not {e.get("invoice") for e in activity.cycle_events(paid, 15)} & set(invoices) + + +# ---- an alarm about an invoice it paid does not ring + + +def session(at, alarms=()): + return Session(at=at, wakes=(Wake("round", "r"),), finished=True, alarms=tuple(alarms)) + + +def test_an_alarm_about_a_paid_invoice_does_not_ring_and_others_do(): + ring = NOW - timedelta(minutes=1) + alarms = [Alarm(ring, "check the flour", set_at=NOW - timedelta(days=1), invoice=PAID), + Alarm(ring, "look at Adeyemi", set_at=NOW - timedelta(days=1), invoice=OPEN), + Alarm(ring, "the weekly room renews", set_at=NOW - timedelta(days=1))] + found = wakes(sessions=[session(NOW - timedelta(days=1), alarms)]) + assert sorted(w.text for w in found if w.kind == "alarm") == [ + "Your alarm: look at Adeyemi", "Your alarm: the weekly room renews"] + later = [replace(a, at=NOW + timedelta(days=4)) for a in alarms] + assert [a.why for a in pending_alarms([session(NOW, later)], NOW, {PAID})] == [ + "look at Adeyemi", "the weekly room renews"] + + +def box(journal, decisions): + return Toolbox(decisions=decisions, ledger=None, journal=journal, policy=POLICY, now=NOW, + zone=LAGOS) + + +def test_an_alarm_names_an_open_invoice_or_none(tmp_path): + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + b = box(journal, [decision(OPEN, "476")]) + b.call("set_alarm", {"at": "2026-10-21T09:00", "why": "look again", "invoice": OPEN}) + b.call("set_alarm", {"at": "2026-10-21T09:00", "why": "the week renews", "invoice": ""}) + with pytest.raises(ToolError, match="not an open invoice"): + b.call("set_alarm", {"at": "2026-10-21T09:00", "why": "x", "invoice": "PINV-9"}) + assert [a.invoice for a in b.alarms] == [OPEN, None] + + +def test_the_invoice_of_an_alarm_is_kept_across_a_restart(tmp_path): + alarm = Alarm(NOW + timedelta(days=4), "look again", set_at=NOW, invoice=OPEN) + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + journal.record_session(session(NOW, [alarm])) + with SqliteJournal(tmp_path / "j.sqlite3") as journal: + [kept] = journal.sessions()[-1].alarms + assert kept.invoice == OPEN + + +# ---- time alone does not wake it + + +def test_hours_without_news_do_not_wake_the_agent(): + held = decision(OPEN, "476") + standing = {OPEN: AgentDecision(OPEN, fingerprint(held), Choice.HOLD, "waiting")} + assert wakes_for(now=NOW, zone=LAGOS, decisions=[held], changes=(), + sessions=[session(NOW - timedelta(hours=10))], standing=standing, + done=set(), payments=[], round_hour=25) == [] + + +# ---- a payment it makes on its own is told in one line + + +def test_a_payment_on_its_own_is_told_in_one_short_line(): + out = Thought(agent={}, payments=(payment(), payment(OPEN, PaymentStatus.BLOCKED))) + assert [e["text"] for e in paid_notices(out, owner_waiting=False)] == [ + "Paid 184 USDC to Northern Grains (ACC-PINV-2026-00012)."] + assert paid_notices(out, owner_waiting=True) == [] # its answer to the owner says it + assert paid_notices(None, owner_waiting=False) == []