diff --git a/hub/agents/email/python/CHANGELOG.md b/hub/agents/email/python/CHANGELOG.md index da6e79b5f..ea3164a04 100644 --- a/hub/agents/email/python/CHANGELOG.md +++ b/hub/agents/email/python/CHANGELOG.md @@ -9,6 +9,10 @@ contract version is tracked separately as ### Fixed +- move_to_label_batch now restores messages to the inbox when its target is + INBOX (#2626). Batch moves used to add the inbox label and then archive + each message again, reporting success while leaving every message outside + the inbox. - **An unexpected failure on `/v1/email/*` now returns parseable JSON instead of a bare text 500 (#3000).** Only four connector exception types were mapped to a status code, so anything else — a `KeyError` on an unexpected Graph payload, diff --git a/hub/agents/email/python/gaia_agent_email/tools/organize_tools.py b/hub/agents/email/python/gaia_agent_email/tools/organize_tools.py index 004a0b659..da5d6a935 100644 --- a/hub/agents/email/python/gaia_agent_email/tools/organize_tools.py +++ b/hub/agents/email/python/gaia_agent_email/tools/organize_tools.py @@ -254,12 +254,16 @@ def move_to_label_impl( mailbox: Optional[str] = None, debug: bool = False, ) -> Dict[str, Any]: - """Add a label and remove INBOX. + """Add a label and remove INBOX, unless the target is INBOX itself. + + Selecting ``INBOX`` restores a message to the inbox and therefore must not + archive it immediately after adding the label. Other labels retain the + existing move-out-of-inbox behavior. Non-atomic: two public Protocol calls (``add_label`` then - ``archive_message``). If the second call raises, the message will - have the new label but still be in INBOX. The ``prior_labels`` field - in the action row captures both the original label set so + ``archive_message`` for non-INBOX targets). If the second call raises, the + message will have the new label but still be in INBOX. The ``prior_labels`` + field in the action row captures both the original label set so ``restore_message`` can recover either partial state. """ with log_tool_call( @@ -274,7 +278,8 @@ def move_to_label_impl( prior_labels = list(prior.get("labelIds", [])) # Gmail call first (ordering invariant: DB write only on success). gmail.add_label(message_id, label_id) - gmail.archive_message(message_id) + if label_id != "INBOX": + gmail.archive_message(message_id) action_id = action_store.record_action( db, action_type="move_to_label", @@ -819,11 +824,12 @@ def label_message(message_id: str, label_id: str, mailbox: str = "") -> str: @tool def move_to_label(message_id: str, label_id: str, mailbox: str = "") -> str: - """Move a message out of INBOX into a label. + """Move a message out of INBOX into a label, or restore it to INBOX. ``label_id`` may be the label's display name or its id; the name is - resolved to an id automatically. ``mailbox`` (optional) routes when - multiple mailboxes are connected. + resolved to an id automatically. Use ``INBOX`` to restore a message + to the inbox. ``mailbox`` (optional) routes when multiple mailboxes + are connected. """ try: if (err := _check_threshold()) is not None: @@ -1170,7 +1176,8 @@ def move_to_label_batch(message_ids: list[str], label_id: str) -> str: def _move_op(backend, mid: str) -> str: resolved = _resolve_label_id(backend, label_id_local, label_cache) backend.add_label(mid, resolved) - backend.archive_message(mid) + if resolved != _INBOX_LABEL: + backend.archive_message(mid) return resolved def _move_prior_fn(msg: Dict[str, Any]) -> List[str]: diff --git a/hub/agents/email/python/tests/test_move_to_label_inbox_2626.py b/hub/agents/email/python/tests/test_move_to_label_inbox_2626.py new file mode 100644 index 000000000..1b9861693 --- /dev/null +++ b/hub/agents/email/python/tests/test_move_to_label_inbox_2626.py @@ -0,0 +1,137 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Regression tests for restoring a message with ``move_to_label`` (#2626).""" + +import json +from types import SimpleNamespace + +import pytest + +from gaia_agent_email import action_store +from gaia_agent_email.tools.organize_tools import OrganizeToolsMixin, move_to_label_impl + +from gaia.agents.base.tools import _TOOL_REGISTRY, get_tool_metadata +from gaia.database.mixin import DatabaseMixin + + +class _FakeMailbox: + def __init__(self): + self.messages = { + "outside": {"labelIds": ["Label_1"]}, + "inbox": {"labelIds": ["INBOX"]}, + } + self.labels = [ + {"id": "INBOX", "name": "INBOX"}, + {"id": "Label_1", "name": "Archive target"}, + ] + self.archive_calls = 0 + + def list_labels(self): + return list(self.labels) + + def get_message(self, message_id): + return {"labelIds": list(self.messages[message_id]["labelIds"])} + + def add_label(self, message_id, label_id): + labels = self.messages[message_id]["labelIds"] + if label_id not in labels: + labels.append(label_id) + + def archive_message(self, message_id): + self.archive_calls += 1 + labels = self.messages[message_id]["labelIds"] + if "INBOX" in labels: + labels.remove("INBOX") + + +class _DB(DatabaseMixin): + pass + + +def _make_db(): + db = _DB() + db.init_db(":memory:") + action_store.init_schema(db) + return db + + +class _FakeAgent(OrganizeToolsMixin, DatabaseMixin): + """Minimal host that registers the real organize tool closures.""" + + def __init__(self, mailbox): + self.config = SimpleNamespace(debug=False, undo_window_seconds=30) + self._backends = {"google": mailbox} + self._providers = { + message_id: "google" for message_id in mailbox.messages + } + self._organize_batch_id = "test-batch" + self._last_archive_batch_id = None + self.init_db(":memory:") + action_store.init_schema(self) + self._register_organize_tools() + + def _organize_batch_threshold_exceeded(self): + return False + + def _provider_for_message(self, message_id, mailbox=None): + return self._providers[message_id] + + def _backend_for_message(self, message_id): + return self._backends[self._providers[message_id]] + + def _record_organize_op(self, message_id, sender): + pass + + +@pytest.fixture(autouse=True) +def _preserve_tool_registry(): + snapshot = dict(_TOOL_REGISTRY) + yield + _TOOL_REGISTRY.clear() + _TOOL_REGISTRY.update(snapshot) + + +def test_move_to_label_inbox_restores_without_archiving(): + mailbox = _FakeMailbox() + + move_to_label_impl(mailbox, _make_db(), message_id="outside", label_id="INBOX") + + assert "INBOX" in mailbox.messages["outside"]["labelIds"] + assert mailbox.archive_calls == 0 + + +def test_move_to_label_non_inbox_target_still_archives(): + mailbox = _FakeMailbox() + + move_to_label_impl( + mailbox, _make_db(), message_id="inbox", label_id="Archive target" + ) + + assert "Label_1" in mailbox.messages["inbox"]["labelIds"] + assert "INBOX" not in mailbox.messages["inbox"]["labelIds"] + assert mailbox.archive_calls == 1 + + +def test_move_to_label_batch_restores_every_message_without_archiving(): + mailbox = _FakeMailbox() + mailbox.messages.update( + { + "outside-2": {"labelIds": ["Label_1"]}, + "outside-3": {"labelIds": ["Label_1"]}, + } + ) + agent = _FakeAgent(mailbox) + + move_batch = get_tool_metadata("move_to_label_batch")["function"] + result = json.loads(move_batch(["outside", "outside-2", "outside-3"], "INBOX")) + + assert result["ok"] is True, result + assert len(result["data"]["succeeded"]) == 3 + assert result["data"]["failed"] == [] + assert all( + "INBOX" in mailbox.messages[mid]["labelIds"] + for mid in ("outside", "outside-2", "outside-3") + ) + assert mailbox.archive_calls == 0 + + agent.close_db()