From 2c5e0ca44319c15ea22f7c45df83126dc7622b3c Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Wed, 2 Sep 2026 06:46:54 +0800 Subject: [PATCH 1/4] fix(email): allow move_to_label to restore inbox --- .../gaia_agent_email/tools/organize_tools.py | 22 +++--- .../tests/test_move_to_label_inbox_2626.py | 70 +++++++++++++++++++ 2 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 hub/agents/email/python/tests/test_move_to_label_inbox_2626.py 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..36e41f6fe 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: 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..96327d57d --- /dev/null +++ b/hub/agents/email/python/tests/test_move_to_label_inbox_2626.py @@ -0,0 +1,70 @@ +# 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).""" + +from gaia_agent_email import action_store +from gaia_agent_email.tools.organize_tools import move_to_label_impl + +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 + + +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 From 3f68d283cf883f7ae9775f8d2e7a68fa0a7a62f9 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 3 Sep 2026 09:15:31 +0800 Subject: [PATCH 2/4] fix(email): restore inbox in batch label moves --- hub/agents/email/python/CHANGELOG.md | 16 ++++++++++++ .../gaia_agent_email/tools/organize_tools.py | 3 ++- .../tests/test_move_to_label_inbox_2626.py | 25 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/hub/agents/email/python/CHANGELOG.md b/hub/agents/email/python/CHANGELOG.md index da6e79b5f..a12bbf7b5 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, @@ -162,6 +166,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. - **A reply/draft/send action could report failure even though it actually succeeded, and a retry made things worse (#2902).** After a draft was created or a message sent, a separate local audit-log write (`state.db`, @@ -441,6 +449,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. - **A Gmail rate-limit no longer kills the whole scan (#2720, #2716).** Gmail enforces a per-user concurrent-request limit, and the metadata-first batch fetch (#2643) was oversized enough to reliably trip it — one 429'd @@ -823,6 +835,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. - **A batch-tool retry no longer gets killed mid-recovery by the streaming layer (#2515).** When the model called a batch tool with a spurious extra argument (e.g. `archive_message_batch` with a stray `mailbox` kwarg), the 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 36e41f6fe..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 @@ -1176,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 index 96327d57d..76887c294 100644 --- 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 @@ -68,3 +68,28 @@ def test_move_to_label_non_inbox_target_still_archives(): 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() From 72e74aa84d404adae1cd001614d00d61461bc05c Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Thu, 3 Sep 2026 09:52:27 +0800 Subject: [PATCH 3/4] test(email): exercise batch inbox restoration through the tool --- .../tests/test_move_to_label_inbox_2626.py | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) 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 index 76887c294..1b9861693 100644 --- 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 @@ -2,9 +2,15 @@ # 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 move_to_label_impl +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 @@ -49,6 +55,42 @@ def _make_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() From 5479085218138ce0d3c0fc29e438d47dd2109499 Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 6 Sep 2026 21:54:06 +0800 Subject: [PATCH 4/4] fix(email): remove duplicate inbox changelog entries --- hub/agents/email/python/CHANGELOG.md | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/hub/agents/email/python/CHANGELOG.md b/hub/agents/email/python/CHANGELOG.md index a12bbf7b5..ea3164a04 100644 --- a/hub/agents/email/python/CHANGELOG.md +++ b/hub/agents/email/python/CHANGELOG.md @@ -166,10 +166,6 @@ 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. - **A reply/draft/send action could report failure even though it actually succeeded, and a retry made things worse (#2902).** After a draft was created or a message sent, a separate local audit-log write (`state.db`, @@ -449,10 +445,6 @@ 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. - **A Gmail rate-limit no longer kills the whole scan (#2720, #2716).** Gmail enforces a per-user concurrent-request limit, and the metadata-first batch fetch (#2643) was oversized enough to reliably trip it — one 429'd @@ -835,10 +827,6 @@ 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. - **A batch-tool retry no longer gets killed mid-recovery by the streaming layer (#2515).** When the model called a batch tool with a spurious extra argument (e.g. `archive_message_batch` with a stray `mailbox` kwarg), the

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