From 40bf21c18225eacb82addfec5a2df91fbbea242a Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 19:56:55 +0800 Subject: [PATCH 01/12] Add Gmail thread list filtering tests --- .../mock-gmail/tests/test_threads.py | 236 ++++++++++++++++++ 1 file changed, 236 insertions(+) create mode 100644 packages/environments/mock-gmail/tests/test_threads.py diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py new file mode 100644 index 00000000..3854a595 --- /dev/null +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -0,0 +1,236 @@ +"""Focused behavior tests for the Gmail Threads read API.""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from mock_gmail.models import ( + Message, + MessageLabel, + Thread, + User, + get_session_factory, +) + + +@pytest.fixture +def db_session(client): + """Open a session against the database initialized by the API client.""" + db = get_session_factory()() + try: + yield db + finally: + db.close() + + +def _user_id(db_session) -> str: + return ( + db_session.query(User.id) + .filter(User.email_address == "alex@nexusai.com") + .scalar() + ) + + +def _add_thread( + db_session, + user_id: str, + thread_id: str, + messages: list[dict], + *, + subject: str = "Threads API regression", +) -> None: + db_session.add( + Thread( + id=thread_id, + user_id=user_id, + snippet=messages[-1].get("body", "")[:200] if messages else "", + history_id=9001, + ) + ) + for spec in messages: + labels = set(spec.get("labels", ["INBOX"])) + message = Message( + id=spec["id"], + thread_id=thread_id, + user_id=user_id, + sender="threads-regression@example.com", + to="alex@nexusai.com", + subject=spec.get("subject", subject), + snippet=spec.get("body", "")[:200], + body_plain=spec.get("body", ""), + internal_date=spec["date"], + is_read="UNREAD" not in labels, + is_starred="STARRED" in labels, + is_trash="TRASH" in labels, + is_spam="SPAM" in labels, + is_draft="DRAFT" in labels, + is_sent="SENT" in labels, + ) + db_session.add(message) + for label_id in labels - { + "UNREAD", + "STARRED", + "TRASH", + "SPAM", + "DRAFT", + "SENT", + }: + db_session.add(MessageLabel(message_id=message.id, label_id=label_id)) + db_session.commit() + + +def _listed_thread_ids(response) -> list[str]: + assert response.status_code == 200 + return [thread["id"] for thread in response.json().get("threads", [])] + + +def _at(month: int, day: int, hour: int = 8, minute: int = 0) -> datetime: + return datetime(2099, month, day, hour, minute) + + +def _message_spec( + message_id: str, + internal_date: datetime, + body: str, + labels: list[str] | None = None, +) -> dict: + spec = {"id": message_id, "date": internal_date, "body": body} + if labels is not None: + spec["labels"] = labels + return spec + + +class TestThreadsListBehavior: + def test_q_matches_any_older_message_and_returns_thread_once( + self, client, db_session + ): + user_id = _user_id(db_session) + marker = "older-message-thread-match-7f19" + _add_thread( + db_session, + user_id, + "thread-q-old-message", + [ + _message_spec("message-q-old-a", _at(1, 1), marker), + _message_spec("message-q-old-b", _at(1, 2), marker), + _message_spec( + "message-q-newest", + _at(1, 3), + "The newest message does not contain the search marker.", + ), + ], + ) + + response = client.get("/gmail/v1/users/me/threads", params={"q": marker}) + + assert _listed_thread_ids(response) == ["thread-q-old-message"] + assert response.json()["resultSizeEstimate"] == 1 + + def test_same_subject_different_thread_ids_remain_separate( + self, client, db_session + ): + user_id = _user_id(db_session) + subject = "Shared subject 4da36d" + for suffix in ("a", "b"): + _add_thread( + db_session, + user_id, + f"thread-same-subject-{suffix}", + [ + _message_spec( + f"message-same-subject-{suffix}", + _at(2, 1, minute=ord(suffix) - ord("a")), + f"Message {suffix}", + ) + ], + subject=subject, + ) + + response = client.get( + "/gmail/v1/users/me/threads", + params={"q": f'subject:"{subject}"'}, + ) + + assert set(_listed_thread_ids(response)) == { + "thread-same-subject-a", + "thread-same-subject-b", + } + assert response.json()["resultSizeEstimate"] == 2 + + def test_all_labels_may_be_on_different_messages(self, client, db_session): + user_id = _user_id(db_session) + marker = "thread-label-union-135b" + scenarios = { + "thread-label-both": (["INBOX"], ["STARRED"]), + "thread-label-inbox-only": (["INBOX"], ["INBOX"]), + "thread-label-starred-only": (["STARRED"], ["STARRED"]), + } + for thread_id, message_labels in scenarios.items(): + _add_thread( + db_session, + user_id, + thread_id, + [ + _message_spec( + f"{thread_id}-message-{index}", + _at(3, index + 1), + marker, + labels, + ) + for index, labels in enumerate(message_labels) + ], + ) + + response = client.get( + "/gmail/v1/users/me/threads", + params=[ + ("q", marker), + ("labelIds", "INBOX"), + ("labelIds", "STARRED"), + ], + ) + + assert _listed_thread_ids(response) == ["thread-label-both"] + assert response.json()["resultSizeEstimate"] == 1 + + def test_include_spam_trash_controls_hidden_threads(self, client, db_session): + user_id = _user_id(db_session) + marker = "thread-hidden-7f921" + scenarios = { + "thread-visible": [["INBOX"]], + "thread-trash-only": [["TRASH"]], + "thread-spam-only": [["SPAM"]], + } + for thread_number, (thread_id, message_labels) in enumerate( + scenarios.items(), start=1 + ): + _add_thread( + db_session, + user_id, + thread_id, + [ + _message_spec( + f"{thread_id}-message-{message_number}", + _at(4, thread_number, hour=message_number), + marker, + labels, + ) + for message_number, labels in enumerate(message_labels, start=1) + ], + ) + + default_response = client.get( + "/gmail/v1/users/me/threads", + params={"q": marker}, + ) + included_response = client.get( + "/gmail/v1/users/me/threads", + params={"q": marker, "includeSpamTrash": "true"}, + ) + + assert _listed_thread_ids(default_response) == ["thread-visible"] + assert default_response.json()["resultSizeEstimate"] == 1 + assert set(_listed_thread_ids(included_response)) == set(scenarios) + assert included_response.json()["resultSizeEstimate"] == 3 From 6125d5646599eeb53c0625eefeef5cef1cd6867e Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 20:13:24 +0800 Subject: [PATCH 02/12] Fix Gmail thread list ordering and pagination Threads with no messages are no longer listed because they have no latest-message timestamp. --- .../mock-gmail/mock_gmail/api/threads.py | 26 ++++++- .../environments/mock-gmail/tests/test_api.py | 3 +- .../mock-gmail/tests/test_threads.py | 74 +++++++++++++++++++ 3 files changed, 100 insertions(+), 3 deletions(-) diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index d2f86c44..3400a783 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -3,6 +3,7 @@ from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func from sqlalchemy.orm import Session from mock_gmail.models import Thread, Message, MessageLabel @@ -34,7 +35,20 @@ def list_threads( db: Session = Depends(get_db), _user_id: str = Depends(resolve_user_id), ): - query = db.query(Thread).filter(Thread.user_id == _user_id) + latest_message = ( + db.query( + Message.thread_id.label("thread_id"), + func.max(Message.internal_date).label("internal_date"), + ) + .filter(Message.user_id == _user_id) + .group_by(Message.thread_id) + .subquery() + ) + query = ( + db.query(Thread) + .join(latest_message, latest_message.c.thread_id == Thread.id) + .filter(Thread.user_id == _user_id) + ) if labelIds: # Normalize: handle both repeated params and comma-separated @@ -86,7 +100,15 @@ def list_threads( pass total = query.count() - threads = query.offset(offset).limit(maxResults).all() + threads = ( + query.order_by( + latest_message.c.internal_date.desc(), + Thread.id.asc(), + ) + .offset(offset) + .limit(maxResults) + .all() + ) next_token = None if offset + maxResults < total: diff --git a/packages/environments/mock-gmail/tests/test_api.py b/packages/environments/mock-gmail/tests/test_api.py index fe869ae9..86655ced 100644 --- a/packages/environments/mock-gmail/tests/test_api.py +++ b/packages/environments/mock-gmail/tests/test_api.py @@ -417,7 +417,8 @@ def test_untrash_thread(self, client): assert "TRASH" not in msg["labelIds"] def test_delete_thread(self, client): - resp = client.get("/gmail/v1/users/me/threads") + # Pick a mailbox thread; deterministic date ordering puts the draft first. + resp = client.get("/gmail/v1/users/me/threads?labelIds=INBOX") thread_id = resp.json()["threads"][0]["id"] resp = client.delete(f"/gmail/v1/users/me/threads/{thread_id}") diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py index 3854a595..58419cb1 100644 --- a/packages/environments/mock-gmail/tests/test_threads.py +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -234,3 +234,77 @@ def test_include_spam_trash_controls_hidden_threads(self, client, db_session): assert default_response.json()["resultSizeEstimate"] == 1 assert set(_listed_thread_ids(included_response)) == set(scenarios) assert included_response.json()["resultSizeEstimate"] == 3 + + def test_latest_message_order_and_pagination_are_stable(self, client, db_session): + user_id = _user_id(db_session) + marker = "thread-page-order-7c981" + # Insert in deliberately different order from the API contract. + thread_dates = [ + ("thread-order-oldest", _at(5, 1)), + ("thread-order-tie-z", _at(5, 3)), + ("thread-order-newest", _at(5, 5)), + ("thread-order-tie-a", _at(5, 3)), + ("thread-order-second", _at(5, 4)), + ] + for thread_id, internal_date in thread_dates: + messages = [] + latest_body = marker + if thread_id == "thread-order-newest": + messages.append( + _message_spec(f"{thread_id}-older-message", _at(4, 30), marker) + ) + latest_body = "The latest message does not match the list query." + messages.append( + _message_spec( + f"{thread_id}-latest-message", internal_date, latest_body + ) + ) + _add_thread( + db_session, + user_id, + thread_id, + messages, + ) + + expected = [ + "thread-order-newest", + "thread-order-second", + "thread-order-tie-a", + "thread-order-tie-z", + "thread-order-oldest", + ] + + full_response = client.get( + "/gmail/v1/users/me/threads", + params={"q": marker, "maxResults": 500}, + ) + assert _listed_thread_ids(full_response) == expected + assert full_response.json()["resultSizeEstimate"] == len(expected) + + def walk_pages() -> tuple[list[str], list[str]]: + collected = [] + seen_tokens = set() + tokens = [] + page_token = None + for _ in range(len(expected)): + params = {"q": marker, "maxResults": 2} + if page_token is not None: + params["pageToken"] = page_token + response = client.get("/gmail/v1/users/me/threads", params=params) + data = response.json() + assert data["resultSizeEstimate"] == len(expected) + collected.extend(_listed_thread_ids(response)) + page_token = data.get("nextPageToken") + if page_token is None: + return collected, tokens + assert page_token not in seen_tokens + seen_tokens.add(page_token) + tokens.append(page_token) + pytest.fail("Pagination did not terminate") + + first_walk, first_tokens = walk_pages() + second_walk, second_tokens = walk_pages() + assert first_walk == expected + assert second_walk == expected + assert first_tokens == second_tokens + assert len(first_walk) == len(set(first_walk)) From 9869bd87e41902bb54f7eecfa704223ca6c7c24f Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 20:26:21 +0800 Subject: [PATCH 03/12] Fix Gmail thread message ordering --- .../mock-gmail/mock_gmail/api/threads.py | 2 +- .../mock-gmail/tests/test_threads.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index 3400a783..b9a25851 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -141,7 +141,7 @@ def get_thread( msgs = ( db.query(Message) .filter(Message.thread_id == threadId, Message.user_id == _user_id) - .order_by(Message.internal_date.asc()) + .order_by(Message.internal_date.asc(), Message.id.asc()) .all() ) diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py index 58419cb1..950fcd37 100644 --- a/packages/environments/mock-gmail/tests/test_threads.py +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -308,3 +308,29 @@ def walk_pages() -> tuple[list[str], list[str]]: assert second_walk == expected assert first_tokens == second_tokens assert len(first_walk) == len(set(first_walk)) + + +class TestThreadsGetBehavior: + def test_messages_are_ordered_by_date_then_id(self, client, db_session): + user_id = _user_id(db_session) + _add_thread( + db_session, + user_id, + "thread-get-order", + [ + _message_spec("message-get-newest", _at(6, 3), "newest"), + _message_spec("message-get-tie-z", _at(6, 2), "tie z"), + _message_spec("message-get-oldest", _at(6, 1), "oldest"), + _message_spec("message-get-tie-a", _at(6, 2), "tie a"), + ], + ) + + response = client.get("/gmail/v1/users/me/threads/thread-get-order") + + assert response.status_code == 200 + assert [message["id"] for message in response.json()["messages"]] == [ + "message-get-oldest", + "message-get-tie-a", + "message-get-tie-z", + "message-get-newest", + ] From 4ce42a6c76f7a66078efcf62aa068ff31e85e2cf Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 20:31:40 +0800 Subject: [PATCH 04/12] Constrain Gmail thread get formats --- .../mock-gmail/mock_gmail/api/threads.py | 4 +- .../mock-gmail/tests/test_threads.py | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index b9a25851..6bb228bc 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Literal + from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func from sqlalchemy.orm import Session @@ -130,7 +132,7 @@ def list_threads( def get_thread( userId: str, threadId: str, - format: str = Query("full"), + format: Literal["full", "metadata", "minimal"] = Query("full"), db: Session = Depends(get_db), _user_id: str = Depends(resolve_user_id), ): diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py index 950fcd37..3a2aa134 100644 --- a/packages/environments/mock-gmail/tests/test_threads.py +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -334,3 +334,59 @@ def test_messages_are_ordered_by_date_then_id(self, client, db_session): "message-get-tie-z", "message-get-newest", ] + + def test_supported_formats_shape_every_message(self, client, db_session): + user_id = _user_id(db_session) + _add_thread( + db_session, + user_id, + "thread-get-formats", + [ + _message_spec("message-format-a", _at(7, 1), "alpha"), + _message_spec("message-format-b", _at(7, 2), "beta"), + ], + ) + + default = client.get("/gmail/v1/users/me/threads/thread-get-formats").json() + full = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": "full"}, + ).json() + metadata = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": "metadata"}, + ).json() + minimal = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": "minimal"}, + ).json() + + assert default == full + assert set(full) == {"id", "historyId", "messages"} + assert set(metadata) == set(full) + assert set(minimal) == set(full) + for message in full["messages"]: + assert "payload" in message + assert "raw" not in message + for message in metadata["messages"]: + assert set(message["payload"]) == {"mimeType", "headers"} + assert "raw" not in message + for message in minimal["messages"]: + assert set(message) == { + "id", + "threadId", + "labelIds", + "snippet", + "historyId", + "internalDate", + "sizeEstimate", + } + assert "payload" not in message + assert "raw" not in message + + for unsupported_format in ("raw", "unsupported"): + response = client.get( + "/gmail/v1/users/me/threads/thread-get-formats", + params={"format": unsupported_format}, + ) + assert response.status_code == 400 From 359fa8d2d676ea0acadc3a73f0067c40a59b7525 Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 20:33:49 +0800 Subject: [PATCH 05/12] Treat empty Gmail threads as not found --- .../mock-gmail/mock_gmail/api/threads.py | 3 +++ .../mock-gmail/tests/test_threads.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index 6bb228bc..420bfdb0 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -146,6 +146,9 @@ def get_thread( .order_by(Message.internal_date.asc(), Message.id.asc()) .all() ) + if not msgs: + # Empty Thread rows are orphaned mock state, not public Gmail resources. + raise HTTPException(404, f"Thread {threadId!r} not found") # Real Gmail threads.get returns {id, historyId, messages} — no snippet (Bug 2) return { diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py index 3a2aa134..aaaec972 100644 --- a/packages/environments/mock-gmail/tests/test_threads.py +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -390,3 +390,19 @@ def test_supported_formats_shape_every_message(self, client, db_session): params={"format": unsupported_format}, ) assert response.status_code == 400 + + def test_not_found_and_empty_thread_return_not_found(self, client, db_session): + missing = client.get("/gmail/v1/users/me/threads/7fffffffffffffff") + assert missing.status_code == 404 + + user_id = _user_id(db_session) + _add_thread(db_session, user_id, "thread-empty", []) + + empty = client.get("/gmail/v1/users/me/threads/thread-empty") + assert empty.status_code == 404 + + listed = client.get( + "/gmail/v1/users/me/threads", + params={"includeSpamTrash": "true", "maxResults": 500}, + ) + assert "thread-empty" not in _listed_thread_ids(listed) From 727e71feea288f419520e07a55ee14ff659700f3 Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 20:38:29 +0800 Subject: [PATCH 06/12] Strengthen Gmail thread fixture conformance tests --- .../mock-gmail/tests/test_conformance.py | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/packages/environments/mock-gmail/tests/test_conformance.py b/packages/environments/mock-gmail/tests/test_conformance.py index 00c16bac..4aceaa28 100644 --- a/packages/environments/mock-gmail/tests/test_conformance.py +++ b/packages/environments/mock-gmail/tests/test_conformance.py @@ -71,6 +71,14 @@ def _assert_any_shape(real_items: list, mock_item, path: str, strict: bool) -> N pytest.fail(f"Mock item at {path} matches no fixture item shape: {'; '.join(errors)}") +def _assert_thread_message_core_types(message: dict) -> None: + for key in ("id", "threadId", "snippet", "historyId", "internalDate"): + assert isinstance(message[key], str) + assert isinstance(message["labelIds"], list) + assert all(isinstance(label_id, str) for label_id in message["labelIds"]) + assert isinstance(message["sizeEstimate"], int) + + class TestProfileConformance: def test_profile_keys(self, client): """Profile response has same keys as real Gmail.""" @@ -525,20 +533,30 @@ def test_thread_get_structure(self, client): pytest.skip("No threads") thread_id = threads[0]["id"] - resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}") + resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}?format=full") mock = resp.json() # Real threads.get returns {id, historyId, messages} — no snippet real = load_fixture("thread_get_full.json") assert set(mock.keys()) == set(real.keys()) + assert isinstance(mock["id"], str) + assert isinstance(mock["historyId"], str) assert "messages" in mock assert len(mock["messages"]) > 0 - # Each message in thread should have payload + real_message_keys = set(real["messages"][0]) + # Each message in thread should match the real full-format top-level shape. for msg in mock["messages"]: + assert set(msg) == real_message_keys + _assert_thread_message_core_types(msg) assert "payload" in msg + assert "raw" not in msg + assert isinstance(msg["payload"], dict) assert "id" in msg assert "threadId" in msg + for header in msg["payload"]["headers"]: + assert set(header) == {"name", "value"} + assert all(isinstance(value, str) for value in header.values()) def test_thread_get_metadata_format(self, client): """threads.get format=metadata messages have only {mimeType, headers} in payload.""" @@ -553,25 +571,37 @@ def test_thread_get_metadata_format(self, client): mock = resp.json() assert set(mock.keys()) == set(real.keys()) + assert isinstance(mock["id"], str) + assert isinstance(mock["historyId"], str) assert "messages" in mock - # Each message payload should match metadata format + # Every message and payload should match metadata format, not only the first. real_msg = real["messages"][0] - mock_msg = mock["messages"][0] - assert set(real_msg["payload"].keys()) == set(mock_msg["payload"].keys()) + for mock_msg in mock["messages"]: + assert set(mock_msg) == set(real_msg) + _assert_thread_message_core_types(mock_msg) + assert set(real_msg["payload"]) == set(mock_msg["payload"]) + assert "raw" not in mock_msg + for header in mock_msg["payload"]["headers"]: + assert set(header) == {"name", "value"} + assert all(isinstance(value, str) for value in header.values()) def test_threads_list_structure(self, client): """threads.list returns items with {id, snippet, historyId}.""" real = load_fixture("threads_list.json") - resp = client.get("/gmail/v1/users/me/threads") + # The real fixture was captured with maxResults=10. + resp = client.get("/gmail/v1/users/me/threads?maxResults=10") mock = resp.json() - assert "resultSizeEstimate" in mock - assert "threads" in mock + assert set(mock) == set(real) + assert isinstance(mock["resultSizeEstimate"], int) + assert isinstance(mock["nextPageToken"], str) + assert len(mock["threads"]) == len(real["threads"]) # Each real thread item has {id, snippet, historyId} for item in real["threads"]: assert set(item.keys()) == {"id", "snippet", "historyId"} for item in mock["threads"]: assert set(item.keys()) == {"id", "snippet", "historyId"} + assert all(isinstance(value, str) for value in item.values()) class TestSettingsConformance: From 539d384cc5f79478935a246348c490f9c63ad046 Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 20:40:33 +0800 Subject: [PATCH 07/12] Record Gmail thread read test coverage --- .../mock-gmail/tests/fixtures/mock_coverage.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json index e7002f21..7f84794b 100644 --- a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json +++ b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json @@ -168,7 +168,9 @@ "implemented": true, "fixture": "threads_list.json", "tests": [ - "tests/test_api.py::TestThreads::test_list_threads" + "tests/test_api.py::TestThreads::test_list_threads", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_structure", + "tests/test_threads.py::TestThreadsListBehavior" ] }, { @@ -180,7 +182,9 @@ "fixture": "thread_get_full.json", "tests": [ "tests/test_api.py::TestThreads::test_get_thread", - "tests/test_conformance.py::TestThreadsConformance::test_thread_get_structure" + "tests/test_conformance.py::TestThreadsConformance::test_thread_get_structure", + "tests/test_conformance.py::TestThreadsConformance::test_thread_get_metadata_format", + "tests/test_threads.py::TestThreadsGetBehavior" ] }, { From ae062eef30a85a2e2f47c79f961b26b6efbfccd7 Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 20:44:26 +0800 Subject: [PATCH 08/12] Document Gmail thread behavior tests --- packages/environments/mock-gmail/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/environments/mock-gmail/README.md b/packages/environments/mock-gmail/README.md index 00cdc123..46491647 100644 --- a/packages/environments/mock-gmail/README.md +++ b/packages/environments/mock-gmail/README.md @@ -93,6 +93,7 @@ uv run --extra dev pytest tests -q |-------|----------------| | `test_api.py` | Full CRUD for messages, threads, labels, drafts, admin, and task surfaces | | `test_conformance.py` | Response-shape validation against real Gmail fixtures | +| `test_threads.py` | Thread list/get filtering, ordering, pagination, formats, and orphan handling | | `test_settings.py` | Settings sub-resources: filters, sendAs, forwarding, delegates, vacation, IMAP, POP, language | | `test_mime.py` | RFC 2822 build/parse, base64url encoding, message-ID generation | | `test_snapshots.py` | Snapshot/reset behavior | From 8523922e08bbbe079c039010a608d1d0ffc94945 Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 21:50:23 +0800 Subject: [PATCH 09/12] Add Gmail thread minimal conformance fixture Capture a sanitized two-message provider response and verify the minimal message shape and order against it. --- packages/environments/mock-gmail/README.md | 2 +- .../tests/fixtures/mock_coverage.json | 1 + .../real_gmail/thread_get_minimal.json | 33 ++++++++++++++++++ .../mock-gmail/tests/test_conformance.py | 34 +++++++++++++++++++ 4 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json diff --git a/packages/environments/mock-gmail/README.md b/packages/environments/mock-gmail/README.md index 46491647..28fdf446 100644 --- a/packages/environments/mock-gmail/README.md +++ b/packages/environments/mock-gmail/README.md @@ -14,7 +14,7 @@ Mock Gmail provides a safe, fully stateful Gmail environment where agents can be - **Full MIME/RFC 2822 support** — agents can send raw base64url-encoded emails exactly like the real API - **Stateful SQLite backend** — persistent CRUD, multi-user mailboxes, local delivery between users - **Snapshot/restore** — save and reset DB state for deterministic evaluation runs -- **35 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior +- **36 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior - **Task-aware seeding** for repo-level example tasks, with DB state diffs and action logs for verifiers - **MCP server** — expose all endpoints as MCP tools via `fastapi-mcp` - **Gymnasium environment** — `GmailEnv` for RL-style agent training diff --git a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json index 7f84794b..7ccdad03 100644 --- a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json +++ b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json @@ -184,6 +184,7 @@ "tests/test_api.py::TestThreads::test_get_thread", "tests/test_conformance.py::TestThreadsConformance::test_thread_get_structure", "tests/test_conformance.py::TestThreadsConformance::test_thread_get_metadata_format", + "tests/test_conformance.py::TestThreadsConformance::test_thread_get_minimal_format", "tests/test_threads.py::TestThreadsGetBehavior" ] }, diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json new file mode 100644 index 00000000..c31a4d54 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_minimal.json @@ -0,0 +1,33 @@ +{ + "id": "19f568dea810b4ec", + "historyId": "10165", + "messages": [ + { + "id": "19f568e4248d0ed2", + "threadId": "19f568dea810b4ec", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "synthetic fixture message one", + "historyId": "10129", + "internalDate": "1783863591000", + "sizeEstimate": 627 + }, + { + "id": "19f568e69c16a0f5", + "threadId": "19f568dea810b4ec", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "synthetic fixture message two On Sun, Jul 12, 2026 at 9:39 PM Fixture User <fixture-user@example.com> wrote: synthetic fixture message one", + "historyId": "10165", + "internalDate": "1783863600000", + "sizeEstimate": 1482 + } + ], + "_captured_at": "2026-07-12T13:42:41.532585+00:00" +} diff --git a/packages/environments/mock-gmail/tests/test_conformance.py b/packages/environments/mock-gmail/tests/test_conformance.py index 4aceaa28..a28a5e5e 100644 --- a/packages/environments/mock-gmail/tests/test_conformance.py +++ b/packages/environments/mock-gmail/tests/test_conformance.py @@ -585,6 +585,40 @@ def test_thread_get_metadata_format(self, client): assert set(header) == {"name", "value"} assert all(isinstance(value, str) for value in header.values()) + def test_thread_get_minimal_format(self, client): + """threads.get format=minimal omits payload and raw from every message.""" + real = load_fixture("thread_get_minimal.json") + resp = client.get("/gmail/v1/users/me/threads") + threads = resp.json()["threads"] + if not threads: + pytest.skip("No threads") + thread_id = threads[0]["id"] + + resp = client.get(f"/gmail/v1/users/me/threads/{thread_id}?format=minimal") + mock = resp.json() + + assert set(mock) == set(real) + assert isinstance(mock["id"], str) + assert isinstance(mock["historyId"], str) + assert "messages" in mock + + # The provider fixture contains two messages and records their API order. + assert len(real["messages"]) >= 2 + real_dates = [int(message["internalDate"]) for message in real["messages"]] + assert real_dates == sorted(real_dates) + + real_message_keys = set(real["messages"][0]) + for real_msg in real["messages"]: + assert set(real_msg) == real_message_keys + _assert_thread_message_core_types(real_msg) + assert "payload" not in real_msg + assert "raw" not in real_msg + for mock_msg in mock["messages"]: + assert set(mock_msg) == real_message_keys + _assert_thread_message_core_types(mock_msg) + assert "payload" not in mock_msg + assert "raw" not in mock_msg + def test_threads_list_structure(self, client): """threads.list returns items with {id, snippet, historyId}.""" real = load_fixture("threads_list.json") From c28c822fbfb2597482d5c410e8594803e0c3ac6f Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 22:04:04 +0800 Subject: [PATCH 10/12] Match Gmail mixed Trash thread ordering Prefer the latest visible message date, falling back to the latest member for fully hidden threads. Add sanitized provider captures and regression coverage. --- packages/environments/mock-gmail/README.md | 2 +- .../mock-gmail/mock_gmail/api/threads.py | 20 ++++++- .../tests/fixtures/mock_coverage.json | 1 + .../thread_get_metadata_mixed_trash_a.json | 52 ++++++++++++++++ .../thread_get_metadata_mixed_trash_b.json | 30 ++++++++++ .../threads_list_mixed_trash_default.json | 16 +++++ .../threads_list_mixed_trash_included.json | 16 +++++ .../mock-gmail/tests/test_conformance.py | 23 +++++++ .../mock-gmail/tests/test_threads.py | 60 ++++++++++++++++++- 9 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json diff --git a/packages/environments/mock-gmail/README.md b/packages/environments/mock-gmail/README.md index 28fdf446..55e77b8e 100644 --- a/packages/environments/mock-gmail/README.md +++ b/packages/environments/mock-gmail/README.md @@ -14,7 +14,7 @@ Mock Gmail provides a safe, fully stateful Gmail environment where agents can be - **Full MIME/RFC 2822 support** — agents can send raw base64url-encoded emails exactly like the real API - **Stateful SQLite backend** — persistent CRUD, multi-user mailboxes, local delivery between users - **Snapshot/restore** — save and reset DB state for deterministic evaluation runs -- **36 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior +- **40 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior - **Task-aware seeding** for repo-level example tasks, with DB state diffs and action logs for verifiers - **MCP server** — expose all endpoints as MCP tools via `fastapi-mcp` - **Gymnasium environment** — `GmailEnv` for RL-style agent training diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index 420bfdb0..76022873 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -5,7 +5,7 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import func +from sqlalchemy import case, func from sqlalchemy.orm import Session from mock_gmail.models import Thread, Message, MessageLabel @@ -37,10 +37,26 @@ def list_threads( db: Session = Depends(get_db), _user_id: str = Depends(resolve_user_id), ): + latest_visible_message_date = func.max( + case( + ( + Message.is_trash.is_(False) & Message.is_spam.is_(False), + Message.internal_date, + ), + else_=None, + ) + ) + # A provider capture shows that a mixed Trash thread stays anchored to its + # latest visible message with includeSpamTrash either false or true. Fully + # hidden threads use their latest member as a deterministic fallback. + thread_sort_date = func.coalesce( + latest_visible_message_date, + func.max(Message.internal_date), + ) latest_message = ( db.query( Message.thread_id.label("thread_id"), - func.max(Message.internal_date).label("internal_date"), + thread_sort_date.label("internal_date"), ) .filter(Message.user_id == _user_id) .group_by(Message.thread_id) diff --git a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json index 7ccdad03..063305b5 100644 --- a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json +++ b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json @@ -170,6 +170,7 @@ "tests": [ "tests/test_api.py::TestThreads::test_list_threads", "tests/test_conformance.py::TestThreadsConformance::test_threads_list_structure", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_trash_order_fixture", "tests/test_threads.py::TestThreadsListBehavior" ] }, diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json new file mode 100644 index 00000000..a99fe2ad --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_a.json @@ -0,0 +1,52 @@ +{ + "id": "19f56999f64f85dd", + "historyId": "10574", + "messages": [ + { + "id": "19f5699da6162829", + "threadId": "19f56999f64f85dd", + "labelIds": [ + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "visible message A1", + "historyId": "10574", + "internalDate": "1783864351000", + "sizeEstimate": 596, + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-hidden-A" + } + ] + } + }, + { + "id": "19f569a79794e0f1", + "threadId": "19f56999f64f85dd", + "labelIds": [ + "UNREAD", + "IMPORTANT", + "TRASH", + "SENT" + ], + "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User <fixture-user@example.com> wrote: visible message A1", + "historyId": "10571", + "internalDate": "1783864391000", + "sizeEstimate": 1427, + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "Re: [env0-fixture] mixed-hidden-A" + } + ] + } + } + ], + "_captured_at": "2026-07-12T13:54:49.469882+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json new file mode 100644 index 00000000..31f82efe --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_trash_b.json @@ -0,0 +1,30 @@ +{ + "id": "19f569a1f4cfbf48", + "historyId": "10504", + "messages": [ + { + "id": "19f569a5d2a5e0a7", + "threadId": "19f569a1f4cfbf48", + "labelIds": [ + "UNREAD", + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "visible message B1", + "historyId": "10504", + "internalDate": "1783864384000", + "sizeEstimate": 596, + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-hidden-B" + } + ] + } + } + ], + "_captured_at": "2026-07-12T13:54:49.201377+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json new file mode 100644 index 00000000..aeaf1f7c --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_default.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f569a1f4cfbf48", + "snippet": "visible message B1", + "historyId": "10504" + }, + { + "id": "19f56999f64f85dd", + "snippet": "visible message A1", + "historyId": "10574" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T13:54:48.618628+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json new file mode 100644 index 00000000..fcaed94f --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_trash_included.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f569a1f4cfbf48", + "snippet": "visible message B1", + "historyId": "10504" + }, + { + "id": "19f56999f64f85dd", + "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User wrote: > visible message A1 > >", + "historyId": "10574" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T13:54:48.941152+00:00" +} diff --git a/packages/environments/mock-gmail/tests/test_conformance.py b/packages/environments/mock-gmail/tests/test_conformance.py index a28a5e5e..537db6dc 100644 --- a/packages/environments/mock-gmail/tests/test_conformance.py +++ b/packages/environments/mock-gmail/tests/test_conformance.py @@ -637,6 +637,29 @@ def test_threads_list_structure(self, client): assert set(item.keys()) == {"id", "snippet", "historyId"} assert all(isinstance(value, str) for value in item.values()) + def test_threads_list_mixed_trash_order_fixture(self): + """Provider capture orders a mixed Trash thread by its visible message.""" + default = load_fixture("threads_list_mixed_trash_default.json") + included = load_fixture("threads_list_mixed_trash_included.json") + thread_a = load_fixture("thread_get_metadata_mixed_trash_a.json") + thread_b = load_fixture("thread_get_metadata_mixed_trash_b.json") + + assert len(thread_a["messages"]) == 2 + assert len(thread_b["messages"]) == 1 + a_visible, a_trashed = thread_a["messages"] + (b_visible,) = thread_b["messages"] + assert int(a_visible["internalDate"]) < int(b_visible["internalDate"]) + assert int(b_visible["internalDate"]) < int(a_trashed["internalDate"]) + assert "TRASH" not in a_visible["labelIds"] + assert "TRASH" not in b_visible["labelIds"] + assert "TRASH" in a_trashed["labelIds"] + + expected_order = [thread_b["id"], thread_a["id"]] + assert [thread["id"] for thread in default["threads"]] == expected_order + assert [thread["id"] for thread in included["threads"]] == expected_order + assert default["resultSizeEstimate"] == 2 + assert included["resultSizeEstimate"] == 2 + class TestSettingsConformance: def test_filters_list_empty_returns_empty_object(self, client): diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py index aaaec972..a226e852 100644 --- a/packages/environments/mock-gmail/tests/test_threads.py +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -232,9 +232,67 @@ def test_include_spam_trash_controls_hidden_threads(self, client, db_session): assert _listed_thread_ids(default_response) == ["thread-visible"] assert default_response.json()["resultSizeEstimate"] == 1 - assert set(_listed_thread_ids(included_response)) == set(scenarios) + # Fully hidden threads use their latest member message as the + # deterministic fallback sort timestamp when explicitly included. + assert _listed_thread_ids(included_response) == [ + "thread-spam-only", + "thread-trash-only", + "thread-visible", + ] assert included_response.json()["resultSizeEstimate"] == 3 + def test_newer_trashed_message_does_not_change_thread_order( + self, client, db_session + ): + user_id = _user_id(db_session) + subject_prefix = "Mixed hidden ordering 6e4b2" + _add_thread( + db_session, + user_id, + "thread-mixed-trash-a", + [ + _message_spec( + "message-mixed-trash-a-visible", + _at(5, 1), + "visible message A1", + ["INBOX"], + ), + _message_spec( + "message-mixed-trash-a-hidden", + _at(5, 3), + "hidden message A2", + ["TRASH"], + ), + ], + subject=f"{subject_prefix}-A", + ) + _add_thread( + db_session, + user_id, + "thread-mixed-trash-b", + [ + _message_spec( + "message-mixed-trash-b-visible", + _at(5, 2), + "visible message B1", + ["INBOX"], + ) + ], + subject=f"{subject_prefix}-B", + ) + + expected = ["thread-mixed-trash-b", "thread-mixed-trash-a"] + for include_spam_trash in (False, True): + response = client.get( + "/gmail/v1/users/me/threads", + params={ + "q": f'subject:"{subject_prefix}"', + "includeSpamTrash": str(include_spam_trash).lower(), + }, + ) + assert _listed_thread_ids(response) == expected + assert response.json()["resultSizeEstimate"] == 2 + def test_latest_message_order_and_pagination_are_stable(self, client, db_session): user_id = _user_id(db_session) marker = "thread-page-order-7c981" From 4c6c2b1ab2f30b03bb7345fa7715e4ac29d13b6b Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 22:21:24 +0800 Subject: [PATCH 11/12] Add Gmail mixed Spam ordering fixtures Record sanitized read-side captures for a mixed Spam thread and extend hidden-message regression coverage across Trash and Spam. --- packages/environments/mock-gmail/README.md | 2 +- .../mock-gmail/mock_gmail/api/threads.py | 6 +-- .../tests/fixtures/mock_coverage.json | 1 + .../thread_get_metadata_mixed_spam_c.json | 49 +++++++++++++++++++ .../thread_get_metadata_mixed_spam_d.json | 30 ++++++++++++ .../threads_list_mixed_spam_default.json | 16 ++++++ .../threads_list_mixed_spam_included.json | 16 ++++++ .../mock-gmail/tests/test_conformance.py | 23 +++++++++ .../mock-gmail/tests/test_threads.py | 24 +++++---- 9 files changed, 153 insertions(+), 14 deletions(-) create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json diff --git a/packages/environments/mock-gmail/README.md b/packages/environments/mock-gmail/README.md index 55e77b8e..ab5bfac5 100644 --- a/packages/environments/mock-gmail/README.md +++ b/packages/environments/mock-gmail/README.md @@ -14,7 +14,7 @@ Mock Gmail provides a safe, fully stateful Gmail environment where agents can be - **Full MIME/RFC 2822 support** — agents can send raw base64url-encoded emails exactly like the real API - **Stateful SQLite backend** — persistent CRUD, multi-user mailboxes, local delivery between users - **Snapshot/restore** — save and reset DB state for deterministic evaluation runs -- **40 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior +- **44 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior - **Task-aware seeding** for repo-level example tasks, with DB state diffs and action logs for verifiers - **MCP server** — expose all endpoints as MCP tools via `fastapi-mcp` - **Gymnasium environment** — `GmailEnv` for RL-style agent training diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index 76022873..a217ee9a 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -46,9 +46,9 @@ def list_threads( else_=None, ) ) - # A provider capture shows that a mixed Trash thread stays anchored to its - # latest visible message with includeSpamTrash either false or true. Fully - # hidden threads use their latest member as a deterministic fallback. + # Provider captures show that mixed Trash and Spam threads stay anchored to + # their latest visible message with includeSpamTrash either false or true. + # Fully hidden threads use their latest member as a deterministic fallback. thread_sort_date = func.coalesce( latest_visible_message_date, func.max(Message.internal_date), diff --git a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json index 063305b5..ec9fada2 100644 --- a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json +++ b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json @@ -171,6 +171,7 @@ "tests/test_api.py::TestThreads::test_list_threads", "tests/test_conformance.py::TestThreadsConformance::test_threads_list_structure", "tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_trash_order_fixture", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_spam_order_fixture", "tests/test_threads.py::TestThreadsListBehavior" ] }, diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json new file mode 100644 index 00000000..62dc2284 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_c.json @@ -0,0 +1,49 @@ +{ + "id": "19f56a705ee6e94d", + "historyId": "11270", + "messages": [ + { + "id": "19f56a73603c0d83", + "threadId": "19f56a705ee6e94d", + "labelIds": [ + "SENT", + "INBOX" + ], + "snippet": "visible message C1", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-spam-C" + } + ] + }, + "sizeEstimate": 594, + "historyId": "11175", + "internalDate": "1783865226000" + }, + { + "id": "19f56a7bafc0aceb", + "threadId": "19f56a705ee6e94d", + "labelIds": [ + "SENT", + "SPAM" + ], + "snippet": "hidden message C2 On Sun, Jul 12, 2026 at 10:07 PM Fixture User <fixture-user@example.com> wrote: visible message C1", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "Re: [env0-fixture] mixed-spam-C" + } + ] + }, + "sizeEstimate": 1427, + "historyId": "11270", + "internalDate": "1783865260000" + } + ], + "_captured_at": "2026-07-12T14:16:54.508049+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json new file mode 100644 index 00000000..53b4280c --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/thread_get_metadata_mixed_spam_d.json @@ -0,0 +1,30 @@ +{ + "id": "19f56a773c7f5c52", + "historyId": "10915", + "messages": [ + { + "id": "19f56a7a3046d75a", + "threadId": "19f56a773c7f5c52", + "labelIds": [ + "UNREAD", + "IMPORTANT", + "SENT", + "INBOX" + ], + "snippet": "visible message D1", + "payload": { + "mimeType": "multipart/alternative", + "headers": [ + { + "name": "Subject", + "value": "[env0-fixture] mixed-spam-D" + } + ] + }, + "sizeEstimate": 594, + "historyId": "10915", + "internalDate": "1783865254000" + } + ], + "_captured_at": "2026-07-12T14:16:54.240014+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json new file mode 100644 index 00000000..2252d78c --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_default.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f56a773c7f5c52", + "snippet": "visible message D1", + "historyId": "10915" + }, + { + "id": "19f56a705ee6e94d", + "snippet": "visible message C1", + "historyId": "11270" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T14:16:53.700176+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json new file mode 100644 index 00000000..b001def4 --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_mixed_spam_included.json @@ -0,0 +1,16 @@ +{ + "threads": [ + { + "id": "19f56a773c7f5c52", + "snippet": "visible message D1", + "historyId": "10915" + }, + { + "id": "19f56a705ee6e94d", + "snippet": "hidden message C2 On Sun, Jul 12, 2026 at 10:07 PM Fixture User wrote: > visible message C1 > >", + "historyId": "11270" + } + ], + "resultSizeEstimate": 2, + "_captured_at": "2026-07-12T14:16:53.981347+00:00" +} diff --git a/packages/environments/mock-gmail/tests/test_conformance.py b/packages/environments/mock-gmail/tests/test_conformance.py index 537db6dc..8f2cea6f 100644 --- a/packages/environments/mock-gmail/tests/test_conformance.py +++ b/packages/environments/mock-gmail/tests/test_conformance.py @@ -660,6 +660,29 @@ def test_threads_list_mixed_trash_order_fixture(self): assert default["resultSizeEstimate"] == 2 assert included["resultSizeEstimate"] == 2 + def test_threads_list_mixed_spam_order_fixture(self): + """Provider capture orders a mixed Spam thread by its visible message.""" + default = load_fixture("threads_list_mixed_spam_default.json") + included = load_fixture("threads_list_mixed_spam_included.json") + thread_c = load_fixture("thread_get_metadata_mixed_spam_c.json") + thread_d = load_fixture("thread_get_metadata_mixed_spam_d.json") + + assert len(thread_c["messages"]) == 2 + assert len(thread_d["messages"]) == 1 + c_visible, c_spam = thread_c["messages"] + (d_visible,) = thread_d["messages"] + assert int(c_visible["internalDate"]) < int(d_visible["internalDate"]) + assert int(d_visible["internalDate"]) < int(c_spam["internalDate"]) + assert "SPAM" not in c_visible["labelIds"] + assert "SPAM" not in d_visible["labelIds"] + assert "SPAM" in c_spam["labelIds"] + + expected_order = [thread_d["id"], thread_c["id"]] + assert [thread["id"] for thread in default["threads"]] == expected_order + assert [thread["id"] for thread in included["threads"]] == expected_order + assert default["resultSizeEstimate"] == 2 + assert included["resultSizeEstimate"] == 2 + class TestSettingsConformance: def test_filters_list_empty_returns_empty_object(self, client): diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py index a226e852..580efa97 100644 --- a/packages/environments/mock-gmail/tests/test_threads.py +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -241,27 +241,31 @@ def test_include_spam_trash_controls_hidden_threads(self, client, db_session): ] assert included_response.json()["resultSizeEstimate"] == 3 - def test_newer_trashed_message_does_not_change_thread_order( - self, client, db_session + @pytest.mark.parametrize("hidden_label", ["TRASH", "SPAM"]) + def test_newer_hidden_message_does_not_change_thread_order( + self, client, db_session, hidden_label ): user_id = _user_id(db_session) - subject_prefix = "Mixed hidden ordering 6e4b2" + hidden_kind = hidden_label.lower() + subject_prefix = f"Mixed {hidden_kind} ordering 6e4b2" + thread_a = f"thread-mixed-{hidden_kind}-a" + thread_b = f"thread-mixed-{hidden_kind}-b" _add_thread( db_session, user_id, - "thread-mixed-trash-a", + thread_a, [ _message_spec( - "message-mixed-trash-a-visible", + f"message-mixed-{hidden_kind}-a-visible", _at(5, 1), "visible message A1", ["INBOX"], ), _message_spec( - "message-mixed-trash-a-hidden", + f"message-mixed-{hidden_kind}-a-hidden", _at(5, 3), "hidden message A2", - ["TRASH"], + [hidden_label], ), ], subject=f"{subject_prefix}-A", @@ -269,10 +273,10 @@ def test_newer_trashed_message_does_not_change_thread_order( _add_thread( db_session, user_id, - "thread-mixed-trash-b", + thread_b, [ _message_spec( - "message-mixed-trash-b-visible", + f"message-mixed-{hidden_kind}-b-visible", _at(5, 2), "visible message B1", ["INBOX"], @@ -281,7 +285,7 @@ def test_newer_trashed_message_does_not_change_thread_order( subject=f"{subject_prefix}-B", ) - expected = ["thread-mixed-trash-b", "thread-mixed-trash-a"] + expected = [thread_b, thread_a] for include_spam_trash in (False, True): response = client.get( "/gmail/v1/users/me/threads", From 06c7868e4fffb5486385524226bd73b757271c03 Mon Sep 17 00:00:00 2001 From: Miaopeng Yu Date: Sun, 12 Jul 2026 22:37:55 +0800 Subject: [PATCH 12/12] Require Gmail thread labels on one message --- packages/environments/mock-gmail/README.md | 2 +- .../mock-gmail/mock_gmail/api/threads.py | 29 ++++++++++--------- .../tests/fixtures/mock_coverage.json | 1 + ...hreads_list_labels_same_inbox_message.json | 11 +++++++ ...hreads_list_labels_same_trash_message.json | 11 +++++++ ...ads_list_labels_split_across_messages.json | 4 +++ .../mock-gmail/tests/test_conformance.py | 21 ++++++++++++++ .../mock-gmail/tests/test_threads.py | 7 +++-- 8 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json create mode 100644 packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json diff --git a/packages/environments/mock-gmail/README.md b/packages/environments/mock-gmail/README.md index ab5bfac5..1d43ac0d 100644 --- a/packages/environments/mock-gmail/README.md +++ b/packages/environments/mock-gmail/README.md @@ -14,7 +14,7 @@ Mock Gmail provides a safe, fully stateful Gmail environment where agents can be - **Full MIME/RFC 2822 support** — agents can send raw base64url-encoded emails exactly like the real API - **Stateful SQLite backend** — persistent CRUD, multi-user mailboxes, local delivery between users - **Snapshot/restore** — save and reset DB state for deterministic evaluation runs -- **44 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior +- **47 golden fixtures** captured from the real Gmail API with conformance tests validating response shapes match Gmail behavior - **Task-aware seeding** for repo-level example tasks, with DB state diffs and action logs for verifiers - **MCP server** — expose all endpoints as MCP tools via `fastapi-mcp` - **Gymnasium environment** — `GmailEnv` for RL-style agent training diff --git a/packages/environments/mock-gmail/mock_gmail/api/threads.py b/packages/environments/mock-gmail/mock_gmail/api/threads.py index a217ee9a..dee42714 100644 --- a/packages/environments/mock-gmail/mock_gmail/api/threads.py +++ b/packages/environments/mock-gmail/mock_gmail/api/threads.py @@ -5,7 +5,7 @@ from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy import case, func +from sqlalchemy import and_, case, func from sqlalchemy.orm import Session from mock_gmail.models import Thread, Message, MessageLabel @@ -76,21 +76,24 @@ def list_threads( lid = lid.strip() if lid: normalized.append(lid) + message_label_filters = [] for lid in normalized: - query = query.filter( - Thread.messages.any( - Message.labels.any(MessageLabel.label_id == lid) - if lid not in ("UNREAD", "STARRED", "TRASH", "SPAM", "DRAFT", "SENT") - else ( - Message.is_read == False if lid == "UNREAD" - else Message.is_starred == True if lid == "STARRED" - else Message.is_trash == True if lid == "TRASH" - else Message.is_spam == True if lid == "SPAM" - else Message.is_draft == True if lid == "DRAFT" - else Message.is_sent == True - ) + message_label_filters.append( + Message.labels.any(MessageLabel.label_id == lid) + if lid not in ("UNREAD", "STARRED", "TRASH", "SPAM", "DRAFT", "SENT") + else ( + Message.is_read.is_(False) if lid == "UNREAD" + else Message.is_starred.is_(True) if lid == "STARRED" + else Message.is_trash.is_(True) if lid == "TRASH" + else Message.is_spam.is_(True) if lid == "SPAM" + else Message.is_draft.is_(True) if lid == "DRAFT" + else Message.is_sent.is_(True) ) ) + if message_label_filters: + query = query.filter( + Thread.messages.any(and_(*message_label_filters)) + ) if not includeSpamTrash: query = query.filter( diff --git a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json index ec9fada2..044a848e 100644 --- a/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json +++ b/packages/environments/mock-gmail/tests/fixtures/mock_coverage.json @@ -170,6 +170,7 @@ "tests": [ "tests/test_api.py::TestThreads::test_list_threads", "tests/test_conformance.py::TestThreadsConformance::test_threads_list_structure", + "tests/test_conformance.py::TestThreadsConformance::test_threads_list_label_filters_share_one_message_fixture", "tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_trash_order_fixture", "tests/test_conformance.py::TestThreadsConformance::test_threads_list_mixed_spam_order_fixture", "tests/test_threads.py::TestThreadsListBehavior" diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json new file mode 100644 index 00000000..727529bf --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_inbox_message.json @@ -0,0 +1,11 @@ +{ + "threads": [ + { + "id": "19f56999f64f85dd", + "snippet": "visible message A1", + "historyId": "10612" + } + ], + "resultSizeEstimate": 1, + "_captured_at": "2026-07-12T14:29:30.182981+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json new file mode 100644 index 00000000..476af25c --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_same_trash_message.json @@ -0,0 +1,11 @@ +{ + "threads": [ + { + "id": "19f56999f64f85dd", + "snippet": "hidden message A2 On Sun, Jul 12, 2026 at 9:52 PM Fixture User wrote: > visible message A1 > >", + "historyId": "10612" + } + ], + "resultSizeEstimate": 1, + "_captured_at": "2026-07-12T14:29:30.472382+00:00" +} diff --git a/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json new file mode 100644 index 00000000..5e26b11e --- /dev/null +++ b/packages/environments/mock-gmail/tests/fixtures/real_gmail/threads_list_labels_split_across_messages.json @@ -0,0 +1,4 @@ +{ + "resultSizeEstimate": 0, + "_captured_at": "2026-07-12T14:29:29.881992+00:00" +} diff --git a/packages/environments/mock-gmail/tests/test_conformance.py b/packages/environments/mock-gmail/tests/test_conformance.py index 8f2cea6f..23af909d 100644 --- a/packages/environments/mock-gmail/tests/test_conformance.py +++ b/packages/environments/mock-gmail/tests/test_conformance.py @@ -637,6 +637,27 @@ def test_threads_list_structure(self, client): assert set(item.keys()) == {"id", "snippet", "historyId"} assert all(isinstance(value, str) for value in item.values()) + def test_threads_list_label_filters_share_one_message_fixture(self): + """Provider requires one member message to satisfy all label IDs.""" + same_inbox = load_fixture("threads_list_labels_same_inbox_message.json") + same_trash = load_fixture("threads_list_labels_same_trash_message.json") + split = load_fixture("threads_list_labels_split_across_messages.json") + thread = load_fixture("thread_get_metadata_mixed_trash_a.json") + + visible, trashed = thread["messages"] + assert {"INBOX", "SENT"} <= set(visible["labelIds"]) + assert {"TRASH", "SENT"} <= set(trashed["labelIds"]) + assert not any( + {"INBOX", "TRASH"} <= set(message["labelIds"]) + for message in thread["messages"] + ) + + assert [item["id"] for item in same_inbox["threads"]] == [thread["id"]] + assert [item["id"] for item in same_trash["threads"]] == [thread["id"]] + assert same_inbox["resultSizeEstimate"] == 1 + assert same_trash["resultSizeEstimate"] == 1 + assert split == {"resultSizeEstimate": 0} + def test_threads_list_mixed_trash_order_fixture(self): """Provider capture orders a mixed Trash thread by its visible message.""" default = load_fixture("threads_list_mixed_trash_default.json") diff --git a/packages/environments/mock-gmail/tests/test_threads.py b/packages/environments/mock-gmail/tests/test_threads.py index 580efa97..1249054c 100644 --- a/packages/environments/mock-gmail/tests/test_threads.py +++ b/packages/environments/mock-gmail/tests/test_threads.py @@ -159,11 +159,12 @@ def test_same_subject_different_thread_ids_remain_separate( } assert response.json()["resultSizeEstimate"] == 2 - def test_all_labels_may_be_on_different_messages(self, client, db_session): + def test_all_labels_must_be_on_the_same_message(self, client, db_session): user_id = _user_id(db_session) marker = "thread-label-union-135b" scenarios = { - "thread-label-both": (["INBOX"], ["STARRED"]), + "thread-label-same-message": (["INBOX", "STARRED"], []), + "thread-label-split": (["INBOX"], ["STARRED"]), "thread-label-inbox-only": (["INBOX"], ["INBOX"]), "thread-label-starred-only": (["STARRED"], ["STARRED"]), } @@ -192,7 +193,7 @@ def test_all_labels_may_be_on_different_messages(self, client, db_session): ], ) - assert _listed_thread_ids(response) == ["thread-label-both"] + assert _listed_thread_ids(response) == ["thread-label-same-message"] assert response.json()["resultSizeEstimate"] == 1 def test_include_spam_trash_controls_hidden_threads(self, client, db_session):