Skip to content

Commit 0354c0f

Browse files
committed
Merge remote-tracking branch 'origin/main' into tmi/issue-2515-m59
2 parents 1b652b5 + ee4af04 commit 0354c0f

4 files changed

Lines changed: 276 additions & 28 deletions

File tree

hub/agents/email/python/CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,17 @@ contract version is tracked separately as
8181
`recoverable` flag through to the wire; a recoverable error folds to a
8282
non-terminal status line instead of a terminal `error`, so the retry can
8383
reach completion and the user still sees the failure as it happens.
84+
- **`get_thread` returns every message in the right order — no more dropped
85+
or duplicated entries on a multi-participant thread (#2531).** Asked to
86+
list a full conversation chronologically, the agent could return the
87+
right message count but the wrong contents — one side of a two-party
88+
thread under-represented, entries duplicated, the last two messages
89+
swapped. Gmail's thread API does not guarantee message order, and
90+
`get_thread` — unlike its `summarize_thread` sibling, which already
91+
sorted defensively — trusted raw backend order and handed the model an
92+
unlabeled list to sort itself. `get_thread` now sorts by timestamp and
93+
numbers each message with its position (`index`/`of_total`), giving the
94+
model an authoritative order instead of one it has to compute.
8495
- **"Show me my inbox" now works on a real mailbox with the default NPU
8596
profile (#2514).** `list_inbox` and `search_messages` capped each
8697
message's body independently but never checked the COMBINED size of the

hub/agents/email/python/gaia_agent_email/tools/read_tools.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,18 @@ def get_message_impl(
267267

268268

269269
def get_thread_impl(gmail, *, thread_id: str, debug: bool = False) -> Dict[str, Any]:
270-
"""Fetch every message in a thread, backend order preserved (no sort).
270+
"""Fetch every message in a thread, sorted chronologically (oldest first).
271+
272+
#2531: Gmail's thread API does not guarantee message order (it is
273+
"usually" oldest-first, not always) — the same risk
274+
``_thread_message_sort_key`` already defends against for
275+
``summarize_thread``. This path used to trust raw backend order instead,
276+
and a live run showed the consequence: the calling LLM, handed an
277+
unlabeled JSON array it had to sort and enumerate itself, returned the
278+
right message COUNT but dropped/duplicated entries and inverted the
279+
trailing pair. Sorting here, and numbering each message with its
280+
position, gives the model an authoritative order instead of one it has
281+
to compute.
271282
272283
The combined body budget mirrors ``_format_thread_for_summary``'s
273284
soft-target semantics (#2073): under ``DEFAULT_THREAD_TRANSCRIPT_CHARS``
@@ -278,7 +289,7 @@ def get_thread_impl(gmail, *, thread_id: str, debug: bool = False) -> Dict[str,
278289
"""
279290
with log_tool_call("get_thread", {"thread_id": thread_id}, debug=debug) as st:
280291
thread = gmail.get_thread(thread_id)
281-
messages = thread.get("messages", [])
292+
messages = sorted(thread.get("messages", []), key=_thread_message_sort_key)
282293
out = [_format_message_for_llm(m) for m in messages]
283294
total = sum(len(f["body"]) for f in out)
284295
if messages and total > DEFAULT_THREAD_TRANSCRIPT_CHARS:
@@ -293,6 +304,9 @@ def get_thread_impl(gmail, *, thread_id: str, debug: bool = False) -> Dict[str,
293304
out = [
294305
_format_message_for_llm(m, body_limit=fair_share) for m in messages
295306
]
307+
for position, formatted in enumerate(out, start=1):
308+
formatted["index"] = position
309+
formatted["of_total"] = len(out)
296310
bodies_clipped = sum(1 for f in out if f["body_truncated"])
297311
st["result_summary"] = {
298312
"thread_id": thread_id,
@@ -1425,10 +1439,14 @@ def get_message(
14251439
def get_thread(thread_id: str, mailbox: str = "") -> str:
14261440
"""Fetch every message in a thread (conversation view).
14271441
1428-
Long threads share a combined body budget: over-budget message
1429-
bodies are clipped with a ``...[truncated]`` marker; messages are
1430-
never dropped. ``mailbox`` (optional) routes when multiple
1431-
mailboxes are connected.
1442+
Messages are returned sorted chronologically (oldest first) and
1443+
each carries ``index``/``of_total`` (its 1-based position in the
1444+
thread) — use these, not the raw list order, when listing or
1445+
counting messages. Long threads share a combined body budget:
1446+
over-budget message bodies are clipped with a
1447+
``...[truncated]`` marker; messages are never dropped.
1448+
``mailbox`` (optional) routes when multiple mailboxes are
1449+
connected.
14321450
"""
14331451
try:
14341452
backend = agent._backend_for_message(thread_id, mailbox or None)
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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

Comments
 (0)