Skip to content

Commit 13242a1

Browse files
committed
Merge remote-tracking branch 'origin/main' into issue-2763
2 parents 0021317 + 6873651 commit 13242a1

3 files changed

Lines changed: 159 additions & 3 deletions

File tree

hub/agents/email/python/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ contract version is tracked separately as
99

1010
### Fixed
1111

12+
- **A meeting proposal in a confidently-classified message vanished from the
13+
view the TUI renders on open (#2580).** `is_meeting_request` was wired into
14+
the scan by #2589, but the `needs_you` worklist (#2743, which replaced the
15+
#2582 `/attention` fetch as the TUI's on-open source) only kept the flag for
16+
messages already routed into `urgent`/`actionable` by category, and dropped
17+
it entirely for anything reaching `needs_review`. A message the category
18+
heuristic confidently calls FYI/PERSONAL — e.g. Gmail's own
19+
`CATEGORY_PERSONAL` label on a colleague's message — kept
20+
`is_meeting_request=True` from the scan but was silently counted only under
21+
`informational_count`, reproducing the original grounding incident
22+
("Any chance to meet this Thursday at 9am?" reported under "0 actionable
23+
items") on current `main`. `is_meeting_request` now vetoes the
24+
informational/suggested-archives routing the same way an unconfident guess
25+
already does, and a meeting-flagged item keeps its `meeting_request` kind
26+
through `needs_review` instead of being downgraded to a generic
27+
"needs review" row.
1228
- **`search_messages` stated a wrong, unstable count for a result set it
1329
received intact (#2756).** Asked "how many messages from X in the last two
1430
weeks", the agent ran the right query, got every matching row back, then

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

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,7 +1863,12 @@ def _remember(mid: Optional[str]) -> None:
18631863
candidates.append({**item, "kind": kind})
18641864
_remember(item.get("message_id"))
18651865
for item in needs_review:
1866-
candidates.append({**item, "kind": "needs_review"})
1866+
# #2580: a meeting-flagged item must keep that kind even when it
1867+
# reaches needs_you via needs_review, same as the urgent/actionable
1868+
# loops above — otherwise the TUI renders a generic "check this"
1869+
# instead of naming the proposed time.
1870+
kind = "meeting_request" if item.get("is_meeting_request") else "needs_review"
1871+
candidates.append({**item, "kind": kind})
18671872
_remember(item.get("message_id"))
18681873

18691874
for w in waiting_on_you or []:
@@ -2070,7 +2075,13 @@ def pre_scan_inbox_impl(
20702075
elif category == CATEGORY_NEEDS_RESPONSE:
20712076
actionable.append({**base, "why": why})
20722077
elif category == CATEGORY_PROMOTIONAL:
2073-
if needs_review_decision(r):
2078+
# is_meeting_request is an additional veto (#2580) — a
2079+
# genuine time proposal must not be silently archived just
2080+
# because the category heuristic is confident about
2081+
# PROMOTIONAL. Mirrors attention_tools._scan_one_backend,
2082+
# which already checks is_meeting_request independent of
2083+
# category.
2084+
if needs_review_decision(r) or base["is_meeting_request"]:
20742085
needs_review_ranked.append(
20752086
(_needs_review_sort_key(r), {**base, "why": why})
20762087
)
@@ -2084,7 +2095,11 @@ def pre_scan_inbox_impl(
20842095
# to the terminal FYI-placeholder fallback). Routed through
20852096
# needs_review_decision (shared with the attention-view
20862097
# aggregator, #2582) rather than a local confidence check.
2087-
if needs_review_decision(r):
2098+
# is_meeting_request is an additional veto (#2580) — a
2099+
# confident FYI/PERSONAL message can still be a real ask,
2100+
# and letting it through would make FILTER_TEST_NO_MEETING_
2101+
# PROPOSAL below a false claim about the message it tags.
2102+
if needs_review_decision(r) or base["is_meeting_request"]:
20882103
needs_review_ranked.append(
20892104
(_needs_review_sort_key(r), {**base, "why": why})
20902105
)

tests/unit/agents/email/test_pre_scan_meeting_detection.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@
3333

3434
pytest.importorskip("gaia_agent_email") # noqa: E402
3535
from gaia_agent_email.tools import read_tools # noqa: E402
36+
from gaia_agent_email.tools.attention_tools import ( # noqa: E402
37+
build_attention_view_impl,
38+
)
3639
from gaia_agent_email.tools.read_tools import ( # noqa: E402
3740
pre_scan_inbox_impl,
3841
triage_inbox_impl,
@@ -249,5 +252,127 @@ def _recording_triage(*args, **kwargs):
249252
assert any(i.get("is_meeting_request") is True for i in all_items)
250253

251254

255+
class TestGroundingIncidentSurfacesAsNeedingReply:
256+
"""End-to-end regression for the #2580 epic's grounding incident.
257+
258+
On 2026-07-28, on ``main``, a colleague's message combining a direct
259+
question with an informal meeting-time proposal — "Did you have a
260+
chance to look through the code and get a pull request? Any chance to
261+
meet this Thursday at 9am?" — was classified confidently FYI and
262+
reported under "0 actionable items" (12 scanned, 12 by heuristic, 0
263+
escalated to the LLM). #2589 wired ``detect_meeting_request_heuristic``
264+
into the scan; #2743 built the ``needs_you`` worklist the TUI actually
265+
renders on open (replacing the #2582 ``/attention`` fetch — see
266+
``tui/internal/ui/chat/model.go``'s ``preScanFetchedMsg`` comment).
267+
268+
That left a gap: ``_build_needs_you_view`` only relabels an item
269+
already routed into ``urgent``/``actionable`` by CATEGORY, and its
270+
``needs_review`` loop dropped ``is_meeting_request`` entirely. A
271+
message the category heuristic confidently calls FYI/PERSONAL (e.g.
272+
Gmail's own ``CATEGORY_PERSONAL`` label — the plausible real shape of
273+
the incident message) kept ``is_meeting_request=True`` from the scan
274+
but was silently invisible in the view the TUI reads on open — the
275+
incident, reproduced verbatim on current ``main``.
276+
"""
277+
278+
INCIDENT_TEXT = (
279+
"Did you have a chance to look through the code and get a pull "
280+
"request? Any chance to meet this Thursday at 9am?"
281+
)
282+
283+
def _gmail_with_incident_message(self, *, label_ids: List[str]) -> FakeGmailBackend:
284+
gmail = FakeGmailBackend()
285+
gmail.add_message(
286+
_msg(
287+
"incident_msg",
288+
subject="Quick check-in",
289+
sender="colleague@example.com",
290+
label_ids=label_ids,
291+
snippet=self.INCIDENT_TEXT,
292+
)
293+
)
294+
return gmail
295+
296+
def test_incident_message_is_not_silently_informational(self):
297+
# Gmail's own Personal-tab label makes the category heuristic
298+
# commit confident=True unconditionally (triage_heuristics.py rule
299+
# 5) — isolating whether is_meeting_request alone can save the
300+
# message from the bare-count bucket.
301+
gmail = self._gmail_with_incident_message(
302+
label_ids=["INBOX", "CATEGORY_PERSONAL"]
303+
)
304+
out = pre_scan_inbox_impl(gmail, max_messages=50)
305+
assert out["informational_count"] == 0, (
306+
"the incident message must not be silently counted as "
307+
f"informational with no other trace: {out}"
308+
)
309+
310+
def test_incident_message_needs_no_llm_classifier(self):
311+
# pre_scan_inbox_impl has no ``classifier`` parameter and never
312+
# passes one to triage_inbox_impl (read_tools.py:1164-1165's own
313+
# docstring: "pre_scan_inbox_impl never wires a classifier") — so
314+
# CATEGORY_URGENT/NEEDS_RESPONSE are structurally unreachable here
315+
# regardless of config, matching the real incident's own log line
316+
# ("12 decided by heuristic, 0 escalated to the LLM"). This fix
317+
# must clear the incident on that exact heuristic-only condition,
318+
# not merely when something upstream supplies a classifier —
319+
# detect_meeting_request_heuristic needs no LLM call to fire.
320+
gmail = self._gmail_with_incident_message(
321+
label_ids=["INBOX", "CATEGORY_PERSONAL"]
322+
)
323+
triage = triage_inbox_impl(gmail, max_messages=50)
324+
by_id = {r["id"]: r for r in triage["results"]}
325+
assert by_id["incident_msg"]["source"] == "heuristic", (
326+
"expected the incident message resolved with zero LLM "
327+
f"escalation, got {by_id['incident_msg']!r}"
328+
)
329+
out = pre_scan_inbox_impl(gmail, max_messages=50)
330+
matches = [i for i in out["needs_you"] if i.get("message_id") == "incident_msg"]
331+
assert matches and matches[0]["kind"] == "meeting_request"
332+
333+
def test_incident_message_surfaces_in_needs_you(self):
334+
gmail = self._gmail_with_incident_message(
335+
label_ids=["INBOX", "CATEGORY_PERSONAL"]
336+
)
337+
out = pre_scan_inbox_impl(gmail, max_messages=50)
338+
# The #2580 acceptance criterion, directly: it must surface as
339+
# needing a reply, not disappear under "0 actionable items".
340+
assert (
341+
out["needs_you_total"] >= 1
342+
), f"expected the incident message to surface in needs_you: {out}"
343+
matches = [i for i in out["needs_you"] if i.get("message_id") == "incident_msg"]
344+
assert matches, f"incident_msg not present in needs_you: {out['needs_you']}"
345+
assert matches[0]["kind"] == "meeting_request", (
346+
"expected kind='meeting_request' so the TUI can name the "
347+
f"proposed time, got {matches[0]!r}"
348+
)
349+
350+
def test_meeting_flag_survives_needs_review_routing(self):
351+
# No category-label signal at all -> unconfident FYI -> needs_review
352+
# by the pre-existing #2584/#2743 path. The meeting flag must not
353+
# be discarded there either (needs_review previously always tagged
354+
# kind="needs_review", regardless of is_meeting_request).
355+
gmail = self._gmail_with_incident_message(label_ids=["INBOX"])
356+
out = pre_scan_inbox_impl(gmail, max_messages=50)
357+
matches = [i for i in out["needs_you"] if i.get("message_id") == "incident_msg"]
358+
assert matches, f"incident_msg not present in needs_you: {out['needs_you']}"
359+
assert (
360+
matches[0]["kind"] == "meeting_request"
361+
), f"needs_review routing must not discard the meeting flag: {matches[0]!r}"
362+
363+
def test_incident_message_via_attention_view(self):
364+
# The originally-shipped #2582 mechanism — still reachable via
365+
# GET /v1/email/attention even though the TUI no longer calls it on
366+
# open (#2743) — must keep catching this. Regression guard so a
367+
# future change to attention_tools.py can't quietly reopen it.
368+
gmail = self._gmail_with_incident_message(
369+
label_ids=["INBOX", "CATEGORY_PERSONAL"]
370+
)
371+
out = build_attention_view_impl({"google": gmail}, max_messages=50)
372+
matches = [i for i in out["items"] if i.get("message_id") == "incident_msg"]
373+
assert matches, f"incident_msg missing from attention view: {out['items']}"
374+
assert matches[0]["kind"] == "meeting_request"
375+
376+
252377
if __name__ == "__main__":
253378
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)