"""Tests for tools returning (text, artifact) via content_and_artifact. The text half must stay identical to the pre-artifact output (the LLM's view), and the artifact must match the contract in docs/cowork-ui-plan.md. """ import datetime import json import os from unittest.mock import AsyncMock, MagicMock import pytest from langchain_core.documents import Document from langchain_core.messages import ToolMessage os.environ.setdefault("TAVILY_API_KEY", "dummy") from blueprints.conversation import agents # noqa: E402 from blueprints.conversation.artifacts_util import ( # noqa: E402 NOTE_MAX_CHARS, SNIPPET_MAX_CHARS, format_date_range, search_documents_artifact, ) from utils.obsidian_service import ObsidianService # noqa: E402 KIND_DATA_KEYS = { "mealie.recipe": { "slug", "name", "description", "servings", "prep_time", "cook_time", "total_time", "ingredients", "instructions", "tags", }, "mealie.meal_plan": {"start_date", "end_date", "days"}, "mealie.shopping_lists": {"lists"}, "obsidian.note": {"path", "title", "markdown", "tags", "frontmatter"}, "search.documents": {"query", "source", "results"}, } 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): assert artifact is not None assert artifact["kind"] == kind assert isinstance(artifact["title"], str) and artifact["title"] assert set(artifact["data"]) == KIND_DATA_KEYS[kind] json.dumps(artifact) @pytest.fixture def mealie(monkeypatch): service = MagicMock() monkeypatch.setattr(agents, "mealie_enabled", True) monkeypatch.setattr(agents, "mealie_service", service, raising=False) monkeypatch.setenv("MEALIE_BASE_URL", "http://mealie.local:9000/") monkeypatch.delenv("MEALIE_GROUP_SLUG", raising=False) return service @pytest.fixture def obsidian(monkeypatch, tmp_path): (tmp_path / "seed.md").write_text("# seed") monkeypatch.setenv("OBSIDIAN_VAULT_PATH", str(tmp_path)) monkeypatch.delenv("OBSIDIAN_VAULT_NAME", raising=False) service = ObsidianService() monkeypatch.setattr(agents, "obsidian_enabled", True) monkeypatch.setattr(agents, "obsidian_service", service, raising=False) return service # --- Mealie ----------------------------------------------------------------- RECIPE = { "name": "Chicken Tikka Masala", "slug": "chicken-tikka-masala", "description": "Creamy and spiced.", "ingredients": [{"display": "1 lb chicken"}, {"display": "1 cup yogurt"}], "instructions": [ {"title": "Marinate", "text": "Mix chicken and yogurt."}, {"title": "", "text": "Cook it."}, ], "prep_time": "20 minutes", "cook_time": "30 minutes", "total_time": "", "servings": "4", "tags": ["Indian", "Dinner"], } RECIPE_TEXT = """**Chicken Tikka Masala** Creamy and spiced. Servings: 4 Prep: 20 minutes | Cook: 30 minutes Ingredients: - 1 lb chicken - 1 cup yogurt Instructions: 1. Mix chicken and yogurt. 2. Cook it. Tags: Indian, Dinner""" async def test_mealie_get_recipe(mealie): mealie.get_recipe = AsyncMock(return_value=RECIPE) msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"}) assert msg.content == RECIPE_TEXT art = msg.artifact assert_artifact(art, "mealie.recipe") assert art["title"] == "Chicken Tikka Masala" assert art["data"]["ingredients"] == ["1 lb chicken", "1 cup yogurt"] assert art["data"]["instructions"] == [ {"title": "Marinate", "text": "Mix chicken and yogurt."}, {"text": "Cook it."}, ] assert art["source_ref"] == { "system": "mealie", "id": "chicken-tikka-masala", "url": "http://mealie.local:9000/g/home/r/chicken-tikka-masala", } async def test_mealie_recipe_url_uses_group_slug(mealie, monkeypatch): monkeypatch.setenv("MEALIE_GROUP_SLUG", "family") mealie.get_recipe = AsyncMock(return_value=RECIPE) msg = await call(agents.mealie_get_recipe, {"recipe_slug": "chicken-tikka-masala"}) assert msg.artifact["source_ref"]["url"].endswith( "/g/family/r/chicken-tikka-masala" ) async def test_mealie_get_recipe_error(mealie): mealie.get_recipe = AsyncMock(side_effect=Exception("404")) msg = await call(agents.mealie_get_recipe, {"recipe_slug": "nope"}) assert msg.content == "Error fetching recipe: 404" assert msg.artifact is None MEAL_PLANS = { "start_date": "2026-10-12", "end_date": "2026-10-19", "total_plans": 3, "plans_by_date": { "2026-10-13": [ { "entry_type": "dinner", "title": "", "recipe_name": "Tacos", "recipe_slug": "tacos", "note": "", } ], "2026-10-12": [ { "entry_type": "lunch", "title": "Leftovers", "recipe_name": "", "recipe_slug": "", "note": "from Sunday", }, { "entry_type": "dinner", "title": "", "recipe_name": "Chicken Tikka Masala", "recipe_slug": "chicken-tikka-masala", "note": "", }, ], }, } MEAL_PLANS_TEXT = """Meal plans from 2026-10-12 to 2026-10-19 (3 meals): 2026-10-12: - Lunch: Leftovers Note: from Sunday - Dinner: Chicken Tikka Masala 2026-10-13: - Dinner: Tacos""" async def test_mealie_meal_plans(mealie): mealie.get_meal_plans = AsyncMock(return_value=MEAL_PLANS) msg = await call(agents.mealie_meal_plans, {}) assert msg.content == MEAL_PLANS_TEXT art = msg.artifact assert_artifact(art, "mealie.meal_plan") assert art["title"] == "Meal plan · Oct 12–19" assert [d["date"] for d in art["data"]["days"]] == ["2026-10-12", "2026-10-13"] assert art["data"]["days"][0]["meals"] == [ {"entry_type": "lunch", "name": "Leftovers", "note": "from Sunday"}, { "entry_type": "dinner", "name": "Chicken Tikka Masala", "recipe_slug": "chicken-tikka-masala", }, ] async def test_mealie_meal_plans_empty(mealie): mealie.get_meal_plans = AsyncMock( return_value={ "start_date": "2026-10-12", "end_date": "2026-10-19", "total_plans": 0, "plans_by_date": {}, } ) msg = await call(agents.mealie_meal_plans, {}) assert msg.content == "No meal plans found from 2026-10-12 to 2026-10-19." assert msg.artifact is None async def test_mealie_todays_meals(mealie): mealie.get_todays_meals = AsyncMock( return_value={ "today": "2026-10-12", "total_meals": 1, "meals": [ { "entry_type": "dinner", "title": "", "recipe_name": "Tacos", "recipe_slug": "tacos", "note": "extra salsa", } ], } ) msg = await call(agents.mealie_todays_meals) assert msg.content == ( "Meals planned for today (2026-10-12):\n- Dinner: Tacos\n Note: extra salsa" ) art = msg.artifact assert_artifact(art, "mealie.meal_plan") assert art["title"] == "Today's meals · Oct 12" assert art["data"] == { "start_date": "2026-10-12", "end_date": "2026-10-12", "days": [ { "date": "2026-10-12", "meals": [ { "entry_type": "dinner", "name": "Tacos", "recipe_slug": "tacos", "note": "extra salsa", } ], } ], } async def test_mealie_todays_meals_empty(mealie): mealie.get_todays_meals = AsyncMock( return_value={"today": "2026-10-12", "total_meals": 0, "meals": []} ) msg = await call(agents.mealie_todays_meals) assert msg.content == "No meals planned for today (2026-10-12)." assert msg.artifact is None async def test_mealie_shopping_lists(mealie): mealie.get_shopping_lists = AsyncMock( return_value={ "total_lists": 1, "total_unchecked_items": 2, "shopping_lists": [ { "name": "Groceries", "unchecked_items": [ {"name": "Milk", "quantity": 1, "note": "", "food": None}, {"name": "Eggs", "quantity": 12.0, "note": "", "food": None}, ], "checked_items": [ {"name": "Bread", "quantity": 1, "note": "", "food": None} ], } ], } ) msg = await call(agents.mealie_shopping_lists) assert msg.content == ( "Shopping lists (2 items needed):\n" "\n**Groceries** (2 items needed)\n" " - [ ] Milk\n" " - [ ] Eggs x12.0\n" " (1 items already checked off)" ) art = msg.artifact assert_artifact(art, "mealie.shopping_lists") assert art["data"]["lists"] == [ { "name": "Groceries", "items": [ {"name": "Milk", "quantity": 1, "checked": False}, {"name": "Eggs", "quantity": 12.0, "checked": False}, {"name": "Bread", "quantity": 1, "checked": True}, ], } ] async def test_mealie_shopping_lists_empty(mealie): mealie.get_shopping_lists = AsyncMock( return_value={ "total_lists": 0, "total_unchecked_items": 0, "shopping_lists": [], } ) msg = await call(agents.mealie_shopping_lists) assert msg.content == "No shopping lists found." assert msg.artifact is None @pytest.mark.parametrize( "tool,args", [ (agents.mealie_get_recipe, {"recipe_slug": "x"}), (agents.mealie_meal_plans, {}), (agents.mealie_todays_meals, {}), (agents.mealie_shopping_lists, {}), ], ) async def test_mealie_not_configured(monkeypatch, tool, args): monkeypatch.setattr(agents, "mealie_enabled", False) msg = await call(tool, args) assert msg.content == ( "Mealie integration is not configured. Please set MEALIE_BASE_URL and " "MEALIE_API_TOKEN environment variables." ) assert msg.artifact is None # --- Obsidian --------------------------------------------------------------- NOTE = """--- title: Vet visit date: 2026-10-01 tags: [simba] --- Simba saw the [[Dr Smith|vet]] today. #simba #health #simba """ async def test_obsidian_read_note(obsidian, monkeypatch): monkeypatch.setenv("OBSIDIAN_VAULT_NAME", "Home Vault") os.makedirs(os.path.join(obsidian.vault_path, "notes")) with open(os.path.join(obsidian.vault_path, "notes", "vet visit.md"), "w") as f: f.write(NOTE) msg = await call(agents.obsidian_read_note, {"relative_path": "notes/vet visit.md"}) assert msg.content == ( "File: notes/vet visit.md\n\n" "Frontmatter:\n{'title': 'Vet visit', 'date': datetime.date(2026, 10, 1), " "'tags': ['simba']}\n\n" "Content:\nSimba saw the vet today. #simba #health #simba\n\n" "Tags: simba, health, simba\n" "Contains 1 wikilinks and 0 embeds" ) art = msg.artifact assert_artifact(art, "obsidian.note") assert art["title"] == "Vet visit" assert art["data"]["frontmatter"] == { "title": "Vet visit", "date": "2026-10-01", "tags": ["simba"], } assert art["data"]["tags"] == ["simba", "health"] assert art["data"]["markdown"] == "Simba saw the vet today. #simba #health #simba" assert art["source_ref"] == { "system": "obsidian", "path": "notes/vet visit.md", "url": "obsidian://open?vault=Home%20Vault&file=notes%2Fvet%20visit.md", } async def test_obsidian_read_note_caps_markdown_and_omits_url(obsidian): with open(os.path.join(obsidian.vault_path, "big.md"), "w") as f: f.write("x" * (NOTE_MAX_CHARS + 500)) msg = await call(agents.obsidian_read_note, {"relative_path": "big.md"}) art = msg.artifact assert art["title"] == "big" assert len(art["data"]["markdown"]) <= NOTE_MAX_CHARS assert art["source_ref"] == {"system": "obsidian", "path": "big.md"} async def test_obsidian_read_note_missing(obsidian): msg = await call(agents.obsidian_read_note, {"relative_path": "nope.md"}) assert msg.content == ( "Note not found at 'nope.md'. Please check the path is correct." ) assert msg.artifact is None async def test_journal_get_today(obsidian): path = obsidian.get_daily_note_path() full = os.path.join(obsidian.vault_path, path) os.makedirs(os.path.dirname(full), exist_ok=True) raw = "## Tasks\n- [ ] Feed Simba\n" with open(full, "w") as f: f.write(raw) today = datetime.datetime.now().strftime("%Y-%m-%d") msg = await call(agents.journal_get_today) assert msg.content == f"Daily note for {today}:\n\n{raw}" art = msg.artifact assert_artifact(art, "obsidian.note") assert art["title"].startswith("Daily note · ") assert art["data"]["path"] == path assert art["data"]["markdown"] == "## Tasks\n- [ ] Feed Simba" async def test_journal_get_today_missing(obsidian): today = datetime.datetime.now().strftime("%Y-%m-%d") msg = await call(agents.journal_get_today) assert msg.content == ( f"No daily note found for {today}. Use journal_add_task to create one." ) assert msg.artifact is None async def test_obsidian_not_configured(monkeypatch): monkeypatch.setattr(agents, "obsidian_enabled", False) for tool, args in [ (agents.obsidian_read_note, {"relative_path": "a.md"}), (agents.obsidian_search_notes, {"query": "a"}), ]: msg = await call(tool, args) assert msg.content == ( "Obsidian integration is not configured. Please set " "OBSIDIAN_VAULT_PATH environment variable." ) assert msg.artifact is None msg = await call(agents.journal_get_today) assert msg.content == "Obsidian integration is not configured." assert msg.artifact is None # --- Search ----------------------------------------------------------------- PAPERLESS_DOCS = [ Document( page_content="Rabies vaccine\nadministered. " + "y" * 600, metadata={ "source": "paperless", "created_date": 1760227200.0, "filename": "vet_invoice.pdf", "document_type": "Invoice", }, ) ] async def test_simba_search(monkeypatch): query_mock = AsyncMock(return_value=("Source: {...}\nContent: x", PAPERLESS_DOCS)) monkeypatch.setattr(agents, "query_vector_store", query_mock) msg = await call(agents.simba_search, {"query": "vet records"}) assert msg.content == "Source: {...}\nContent: x" query_mock.assert_awaited_once_with(query="vet records", source="paperless") art = msg.artifact assert_artifact(art, "search.documents") assert art["title"] == "Search: vet records" assert art["data"]["source"] == "paperless" (result,) = art["data"]["results"] assert result["title"] == "vet_invoice" assert result["filename"] == "vet_invoice.pdf" assert result["document_type"] == "Invoice" assert len(result["date"]) == 10 assert result["snippet"].startswith("Rabies vaccine administered. ") assert len(result["snippet"]) <= SNIPPET_MAX_CHARS async def test_simba_search_empty(monkeypatch): monkeypatch.setattr(agents, "query_vector_store", AsyncMock(return_value=("", []))) msg = await call(agents.simba_search, {"query": "nothing"}) assert msg.content == "" assert msg.artifact is None async def test_obsidian_search_notes(obsidian, monkeypatch): docs = [ Document( page_content="Simba's vet is Dr Smith.", metadata={ "source": "obsidian", "filepath": os.path.join(obsidian.vault_path, "pets", "simba.md"), "tags": ["simba"], "created_at": "2026-01-02T10:00:00", }, ) ] monkeypatch.setattr( agents, "query_vector_store", AsyncMock(return_value=("serialized", docs)) ) msg = await call(agents.obsidian_search_notes, {"query": "vet"}) assert msg.content == "serialized" art = msg.artifact assert_artifact(art, "search.documents") assert art["data"]["results"] == [ { "snippet": "Simba's vet is Dr Smith.", "title": "simba", "path": "pets/simba.md", "date": "2026-01-02", } ] async def test_obsidian_search_notes_error(obsidian, monkeypatch): monkeypatch.setattr( agents, "query_vector_store", AsyncMock(side_effect=Exception("db down")) ) msg = await call(agents.obsidian_search_notes, {"query": "vet"}) assert msg.content == "Error searching Obsidian notes: db down" assert msg.artifact is None def test_search_documents_artifact_empty(): assert search_documents_artifact("q", "paperless", []) is None @pytest.mark.parametrize( "start,end,expected", [ ("2026-10-12", "2026-10-19", "Oct 12–19"), ("2026-10-28", "2026-11-03", "Oct 28–Nov 3"), ("2026-12-28", "2027-01-03", "Dec 28, 2026–Jan 3, 2027"), ("2026-10-12", "2026-10-12", "Oct 12"), ("bad", "2026-10-12", "bad–2026-10-12"), ], ) def test_format_date_range(start, end, expected): assert format_date_range(start, end) == expected async def test_obsidian_note_line_range_retains_full_artifact(obsidian): with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f: f.write("first\nsecond\nthird\nfourth") msg = await call( agents.obsidian_read_note, {"relative_path": "range.md", "start_line": 2, "max_lines": 2}, ) assert "Content lines 2-3 of 4:\nsecond\nthird\n\n" in msg.content assert "first" not in msg.content assert msg.artifact["data"]["markdown"] == "first\nsecond\nthird\nfourth" @pytest.mark.parametrize( "args", [{"start_line": 0}, {"max_lines": 0}, {"start_line": 99}] ) async def test_obsidian_note_invalid_line_range(obsidian, args): with open(os.path.join(obsidian.vault_path, "range.md"), "w") as f: f.write("first\nsecond") msg = await call(agents.obsidian_read_note, {"relative_path": "range.md", **args}) assert msg.artifact is None assert "positive integers" in msg.content or "beyond the end" in msg.content