Skip to content

Navigation Menu

Sign in
Sign up

fix(email): allow move_to_label to restore inbox #3267

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
mikemikimike wants to merge 4 commits into amd:main
base: main
Choose a base branch
Loading
from mikemikimike:fix/2626-restore-inbox
Open
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
4 changes: 4 additions & 0 deletions hub/agents/email/python/CHANGELOG.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down
137 changes: 137 additions & 0 deletions hub/agents/email/python/tests/test_move_to_label_inbox_2626.py
View file Open in desktop
Original file line number Diff line number Diff line change
@@ -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()
Loading

AltStyle によって変換されたページ (->オリジナル) /