diff --git a/docs/guides/email.mdx b/docs/guides/email.mdx index 7d58175fe..1028f67b7 100644 --- a/docs/guides/email.mdx +++ b/docs/guides/email.mdx @@ -10,6 +10,7 @@ The Email Triage Agent connects to your Gmail account through GAIA's connectors ## What it does - **Triage your inbox** — classify every message as `urgent`, `actionable`, `informational`, or `low priority`, plus separate `is_spam` and `is_phishing` flags. +- **Follow-up tracking** — flag the mail you *sent* that never got a reply after a configurable window (default 3 days). Detection only — the agent never sends a nudge on its own. - **Organize** — archive, label, mark read/unread, star/unstar. Reversible via the per-action undo log. - **Soft-delete with undo** — `trash_message` records the action; `restore_message` reverses it within a 30-second window. - **Draft + confirmed send** — generate replies (`draft_reply`) and forwards (`draft_forward`); `send_draft` and `send_now` require explicit user confirmation in the UI. @@ -146,7 +147,7 @@ Senders you reply to quickly are automatically promoted to priority on the next ### Read -`list_inbox`, `get_message`, `get_thread`, `search_messages`, `list_labels`, `triage_inbox`, `pre_scan_inbox` +`list_inbox`, `get_message`, `get_thread`, `search_messages`, `list_labels`, `triage_inbox`, `pre_scan_inbox`, `check_followups` ### Session preferences (in-memory; wiped on agent restart) @@ -156,6 +157,12 @@ Senders you reply to quickly are automatically promoted to priority on the next `profile_inbox` — asks "who emails me most?" and returns a frequency ranking of senders with their dominant category (e.g. *urgent*, *informational*) and the timestamp of their most recent message. Profiling is built from the interaction history the agent accumulates during triage, so it improves the more you use the agent. +### Follow-up tracking (read-only) + +`check_followups` — answers *"who hasn't replied to me?"*. It scans the Sent folder of every connected mailbox and flags each thread whose latest message is still your own outbound mail — meaning nobody replied — once it is older than the window (default 3 days; ask for a different one, e.g. *"what's still unanswered after a week?"*). Each flagged item carries the recipient, subject, and age in days, sorted most overdue first, and threads you answered yourself or addressed only to yourself are skipped. + +This is detection only, distinct from autonomous follow-ups ([#555](https://github.com/amd/gaia/issues/555)): the agent surfaces the dropped threads but never sends a chase-up on its own — if you ask it to nudge someone, that reply goes through the normal draft → confirm → send flow. + ### Organize (reversible via the undo log) `archive_message`, `mark_read`, `mark_unread`, `add_star`, `remove_star`, `label_message`, `move_to_label` diff --git a/docs/plans/email-sidecar-agent-ui.md b/docs/plans/email-sidecar-agent-ui.md new file mode 100644 index 000000000..058e8e30c --- /dev/null +++ b/docs/plans/email-sidecar-agent-ui.md @@ -0,0 +1,242 @@ +# Design: Email Agent in the Agent UI — frozen sidecar + dual user/dev modes + +**Date:** 2026-06-26 +**Status:** Draft (v3.1 — sidecar direction, bulletproofed) — pending user review +**Decision (2026-06-26):** The Agent UI's email backend is the **out-of-process +frozen sidecar** (the published Hub product), lazily downloaded on demand. Dev mode +runs the same contract from local Python source for fast iteration. This **reopens +the in-process decision in epic #1767** — see "Relationship to #1767" — and needs +maintainer + @itomek sign-off before build. +**Related:** #1767 (epic), #1768 (`/v1/email/*` REST surface), #1778/#1779/#1780/#1781 +(REST capability build-out), npm pkg `@amd-gaia/agent-email`, Python agent +`hub/agents/python/email` + +## Problem + +Triage the user's **personal Gmail** through the Agent UI, with: +1. **Users** — stable, no-Python: the agent runs as the frozen sidecar; the core + backend ships without heavy email deps, and the install stays **lightweight** — + only the email sidecar is downloaded, on demand. +2. **Developers** — improve the agent and see changes **live**, no + freeze → `npm publish` → version-bump → re-integrate patch loop. + +## Why a sidecar (not the simpler in-process editable install) + +A code review fairly asked: an editable install (`uv pip install -e`) already gives +in-process hot-iteration — why add a sidecar? Three reasons the sidecar earns its +place, beyond iteration speed: + +1. **It validates the shipped product.** User mode runs the *exact* frozen binary + published to the Agent Hub (pinned via `binaries.lock.json`). The Agent UI + becomes a first-class consumer of the real artifact — so product regressions + surface in our own app, not just in a downstream integrator's. Editable-install + never exercises the thing customers actually run. +2. **It keeps the core backend lightweight.** The email agent's heavy deps stay out + of the core wheel/installer; the sidecar is fetched **only when email is used**, + and **only the one platform binary** is downloaded — nothing else. +3. **Crash isolation.** A fault in email tooling can't take down the chat backend. + +Dev mode (uvicorn-from-source) and user mode (frozen binary) therefore serve two +distinct purposes — **fast iteration** and **product validation** — over one shared +REST contract. + +## Ground truth: how the UI wires email today (verified) + +- The frontend has **no email REST client**. Email triage is surfaced via the + **chat pipeline**: the chat agent calls a tool (`pre_scan_inbox`), the result is + emitted on the chat SSE stream (`sse_handler.py:472`), and the renderer draws + `EmailPreScanCard` in `MessageBubble` (`MessageBubble.tsx:727`). +- The agent runs **in-process** in the Python backend via the session agent factory + (`_chat_helpers.py:1300,1776`, passing `mail_provider`); `agent_type=email` + selects it. +- `server.py:599-601` mounts `gaia_agent_email`'s `/v1/email/*` router in-process — + a separate external/programmatic surface (#1768), not what the UI renders from. +- The agent reads the mailbox itself via connectors, using the Google grant at + `~/.gaia/connectors/grants.json` (`grants.py:53`); OAuth secrets resolve via the + OS keyring, not the JSON ledger. + +## ⚠️ Precondition: the REST contract is not yet sufficient for the UI + +The sidecar's contract **today** (verified) is: + +| UI capability | REST route today | Status | +|---|---|---| +| triage a pasted email/thread | `POST /v1/email/triage` | ✅ exists | +| draft / send | `POST /v1/email/draft` · `/send` | ✅ exists | +| health / version / spec / playground | `GET /health` `/version` `/spec` `/playground` | ✅ exists | +| connector configure/complete/list/remove | `/v1/email/connectors/*` | ✅ exists | +| **inbox pre-scan** (`email_pre_scan` card) | — | ❌ in flight (pre-scan REST) | +| **inbox search** | — | ❌ #1781 | +| **archive / quarantine** | — | ❌ #1779 | +| **calendar** | — | ❌ #1780 | + +Critically, `/triage` takes the email **in the request body** — it does not scan the +mailbox. The UI's headline feature, the `email_pre_scan` card, is produced **only** +by the agent-loop tool that scans the inbox (`agent.py:663-717`, `read_tools.py:572`). +So the **sidecar cannot serve the UI's inbox features until those become REST +routes** — work that is in flight (#1779/#1780/#1781 + inbox pre-scan REST). **This +design is sequenced behind them.** Each new route is a contract change governed by +the npm version guard (`lifecycle.ts:checkVersion`). + +## Key seam — one REST contract, two interchangeable backends + +The frozen binary is PyInstaller wrapping `packaging/server.py`'s `build_app()`, so +once the routes above exist, the **binary and the raw Python source serve an +identical contract** at `http://127.0.0.1:/v1/email/*`. The agent owns +its mailbox connection and reads the shared `~/.gaia/connectors/grants.json`, so +**personal Gmail works identically in both modes** — the mode only swaps which +*process* answers the calls. + +## Architecture + +``` +Agent UI renderer (UNCHANGED — renders email_pre_scan cards from chat SSE) + │ chat SSE + ▼ +Python UI backend (port 4200 — owns chat loop, SSE, connector OAuth, grant writes) + │ agent_type=email → Email proxy agent + ▼ +Email proxy agent + tools (chat agent tool layer — NEW; replaces in-process agent) + │ POST http://127.0.0.1:/v1/email/* + ▼ +EmailSidecarManager (Python, in the UI backend — NEW; spawn/health/shutdown/port) + │ reads GAIA_EMAIL_AGENT_MODE + ├─ user mode → frozen binary (lazy-fetched on first email use; Python verified download, NO Node) + └─ dev mode → uvicorn --reload (local Python source) + │ + ▼ both serve the identical contract + sidecar ──reads (no writes)──> ~/.gaia/connectors/grants.json ──> user's Gmail +``` + +The renderer and SSE card pipeline are untouched: the proxy agent's tools return the +same envelopes (e.g. `pre_scan_inbox`), so `sse_handler.py` and `EmailPreScanCard` +keep working once the pre-scan REST route exists. + +**Both modes use the same HTTP path** — only the served process differs. Production +is now out-of-process too, so dev and prod share the same isolation topology (no +in-process/out-of-process divergence). + +### Components + +| Unit | Home | Responsibility | +|------|------|----------------| +| `EmailSidecarManager` | `src/gaia/ui/` (Python backend) | mode select; lazy-spawn binary/uvicorn on first email use; health-poll; tree-kill on shutdown; own an ephemeral per-instance port | +| Email proxy agent + tools | chat agent tool layer (`agent_type=email`) | forward triage/draft/send/pre-scan/etc. to the sidecar; return the existing envelopes unchanged | +| Binary fetch | **Python**, in the UI backend — **no Node** | download the current platform's binary from R2/`ASSETS_BASE_URL`, **verify SHA-256 against `binaries.lock.json`** (the security boundary), cache in `~/.gaia`. The npm `fetch.ts` remains the *external Node-integrator* channel — a separate consumer, not the UI's path | +| Mode config | `GAIA_EMAIL_AGENT_MODE` env (`user` default / `dev`) | select backend | + +## Resolved design decisions + +1. **Proxy *agent*, not loose tools.** Reuse the existing `agent_type=email` session + machinery: replace the in-process `EmailTriageAgent` constructed in + `_chat_helpers.py` with a thin **proxy agent** whose tools call the sidecar and + return the same envelopes. This preserves session construction, `mail_provider` + plumbing, and the SSE card path with the smallest diff. +2. **Lightweight, lazy, scoped download.** The sidecar is fetched **on first email + use** (not at app startup), **only the current platform's binary**, into a + `~/.gaia/agents/email/` cache (cache-hit skips re-download; offline thereafter). + The core install bundles **no** email sidecar. This establishes a reusable + *lazy-per-agent-sidecar* pattern, but scope here is **email only** (YAGNI). +3. **User mode pins the published artifact.** Fetch verifies against the shipped + `binaries.lock.json`, so the UI exercises the exact Hub-published binary + (dogfooding). A failed/integrity-mismatched fetch fails loudly — never falls back + to dev mode or to in-process. +4. **The sidecar is the single `/v1/email` surface; remove the in-process mount.** + The in-process `server.py:599-601` mount (#1768) becomes redundant once the UI is + sidecar-only and contradicts the lightweight-core goal (it imports the email + wheel into core). Remove it from core; the sidecar itself serves the external + `/v1/email` surface. Coordinate the removal with the #1768 owner. + +### Dev mode enabler (one small, owned change) +Hot-reload needs uvicorn's import-string form, which needs a **module-level app** +that does not exist today (it lives inside `build_app()`/`main()`): + +```python +# hub/agents/python/email/packaging/server.py — add at module scope: +app = build_app() # build_app also mounts /v1/email/connectors/* — fine for dev +``` +```bash +python -m uvicorn packaging.server:app --reload --host 127.0.0.1 --port +``` +Edit any `.py` / prompt / tool → uvicorn reloads in ~1s → next chat action hits new +code. Dev mode assumes the source env is set up; if uvicorn/the package can't be +imported, fail loudly with `uv pip install -e hub/agents/python/email` — no silent +auto-install, no fallback. Dev mode is source-checkout only. + +## Lifecycle, port & ownership (review fixes) +- **No Node.js in the Agent UI.** The sidecar is a self-contained HTTP binary; the + Python backend fetches (verified download), spawns, health-checks, proxies to, and + tree-kills it directly — no Node runtime, no npm package in the UI's path. This is + the precise answer to #1767's objection (below). The npm `lifecycle.ts`/`fetch.ts` + stay as the *external Node-integrator* channel; they are reference implementations + for the Python port of the SHA-verify + tree-kill logic, which has unit tests. +- **Port:** a fixed `8131` breaks two concurrent `gaia chat --ui` instances. The + manager binds an **ephemeral port per backend instance** and passes it to the + proxy agent. **Never 4001.** +- **One backend for the UI:** the UI talks only to the sidecar (decision 4). + +## Auth & grants (review fixes) +- **Single writer:** all connector OAuth flows stay on the **Python backend**; the + sidecar **reads** the grant + resolves keyring secrets but does **not** run OAuth + writes (its `/connectors/{provider}/complete` write route is **not** exposed to + the UI). This avoids cross-process writes to `grants.json`, whose concurrency + guard is per-process only (`grants.py:56-61`). +- **Bundling verified:** `freeze.py:119` collects `gaia.connectors`, so the binary + can read the grant. Cold-start test must assert a **real keyring token resolve** + from the frozen binary, not merely that `gaia.connectors` imports. + +## Error handling (fail loudly) +- Binary missing/integrity-fail in user mode → loud error with the fetch remedy; + never fall back to dev/in-process. +- `/health` timeout → loud error with the sidecar's last stderr. +- Lemonade unreachable → sidecar returns HTTP 502; surface verbatim. +- Dev mode without source/uvicorn → loud error naming the path + `uv pip install -e`. +- Sidecar port in use → fail with the conflicting-process hint. + +## Testing +- **Unit:** mode selection + spawn-arg *shape* (dev → `--reload` import-string; + user → cached binary path); health-poll; tree-kill on shutdown; ephemeral-port + wiring to the proxy agent; lazy-fetch only on first email use. +- **Integration:** with a running sidecar (both modes) `GET /health` then a real + inbox pre-scan round-trip through the proxy agent — proves the *call is valid*. +- **Dogfood/product check:** user mode launches the binary resolved from + `binaries.lock.json` and a smoke triage succeeds — proves the *shipped* artifact + works end-to-end in the UI. +- **Cold-start:** frozen binary resolves a real keyring token from an empty-state + machine (not a warm box). +- **Card pipeline:** the proxy agent's envelope still triggers `email_pre_scan` SSE + injection + `EmailPreScanCard` render. +- **Prompt/LLM changes:** `gaia eval agent` (email category) vs. baseline before + "done." + +## Sequencing +1. **Blocked on** the REST capability build-out (inbox pre-scan + #1779/#1780/#1781) + — the sidecar can't serve the UI's inbox features until those routes exist. +2. Land the dev-mode enabler (`app = build_app()` at module scope). +3. Build `EmailSidecarManager` + the email proxy agent; wire user/dev modes + lazy + on-demand fetch. +4. Switch `agent_type=email` sessions from the in-process agent to the proxy agent + (`_chat_helpers.py`); remove the in-process #1768 mount (with #1768 owner). +5. Tests (incl. the dogfood/product check) + a short dev-mode doc. + +## Relationship to epic #1767 (must reconcile before build) +#1767 (epic) and its capstone PR #1785 were **closed unmerged on 2026-06-26**. +@itomek's closing rationale was explicit: *"Given the nature of Agent UI having a +Python backend, mixing in an npm package that requires Node.js would make it too +dirty. This will get replaced by a future issue."* + +This plan **is that future issue's design**, and it removes the exact thing itomek +objected to: **there is no npm package and no Node.js in the Agent UI's path.** The +sidecar is a language-agnostic, self-contained HTTP binary; the Python backend +fetches it (verified download), spawns it as a subprocess, and proxies to it over +HTTP. The npm package stays purely as the *external Node-integrator* distribution — +never imported or executed by the UI. The sidecar is justified over an in-process +mount by product validation (the UI runs the exact Hub-published artifact), +lightweight core (no email deps; lazy per-agent download), and crash isolation. +**Action: get @itomek + maintainer sign-off on this Node-free sidecar direction +before implementation.** + +## Remaining decision for sign-off +- Confirm removal of the #1768 in-process `/v1/email` external mount once the UI is + sidecar-only (decision 4), vs. keeping it as a distinct external surface. This is + the only open architectural choice; everything else is resolved above. diff --git a/hub/agents/npm/agent-email/CHANGELOG.md b/hub/agents/npm/agent-email/CHANGELOG.md index ba98889d9..1bddd2fe3 100644 --- a/hub/agents/npm/agent-email/CHANGELOG.md +++ b/hub/agents/npm/agent-email/CHANGELOG.md @@ -5,6 +5,19 @@ follows [SemVer](https://semver.org/): the **MAJOR** of the on-the-wire `SCHEMA_VERSION` is what `checkVersion` enforces at startup, so a contract MAJOR bump is always at least a package MINOR bump with a migration note. +## Unreleased + +### Added + +- **Follow-up tracking (#1606).** The agent gains a read-only `check_followups` + tool that scans the Sent folder of every connected mailbox and flags threads + whose latest message is still the user's own outbound mail past a + configurable window (default 3 days) — surfacing message id, recipient, + subject, and age, most overdue first. Detection only: it never sends a + nudge (any send stays confirmation-gated). Agent-loop surface (chat / + Agent UI / `gaia email`); the sidecar REST/MCP surface is unchanged and + `SCHEMA_VERSION` stays `2.0`. + ## 0.2.2 Release-reliability fix. The 0.2.1 tag published the per-platform binaries to the diff --git a/hub/agents/python/email/gaia_agent_email/__init__.py b/hub/agents/python/email/gaia_agent_email/__init__.py index caa4e8f41..827d8c76e 100644 --- a/hub/agents/python/email/gaia_agent_email/__init__.py +++ b/hub/agents/python/email/gaia_agent_email/__init__.py @@ -107,6 +107,7 @@ def email_factory(**kwargs): conversation_starters=[ "Run a pre-scan", "Triage my inbox", + "Which of my sent emails are still waiting on a reply?", "Summarize my unread emails", "Draft a reply to my most recent message", "Show me today's calendar", diff --git a/hub/agents/python/email/gaia_agent_email/agent.py b/hub/agents/python/email/gaia_agent_email/agent.py index bbfe1ab5f..8caf666a4 100644 --- a/hub/agents/python/email/gaia_agent_email/agent.py +++ b/hub/agents/python/email/gaia_agent_email/agent.py @@ -47,6 +47,7 @@ class never passes ``use_claude=True`` / ``use_chatgpt=True`` to ) from gaia_agent_email.tools.calendar_tools import CalendarToolsMixin from gaia_agent_email.tools.delete_tools import DeleteToolsMixin +from gaia_agent_email.tools.followup_tools import FollowupToolsMixin from gaia_agent_email.tools.organize_tools import OrganizeToolsMixin from gaia_agent_email.tools.phishing_tools import PhishingToolsMixin from gaia_agent_email.tools.preference_tools import ( @@ -118,7 +119,10 @@ def __getattr__(self, name: str): ACTIONS: - Read tools (list_inbox, get_message, get_thread, search_messages, - list_labels, triage_inbox, pre_scan_inbox) — never require confirmation. + list_labels, triage_inbox, pre_scan_inbox, check_followups) — never + require confirmation. check_followups flags sent mail still awaiting a + reply; it only reports — never draft or send a follow-up nudge unless the + user explicitly asks, and any send remains confirmation-gated. - Organize tools (archive_message, mark_read, mark_unread, add_star, remove_star, label_message, move_to_label) — reversible via the undo log; do not require per-action confirmation, but bulk operations @@ -167,6 +171,7 @@ class EmailTriageAgent( MemoryMixin, DatabaseMixin, ReadToolsMixin, + FollowupToolsMixin, OrganizeToolsMixin, ReplyToolsMixin, SummarizeToolsMixin, @@ -201,6 +206,7 @@ class EmailTriageAgent( CONVERSATION_STARTERS: ClassVar[List[str]] = [ "Run a pre-scan", "Triage my inbox", + "Which of my sent emails are still waiting on a reply?", "Summarize my unread emails", "Draft a reply to my most recent message", "Show me today's calendar", @@ -349,6 +355,7 @@ def _register_tools(self) -> None: _TOOL_REGISTRY.clear() self._reset_organize_counter() self._register_read_tools() + self._register_followup_tools() self._register_organize_tools() self._register_reply_tools() self._register_summarize_tools() diff --git a/hub/agents/python/email/gaia_agent_email/config.py b/hub/agents/python/email/gaia_agent_email/config.py index a7122e5e7..f6c208bf6 100644 --- a/hub/agents/python/email/gaia_agent_email/config.py +++ b/hub/agents/python/email/gaia_agent_email/config.py @@ -79,6 +79,9 @@ class EmailAgentConfig: - ``undo_window_seconds``: how long after a soft-delete the user has to ``restore_message``. After this window ``restore_message`` raises with a "use Trash to recover" message. + - ``followup_window_days``: how many days a sent message may sit + without an inbound reply before ``check_followups`` flags it + (#1606). Must be a positive integer. - ``db_path``: where ``email_actions`` / ``email_drafts`` live. Defaults to ``~/.gaia/email/state.db``. Eval harness passes a ``tmp_path``-derived path so concurrent live + eval runs don't @@ -114,6 +117,7 @@ class EmailAgentConfig: show_stats: bool = False output_dir: Optional[str] = None undo_window_seconds: int = 30 + followup_window_days: int = 3 db_path: Optional[str] = None memory_db_path: Optional[str] = None mail_provider: Optional[str] = None @@ -140,6 +144,13 @@ def validate(self) -> None: "cloud LLM endpoints are permitted (AC3). To use a " "non-default Lemonade port, set LEMONADE_BASE_URL." ) + if not isinstance(self.followup_window_days, int) or ( + self.followup_window_days <= 0 + ): + raise ConfigurationError( + f"EmailAgentConfig.followup_window_days must be a positive " + f"integer number of days, got {self.followup_window_days!r}." + ) def resolved_db_path(self) -> str: """Return the SQLite path with ``$HOME`` expanded. diff --git a/hub/agents/python/email/gaia_agent_email/outlook_backend.py b/hub/agents/python/email/gaia_agent_email/outlook_backend.py index e11caecc6..205b5357f 100644 --- a/hub/agents/python/email/gaia_agent_email/outlook_backend.py +++ b/hub/agents/python/email/gaia_agent_email/outlook_backend.py @@ -83,6 +83,7 @@ _LABEL_INBOX = "INBOX" _LABEL_UNREAD = "UNREAD" _LABEL_STARRED = "STARRED" +_LABEL_SENT = "SENT" # ``$select`` for ``get_message`` — pull exactly the fields the Gmail-shape # translation needs (Graph returns a large default projection otherwise). @@ -368,6 +369,11 @@ def list_messages( params["$filter"] = "isRead eq false" params["$orderby"] = "receivedDateTime desc" path = "/me/mailFolders/inbox/messages" + elif _LABEL_SENT in labels: + # Sent-folder scan (follow-up tracking, #1606) — falling + # through to the inbox would silently scan the wrong mail. + params["$orderby"] = "receivedDateTime desc" + path = "/me/mailFolders/sentitems/messages" else: # Default (INBOX or unspecified) -> inbox folder, newest first. params["$orderby"] = "receivedDateTime desc" diff --git a/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py b/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py new file mode 100644 index 000000000..1f0b1c486 --- /dev/null +++ b/hub/agents/python/email/gaia_agent_email/tools/followup_tools.py @@ -0,0 +1,320 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Follow-up tracking tools mixin for ``EmailTriageAgent`` (#1606). + +Tools: ``check_followups`` — scan the Sent folder and flag threads where the +latest message is still the user's own outbound mail (no inbound reply) past +a configurable window. The dropped thread is the inbox's biggest silent +failure mode; this surfaces it. + +READ-ONLY BY DESIGN: this module detects and reports only. It never drafts, +never transmits mail, and never mutates a message — distinct from #555 +(autonomous follow-up scheduling). ``tests/test_email_followups.py`` locks +this in both dynamically (backend call log) and statically (module source). + +Detection semantics (per thread reached from a Sent-folder scan): + +- The thread is *answered* when its latest message is inbound (``From`` is + not the user). An inbound message that predates the user's last send does + NOT count — only a reply AFTER the send suppresses the flag. +- The thread is *awaiting reply* when its latest message is outbound and its + age is at least ``window_days``. +- Self-addressed-only mail (note-to-self) is skipped: no inbound reply can + ever arrive, so flagging it would be permanent noise. +""" + +from __future__ import annotations + +import time +from datetime import datetime, timezone +from email.utils import getaddresses +from typing import Any, Dict, List, Optional + +from gaia_agent_email.tools.read_tools import ( + _envelope_err, + _envelope_ok, + extract_sender_email, +) +from gaia_agent_email.verbose import log_tool_call + +from gaia.agents.base.tools import tool +from gaia.connectors.errors import ConnectorsError +from gaia.connectors.formatting import format_connector_error +from gaia.logger import get_logger + +log = get_logger(__name__) + +# Days without an inbound reply before a sent message is flagged, when the +# caller passes no explicit window (mirrors ``EmailAgentConfig.followup_window_days``). +DEFAULT_FOLLOWUP_WINDOW_DAYS = 3 + +# How many Sent-folder messages one scan enumerates. Each distinct thread +# costs a ``get_thread`` round-trip, so the budget bounds scan latency. +DEFAULT_MAX_SENT_SCAN = 50 +MAX_SENT_SCAN_CAP = 200 + +_DAY_MS = 24 * 60 * 60 * 1000 + + +def _timestamp_ms(msg: Dict[str, Any]) -> int: + """Return a message's ``internalDate`` as epoch milliseconds. + + Gmail returns epoch-millis strings; the Outlook translation carries the + Graph ISO-8601 ``receivedDateTime``. Anything else raises — an age + computed from a guessed timestamp would silently mis-flag threads. + """ + raw = msg.get("internalDate") + if raw in (None, ""): + raise ValueError( + f"message {msg.get('id')!r} has no internalDate; cannot compute " + "its follow-up age" + ) + text = str(raw) + try: + return int(text) + except ValueError: + pass + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError( + f"message {msg.get('id')!r} internalDate {raw!r} is neither epoch " + "milliseconds nor ISO-8601" + ) from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return int(parsed.timestamp() * 1000) + + +def _header_map(msg: Dict[str, Any]) -> Dict[str, str]: + return { + (h.get("name") or "").lower(): h.get("value", "") + for h in (msg.get("payload") or {}).get("headers", []) + } + + +def _recipient_addresses(to_header: str) -> List[str]: + """Bare, lowercased addresses from a ``To`` header, order-preserving. + + ``getaddresses`` (not a naive comma split) so a quoted display name + containing a comma ('"Doe, John" ') doesn't shed a garbage + recipient. + """ + out: List[str] = [] + for _name, addr in getaddresses([to_header or ""]): + addr = addr.strip().lower() + if addr and addr not in out: + out.append(addr) + return out + + +def check_followups_impl( + gmail, + *, + window_days: int, + max_sent: int = DEFAULT_MAX_SENT_SCAN, + now_ms: Optional[int] = None, + debug: bool = False, +) -> Dict[str, Any]: + """Scan the Sent folder for threads still awaiting an inbound reply. + + Read-only: calls only ``get_user_email`` / ``list_messages`` / + ``get_thread`` on the backend. + + Args: + gmail: Any ``GmailBackend`` (live Gmail, live Outlook, or a fake). + window_days: Flag a thread once the user's latest outbound message is + at least this many days old. Must be positive. + max_sent: Sent-folder enumeration budget (each distinct thread costs + one ``get_thread`` call). + now_ms: Injectable "now" in epoch milliseconds (tests); defaults to + the current time. + debug: Verbose tool-call logging. + + Returns:: + + { + "awaiting_reply": [ + {"message_id", "thread_id", "recipient", "recipients", + "subject", "sent_at", "age_days"}, + ... # most overdue first + ], + "window_days": int, + "sent_scanned": int, + } + """ + if not isinstance(window_days, int) or window_days <= 0: + raise ValueError( + f"check_followups window_days must be a positive number of days, " + f"got {window_days!r}" + ) + with log_tool_call( + "check_followups", + {"window_days": window_days, "max_sent": max_sent}, + debug=debug, + ) as st: + if now_ms is None: + now_ms = int(time.time() * 1000) + user_email = (gmail.get_user_email() or "").strip().lower() + if not user_email: + raise ValueError( + "the mail backend returned no user email address; cannot " + "distinguish outbound mail from inbound replies" + ) + + listing = gmail.list_messages(label_ids=["SENT"], max_results=max_sent) + stubs = listing.get("messages", []) + thread_ids: List[str] = [] + for stub in stubs: + tid = stub.get("threadId") + if not tid: + raise ValueError( + f"Sent-folder listing returned message {stub.get('id')!r} " + "without a threadId; cannot group it into a conversation" + ) + if tid not in thread_ids: + thread_ids.append(tid) + + flagged: List[tuple[int, Dict[str, Any]]] = [] + for tid in thread_ids: + thread = gmail.get_thread(tid) + messages = thread.get("messages", []) or [] + if not messages: + raise ValueError( + f"thread {tid!r} from the Sent-folder listing came back " + "empty; the mail backend is inconsistent" + ) + ordered = sorted(messages, key=_timestamp_ms) + latest = ordered[-1] + latest_from = extract_sender_email(_header_map(latest).get("from", "")) + if latest_from != user_email: + # Latest message is inbound — the thread has been answered. + continue + sent_ms = _timestamp_ms(latest) + age_ms = now_ms - sent_ms + if age_ms < window_days * _DAY_MS: + continue + headers = _header_map(latest) + recipients = [ + a for a in _recipient_addresses(headers.get("to", "")) if a != user_email + ] + if not recipients: + # Note-to-self (or no recipient) — an inbound reply can never + # arrive, so a flag would be permanent noise. + continue + flagged.append( + ( + sent_ms, + { + "message_id": latest.get("id"), + "thread_id": tid, + "recipient": recipients[0], + "recipients": recipients, + "subject": headers.get("subject", ""), + "sent_at": headers.get("date", ""), + "age_days": int(age_ms // _DAY_MS), + }, + ) + ) + + flagged.sort(key=lambda pair: pair[0]) # oldest send = most overdue first + awaiting = [item for _, item in flagged] + st["result_summary"] = { + "awaiting": len(awaiting), + "threads_checked": len(thread_ids), + } + return { + "awaiting_reply": awaiting, + "window_days": window_days, + "sent_scanned": len(stubs), + } + + +# --------------------------------------------------------------------------- +# Mixin +# --------------------------------------------------------------------------- + + +class FollowupToolsMixin: + """Mixin that registers the follow-up tracking tool. + + State-free at construction time (Critical CA-1) — relies on the agent + having set ``self._backends``, ``self.config``, and the + ``_remember_message_mailbox`` provenance helper before + ``self._register_followup_tools()`` runs. + """ + + def _register_followup_tools(self) -> None: + debug_flag = bool(getattr(self.config, "debug", False)) + agent = self # captured for live access to backends + provenance map + + @tool + def check_followups(window_days: int = 0, max_sent: int = 50) -> str: + """Flag sent mail still awaiting a reply (follow-up tracking). + + Scans the Sent folder of every connected mailbox and returns the + threads whose latest message is still the user's own outbound + mail — i.e. nobody has replied — older than the window. Use this + when the user asks what they're waiting on, which emails went + unanswered, or who they should chase. + + READ-ONLY: this tool only detects and reports. It never composes + or transmits a follow-up nudge — if the user wants to chase a + thread, draft a reply as a separate, confirmed action. + + Args: + window_days: Minimum age (days) of the unanswered message + before it is flagged. 0 (default) uses the configured + default (3 days). + max_sent: How many Sent messages to scan per mailbox + (default 50, max 200). + + Returns: + JSON envelope with ``{"awaiting_reply": [...]}`` — per item: + message_id, thread_id, recipient, recipients, subject, + sent_at, age_days, mailbox — sorted most overdue first, plus + ``window_days`` and ``sent_scanned``. + """ + try: + effective_window = int(window_days or 0) + if effective_window == 0: + effective_window = int( + getattr( + agent.config, + "followup_window_days", + DEFAULT_FOLLOWUP_WINDOW_DAYS, + ) + ) + max_sent_budget = max( + 1, min(int(max_sent or DEFAULT_MAX_SENT_SCAN), MAX_SENT_SCAN_CAP) + ) + merged: List[Dict[str, Any]] = [] + sent_scanned = 0 + for provider, backend in agent._backends.items(): + result = check_followups_impl( + backend, + window_days=effective_window, + max_sent=max_sent_budget, + debug=debug_flag, + ) + for item in result["awaiting_reply"]: + item["mailbox"] = provider + agent._remember_message_mailbox( + item.get("message_id"), provider + ) + agent._remember_message_mailbox(item.get("thread_id"), provider) + merged.append(item) + sent_scanned += result["sent_scanned"] + merged.sort(key=lambda item: item["age_days"], reverse=True) + return _envelope_ok( + { + "awaiting_reply": merged, + "window_days": effective_window, + "sent_scanned": sent_scanned, + } + ) + except ConnectorsError as exc: + return _envelope_err(format_connector_error(exc)) + except Exception as exc: + log.exception("email tool error: %s", type(exc).__name__) + return _envelope_err(f"{type(exc).__name__}: {exc}") diff --git a/hub/agents/python/email/specification.html b/hub/agents/python/email/specification.html index dfa2009f5..e92c2f832 100644 --- a/hub/agents/python/email/specification.html +++ b/hub/agents/python/email/specification.html @@ -354,7 +354,7 @@

