Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions tests/test_gmail_headers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
'<mailto:unsub@example.com?subject=unsub-token>, '
Expand Down
39 changes: 39 additions & 0 deletions tests/test_prune_scope.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Derivation of the cache-prune folder scope from a run query."""

from use_agent import agent

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use module-level import instead of from ... import.

As per coding guidelines, **/*.py should "Use module-level imports only; do not use from ... import ... style imports. Import the module ... and reference members through the module namespace."

♻️ Proposed fix
-from use_agent import agent
+import use_agent.agent as agent
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from use_agent import agent
import use_agent.agent as agent
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_prune_scope.py` at line 3, The import in this test file uses a
direct symbol import, which violates the module-level import guideline. Update
the top-level import to import the use_agent module itself, then reference agent
through that module namespace wherever it is used in the test code.

Source: Coding guidelines



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'
52 changes: 39 additions & 13 deletions use_agent/agent.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Orchestrates a Claude Agent run over the Gmail inbox."""

import logging
import re

import claude_agent_sdk
import jinja2
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -473,25 +474,50 @@ def _forward(message: object, reporter: reporter_mod.Reporter) -> None:
reporter.on_text(block.text)


_PRUNE_OPERAND_RE = re.compile(r'(?<!-)\b(in|label):(\S+)')


def _prune_query(query: str) -> str:
"""Derive the cache-prune folder scope from a run's query.

Extracts the first ``in:<x>`` or ``label:<x>`` 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
27 changes: 14 additions & 13 deletions use_agent/gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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

Expand Down
Loading