diff --git a/tests/test_gmail_headers.py b/tests/test_gmail_headers.py index 5cda8ad..eb61b0f 100644 --- a/tests/test_gmail_headers.py +++ b/tests/test_gmail_headers.py @@ -3,6 +3,61 @@ from use_agent import gmail +class _FakeList: + """Records the ``q`` passed to ``users().messages().list``.""" + + def __init__(self, calls: list[str], pages: list[dict]) -> None: + self._calls = calls + self._pages = pages + + def list(self, *, userId, q, maxResults, pageToken): # noqa: N803 + self._calls.append(q) + return self + + def execute(self) -> dict: + return self._pages.pop(0) + + +class _FakeMessages: + def __init__(self, lst: _FakeList) -> None: + self._lst = lst + + def messages(self) -> _FakeList: + return self._lst + + +class _FakeService: + def __init__(self, lst: _FakeList) -> None: + self._msgs = _FakeMessages(lst) + + def users(self) -> _FakeMessages: + return self._msgs + + +def _client_with(pages: list[dict], calls: list[str]) -> gmail.GmailClient: + client = object.__new__(gmail.GmailClient) + client._service = _FakeService(_FakeList(calls, pages)) + return client + + +def test_list_message_ids_defaults_to_inbox() -> None: + calls: list[str] = [] + client = _client_with([{'messages': [{'id': 'a'}]}], calls) + assert client.list_message_ids() == {'a'} + assert calls == ['in:inbox'] + + +def test_list_message_ids_honors_query_and_paginates() -> None: + calls: list[str] = [] + pages = [ + {'messages': [{'id': 'a'}], 'nextPageToken': 't'}, + {'messages': [{'id': 'b'}]}, + ] + client = _client_with(pages, calls) + assert client.list_message_ids('in:spam') == {'a', 'b'} + assert calls == ['in:spam', 'in:spam'] + + def test_parses_https_and_mailto_pair() -> None: header = ( ', ' diff --git a/tests/test_prune_scope.py b/tests/test_prune_scope.py new file mode 100644 index 0000000..b492235 --- /dev/null +++ b/tests/test_prune_scope.py @@ -0,0 +1,39 @@ +"""Derivation of the cache-prune folder scope from a run query.""" + +from use_agent import agent + + +def test_default_inbox_query_scopes_to_inbox() -> None: + # Byte-for-byte identical to the pre-fix hardcoded behavior. + assert agent._prune_query('in:inbox is:unread') == 'in:inbox' + + +def test_spam_query_scopes_to_spam() -> None: + assert agent._prune_query('in:spam') == 'in:spam' + + +def test_first_in_operand_wins() -> None: + assert agent._prune_query('in:spam -from:example.com') == 'in:spam' + + +def test_label_operand_is_preserved() -> None: + assert agent._prune_query('label:promotions is:unread') == ( + 'label:promotions' + ) + + +def test_appended_lookback_does_not_shadow_folder() -> None: + query = 'in:spam is:unread newer_than:2d' + assert agent._prune_query(query) == 'in:spam' + + +def test_query_without_folder_falls_back_to_inbox() -> None: + assert agent._prune_query('is:unread from:foo@example.com') == 'in:inbox' + + +def test_negated_folder_operand_does_not_scope() -> None: + assert agent._prune_query('-in:spam is:unread') == 'in:inbox' + + +def test_negated_folder_operand_does_not_shadow_positive_one() -> None: + assert agent._prune_query('in:spam -label:promotions') == 'in:spam' diff --git a/use_agent/agent.py b/use_agent/agent.py index 23080e6..353c006 100644 --- a/use_agent/agent.py +++ b/use_agent/agent.py @@ -1,6 +1,7 @@ """Orchestrates a Claude Agent run over the Gmail inbox.""" import logging +import re import claude_agent_sdk import jinja2 @@ -373,7 +374,7 @@ async def run( scopes=config.GMAIL_SCOPES, ) client = gmail.GmailClient(creds) - seen = _load_and_prune_cache(client) + seen = _load_and_prune_cache(client, _prune_query(effective_query)) # No history is written on a dry run; the store stays None and the # record_action tool no-ops. store = ( @@ -473,25 +474,50 @@ def _forward(message: object, reporter: reporter_mod.Reporter) -> None: reporter.on_text(block.text) +_PRUNE_OPERAND_RE = re.compile(r'(? str: + """Derive the cache-prune folder scope from a run's query. + + Extracts the first ``in:`` or ``label:`` operand and + returns it as a standalone folder query, so pruning lists only + the folder the run actually targets. Falls back to ``in:inbox`` + when the query names no folder — keeping default inbox runs + byte-for-byte identical. + """ + match = _PRUNE_OPERAND_RE.search(query) + if match is None: + return 'in:inbox' + return f'{match.group(1)}:{match.group(2)}' + + def _load_and_prune_cache( client: gmail.GmailClient, + prune_query: str = 'in:inbox', ) -> cache_mod.Cache: - """Load the seen-message cache and drop entries no longer in inbox. - - A listing failure (network error, auth hiccup, pagination cap) - degrades gracefully: the cache is left untouched rather than - wiped, so a transient failure can't force re-investigation of - every cached message. + """Load the seen-message cache and drop entries no longer present. + + ``prune_query`` scopes the listing to the folder the run targets + (e.g. ``in:inbox`` or ``in:spam``); cached ids outside that folder + are pruned. A listing failure (network error, auth hiccup, + pagination cap) degrades gracefully: the cache is left untouched + rather than wiped, so a transient failure can't force + re-investigation of every cached message. """ seen = cache_mod.Cache.load(config.cache_path()) try: - inbox_ids = client.list_inbox_message_ids() + current_ids = client.list_message_ids(prune_query) except Exception: - LOGGER.exception('failed to list inbox; skipping cache prune') - inbox_ids = None - if inbox_ids is not None: - dropped = seen.retain(inbox_ids) + LOGGER.exception('failed to list messages; skipping cache prune') + current_ids = None + if current_ids is not None: + dropped = seen.retain(current_ids) if dropped: - LOGGER.debug('pruned %d cache entries no longer in inbox', dropped) + LOGGER.debug( + 'pruned %d cache entries no longer in %s', + dropped, + prune_query, + ) LOGGER.debug('seen-message cache: %d entries', len(seen)) return seen diff --git a/use_agent/gmail.py b/use_agent/gmail.py index f60463e..282b862 100644 --- a/use_agent/gmail.py +++ b/use_agent/gmail.py @@ -19,8 +19,8 @@ # Guard against huge inboxes: each page costs one API call. 20 pages # of 500 covers 10,000 messages — well past any realistic use. -_INBOX_LIST_PAGE_CAP: int = 20 -_INBOX_LIST_PAGE_SIZE: int = 500 +_LIST_PAGE_CAP: int = 20 +_LIST_PAGE_SIZE: int = 500 # A sender is treated as an established inbound contact (not cold # outreach) if they've been emailing since at least this long before @@ -181,24 +181,25 @@ def search( ) return out - def list_inbox_message_ids(self) -> set[str] | None: - """Return every message_id currently labeled INBOX. + def list_message_ids(self, query: str = 'in:inbox') -> set[str] | None: + """Return every message_id matching ``query``. - Paginates over ``users.messages.list`` with ``q=in:inbox``. - Returns ``None`` if pagination exceeds the page cap without - finishing — callers should treat that as "unknown" and skip - any cache-prune step that would otherwise drop real entries. + Paginates over ``users.messages.list`` with ``q=query`` + (defaults to ``in:inbox``). Returns ``None`` if pagination + exceeds the page cap without finishing — callers should treat + that as "unknown" and skip any cache-prune step that would + otherwise drop real entries. """ ids: set[str] = set() page_token: str | None = None - for _ in range(_INBOX_LIST_PAGE_CAP): + for _ in range(_LIST_PAGE_CAP): resp = ( self._service.users() .messages() .list( userId='me', - q='in:inbox', - maxResults=_INBOX_LIST_PAGE_SIZE, + q=query, + maxResults=_LIST_PAGE_SIZE, pageToken=page_token, ) .execute() @@ -209,8 +210,8 @@ def list_inbox_message_ids(self) -> set[str] | None: if not page_token: return ids LOGGER.warning( - 'inbox listing exceeded %d pages; skipping cache prune', - _INBOX_LIST_PAGE_CAP, + 'message listing exceeded %d pages; skipping cache prune', + _LIST_PAGE_CAP, ) return None