5 + schema 2.0

12Calendar — createWiredCreate events from email contextTier 2Manual 13Search past emailPlannedNatural-language / keyword search across the mailboxTier 0Manual 14Task extraction → listPlannedPersist action items into a trackable task listTier 0New mail - 15Follow-up trackingPlannedFlag sent mail awaiting a reply; nudge when overdueTier 0Scheduled + 15Follow-up trackingWiredFlag sent mail awaiting a reply past a configurable window (detection only)Tier 0Manual 16Proactive daily briefingPlannedScheduled morning inbox + calendar + tasks briefTier 0Scheduled 17Scheduled send & snoozePlannedDefer a send; snooze a message out of the inboxTier 2Scheduled @@ -775,12 +775,12 @@

Each capability, in full

Tracking
#1605 · Milestone 40 (P3 stretch)
- +
-
15

Follow-up tracking

PlannedTier 0 · read-only
+
15

Follow-up tracking

WiredTier 0 · read-only
Use-case
Watch the messages the user has sent and flag the ones that have gone unanswered past a configurable window, so a dropped thread resurfaces instead of disappearing. The agent scans the Sent folder, matches each outbound message against later inbound replies on the same thread, and surfaces the still-open ones (recipient, subject, age) for a nudge. It only detects and surfaces; it never auto-sends a follow-up (that is a separate, confirmation-gated action).
Why it matters
The unanswered email you forgot you sent is the inbox's biggest silent failure mode — and a top-billed feature in every comparable assistant.
-
Backed by
Sent-folder scan + reply detection. Distinct from autonomy #555 (which proactively sends follow-ups). Highest-impact gap.
+
Backed by
check_followups — Sent-folder scan + same-thread reply detection across every connected mailbox (window default 3 days, configurable). Distinct from autonomy #555 (which proactively sends follow-ups); the scheduled sweep rides the autonomy engine.
Guardrails
Tier 0 — read-only detection; never auto-sends.
Tracking
#1606 · Milestone 40 (P3 stretch)
@@ -974,7 +974,7 @@

