What changed, and why
When a category overspends, the W6 monitor emails an alert, then hands the thread to the W7 budget balancer, which offers coverage moves and reads the owner's plain-English reply. The balancer was posting that offer — and every follow-up — on a brand-new email thread, so a single overspend showed up as two disconnected conversations in the owner's inbox. The reported bug: balancing follow-ups should reply on the alert thread, not start their own.
The separate thread was not an oversight. It was a workaround (commit a60e676) for a real delivery bug: the balancer replied on the alert thread by replying to its latest message, which is the agent's own alert. AgentMail addressed that reply back to the agent's inbox, so the owner got the alert but never the options. The workaround swapped threading for delivery.
This change gets both. AgentMail's reply() takes a to recipient override, so the balancer can reply on the alert thread and still address the owners. The fix is a small recipient parameter in the mail layer, plus reverting the balancer to reply-on-thread now that delivery is handled. The sections below follow the change from the mail seam outward to the workflow and the tests.
The recipient override, threaded through the mail client
send_on_thread is the reply workhorse. It already deduped on a sequence label and replied to the thread's latest message; it now takes an optional to. When set, those recipients override AgentMail's default reply addressing — which is exactly what fails when the latest message is the agent's own alert. The other callers (the per-transaction W2, the autonomy offer) pass nothing and keep the default, because they only ever reply after the owner has spoken.
send_on_thread gains to, forwarded to the backend reply.
src/ynab_agent/mail/client.py · 274 lines
⋯ 183 lines hidden (lines 1–183)
⋯ 65 lines hidden (lines 210–274)
The AgentMail adapter passes to to the SDK's reply() only when it is set. Omitting the argument (rather than sending None) lets AgentMail keep its own reply-addressing behavior for every caller that does not override it — the empty default stays a true no-op.
_AgentMailBackend.send_reply: include to only when provided.
src/ynab_agent/mail/client.py · 274 lines
⋯ 236 lines hidden (lines 1–236)
⋯ 17 lines hidden (lines 258–274)
The balancer replies on the alert thread
With delivery handled in the mail layer, the balancer's send activity goes back to a reply. send_balance_email now replies on the alert thread and passes to=list(settings.owners), because that thread's latest message is often the agent's own — the opening alert, or a back-to-back agent message — so the recipients must be set explicitly. The open_balance_thread activity that opened a separate thread is gone, along with its entry in the activity registry.
send_balance_email: reply on the thread, addressed to the owners.
src/ynab_agent/workflow/balance_activities.py · 295 lines
⋯ 270 lines hidden (lines 1–270)
Indexing the workflow by the alert thread
The workflow stamps its BalanceThreadId search attribute with params.thread_id — the alert thread — so the owner's reply on that thread routes back here through W3. Every post (the options offer, the could-not-cover note, clarifications, the apply/decline confirmation) goes through one _send helper that replies on that thread; the old _open / self._thread_id indirection is removed.
Stamp the alert thread, then post the options as a reply on it.
src/ynab_agent/workflow/balance_workflow.py · 253 lines
⋯ 121 lines hidden (lines 1–121)
⋯ 113 lines hidden (lines 141–253)
One reply helper, always on params.thread_id.
src/ynab_agent/workflow/balance_workflow.py · 253 lines
⋯ 236 lines hidden (lines 1–236)
⋯ 1 line hidden (lines 253–253)
Tests: a regression guard and a live proof
The workflow test is the regression guard. It runs the balancer through an offer-then-decline, then asserts two things the old code would fail: every balancer email replied on the alert thread ("thr-overspend"), and BalanceThreadId is stamped with that same thread. On the old code these were a freshly opened thread id instead.
Offer + decline: posts and the search attribute land on the alert thread.
tests/workflow/test_balance_workflow.py · 343 lines
⋯ 306 lines hidden (lines 1–306)
Offline tests run against a fake backend, so they prove the code calls reply with the right recipients but not how AgentMail behaves. A gated live test closes that gap: it reproduces the W6→W7 shape (open an alert, then reply on it), then fetches the sent reply from the real server.
Live: the reply threads, and its To is the owner — not the agent.