From 20ec7ca8b6e1af4380cf7711fdcbfa36d68d5b21 Mon Sep 17 00:00:00 2001 From: Ryan Chen Date: Mon, 12 Oct 2026 18:12:23 -0400 Subject: [PATCH] Return artifacts from YNAB budget, transaction and spending tools Convert ynab_budget_summary, ynab_search_transactions, ynab_spending_report and ynab_category_budget to content_and_artifact (ynab.summary, ynab.transactions, ynab.spending_report, ynab.category_budget). The text the LLM sees is unchanged. Not-synced, bad-input, empty and error paths return no artifact; Decimals become floats and dates ISO strings. ynab_search_transactions now fetches up to 500 rows in one query (with an id tie-break so ordering is stable) and lists the first `limit` in the text; the artifact carries all of them. ynab_account_balances, ynab_recent_changes and ynab_sql stay text-only. Co-Authored-By: Claude Opus 5.5 --- blueprints/conversation/artifacts_util.py | 9 + blueprints/ynab/tools.py | 150 +++++++- tests/unit/test_ynab_tool_artifacts.py | 427 ++++++++++++++++++++++ 3 files changed, 566 insertions(+), 20 deletions(-) create mode 100644 tests/unit/test_ynab_tool_artifacts.py diff --git a/blueprints/conversation/artifacts_util.py b/blueprints/conversation/artifacts_util.py index 9f99995..cfe64af 100644 --- a/blueprints/conversation/artifacts_util.py +++ b/blueprints/conversation/artifacts_util.py @@ -7,6 +7,7 @@ stored in the LangGraph checkpoint. import os from datetime import date, datetime +from decimal import Decimal from pathlib import Path from urllib.parse import quote, urlencode @@ -40,6 +41,8 @@ def json_safe(value): """Convert a value (e.g. parsed YAML frontmatter) to JSON-safe types.""" if isinstance(value, (str, int, float, bool, type(None))): return value + if isinstance(value, Decimal): + return float(value) if isinstance(value, (datetime, date)): return value.isoformat() if isinstance(value, (list, tuple, set)): @@ -67,6 +70,12 @@ def format_day(value: str) -> str: return f"{d:%b} {d.day}" if d else str(value) +def format_month(value: str) -> str: + """'2026-10' or '2026-10-01' -> 'October 2026'. Unparseable input is returned unchanged.""" + d = _parse_date(str(value)[:7] + "-01") + return f"{d:%B %Y}" if d else str(value) + + def format_date_range(start: str, end: str) -> str: """Human date range: 'Oct 12–19', 'Oct 28–Nov 3', 'Dec 28, 2026–Jan 3, 2027'.""" s, e = _parse_date(start), _parse_date(end) diff --git a/blueprints/ynab/tools.py b/blueprints/ynab/tools.py index 86b60ee..8418c33 100644 --- a/blueprints/ynab/tools.py +++ b/blueprints/ynab/tools.py @@ -14,6 +14,13 @@ from zoneinfo import ZoneInfo from langchain.tools import tool from tortoise import connections +from blueprints.conversation.artifacts_util import ( + format_date_range, + format_month, + json_safe, + make_artifact, +) + from .sync import ensure_fresh logger = logging.getLogger(__name__) @@ -23,6 +30,8 @@ NOT_SYNCED = "YNAB data is unavailable: the budget has not been synced yet." # Keep in step with TOOL_RESULT_MAX_CHARS in blueprints/conversation/logic.py; # longer results get truncated there anyway and crowd the 32k context. MAX_TRANSACTIONS = 40 +# Rows in the ynab.transactions artifact (the side panel, not the LLM) +ARTIFACT_MAX_TRANSACTIONS = 500 # ynab_recent_changes accepts the names it prints as well as stored types ENTITY_TYPE_ALIASES = { @@ -64,6 +73,19 @@ def ynab_tool_errors(fn): return wrapper +def ynab_artifact_tool(fn): + """For content_and_artifact tools: errors become (text, None), and bare + strings (not-synced, bad input, empty results) get no artifact.""" + wrapped = ynab_tool_errors(fn) + + @functools.wraps(fn) + async def wrapper(*args, **kwargs): + result = await wrapped(*args, **kwargs) + return (result, None) if isinstance(result, str) else result + + return wrapper + + # Every transaction line, with split transactions expanded into their # subtransactions so category/payee filters see the real allocation. LINES_CTE = """ @@ -114,6 +136,11 @@ def _usd(value: Decimal | float | None) -> str: return f"{sign}${abs(value):,.2f}" +def _amount(value: Decimal | float | None) -> float: + """Dollar amount for artifacts (JSON has no Decimal).""" + return float(value or 0) + + def _parse_date(value: str, default: date | None) -> date | None: return date.fromisoformat(value) if value else default @@ -128,9 +155,9 @@ async def _query(sql: str, *params) -> list[dict]: return await connections.get("default").execute_query_dict(sql, list(params)) -@tool -@ynab_tool_errors -async def ynab_budget_summary(month: str = "") -> str: +@tool(response_format="content_and_artifact") +@ynab_artifact_tool +async def ynab_budget_summary(month: str = ""): """Get YNAB budget overview for a month: Ready to Assign, income, assigned, spent, age of money, overspent categories, and net worth. Args: @@ -191,12 +218,29 @@ async def ynab_budget_summary(month: str = "") -> str: ) else: lines.append("No overspent categories.") + net_worth = sum(by_budget.values(), Decimal(0)) lines.append( - f"Net worth: {_usd(sum(by_budget.values(), Decimal(0)))} " + f"Net worth: {_usd(net_worth)} " f"(budget accounts {_usd(by_budget.get(True))}, " f"tracking accounts {_usd(by_budget.get(False))})" ) - return "\n".join(lines) + data = { + "month": f"{start:%Y-%m}", + "ready_to_assign": _amount(m["to_be_budgeted"]), + "income": _amount(m["income"]), + "assigned": _amount(m["budgeted"]), + "spent": _amount(spent[0]["spent"]), + "net_worth": _amount(net_worth), + "overspent": [ + {"name": r["name"], "available": _amount(r["balance"])} for r in overspent + ], + } + if m["age_of_money"] is not None: + data["age_of_money"] = m["age_of_money"] + artifact = make_artifact( + "ynab.summary", f"Budget · {format_month(data['month'])}", json_safe(data) + ) + return "\n".join(lines), artifact @tool @@ -230,8 +274,8 @@ async def ynab_account_balances(include_closed: bool = False) -> str: return "\n".join(lines) + f"\n\nNet worth (open accounts): {_usd(total)}" -@tool -@ynab_tool_errors +@tool(response_format="content_and_artifact") +@ynab_artifact_tool async def ynab_search_transactions( start_date: str = "", end_date: str = "", @@ -242,7 +286,7 @@ async def ynab_search_transactions( min_amount: float = 0, max_amount: float = 0, limit: int = 20, -) -> str: +): """Search YNAB transactions. Split transactions are expanded into their category lines. Args: @@ -305,12 +349,16 @@ async def ynab_search_transactions( return f"No transactions found from {range_text}." limit = min(max(1, limit), MAX_TRANSACTIONS) - rows = await _query( + # One query feeds both the text (first `limit` rows) and the artifact; the + # id tie-break keeps the order stable regardless of LIMIT. + all_rows = await _query( f"""{LINES_CTE} SELECT * FROM lines l WHERE {where} - ORDER BY l.date DESC, abs(l.amount) DESC LIMIT {limit}""", + ORDER BY l.date DESC, abs(l.amount) DESC, l.id + LIMIT {ARTIFACT_MAX_TRANSACTIONS}""", *params, ) + rows = all_rows[:limit] lines = [ f"Found {count} transactions from {range_text}. " f"Outflows {_usd(totals[0]['outflow'])}, inflows {_usd(totals[0]['inflow'])}." @@ -327,11 +375,38 @@ async def ynab_search_transactions( lines.append(line) if count > len(rows): lines.append(f"(Showing {len(rows)} most recent of {count})") - return "\n".join(lines) + + transactions = [] + for r in all_rows: + txn = { + "date": r["date"], + "payee": r["payee_name"] or "Unknown payee", + "category": r["category_name"] or "Uncategorized", + "account": r["account_name"], + "amount": _amount(r["amount"]), + } + if r["memo"]: + txn["memo"] = r["memo"] + transactions.append(txn) + title_range = format_date_range(str(start), str(end)) if start else f"through {end}" + artifact = make_artifact( + "ynab.transactions", + f"Transactions · {title_range}", + json_safe( + { + "start_date": start, + "end_date": end, + "count": count, + "total": _amount(totals[0]["outflow"] + totals[0]["inflow"]), + "transactions": transactions, + } + ), + ) + return "\n".join(lines), artifact -@tool -@ynab_tool_errors +@tool(response_format="content_and_artifact") +@ynab_artifact_tool async def ynab_spending_report( group_by: str = "category", start_date: str = "", @@ -339,7 +414,7 @@ async def ynab_spending_report( category: str = "", payee: str = "", top: int = 15, -) -> str: +): """Total YNAB spending grouped by category, category_group, payee, account, or month. Use group_by='month' for trends over time. Args: @@ -377,7 +452,7 @@ async def ynab_spending_report( JOIN ynab_accounts a ON a.id = l.account_id LEFT JOIN ynab_categories c ON c.id = l.category_id LEFT JOIN ynab_category_groups g ON g.id = c.category_group_id - WHERE {' AND '.join(conditions)} + WHERE {" AND ".join(conditions)} GROUP BY 1 ORDER BY {order}""", *params, ) @@ -394,12 +469,29 @@ async def ynab_spending_report( lines.append(f"- {len(rows) - len(shown)} others: {_usd(rest)}") if group_by == "month" and len(rows) > 1: lines.append(f"Monthly average: {_usd(total / len(rows))}") - return "\n".join(lines) + artifact = make_artifact( + "ynab.spending_report", + f"Spending by {group_by.replace('_', ' ')} · " + f"{format_date_range(str(start), str(end))}", + json_safe( + { + "start_date": start, + "end_date": end, + "group_by": group_by, + "rows": [ + {"key": r["key"], "amount": _amount(r["spent"]), "count": r["n"]} + for r in rows + ], + "total": _amount(total), + } + ), + ) + return "\n".join(lines), artifact -@tool -@ynab_tool_errors -async def ynab_category_budget(month: str = "", category_group: str = "") -> str: +@tool(response_format="content_and_artifact") +@ynab_artifact_tool +async def ynab_category_budget(month: str = "", category_group: str = ""): """Get YNAB categories for a month grouped by category group: assigned, activity (negative = spent), available, and goal target. Flags overspending. Args: @@ -432,12 +524,23 @@ async def ynab_category_budget(month: str = "", category_group: str = "") -> str lines = [f"Categories for {start:%Y-%m}:"] current = None + groups = [] for r in rows: if not (r["budgeted"] or r["activity"] or r["balance"]): continue if r["group_name"] != current: current = r["group_name"] lines.append(f"\n{current}") + groups.append({"name": current, "categories": []}) + category = { + "name": r["name"], + "assigned": _amount(r["budgeted"]), + "activity": _amount(r["activity"]), + "available": _amount(r["balance"]), + } + if r["goal_target"]: + category["goal_target"] = _amount(r["goal_target"]) + groups[-1]["categories"].append(category) line = ( f" {r['name']}: assigned {_usd(r['budgeted'])}, " f"activity {_usd(r['activity'])}, available {_usd(r['balance'])}" @@ -447,7 +550,14 @@ async def ynab_category_budget(month: str = "", category_group: str = "") -> str if r["balance"] < 0: line += " [OVERSPENT]" lines.append(line) - return "\n".join(lines) + artifact = None + if groups: + artifact = make_artifact( + "ynab.category_budget", + f"Category budget · {format_month(f'{start:%Y-%m}')}", + json_safe({"month": f"{start:%Y-%m}", "groups": groups}), + ) + return "\n".join(lines), artifact @tool diff --git a/tests/unit/test_ynab_tool_artifacts.py b/tests/unit/test_ynab_tool_artifacts.py new file mode 100644 index 0000000..ece9dfe --- /dev/null +++ b/tests/unit/test_ynab_tool_artifacts.py @@ -0,0 +1,427 @@ +"""Tests for the YNAB tools' content_and_artifact output. + +`ensure_fresh` and the `_query` DB helper are mocked; the fake answers each +query by matching a fragment of its SQL. +""" + +import json +import re +from datetime import date +from decimal import Decimal +from types import SimpleNamespace + +import pytest +from langchain_core.messages import ToolMessage + +from blueprints.ynab import tools + +KIND_DATA_KEYS = { + "ynab.summary": { + "month", + "ready_to_assign", + "income", + "assigned", + "spent", + "net_worth", + "overspent", + }, + "ynab.transactions": {"start_date", "end_date", "count", "total", "transactions"}, + "ynab.spending_report": {"start_date", "end_date", "group_by", "rows", "total"}, + "ynab.category_budget": {"month", "groups"}, +} + + +async def call(tool, args=None) -> ToolMessage: + """Invoke a tool the way the agent does: with a ToolCall dict.""" + msg = await tool.ainvoke( + {"type": "tool_call", "id": "call-1", "name": tool.name, "args": args or {}} + ) + assert isinstance(msg, ToolMessage) + return msg + + +def assert_artifact(artifact, kind, optional=()): + assert artifact is not None + assert artifact["kind"] == kind + assert isinstance(artifact["title"], str) and artifact["title"] + assert KIND_DATA_KEYS[kind] <= set(artifact["data"]) + assert set(artifact["data"]) <= KIND_DATA_KEYS[kind] | set(optional) + json.dumps(artifact) + + +def fake_db(monkeypatch, responses: dict, synced=True): + """Route `_query` calls to `responses` by SQL fragment; honour LIMIT.""" + calls = [] + + async def fake_query(sql, *params): + calls.append((sql, params)) + for fragment, rows in responses.items(): + if fragment in sql: + limit = re.search(r"LIMIT (\d+)", sql) + return rows[: int(limit.group(1))] if limit else rows + raise AssertionError(f"unexpected query: {sql}") + + async def fake_ensure_fresh(): + return SimpleNamespace(budget_name="Home") if synced else None + + monkeypatch.setattr(tools, "_query", fake_query) + monkeypatch.setattr(tools, "ensure_fresh", fake_ensure_fresh) + monkeypatch.setattr(tools, "_today", lambda: date(2026, 10, 12)) + return calls + + +# --- ynab_budget_summary ---------------------------------------------------- + +SUMMARY_DB = { + "FROM ynab_months": [ + { + "to_be_budgeted": Decimal("120.50"), + "income": Decimal("5000.00"), + "budgeted": Decimal("4800.00"), + "age_of_money": 42, + } + ], + "mc.balance < 0": [{"name": "Dining Out", "balance": Decimal("-23.10")}], + "AS spent": [{"spent": Decimal("3100.25")}], + "GROUP BY on_budget": [ + {"on_budget": True, "total": Decimal("10000.00")}, + {"on_budget": False, "total": Decimal("-2500.00")}, + ], +} + + +async def test_budget_summary(monkeypatch): + fake_db(monkeypatch, SUMMARY_DB) + + msg = await call(tools.ynab_budget_summary, {"month": "2026-10"}) + + assert msg.content == ( + "YNAB budget 'Home' for 2026-10:\n" + "Ready to Assign: $120.50\n" + "Income: $5,000.00\n" + "Assigned: $4,800.00\n" + "Spent: $3,100.25\n" + "Age of money: 42 days\n" + "Overspent: Dining Out (-$23.10)\n" + "Net worth: $7,500.00 (budget accounts $10,000.00, " + "tracking accounts -$2,500.00)" + ) + art = msg.artifact + assert_artifact(art, "ynab.summary", optional={"age_of_money"}) + assert art["title"] == "Budget · October 2026" + assert art["data"] == { + "month": "2026-10", + "ready_to_assign": 120.5, + "income": 5000.0, + "assigned": 4800.0, + "spent": 3100.25, + "age_of_money": 42, + "net_worth": 7500.0, + "overspent": [{"name": "Dining Out", "available": -23.1}], + } + + +async def test_budget_summary_without_age_of_money(monkeypatch): + db = dict(SUMMARY_DB) + db["FROM ynab_months"] = [ + {**SUMMARY_DB["FROM ynab_months"][0], "age_of_money": None} + ] + fake_db(monkeypatch, db) + + msg = await call(tools.ynab_budget_summary, {"month": "2026-10"}) + + assert "Age of money" not in msg.content + assert "age_of_money" not in msg.artifact["data"] + + +async def test_budget_summary_no_month_data(monkeypatch): + fake_db(monkeypatch, {"FROM ynab_months": []}) + + msg = await call(tools.ynab_budget_summary, {"month": "2026-10"}) + + assert msg.content == "No YNAB budget data for 2026-10." + assert msg.artifact is None + + +# --- ynab_search_transactions ----------------------------------------------- + + +def _txn(i, amount, **extra): + return { + "id": f"t{i}", + "date": date(2026, 10, 12 - i % 10), + "amount": Decimal(amount), + "memo": None, + "approved": True, + "account_name": "Checking", + "payee_name": "Costco", + "category_name": "Groceries", + **extra, + } + + +TXNS = [ + _txn(0, "-54.20", memo="milk"), + _txn(1, "-12.00", approved=False, payee_name=None, category_name=None), + _txn(2, "100.00"), +] +TXN_TOTALS = { + "count(*) AS n": [ + {"n": 3, "outflow": Decimal("-66.20"), "inflow": Decimal("100.00")} + ], +} + + +async def test_search_transactions(monkeypatch): + calls = fake_db(monkeypatch, {**TXN_TOTALS, "SELECT * FROM lines": TXNS}) + + msg = await call(tools.ynab_search_transactions, {"limit": 2}) + + assert msg.content == ( + "Found 3 transactions from 2026-09-12 to 2026-10-12. " + "Outflows -$66.20, inflows $100.00.\n" + "- 2026-10-12: Costco -$54.20 (Groceries; Checking) — milk\n" + "- 2026-10-11: Unknown payee -$12.00 (Uncategorized; Checking) [unapproved]\n" + "(Showing 2 most recent of 3)" + ) + assert "LIMIT 500" in calls[-1][0] + art = msg.artifact + assert_artifact(art, "ynab.transactions") + assert art["title"] == "Transactions · Sep 12–Oct 12" + data = art["data"] + assert data["start_date"] == "2026-09-12" + assert data["end_date"] == "2026-10-12" + assert data["count"] == 3 + assert data["total"] == pytest.approx(33.8) + # the artifact keeps every row, not just the `limit` shown to the model + assert len(data["transactions"]) == 3 + assert data["transactions"][0] == { + "date": "2026-10-12", + "payee": "Costco", + "category": "Groceries", + "account": "Checking", + "memo": "milk", + "amount": -54.2, + } + assert "memo" not in data["transactions"][1] + + +async def test_search_transactions_caps_artifact_rows(monkeypatch): + many = [_txn(i, "-1.00") for i in range(600)] + totals = {"count(*) AS n": [{"n": 600, "outflow": Decimal("-600"), "inflow": 0}]} + fake_db(monkeypatch, {**totals, "SELECT * FROM lines": many}) + + msg = await call(tools.ynab_search_transactions, {"payee": "costco"}) + + assert msg.content.endswith("(Showing 20 most recent of 600)") + assert len(msg.artifact["data"]["transactions"]) == tools.ARTIFACT_MAX_TRANSACTIONS + assert msg.artifact["data"]["start_date"] is None + assert msg.artifact["title"] == "Transactions · through 2026-10-12" + + +async def test_search_transactions_empty(monkeypatch): + fake_db(monkeypatch, {"count(*) AS n": [{"n": 0, "outflow": 0, "inflow": 0}]}) + + msg = await call(tools.ynab_search_transactions, {}) + + assert msg.content == "No transactions found from 2026-09-12 to 2026-10-12." + assert msg.artifact is None + + +async def test_search_transactions_bad_date(monkeypatch): + fake_db(monkeypatch, {}) + + msg = await call(tools.ynab_search_transactions, {"start_date": "nope"}) + + assert msg.content == "Dates must be YYYY-MM-DD." + assert msg.artifact is None + + +# --- ynab_spending_report --------------------------------------------------- + +SPENDING_ROWS = [ + {"key": "Groceries", "spent": Decimal("400.00"), "n": 10}, + {"key": "Dining Out", "spent": Decimal("150.50"), "n": 5}, + {"key": "Gas", "spent": Decimal("60.00"), "n": 2}, +] + + +async def test_spending_report(monkeypatch): + fake_db(monkeypatch, {"GROUP BY 1": SPENDING_ROWS}) + + msg = await call(tools.ynab_spending_report, {"top": 2}) + + assert msg.content == ( + "Spending by category from 2026-10-01 to 2026-10-12: total $610.50\n" + "- Groceries: $400.00 (10 txns)\n" + "- Dining Out: $150.50 (5 txns)\n" + "- 1 others: $60.00" + ) + art = msg.artifact + assert_artifact(art, "ynab.spending_report") + assert art["title"] == "Spending by category · Oct 1–12" + assert art["data"] == { + "start_date": "2026-10-01", + "end_date": "2026-10-12", + "group_by": "category", + "rows": [ + {"key": "Groceries", "amount": 400.0, "count": 10}, + {"key": "Dining Out", "amount": 150.5, "count": 5}, + {"key": "Gas", "amount": 60.0, "count": 2}, + ], + "total": 610.5, + } + + +async def test_spending_report_by_month(monkeypatch): + rows = [ + {"key": "2026-09", "spent": Decimal("100"), "n": 1}, + {"key": "2026-10", "spent": Decimal("200"), "n": 2}, + ] + fake_db(monkeypatch, {"GROUP BY 1": rows}) + + msg = await call( + tools.ynab_spending_report, + {"group_by": "month", "start_date": "2026-09-01"}, + ) + + assert msg.content.endswith("Monthly average: $150.00") + assert [r["key"] for r in msg.artifact["data"]["rows"]] == ["2026-09", "2026-10"] + + +async def test_spending_report_empty_and_invalid(monkeypatch): + fake_db(monkeypatch, {"GROUP BY 1": []}) + + msg = await call(tools.ynab_spending_report, {}) + assert msg.content == "No spending found from 2026-10-01 to 2026-10-12." + assert msg.artifact is None + + msg = await call(tools.ynab_spending_report, {"group_by": "vibes"}) + assert msg.content == ( + "group_by must be one of: category, category_group, payee, account, month." + ) + assert msg.artifact is None + + +# --- ynab_category_budget --------------------------------------------------- + + +def _cat(group, name, budgeted, activity, balance, goal=None): + return { + "group_name": group, + "name": name, + "goal_target": Decimal(goal) if goal else None, + "budgeted": Decimal(budgeted), + "activity": Decimal(activity), + "balance": Decimal(balance), + } + + +async def test_category_budget(monkeypatch): + rows = [ + _cat("Bills", "Rent", "2000", "-2000", "0"), + _cat("Bills", "Unused", "0", "0", "0"), + _cat("Food", "Dining Out", "100", "-123.10", "-23.10", goal="150"), + ] + fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows}) + + msg = await call(tools.ynab_category_budget, {"month": "2026-10"}) + + assert msg.content == ( + "Categories for 2026-10:\n" + "\nBills\n" + " Rent: assigned $2,000.00, activity -$2,000.00, available $0.00\n" + "\nFood\n" + " Dining Out: assigned $100.00, activity -$123.10, available -$23.10, " + "goal $150.00 [OVERSPENT]" + ) + art = msg.artifact + assert_artifact(art, "ynab.category_budget") + assert art["title"] == "Category budget · October 2026" + assert art["data"] == { + "month": "2026-10", + "groups": [ + { + "name": "Bills", + "categories": [ + { + "name": "Rent", + "assigned": 2000.0, + "activity": -2000.0, + "available": 0.0, + } + ], + }, + { + "name": "Food", + "categories": [ + { + "name": "Dining Out", + "assigned": 100.0, + "activity": -123.1, + "available": -23.1, + "goal_target": 150.0, + } + ], + }, + ], + } + + +async def test_category_budget_all_zero_rows(monkeypatch): + rows = [_cat("Bills", "Unused", "0", "0", "0")] + fake_db(monkeypatch, {"FROM ynab_month_categories mc": rows}) + + msg = await call(tools.ynab_category_budget, {"month": "2026-10"}) + + assert msg.content == "Categories for 2026-10:" + assert msg.artifact is None + + +async def test_category_budget_empty(monkeypatch): + fake_db(monkeypatch, {"FROM ynab_month_categories mc": []}) + + msg = await call(tools.ynab_category_budget, {"month": "2026-10"}) + + assert msg.content == "No YNAB category data for 2026-10." + assert msg.artifact is None + + +# --- shared paths ----------------------------------------------------------- + +ARTIFACT_TOOLS = [ + tools.ynab_budget_summary, + tools.ynab_search_transactions, + tools.ynab_spending_report, + tools.ynab_category_budget, +] + + +@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name) +async def test_not_synced(monkeypatch, tool): + fake_db(monkeypatch, {}, synced=False) + + msg = await call(tool) + + assert msg.content == tools.NOT_SYNCED + assert msg.artifact is None + + +@pytest.mark.parametrize("tool", ARTIFACT_TOOLS, ids=lambda t: t.name) +async def test_db_error(monkeypatch, tool): + fake_db(monkeypatch, {}) + + async def boom(sql, *params): + raise RuntimeError("db down") + + monkeypatch.setattr(tools, "_query", boom) + + msg = await call(tool) + + assert msg.content == f"YNAB error in {tool.name}: RuntimeError: db down" + assert msg.artifact is None + + +def test_text_only_tools_unchanged(): + assert tools.ynab_account_balances.response_format == "content" + assert tools.ynab_recent_changes.response_format == "content"