Capabilities, chained

  1. Triage marks a message NEEDS_RESPONSE and extracts its action items T0
  2. Action items are persisted to the task list, de-duplicated and linked to the source message T0 (14, planned)
  3. -
  4. Days later, the reply you sent gets no response → follow-up tracking surfaces a nudge T0 (15, planned)
  5. +
  6. Days later, the reply you sent gets no response → follow-up tracking surfaces a nudge T0 (15, wired)
Dataset: an inbox + sent items over time. Expected: tasks created and de-duplicated, the unanswered sent message flagged after the window, and nothing auto-sent.
diff --git a/hub/agents/python/email/tests/test_email_followups.py b/hub/agents/python/email/tests/test_email_followups.py new file mode 100644 index 000000000..eba8fccc5 --- /dev/null +++ b/hub/agents/python/email/tests/test_email_followups.py @@ -0,0 +1,375 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +""" +Follow-up tracking tests for EmailTriageAgent (#1606). + +Acceptance criteria covered: +- AC1: the Sent folder is scanned for messages with no inbound reply on the + same thread after a configurable window. +- AC2: the awaiting-reply set surfaces (message_id, recipient, subject, age) + via a read-only tool. +- AC3: read-only — the detector never touches any send path. + +Test acceptance criteria covered: +- A sent message WITH a later inbound reply is NOT flagged; one WITHOUT is + flagged after the window. +- The detector touches no send path (no ``send_*`` side effects) — asserted + both dynamically (FakeGmailTransport call log) and statically (module + source references no send/draft/mutate backend calls). + +All tests are hermetic: FakeGmailBackend only, no Lemonade, no network. +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from types import SimpleNamespace +from typing import Any, Dict, List, Optional + +import pytest + +# --------------------------------------------------------------------------- +# Path / import bootstrap +# --------------------------------------------------------------------------- + +# parents[0] = tests/, [1] = email/, [2] = python/, [3] = agents/, +# [4] = hub/, [5] = repo-root +_REPO_ROOT = Path(__file__).resolve().parents[5] +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) + +pytest.importorskip("gaia_agent_email") + +import gaia_agent_email.tools.followup_tools as followup_tools # noqa: E402 +from gaia_agent_email.tools.followup_tools import ( # noqa: E402 + FollowupToolsMixin, + check_followups_impl, +) + +from gaia.agents.base.tools import _TOOL_REGISTRY # noqa: E402 + +from tests.fixtures.email.fake_gmail import FakeGmailBackend # noqa: E402 + +# --------------------------------------------------------------------------- +# Fixture helpers +# --------------------------------------------------------------------------- + +USER_EMAIL = "user@example.com" + +DAY_MS = 24 * 60 * 60 * 1000 +# Fixed "now" so ages are deterministic. +NOW_MS = 1_750_000_000_000 + + +def _msg( + msg_id: str, + *, + thread_id: str, + sender: str, + to: str, + subject: str, + age_days: float, + label_ids: List[str], + internal_date: Optional[str] = None, +) -> Dict[str, Any]: + """Build a minimal Gmail API v1 message dict for the fake backend.""" + return { + "id": msg_id, + "threadId": thread_id, + "labelIds": list(label_ids), + "snippet": f"snippet of {msg_id}", + "internalDate": internal_date or str(int(NOW_MS - age_days * DAY_MS)), + "payload": { + "mimeType": "text/plain", + "filename": "", + "headers": [ + {"name": "Subject", "value": subject}, + {"name": "From", "value": sender}, + {"name": "To", "value": to}, + {"name": "Date", "value": f"{age_days} days ago"}, + ], + "body": {"size": 4, "data": "Ym9keQ"}, + }, + "sizeEstimate": 4, + } + + +def _sent( + msg_id: str, + *, + thread_id: str, + to: str = "alice@example.com", + subject: str = "Question about Q3", + age_days: float = 5, + internal_date: Optional[str] = None, +) -> Dict[str, Any]: + return _msg( + msg_id, + thread_id=thread_id, + sender=f"Me <{USER_EMAIL}>", + to=to, + subject=subject, + age_days=age_days, + label_ids=["SENT"], + internal_date=internal_date, + ) + + +def _inbound( + msg_id: str, + *, + thread_id: str, + sender: str = "Alice ", + subject: str = "Re: Question about Q3", + age_days: float = 4, + internal_date: Optional[str] = None, +) -> Dict[str, Any]: + return _msg( + msg_id, + thread_id=thread_id, + sender=sender, + to=USER_EMAIL, + subject=subject, + age_days=age_days, + label_ids=["INBOX"], + internal_date=internal_date, + ) + + +def _backend(*messages: Dict[str, Any]) -> FakeGmailBackend: + gmail = FakeGmailBackend(user_email=USER_EMAIL) + for m in messages: + gmail.add_message(m) + return gmail + + +def _run(gmail: FakeGmailBackend, *, window_days: int = 3) -> Dict[str, Any]: + return check_followups_impl(gmail, window_days=window_days, now_ms=NOW_MS) + + +# --------------------------------------------------------------------------- +# Core detection semantics (issue #1606 test acceptance criteria) +# --------------------------------------------------------------------------- + + +class TestReplyDetection: + def test_sent_with_later_inbound_reply_not_flagged(self): + gmail = _backend( + _sent("s1", thread_id="t1", age_days=5), + _inbound("r1", thread_id="t1", age_days=4), + ) + out = _run(gmail, window_days=3) + assert out["awaiting_reply"] == [] + + def test_sent_without_reply_flagged_after_window(self): + gmail = _backend(_sent("s1", thread_id="t1", age_days=5)) + out = _run(gmail, window_days=3) + assert len(out["awaiting_reply"]) == 1 + item = out["awaiting_reply"][0] + assert item["message_id"] == "s1" + assert item["thread_id"] == "t1" + assert item["recipient"] == "alice@example.com" + assert item["subject"] == "Question about Q3" + assert item["age_days"] == 5 + + def test_sent_within_window_not_flagged(self): + gmail = _backend(_sent("s1", thread_id="t1", age_days=1)) + out = _run(gmail, window_days=3) + assert out["awaiting_reply"] == [] + + def test_inbound_before_send_does_not_count_as_reply(self): + # The user replied LAST — the earlier inbound message must not + # suppress the flag (only replies AFTER the send count). + gmail = _backend( + _inbound("r1", thread_id="t1", age_days=7), + _sent("s1", thread_id="t1", age_days=5), + ) + out = _run(gmail, window_days=3) + assert [i["message_id"] for i in out["awaiting_reply"]] == ["s1"] + + def test_multiple_sends_same_thread_flagged_once(self): + gmail = _backend( + _sent("s1", thread_id="t1", age_days=9), + _sent("s2", thread_id="t1", age_days=6), + ) + out = _run(gmail, window_days=3) + # One entry per thread, anchored on the LATEST outbound message. + assert [i["message_id"] for i in out["awaiting_reply"]] == ["s2"] + assert out["awaiting_reply"][0]["age_days"] == 6 + + def test_self_addressed_sent_not_flagged(self): + # A note-to-self can never receive an inbound reply; flagging it + # forever would be pure noise. + gmail = _backend( + _sent("s1", thread_id="t1", to=USER_EMAIL, age_days=30), + ) + out = _run(gmail, window_days=3) + assert out["awaiting_reply"] == [] + + def test_recipient_display_name_with_comma_parsed_cleanly(self): + # RFC 5322 allows a comma inside a quoted display name; the split + # must not surface '"Doe' as a recipient. + gmail = _backend( + _sent( + "s1", + thread_id="t1", + to='"Doe, John" , carol@y.com', + age_days=5, + ) + ) + out = _run(gmail, window_days=3) + assert out["awaiting_reply"][0]["recipient"] == "john@x.com" + assert out["awaiting_reply"][0]["recipients"] == [ + "john@x.com", + "carol@y.com", + ] + + def test_results_sorted_most_overdue_first(self): + gmail = _backend( + _sent("s_new", thread_id="t1", age_days=4), + _sent("s_old", thread_id="t2", age_days=10), + ) + out = _run(gmail, window_days=3) + assert [i["message_id"] for i in out["awaiting_reply"]] == ["s_old", "s_new"] + + def test_iso8601_internal_date_supported(self): + # The Outlook backend translates Graph messages with an ISO-8601 + # ``internalDate`` (e.g. "2026-06-24T10:00:00Z"), not epoch millis — + # exercised via a minimal Outlook-shaped backend (the Gmail fake + # models millis only). + from datetime import datetime, timezone + + sent_dt = datetime.fromtimestamp((NOW_MS - 5 * DAY_MS) / 1000, tz=timezone.utc) + iso = sent_dt.strftime("%Y-%m-%dT%H:%M:%SZ") + message = _sent("s1", thread_id="t1", age_days=5, internal_date=iso) + + class _IsoBackend: + def get_user_email(self): + return USER_EMAIL + + def list_messages(self, **_kwargs): + return {"messages": [{"id": "s1", "threadId": "t1"}]} + + def get_thread(self, thread_id): + return {"id": thread_id, "messages": [message]} + + out = check_followups_impl(_IsoBackend(), window_days=3, now_ms=NOW_MS) + assert [i["message_id"] for i in out["awaiting_reply"]] == ["s1"] + assert out["awaiting_reply"][0]["age_days"] == 5 + + def test_invalid_window_fails_loudly(self): + gmail = _backend(_sent("s1", thread_id="t1")) + with pytest.raises(ValueError, match="window_days"): + check_followups_impl(gmail, window_days=0, now_ms=NOW_MS) + with pytest.raises(ValueError, match="window_days"): + check_followups_impl(gmail, window_days=-2, now_ms=NOW_MS) + + +# --------------------------------------------------------------------------- +# Read-only guarantee (issue #1606 test acceptance criterion 2) +# --------------------------------------------------------------------------- + + +# Backend methods a read-only detector is allowed to call. Everything else +# (send_*, create_draft, trash, label mutations, ...) is a violation. +_ALLOWED_BACKEND_CALLS = {"get_user_email", "list_messages", "get_thread"} + + +class TestReadOnly: + def test_detector_touches_no_send_path(self): + gmail = _backend( + _sent("s1", thread_id="t1", age_days=9), + _sent("s2", thread_id="t2", age_days=6), + _inbound("r2", thread_id="t2", age_days=5), + ) + _run(gmail, window_days=3) + called = {method for method, _ in gmail.transport.calls} + assert called <= _ALLOWED_BACKEND_CALLS, ( + f"read-only detector called mutating backend methods: " + f"{sorted(called - _ALLOWED_BACKEND_CALLS)}" + ) + + def test_module_references_no_send_path(self): + src = Path(followup_tools.__file__).read_text(encoding="utf-8") + assert not re.search( + r"\bsend_message\b|\bsend_draft\b|\bsend_now\b|\bcreate_draft\b", src + ), "followup_tools must never reference a send/draft backend call" + + +# --------------------------------------------------------------------------- +# Tool registration + envelope (mixin surface) +# --------------------------------------------------------------------------- + + +# The tool closure computes ages against the real clock (no ``now_ms`` seam +# at the tool surface), so ToolSurface fixtures anchor to real time. +def _sent_real_now(msg_id: str, *, thread_id: str, age_days: float) -> Dict[str, Any]: + import time as _time + + real_now_ms = int(_time.time() * 1000) + return _sent( + msg_id, + thread_id=thread_id, + internal_date=str(int(real_now_ms - age_days * DAY_MS)), + ) + + +class _Host(FollowupToolsMixin): + """Minimal stand-in for EmailTriageAgent's tool-hosting surface.""" + + def __init__(self, backend: FakeGmailBackend, *, window_days: int = 3): + self._gmail = backend + self._backends = {"google": backend} + self._message_mailbox: Dict[str, str] = {} + self.config = SimpleNamespace(debug=False, followup_window_days=window_days) + + def _remember_message_mailbox(self, message_id, provider): + if message_id: + self._message_mailbox[message_id] = provider + + +def _registered_tool(host: _Host): + _TOOL_REGISTRY.clear() + host._register_followup_tools() + assert "check_followups" in _TOOL_REGISTRY + return _TOOL_REGISTRY["check_followups"]["function"] + + +class TestToolSurface: + def test_tool_returns_ok_envelope_with_mailbox_tag(self): + gmail = _backend(_sent_real_now("s1", thread_id="t1", age_days=5)) + host = _Host(gmail) + check_followups = _registered_tool(host) + + payload = json.loads(check_followups()) + assert payload["ok"] is True + data = payload["data"] + assert data["window_days"] == 3 + assert len(data["awaiting_reply"]) == 1 + item = data["awaiting_reply"][0] + assert item["mailbox"] == "google" + # Provenance is remembered so downstream tools route correctly. + assert host._message_mailbox["s1"] == "google" + assert host._message_mailbox["t1"] == "google" + + def test_tool_window_arg_overrides_config_default(self): + gmail = _backend(_sent_real_now("s1", thread_id="t1", age_days=5)) + host = _Host(gmail, window_days=3) + check_followups = _registered_tool(host) + + payload = json.loads(check_followups(window_days=7)) + assert payload["ok"] is True + assert payload["data"]["window_days"] == 7 + assert payload["data"]["awaiting_reply"] == [] + + def test_tool_invalid_window_returns_error_envelope(self): + gmail = _backend(_sent_real_now("s1", thread_id="t1", age_days=5)) + host = _Host(gmail) + check_followups = _registered_tool(host) + + payload = json.loads(check_followups(window_days=-1)) + assert payload["ok"] is False + assert "window_days" in payload["error"] diff --git a/tests/unit/agents/email/test_outlook_backend.py b/tests/unit/agents/email/test_outlook_backend.py index 0e4c31169..4628d8cfb 100644 --- a/tests/unit/agents/email/test_outlook_backend.py +++ b/tests/unit/agents/email/test_outlook_backend.py @@ -237,6 +237,14 @@ def test_list_messages_unread_filters_isread_false(self): # MS Graph $filter for unread. assert any("isRead eq false" in v for v in params.get("$filter", [])) + def test_list_messages_sent_hits_sentitems_folder(self): + # The follow-up tracker (#1606) scans SENT; falling through to the + # inbox folder here would make it silently scan the wrong mail. + backend, rec, _ = _backend(lambda r: _ok({"value": []})) + backend.list_messages(label_ids=["SENT"], max_results=5) + path = urlparse(str(rec.requests[0].url)).path + assert path.endswith("/me/mailFolders/sentitems/messages") + def test_list_messages_query_uses_search(self): backend, rec, _ = _backend(lambda r: _ok({"value": []})) backend.list_messages(query="invoice", max_results=5) diff --git a/tests/unit/agents/test_email_agent.py b/tests/unit/agents/test_email_agent.py index f783ec21d..e04220d13 100644 --- a/tests/unit/agents/test_email_agent.py +++ b/tests/unit/agents/test_email_agent.py @@ -152,6 +152,8 @@ class TestToolRegistry: "triage_inbox", "pre_scan_inbox", "profile_inbox", + # Follow-up tracking (#1606) — read-only detection + "check_followups", # Organize "archive_message", "mark_read",