|
| 1 | +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. |
| 2 | +# SPDX-License-Identifier: MIT |
| 3 | +""" |
| 4 | +``get_thread`` chronology / dedupe tests for #2531. |
| 5 | +
|
| 6 | +Reproduces the reported defect: a real 8-message, 2-participant alternating |
| 7 | +thread came back from the agent with the right message COUNT (8) but the |
| 8 | +wrong CONTENTS — a 4/2/2 sender split instead of the true 4/4, and the last |
| 9 | +two entries (98 seconds apart) inverted. A total-count-only assertion is |
| 10 | +exactly what let this ship, so every test here asserts per-sender counts and |
| 11 | +distinct message ids, never just ``len(...)``. |
| 12 | +
|
| 13 | +Layer isolation (see the PR description for the full raw-vs-formatted |
| 14 | +writeup): these tests exercise ``get_thread_impl`` directly against |
| 15 | +``FakeGmailBackend`` — the TOOL/assembly layer, with no LLM involved. They |
| 16 | +prove two things about that layer, pre-fix: |
| 17 | +
|
| 18 | +1. Given a well-behaved backend, ``get_thread_impl``'s assembly code does |
| 19 | + NOT structurally drop or duplicate messages — every distinct id fetched |
| 20 | + from the backend is formatted exactly once. So the reported 4/2/2 split |
| 21 | + is not explained by a dedupe/drop bug at this layer. |
| 22 | +2. ``get_thread_impl`` documented and tested "backend order preserved (no |
| 23 | + sort)" — inconsistent with Gmail's own documented non-guarantee of |
| 24 | + thread-message order, and inconsistent with this same file's established |
| 25 | + defensive pattern for ``summarize_thread`` (``_thread_message_sort_key``). |
| 26 | + A misordered backend response reproduces the reported ordering inversion |
| 27 | + at THIS layer, independent of any LLM. That is the proven, fixed bug. |
| 28 | +""" |
| 29 | + |
| 30 | +from __future__ import annotations |
| 31 | + |
| 32 | +import base64 |
| 33 | +import sys |
| 34 | +from pathlib import Path |
| 35 | +from typing import Any, Dict, List |
| 36 | + |
| 37 | +import pytest |
| 38 | + |
| 39 | +_REPO_ROOT = Path(__file__).resolve().parents[5] |
| 40 | +if str(_REPO_ROOT) not in sys.path: |
| 41 | + sys.path.insert(0, str(_REPO_ROOT)) |
| 42 | + |
| 43 | +pytest.importorskip("gaia_agent_email") |
| 44 | + |
| 45 | +from gaia_agent_email.tools.read_tools import get_thread_impl # noqa: E402 |
| 46 | + |
| 47 | +from tests.fixtures.email.fake_gmail import FakeGmailBackend # noqa: E402 |
| 48 | + |
| 49 | + |
| 50 | +def _b64url(text: str) -> str: |
| 51 | + return base64.urlsafe_b64encode(text.encode("utf-8")).decode("ascii").rstrip("=") |
| 52 | + |
| 53 | + |
| 54 | +def _msg( |
| 55 | + msg_id: str, |
| 56 | + *, |
| 57 | + thread_id: str, |
| 58 | + sender: str, |
| 59 | + internal_date_ms: int, |
| 60 | + date_header: str, |
| 61 | + subject: str = "Contributing to GAIA", |
| 62 | +) -> Dict[str, Any]: |
| 63 | + body = f"body of {msg_id}" |
| 64 | + return { |
| 65 | + "id": msg_id, |
| 66 | + "threadId": thread_id, |
| 67 | + "labelIds": ["INBOX"], |
| 68 | + "snippet": body[:200], |
| 69 | + "internalDate": str(internal_date_ms), |
| 70 | + "payload": { |
| 71 | + "mimeType": "text/plain", |
| 72 | + "filename": "", |
| 73 | + "headers": [ |
| 74 | + {"name": "Subject", "value": subject}, |
| 75 | + {"name": "From", "value": sender}, |
| 76 | + {"name": "To", "value": "user@example.com"}, |
| 77 | + {"name": "Date", "value": date_header}, |
| 78 | + ], |
| 79 | + "body": {"data": _b64url(body), "size": len(body)}, |
| 80 | + }, |
| 81 | + "sizeEstimate": len(body), |
| 82 | + } |
| 83 | + |
| 84 | + |
| 85 | +# One evening, 8 messages, strictly alternating between two participants. |
| 86 | +# Chronological (true) order is m1..m8. The last pair is 98 seconds apart — |
| 87 | +# the exact gap the real reproduction observed inverted (18:53:03 / 18:54:41). |
| 88 | +_ALICE = "alice@example.com" |
| 89 | +_BOB = "bob@example.org" |
| 90 | +_THREAD_ID = "thread-contributing-to-gaia" |
| 91 | + |
| 92 | +_BASE_MS = 1_800_000_000_000 # arbitrary fixed epoch-millis anchor |
| 93 | +_TRUE_ORDER: List[Dict[str, Any]] = [ |
| 94 | + _msg( |
| 95 | + f"m{i}", |
| 96 | + thread_id=_THREAD_ID, |
| 97 | + sender=_ALICE if i % 2 == 1 else _BOB, |
| 98 | + internal_date_ms=_BASE_MS + offset_ms, |
| 99 | + date_header=date_header, |
| 100 | + ) |
| 101 | + for i, (offset_ms, date_header) in enumerate( |
| 102 | + [ |
| 103 | + (0, "Mon, 27 Jul 2026 18:00:00 -0700"), |
| 104 | + (10 * 60_000, "Mon, 27 Jul 2026 18:10:00 -0700"), |
| 105 | + (25 * 60_000, "Mon, 27 Jul 2026 18:25:00 -0700"), |
| 106 | + (40 * 60_000, "Mon, 27 Jul 2026 18:40:00 -0700"), |
| 107 | + (48 * 60_000, "Mon, 27 Jul 2026 18:48:00 -0700"), |
| 108 | + (51 * 60_000, "Mon, 27 Jul 2026 18:51:00 -0700"), |
| 109 | + (53 * 60_000 + 3_000, "Mon, 27 Jul 2026 18:53:03 -0700"), |
| 110 | + (54 * 60_000 + 41_000, "Mon, 27 Jul 2026 18:54:41 -0700"), |
| 111 | + ], |
| 112 | + start=1, |
| 113 | + ) |
| 114 | +] |
| 115 | + |
| 116 | + |
| 117 | +def _build_backend(insertion_order: List[Dict[str, Any]]) -> FakeGmailBackend: |
| 118 | + """Seed a FakeGmailBackend, inserted in ``insertion_order``. |
| 119 | +
|
| 120 | + ``FakeGmailBackend.get_thread`` returns messages in dict-insertion order |
| 121 | + (no sort of its own — see ``fake_gmail.py``), so this directly controls |
| 122 | + what "raw backend order" ``get_thread_impl`` sees, letting us simulate |
| 123 | + Gmail's own documented non-guarantee of in-order thread results. |
| 124 | + """ |
| 125 | + gmail = FakeGmailBackend(user_email="user@example.com") |
| 126 | + for msg in insertion_order: |
| 127 | + gmail.add_message(msg) |
| 128 | + return gmail |
| 129 | + |
| 130 | + |
| 131 | +class TestSenderDistributionAndDedup: |
| 132 | + """Per-sender counts and distinct ids — the assertion shape the issue |
| 133 | + says a total-count-only test would have missed.""" |
| 134 | + |
| 135 | + def test_8_message_thread_returns_all_distinct_ids_true_4_4_split(self): |
| 136 | + gmail = _build_backend(_TRUE_ORDER) |
| 137 | + result = get_thread_impl(gmail, thread_id=_THREAD_ID) |
| 138 | + messages = result["messages"] |
| 139 | + |
| 140 | + assert len(messages) == 8 |
| 141 | + ids = [m["id"] for m in messages] |
| 142 | + assert len(set(ids)) == 8, f"duplicate message ids in result: {ids}" |
| 143 | + |
| 144 | + senders = [m["from"] for m in messages] |
| 145 | + assert senders.count(_ALICE) == 4 |
| 146 | + assert senders.count(_BOB) == 4 |
| 147 | + |
| 148 | + def test_no_duplicated_message_ids_even_when_backend_order_is_scrambled(self): |
| 149 | + scrambled = [_TRUE_ORDER[i] for i in (2, 0, 4, 1, 7, 3, 6, 5)] |
| 150 | + gmail = _build_backend(scrambled) |
| 151 | + result = get_thread_impl(gmail, thread_id=_THREAD_ID) |
| 152 | + ids = [m["id"] for m in result["messages"]] |
| 153 | + assert sorted(ids) == [f"m{i}" for i in range(1, 9)] |
| 154 | + assert len(set(ids)) == 8 |
| 155 | + |
| 156 | + |
| 157 | +class TestChronologicalOrdering: |
| 158 | + """Ordering must be correct even when the backend hands messages back |
| 159 | + out of order — the exact failure mode Gmail's own API docs warn about |
| 160 | + and that ``_thread_message_sort_key`` already defends against for |
| 161 | + ``summarize_thread``.""" |
| 162 | + |
| 163 | + def test_strict_chronological_order_when_backend_is_well_ordered(self): |
| 164 | + gmail = _build_backend(_TRUE_ORDER) |
| 165 | + result = get_thread_impl(gmail, thread_id=_THREAD_ID) |
| 166 | + ids = [m["id"] for m in result["messages"]] |
| 167 | + assert ids == [f"m{i}" for i in range(1, 9)] |
| 168 | + |
| 169 | + def test_last_two_close_messages_are_not_inverted_when_backend_inverts_them(self): |
| 170 | + """Reproduces the reported defect directly: backend returns the last |
| 171 | + two messages (98s apart) in reverse order; the tool must still |
| 172 | + present them chronologically. |
| 173 | + """ |
| 174 | + insertion_order = _TRUE_ORDER[:6] + [_TRUE_ORDER[7], _TRUE_ORDER[6]] |
| 175 | + gmail = _build_backend(insertion_order) |
| 176 | + result = get_thread_impl(gmail, thread_id=_THREAD_ID) |
| 177 | + ids = [m["id"] for m in result["messages"]] |
| 178 | + assert ids == [f"m{i}" for i in range(1, 9)], ( |
| 179 | + "get_thread_impl must sort defensively — a misordered backend " |
| 180 | + "must not leak an out-of-order thread to the caller" |
| 181 | + ) |
| 182 | + # The specific close pair the real run inverted. |
| 183 | + assert ids.index("m7") < ids.index("m8") |
| 184 | + |
| 185 | + def test_fully_reversed_backend_order_is_corrected(self): |
| 186 | + gmail = _build_backend(list(reversed(_TRUE_ORDER))) |
| 187 | + result = get_thread_impl(gmail, thread_id=_THREAD_ID) |
| 188 | + ids = [m["id"] for m in result["messages"]] |
| 189 | + assert ids == [f"m{i}" for i in range(1, 9)] |
| 190 | + |
| 191 | + |
| 192 | +class TestSingleMessageThreadGuard: |
| 193 | + """Guard against over-correcting: a genuinely single-message thread |
| 194 | + (e.g. a newsletter) must still return exactly that one message.""" |
| 195 | + |
| 196 | + def test_single_message_thread_returns_exactly_one_message(self): |
| 197 | + solo = _msg( |
| 198 | + "solo1", |
| 199 | + thread_id="solo-thread", |
| 200 | + sender="newsletter@example.com", |
| 201 | + internal_date_ms=_BASE_MS, |
| 202 | + date_header="Mon, 27 Jul 2026 09:00:00 -0700", |
| 203 | + ) |
| 204 | + gmail = _build_backend([solo]) |
| 205 | + result = get_thread_impl(gmail, thread_id="solo-thread") |
| 206 | + assert len(result["messages"]) == 1 |
| 207 | + assert result["messages"][0]["id"] == "solo1" |
0 commit comments