|
33 | 33 |
|
34 | 34 | pytest.importorskip("gaia_agent_email") # noqa: E402 |
35 | 35 | 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 | +) |
36 | 39 | from gaia_agent_email.tools.read_tools import ( # noqa: E402 |
37 | 40 | pre_scan_inbox_impl, |
38 | 41 | triage_inbox_impl, |
@@ -249,5 +252,127 @@ def _recording_triage(*args, **kwargs): |
249 | 252 | assert any(i.get("is_meeting_request") is True for i in all_items) |
250 | 253 |
|
251 | 254 |
|
| 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 | + |
252 | 377 | if __name__ == "__main__": |
253 | 378 | pytest.main([__file__, "-v"]) |
0 commit comments