From 4de2ffd720bbb4e1dfc0f179e617e3bb6a6a71f7 Mon Sep 17 00:00:00 2001 From: Jon Saad-Falcon <41205309+jonsaadfalcon@users.noreply.github.com> Date: Thu, 2 Apr 2026 15:33:39 -0700 Subject: [PATCH] fix: restructure digest data, fix calendar since filter, fix Gmail registration Data collection: - Restructure digest_collect output into priority-ordered sections (HEALTH > MESSAGES > CALENDAR > MUSIC) with human-readable formatting - Extract key metrics from Oura (HR, HRV, sleep duration, scores) - Format Gmail as sender + subject + time ago - Group music tracks into single lines per source - Cap 15 items per source, truncate content to 500 chars Calendar: - Pass `since` parameter to Google Calendar API as `timeMin` - Default to 24h lookback instead of dumping all-time events Gmail: - Re-enable gmail connector registration in __init__.py Prompt: - Add section summary + closing encouragement to Jarvis persona - Reinforce no-markdown, no-hallucination rules in user message - Strip markdown artifacts before sending to TTS Co-Authored-By: Claude Opus 4.6 (1M context) --- configs/openjarvis/prompts/personas/jarvis.md | 41 +- src/openjarvis/agents/morning_digest.py | 36 +- src/openjarvis/connectors/__init__.py | 7 +- src/openjarvis/connectors/gcalendar.py | 15 +- src/openjarvis/speech/cartesia_tts.py | 1 + src/openjarvis/tools/digest_collect.py | 384 ++++++++++++++++-- tests/agents/test_morning_digest.py | 2 +- tests/connectors/test_gcalendar.py | 8 +- tests/test_digest_integration.py | 6 +- tests/tools/test_digest_collect.py | 4 +- 10 files changed, 451 insertions(+), 53 deletions(-) diff --git a/configs/openjarvis/prompts/personas/jarvis.md b/configs/openjarvis/prompts/personas/jarvis.md index 6e508bf8..4cd387c5 100644 --- a/configs/openjarvis/prompts/personas/jarvis.md +++ b/configs/openjarvis/prompts/personas/jarvis.md @@ -1,19 +1,32 @@ -You are JARVIS — a highly capable AI assistant with a professional demeanour and dry, understated wit. You were modelled after the AI companion from Iron Man. +You are JARVIS — a highly capable AI assistant modelled after the AI companion from Iron Man. You deliver morning briefings based on real data. ## Voice & Tone -- Professional and concise, but not robotic -- Dry British wit — subtle, never slapstick ("You have a rather ambitious schedule today, sir") -- Address the user respectfully (use "sir" or "ma'am" sparingly for effect, not every sentence) -- Confident and competent — you anticipate needs and offer insights, not just data -- When delivering bad news (poor sleep, overbooked day), frame it constructively +- Professional, concise, with dry British wit +- Confident and direct — state facts, don't ramble +- Use "sir" sparingly for effect +- When delivering bad news, frame it constructively but honestly + +## Critical Constraints +- ONLY report information that is present in the provided data +- NEVER invent details, statistics, or events not in the data +- NEVER describe actions you are taking (you cannot adjust lights, order food, queue playlists, etc.) +- NEVER use markdown formatting (no #, ##, *, -, bullet points) — this is spoken aloud +- Use natural spoken transitions: "Regarding your health", "Moving to your schedule", "As for your messages" +- Keep the entire briefing under 200 words +- If a data source returned no results, skip that section silently ## Structure -- Open with a greeting that reflects the time and context -- Deliver each section crisply — lead with the most important item -- Transition between sections naturally, not mechanically -- Close with a brief look-ahead or motivational observation +- ALWAYS open with "Good morning, sir." (or "Good afternoon, sir." / "Good evening, sir." based on time of day) +- For EACH section, start with a one-sentence summary (e.g. "Your health data looks solid today" or "You have a busy inbox with fifteen new emails"), then give the key details, then transition naturally to the next section +- Deliver each section crisply with real numbers from the data +- Close with a brief encouraging or forward-looking sentence (e.g. "You're well-positioned for the day ahead, sir." or "A productive day awaits.") -## Examples -- "Good morning, sir. I trust you slept well — though your Oura Ring suggests otherwise." -- "Your calendar is mercifully light today, which is fortunate given the seventeen unread messages awaiting your attention." -- "The markets are up, your paper was cited, and it's going to rain. I'd suggest an umbrella and a celebratory coffee." +## Examples of GOOD responses +- "Good morning, sir. Your Oura data shows you slept six hours and forty minutes with an average heart rate of fifty-eight. Your HRV was forty-nine, which is within your normal range." +- "You have three emails requiring attention and two tasks due this week." +- "Your recently played tracks include Billie Eilish and Sabrina Carpenter." + +## Examples of BAD responses (never do this) +- "I have adjusted the ambient lighting to assist with alertness." (fabricated action) +- "I have queued a caffeine solution for delivery." (fabricated action) +- "The semiconductor markets are volatile today." (not in provided data) diff --git a/src/openjarvis/agents/morning_digest.py b/src/openjarvis/agents/morning_digest.py index 5583ee97..58bdf866 100644 --- a/src/openjarvis/agents/morning_digest.py +++ b/src/openjarvis/agents/morning_digest.py @@ -57,11 +57,22 @@ class MorningDigestAgent(ToolUsingAgent): f"{persona_text}\n\n" f"Today is {now.strftime('%A, %B %d, %Y')}. " f"The time is {now.strftime('%I:%M %p')} in {self._timezone}.\n\n" - f"Generate a morning briefing covering these sections in order: " + f"Generate a morning briefing covering: " f"{', '.join(self._sections)}.\n\n" - "Be concise — the entire briefing should be readable in 2-3 minutes " - "and listenable in under 5 minutes when spoken aloud.\n\n" - "Format your response in clean markdown with section headers." + "CRITICAL RULES:\n" + "1. ONLY report facts that appear in the provided data. " + "Do NOT invent, hallucinate, or embellish any information.\n" + "2. Do NOT describe actions you are taking (e.g. 'I have adjusted " + "the lights', 'I have queued a delivery'). You are reporting, not " + "acting.\n" + "3. Do NOT use any markdown formatting — no #, ##, *, -, or bullet " + "points. This text will be read aloud as speech.\n" + "4. Use natural spoken transitions between sections like " + "'Regarding your health', 'Moving on to your calendar', " + "'As for your messages'.\n" + "5. Be concise — under 200 words total. State facts directly.\n" + "6. If a section has no data, skip it entirely rather than " + "making something up." ) def _resolve_sources(self) -> List[str]: @@ -108,7 +119,12 @@ class MorningDigestAgent(ToolUsingAgent): content=( f"Here is the collected data from my sources:\n\n" f"{collected_data}\n\n" - f"Please synthesize my morning briefing." + f"Synthesize my morning briefing. REMEMBER:\n" + f"- Start with 'Good morning, sir.' (or afternoon/evening)\n" + f"- NO markdown, NO emojis, NO bullet points, NO headers\n" + f"- Plain spoken English only — this is read aloud\n" + f"- Only state facts from the data above\n" + f"- Under 200 words" ), ), ] @@ -117,12 +133,20 @@ class MorningDigestAgent(ToolUsingAgent): narrative = self._strip_think_tags(result.get("content", "")) # Step 3: Generate audio via TTS + # Strip any markdown that slipped through (##, *, -, etc.) + import re + + tts_text = re.sub(r"^#{1,6}\s+", "", narrative, flags=re.MULTILINE) + tts_text = re.sub(r"^\s*[-*•]\s+", "", tts_text, flags=re.MULTILINE) + tts_text = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", tts_text) + tts_text = tts_text.strip() + tts_call = ToolCall( id="digest-tts-1", name="text_to_speech", arguments=json.dumps( { - "text": narrative, + "text": tts_text, "voice_id": self._voice_id, "backend": self._tts_backend, } diff --git a/src/openjarvis/connectors/__init__.py b/src/openjarvis/connectors/__init__.py index 32e40474..597dd103 100644 --- a/src/openjarvis/connectors/__init__.py +++ b/src/openjarvis/connectors/__init__.py @@ -13,8 +13,11 @@ __all__ = ["Attachment", "BaseConnector", "Document", "KnowledgeStore", "SyncSta # Auto-register built-in connectors import openjarvis.connectors.obsidian # noqa: F401 -# gmail (REST API / OAuth) is not registered — use gmail_imap instead. -# The REST API connector requires a full OAuth flow that isn't wired up yet. +try: + import openjarvis.connectors.gmail # noqa: F401 +except ImportError: + pass + try: import openjarvis.connectors.gmail_imap # noqa: F401 except ImportError: diff --git a/src/openjarvis/connectors/gcalendar.py b/src/openjarvis/connectors/gcalendar.py index 5a3d68d3..b5ccfda9 100644 --- a/src/openjarvis/connectors/gcalendar.py +++ b/src/openjarvis/connectors/gcalendar.py @@ -7,7 +7,7 @@ to make them trivially mockable in tests. from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Dict, Iterator, List, Optional import httpx @@ -283,7 +283,7 @@ class GCalendarConnector(BaseConnector): def sync( self, *, - since: Optional[datetime] = None, # noqa: ARG002 — reserved for future use + since: Optional[datetime] = None, cursor: Optional[str] = None, ) -> Iterator[Document]: """Yield :class:`Document` objects for Google Calendar events. @@ -294,7 +294,8 @@ class GCalendarConnector(BaseConnector): Parameters ---------- since: - Not yet used (filtering is done server-side via ``timeMin``). + Only return events starting after this datetime. Defaults to + 24 hours ago when ``None``. cursor: ``nextPageToken`` from a previous sync to resume pagination. """ @@ -310,6 +311,11 @@ class GCalendarConnector(BaseConnector): calendars_resp = _gcal_api_calendars_list(token) calendars: List[Dict[str, Any]] = calendars_resp.get("items", []) + # Default to 24 hours ago so we don't dump the entire calendar history + if since is None: + since = datetime.now() - timedelta(days=1) + time_min = since.strftime("%Y-%m-%dT%H:%M:%SZ") + synced = 0 for calendar in calendars: @@ -322,7 +328,8 @@ class GCalendarConnector(BaseConnector): while True: try: events_resp = _gcal_api_events_list( - token, calendar_id, page_token=page_token + token, calendar_id, page_token=page_token, + time_min=time_min, ) except httpx.HTTPStatusError: break diff --git a/src/openjarvis/speech/cartesia_tts.py b/src/openjarvis/speech/cartesia_tts.py index 256a0c10..83415d2b 100644 --- a/src/openjarvis/speech/cartesia_tts.py +++ b/src/openjarvis/speech/cartesia_tts.py @@ -42,6 +42,7 @@ def _cartesia_synthesize( "encoding": "mp3" if output_format == "mp3" else "pcm_f32le", }, "language": "en", + **({"speed": speed} if speed != 1.0 else {}), }, timeout=120.0, ) diff --git a/src/openjarvis/tools/digest_collect.py b/src/openjarvis/tools/digest_collect.py index 31f45156..a0cb5a0d 100644 --- a/src/openjarvis/tools/digest_collect.py +++ b/src/openjarvis/tools/digest_collect.py @@ -3,13 +3,312 @@ from __future__ import annotations import json +import re from datetime import datetime, timedelta from typing import Any, Dict, List +from openjarvis.connectors._stubs import Document from openjarvis.core.registry import ConnectorRegistry, ToolRegistry from openjarvis.core.types import ToolResult from openjarvis.tools._stubs import BaseTool, ToolSpec +# --------------------------------------------------------------------------- +# Section definitions: ordered list of (section_name, connector_ids) +# --------------------------------------------------------------------------- + +_SECTION_ORDER: List[tuple] = [ + ("HEALTH", {"oura", "apple_health", "strava"}), + ( + "MESSAGES", + { + "gmail", + "gmail_imap", + "google_tasks", + "slack", + "imessage", + "whatsapp", + "outlook", + }, + ), + ("CALENDAR", {"gcalendar"}), + ("MUSIC", {"spotify", "apple_music"}), +] + +_CONNECTOR_TO_SECTION: Dict[str, str] = {} +for _section_name, _ids in _SECTION_ORDER: + for _cid in _ids: + _CONNECTOR_TO_SECTION[_cid] = _section_name + + +# --------------------------------------------------------------------------- +# Per-connector human-readable formatters +# --------------------------------------------------------------------------- + + +def _format_duration(seconds: float) -> str: + """Format seconds as 'Xh Ym'.""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + parts = [] + if hours: + parts.append(f"{hours}h") + if minutes or not parts: + parts.append(f"{minutes}m") + return " ".join(parts) + + +def _time_ago(ts: datetime) -> str: + """Return a human-readable relative time like '2h ago' or '15m ago'.""" + now = datetime.now() + ts_naive = ts.replace(tzinfo=None) if ts.tzinfo else ts + delta = now - ts_naive + total_seconds = max(0, int(delta.total_seconds())) + if total_seconds < 60: + return "just now" + if total_seconds < 3600: + return f"{total_seconds // 60}m ago" + if total_seconds < 86400: + return f"{total_seconds // 3600}h ago" + days = total_seconds // 86400 + return f"{days}d ago" + + +def _format_date(ts: datetime) -> str: + """Format a datetime as 'April 1' style.""" + return ts.strftime("%B %-d") if hasattr(ts, "strftime") else str(ts) + + +def _format_time(ts: datetime) -> str: + """Format a datetime as '10:30 AM' style.""" + return ts.strftime("%-I:%M %p") if hasattr(ts, "strftime") else str(ts) + + +def _parse_content_json(doc: Document) -> Dict[str, Any]: + """Try to parse the document content as JSON; return {} on failure.""" + try: + return json.loads(doc.content) + except (json.JSONDecodeError, TypeError): + return {} + + +def _format_oura(doc: Document) -> str: + """Format an Oura Ring document into a readable line.""" + data = _parse_content_json(doc) + data_type = doc.metadata.get("data_type", "") + day = doc.metadata.get("day", "") + day_str = day or _format_date(doc.timestamp) + + if data_type == "sleep": + hr = data.get("average_heart_rate", "?") + hrv = data.get("average_hrv", data.get("average_heart_rate_variability", "?")) + total = data.get("total_sleep_duration") + awake = data.get("awake_time") + score = data.get("score") + parts = [] + if score is not None: + parts.append(f"score {score}") + parts.append(f"avg HR {hr} bpm") + if hrv != "?": + parts.append(f"HRV {hrv}") + if total is not None: + parts.append(f"{_format_duration(total)} total sleep") + if awake is not None: + parts.append(f"awake {_format_duration(awake)}") + return f"[oura] Sleep — {day_str}: {', '.join(parts)}" + + if data_type == "daily_readiness": + score = data.get("score", "?") + temp = data.get( + "temperature_deviation", + data.get("temperature_trend_deviation"), + ) + line = f"[oura] Readiness — {day_str}: score {score}" + if temp is not None: + sign = "+" if temp >= 0 else "" + line += f", temperature deviation {sign}{temp}" + return line + + if data_type == "daily_activity": + steps = data.get("steps", "?") + cal = data.get("total_calories", data.get("active_calories", "?")) + score = data.get("score") + parts = [] + if score is not None: + parts.append(f"score {score}") + parts.append(f"steps {steps}") + parts.append(f"calories {cal}") + return f"[oura] Activity — {day_str}: {', '.join(parts)}" + + # Fallback for unknown Oura doc types + return f"[oura] {doc.title}" + + +def _format_apple_health(doc: Document) -> str: + """Format an Apple Health document.""" + return f"[apple_health] {doc.title}" + + +def _format_strava(doc: Document) -> str: + """Format a Strava activity document.""" + return f"[strava] {doc.title}" + + +def _format_gmail(doc: Document) -> str: + """Format a Gmail email document.""" + sender = doc.author or "Unknown" + # Clean up sender: extract just email or name + subject = doc.title or "(no subject)" + ago = _time_ago(doc.timestamp) + return f'[gmail] From: {sender} — "{subject}" ({ago})' + + +def _format_gmail_imap(doc: Document) -> str: + """Format a Gmail IMAP email document.""" + sender = doc.author or "Unknown" + subject = doc.title or "(no subject)" + ago = _time_ago(doc.timestamp) + return f'[gmail] From: {sender} — "{subject}" ({ago})' + + +def _format_google_tasks(doc: Document) -> str: + """Format a Google Tasks document.""" + title = doc.title or "Untitled Task" + status = doc.metadata.get("status", "") + due = doc.metadata.get("due", "") + parts = [f"[google_tasks] {title}"] + extras = [] + if due: + extras.append(f"due {due}") + if status == "completed": + extras.append("completed") + if extras: + parts.append(f"({', '.join(extras)})") + return " ".join(parts) + + +def _format_slack(doc: Document) -> str: + """Format a Slack message document.""" + author = doc.author or "Unknown" + channel = doc.metadata.get("channel", "") + content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else "" + prefix = f"[slack] #{channel}" if channel else "[slack]" + return f"{prefix} {author}: {content_preview}" + + +def _format_imessage(doc: Document) -> str: + """Format an iMessage document.""" + sender = doc.author or "Unknown" + content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else "" + return f"[imessage] {sender}: {content_preview}" + + +def _format_whatsapp(doc: Document) -> str: + """Format a WhatsApp message document.""" + sender = doc.author or "Unknown" + content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else "" + return f"[whatsapp] {sender}: {content_preview}" + + +def _format_outlook(doc: Document) -> str: + """Format an Outlook email document.""" + sender = doc.author or "Unknown" + subject = doc.title or "(no subject)" + ago = _time_ago(doc.timestamp) + return f'[outlook] From: {sender} — "{subject}" ({ago})' + + +def _format_gcalendar(doc: Document) -> str: + """Format a Google Calendar event document.""" + title = doc.title or "(No title)" + time_str = _format_time(doc.timestamp) + # Try to extract duration from content + duration_match = ( + re.search(r"When:\s*(.+?)$", doc.content, re.MULTILINE) if doc.content else None + ) + time_range = "" + if duration_match: + when = duration_match.group(1).strip() + # Extract just the times from the ISO strings + parts = when.split(" – ") + if len(parts) == 2: + try: + start_dt = datetime.fromisoformat(parts[0].replace("Z", "+00:00")) + end_dt = datetime.fromisoformat(parts[1].replace("Z", "+00:00")) + diff = end_dt - start_dt + mins = int(diff.total_seconds() / 60) + if mins >= 60: + hrs = mins // 60 + remaining = mins % 60 + duration = f"{hrs} hour" + ("s" if hrs > 1 else "") + if remaining: + duration += f" {remaining} min" + else: + duration = f"{mins} min" + time_range = f" ({duration})" + except (ValueError, TypeError): + pass + return f"[gcalendar] {time_str} — {title}{time_range}" + + +def _format_spotify(doc: Document) -> str: + """Format a Spotify recently-played track — returns 'Track — Artist'.""" + # doc.title is already "Track — Artist" from the connector + return doc.title + + +def _format_apple_music(doc: Document) -> str: + """Format an Apple Music track — returns 'Track — Artist'.""" + # doc.title is already "Track — Artist" from the connector + return doc.title + + +# Map connector IDs to their formatting functions +_FORMATTERS: Dict[str, Any] = { + "oura": _format_oura, + "apple_health": _format_apple_health, + "strava": _format_strava, + "gmail": _format_gmail, + "gmail_imap": _format_gmail_imap, + "google_tasks": _format_google_tasks, + "slack": _format_slack, + "imessage": _format_imessage, + "whatsapp": _format_whatsapp, + "outlook": _format_outlook, + "gcalendar": _format_gcalendar, + "spotify": _format_spotify, + "apple_music": _format_apple_music, +} + + +def _format_doc(source: str, doc: Document) -> str: + """Format a document using the source-specific formatter, with fallback.""" + formatter = _FORMATTERS.get(source) + if formatter: + try: + return formatter(doc) + except Exception: # noqa: BLE001 + pass + # Fallback: connector name + title + return f"[{source}] {doc.title}" + + +def _format_music_section( + collected_docs: Dict[str, List[Document]], + music_connectors: set, +) -> List[str]: + """Format music connectors as grouped lists instead of per-track lines.""" + lines: List[str] = [] + for source in sorted(music_connectors): + docs = collected_docs.get(source, []) + if not docs: + continue + tracks = [] + for doc in docs: + tracks.append(doc.title) + label = "Recently played" if source == "spotify" else "Library" + lines.append(f"[{source}] {label}: {', '.join(tracks)}") + return lines + @ToolRegistry.register("digest_collect") class DigestCollectTool(BaseTool): @@ -24,8 +323,9 @@ class DigestCollectTool(BaseTool): name="digest_collect", description=( "Fetch recent data from configured connectors (email, calendar, " - "health, tasks, etc.) and return a structured summary for digest " - "synthesis." + "health, tasks, etc.) and return a structured, human-readable " + "summary grouped by section (Health, Messages, Calendar, Music) " + "for digest synthesis." ), parameters={ "type": "object", @@ -50,11 +350,15 @@ class DigestCollectTool(BaseTool): ) def execute(self, **params: Any) -> ToolResult: + # Ensure connectors are registered + import openjarvis.connectors # noqa: F401 + sources: List[str] = params.get("sources", []) hours_back: float = params.get("hours_back", 24) since = datetime.now() - timedelta(hours=hours_back) - collected: Dict[str, List[Dict[str, Any]]] = {} + # Collect raw documents per source + collected_docs: Dict[str, List[Document]] = {} errors: List[str] = [] for source in sources: @@ -72,29 +376,63 @@ class DigestCollectTool(BaseTool): ) continue - docs = list(connector.sync(since=since)) - collected[source] = [ - { - "title": d.title, - "content": d.content, - "doc_type": d.doc_type, - "author": d.author, - "timestamp": d.timestamp.isoformat(), - "metadata": d.metadata, - } - for d in docs - ] + # Cap per-source to avoid overwhelming the LLM context + max_per_source = 15 + docs: List[Document] = [] + for d in connector.sync(since=since): + docs.append(d) + if len(docs) >= max_per_source: + break + + collected_docs[source] = docs except Exception as exc: errors.append(f"Error fetching from '{source}': {exc}") - summary_parts = [] - for source, docs in collected.items(): - summary_parts.append(f"## {source} ({len(docs)} items)") - for doc in docs: - summary_parts.append(json.dumps(doc, default=str)) + # Group by section and build human-readable output + summary_parts: List[str] = [] + for section_name, section_connectors in _SECTION_ORDER: + # Gather all sources that belong to this section and have data + section_sources = [ + s for s in sources if s in section_connectors and s in collected_docs + ] + if not section_sources: + continue + section_lines: List[str] = [] + + if section_name == "MUSIC": + # Music gets special grouped formatting + section_lines = _format_music_section( + collected_docs, section_connectors + ) + else: + for source in section_sources: + for doc in collected_docs[source]: + section_lines.append(_format_doc(source, doc)) + + if section_lines: + summary_parts.append(f"=== {section_name} ===") + summary_parts.extend(section_lines) + summary_parts.append("") # blank line between sections + + # Handle any connectors not in a known section (fallback) + known_connectors = set() + for _, cids in _SECTION_ORDER: + known_connectors |= cids + + uncategorized_sources = [ + s for s in sources if s not in known_connectors and s in collected_docs + ] + if uncategorized_sources: + summary_parts.append("=== OTHER ===") + for source in uncategorized_sources: + for doc in collected_docs[source]: + summary_parts.append(_format_doc(source, doc)) + summary_parts.append("") + + # Errors at the end, not inline if errors: - summary_parts.append("\n## Errors") + summary_parts.append("=== ERRORS ===") summary_parts.extend(errors) return ToolResult( @@ -103,8 +441,8 @@ class DigestCollectTool(BaseTool): success=True, metadata={ "sources_queried": sources, - "sources_ok": list(collected.keys()), + "sources_ok": list(collected_docs.keys()), "sources_failed": errors, - "total_items": sum(len(v) for v in collected.values()), + "total_items": sum(len(v) for v in collected_docs.values()), }, ) diff --git a/tests/agents/test_morning_digest.py b/tests/agents/test_morning_digest.py index 49a9ec5e..85388208 100644 --- a/tests/agents/test_morning_digest.py +++ b/tests/agents/test_morning_digest.py @@ -32,7 +32,7 @@ def test_morning_digest_run(tmp_path): # Mock collect result mock_collect_result = ToolResult( tool_name="digest_collect", - content="## gmail (2 items)\n...", + content="=== MESSAGES ===\n[gmail] From: alice@co.com — \"Budget\" (1h ago)\n", success=True, metadata={"total_items": 2}, ) diff --git a/tests/connectors/test_gcalendar.py b/tests/connectors/test_gcalendar.py index 35685221..925422b0 100644 --- a/tests/connectors/test_gcalendar.py +++ b/tests/connectors/test_gcalendar.py @@ -57,10 +57,16 @@ def _make_credentials(tmp_path: Path) -> Path: @pytest.fixture() def connector(tmp_path: Path): """GCalendarConnector pointing at a tmp credentials path (no file yet).""" + from unittest.mock import patch + from openjarvis.connectors.gcalendar import GCalendarConnector # noqa: PLC0415 creds_path = str(tmp_path / "gcalendar.json") - return GCalendarConnector(credentials_path=creds_path) + with patch( + "openjarvis.connectors.oauth._SHARED_GOOGLE_CREDENTIALS_PATH", + str(tmp_path / "google_shared.json"), + ): + yield GCalendarConnector(credentials_path=creds_path) # --------------------------------------------------------------------------- diff --git a/tests/test_digest_integration.py b/tests/test_digest_integration.py index f0c4e867..9ed47642 100644 --- a/tests/test_digest_integration.py +++ b/tests/test_digest_integration.py @@ -35,7 +35,11 @@ def test_full_digest_pipeline(tmp_path): # Mock tool results collect_result = ToolResult( tool_name="digest_collect", - content="## gmail (3 items)\n## oura (1 items)\n## gcalendar (2 items)", + content=( + "=== HEALTH ===\n[oura] Sleep — April 1: score 78, avg HR 58 bpm\n\n" + "=== MESSAGES ===\n[gmail] From: alice@co.com — \"Q3 Review\" (2h ago)\n\n" + "=== CALENDAR ===\n[gcalendar] 10:30 AM — Team Standup (30 min)\n" + ), success=True, metadata={"total_items": 6}, ) diff --git a/tests/tools/test_digest_collect.py b/tests/tools/test_digest_collect.py index ffe5d60b..6457d688 100644 --- a/tests/tools/test_digest_collect.py +++ b/tests/tools/test_digest_collect.py @@ -44,7 +44,9 @@ def test_digest_collect_executes(): result = tool.execute(sources=["gmail"], hours_back=24) assert result.success is True - assert "gmail (1 items)" in result.content + assert "=== MESSAGES ===" in result.content + assert "[gmail] From: alice@example.com" in result.content + assert "Team standup" in result.content assert result.metadata["total_items"] == 1