|
29 | 29 | from __future__ import annotations |
30 | 30 |
|
31 | 31 | import json |
| 32 | +import re |
32 | 33 | import sys |
33 | 34 | import time |
34 | 35 | from pathlib import Path |
|
58 | 59 | find_unlicensed_cross_mailbox_claim, |
59 | 60 | find_unqualified_negative_claim, |
60 | 61 | ground_final_answer, |
| 62 | + normalize_triage_list, |
| 63 | + render_needs_you_list, |
| 64 | + rewrite_triage_answer, |
61 | 65 | strip_scaffolding_leaks, |
62 | 66 | tools_called_this_turn, |
63 | 67 | ) |
@@ -435,6 +439,150 @@ def test_leaves_clean_text_unchanged_apart_from_whitespace_trim(self): |
435 | 439 | assert strip_scaffolding_leaks(clean) == clean |
436 | 440 |
|
437 | 441 |
|
| 442 | +# --------------------------------------------------------------------------- |
| 443 | +# render_needs_you_list / rewrite_triage_answer — the triage list is rendered |
| 444 | +# from the scan's own needs_you view, never retyped by the model (#2789 |
| 445 | +# follow-up: three consecutive live runs invented numbering, dropped items, |
| 446 | +# or merged sections when the model was asked to write the list itself). |
| 447 | +# --------------------------------------------------------------------------- |
| 448 | + |
| 449 | + |
| 450 | +def _needs_you_row(ref: int, kind: str, **overrides) -> dict: |
| 451 | + base = { |
| 452 | + "ref": ref, |
| 453 | + "kind": kind, |
| 454 | + "message_id": f"m{ref}", |
| 455 | + "thread_id": f"t{ref}", |
| 456 | + "sender": "Someone <someone@example.com>", |
| 457 | + "subject": f"subject {ref}", |
| 458 | + "age_seconds": 3600, |
| 459 | + "why": "", |
| 460 | + "detail": [], |
| 461 | + "due_hint": None, |
| 462 | + "mailbox": None, |
| 463 | + } |
| 464 | + base.update(overrides) |
| 465 | + return base |
| 466 | + |
| 467 | + |
| 468 | +class TestRenderNeedsYouList: |
| 469 | + def test_empty_needs_you_renders_nothing(self): |
| 470 | + assert render_needs_you_list(_prescan_envelope(needs_you=[])) == "" |
| 471 | + assert render_needs_you_list({}) == "" |
| 472 | + |
| 473 | + def test_sections_appear_in_display_order_with_ascending_refs(self): |
| 474 | + envelope = _prescan_envelope( |
| 475 | + needs_you=[ |
| 476 | + _needs_you_row(1, "waiting_on_you"), |
| 477 | + _needs_you_row(2, "needs_response"), |
| 478 | + _needs_you_row(3, "meeting_request"), |
| 479 | + _needs_you_row(4, "needs_review"), |
| 480 | + ] |
| 481 | + ) |
| 482 | + rendered = render_needs_you_list(envelope) |
| 483 | + # Every section header appears, in _TRIAGE_SECTIONS order. |
| 484 | + for heading in ( |
| 485 | + "Waiting on your reply", |
| 486 | + "Needs a response", |
| 487 | + "Meetings to decide", |
| 488 | + "Needs a manual look", |
| 489 | + ): |
| 490 | + assert heading in rendered |
| 491 | + assert rendered.index("Waiting on your reply") < rendered.index("Needs a response") |
| 492 | + assert rendered.index("Needs a response") < rendered.index("Meetings to decide") |
| 493 | + assert rendered.index("Meetings to decide") < rendered.index("Needs a manual look") |
| 494 | + # Refs ascend in the order they appear on the page, unbroken. |
| 495 | + seen_refs = [int(tok) for tok in re.findall(r"^(\d+)\.", rendered, re.MULTILINE)] |
| 496 | + assert seen_refs == [1, 2, 3, 4] |
| 497 | + |
| 498 | + def test_urgent_and_waiting_on_you_share_one_reply_section(self): |
| 499 | + # Both kinds render as REPLY (verbForKind, tui/cards/emailprescan.go) — |
| 500 | + # a separate heading per kind would split one verb into two sections. |
| 501 | + envelope = _prescan_envelope( |
| 502 | + needs_you=[ |
| 503 | + _needs_you_row(1, "urgent"), |
| 504 | + _needs_you_row(2, "waiting_on_you"), |
| 505 | + ] |
| 506 | + ) |
| 507 | + rendered = render_needs_you_list(envelope) |
| 508 | + assert rendered.count("Waiting on your reply") == 1 |
| 509 | + |
| 510 | + def test_each_item_carries_its_classifier_reason(self): |
| 511 | + envelope = _prescan_envelope( |
| 512 | + needs_you=[ |
| 513 | + _needs_you_row(1, "needs_response", why="flagged as phishing"), |
| 514 | + ] |
| 515 | + ) |
| 516 | + assert "flagged as phishing" in render_needs_you_list(envelope) |
| 517 | + |
| 518 | + def test_sender_address_is_dropped_when_a_display_name_exists(self): |
| 519 | + envelope = _prescan_envelope( |
| 520 | + needs_you=[ |
| 521 | + _needs_you_row(1, "waiting_on_you", sender="Jane Doe <jane@example.com>") |
| 522 | + ] |
| 523 | + ) |
| 524 | + rendered = render_needs_you_list(envelope) |
| 525 | + assert "Jane Doe" in rendered |
| 526 | + assert "jane@example.com" not in rendered |
| 527 | + |
| 528 | + def test_address_only_sender_is_kept_but_not_autolinkable(self): |
| 529 | + envelope = _prescan_envelope( |
| 530 | + needs_you=[_needs_you_row(1, "waiting_on_you", sender="jane@example.com")] |
| 531 | + ) |
| 532 | + rendered = render_needs_you_list(envelope) |
| 533 | + assert "`jane@example.com`" in rendered |
| 534 | + |
| 535 | + |
| 536 | +class TestRewriteTriageAnswer: |
| 537 | + def test_no_pre_scan_tool_call_leaves_the_answer_untouched(self): |
| 538 | + text = "You have no new mail today." |
| 539 | + assert rewrite_triage_answer(text, conversation=[]) == text |
| 540 | + |
| 541 | + def test_pre_scan_with_no_needs_you_items_leaves_the_answer_untouched(self): |
| 542 | + conversation = [_tool_entry("pre_scan_inbox", _prescan_envelope(needs_you=[]))] |
| 543 | + text = "Your inbox is clear." |
| 544 | + assert rewrite_triage_answer(text, conversation) == text |
| 545 | + |
| 546 | + def test_the_models_own_list_is_discarded_in_favor_of_the_rendered_one(self): |
| 547 | + envelope = _prescan_envelope( |
| 548 | + needs_you=[_needs_you_row(1, "waiting_on_you", subject="Re: Q3 roadmap")] |
| 549 | + ) |
| 550 | + conversation = [_tool_entry("pre_scan_inbox", envelope)] |
| 551 | + model_answer = ( |
| 552 | + "Here's your inbox — 1 item needs attention.\n\n" |
| 553 | + "### Stuff\n- **9.** a row the model invented\n- **3.** a duplicate" |
| 554 | + ) |
| 555 | + out = rewrite_triage_answer(model_answer, conversation) |
| 556 | + assert "the model invented" not in out |
| 557 | + assert "1." in out and "Re: Q3 roadmap" in out |
| 558 | + # The opening sentence survives; only the list is replaced. |
| 559 | + assert out.startswith("Here's your inbox — 1 item needs attention.") |
| 560 | + |
| 561 | + |
| 562 | +class TestNormalizeTriageList: |
| 563 | + def test_ordinary_prose_is_untouched(self): |
| 564 | + prose = "We looked at 5. Then we stopped." |
| 565 | + assert normalize_triage_list(prose) == prose |
| 566 | + |
| 567 | + def test_run_on_items_are_split_onto_their_own_line(self): |
| 568 | + # normalize_triage_list only activates once the text already contains |
| 569 | + # a line-start numbered item (the signal that this IS a triage list); |
| 570 | + # item 3 provides that, item 4/5 are the run-on fragment being fixed. |
| 571 | + text = ( |
| 572 | + "3. Carol: Q3 roadmap\n" |
| 573 | + "These emails propose scheduling: 4. Alice: 30 minutes? 5. Bob: Design review" |
| 574 | + ) |
| 575 | + out = normalize_triage_list(text) |
| 576 | + assert "\n4." in out |
| 577 | + assert "\n5." in out |
| 578 | + |
| 579 | + def test_duplicated_address_is_stripped_from_a_numbered_line(self): |
| 580 | + text = "1. Tomasz Testingiewicz tomasz.t@outlook.com - Re: Partnership intro" |
| 581 | + out = normalize_triage_list(text) |
| 582 | + assert "tomasz.t@outlook.com" not in out |
| 583 | + assert "Tomasz Testingiewicz" in out |
| 584 | + |
| 585 | + |
438 | 586 | class TestDecodeStrayUnicodeEscapes: |
439 | 587 | def test_decodes_known_escapes(self): |
440 | 588 | assert decode_stray_unicode_escapes("a\\u2013b\\u2019c") == "a\u2013b\u2019c" |
@@ -1080,6 +1228,50 @@ def test_grounded_answer_passes_through_unchanged(self, tmp_path): |
1080 | 1228 | finally: |
1081 | 1229 | agent.close_db() |
1082 | 1230 |
|
| 1231 | + def test_finalize_answer_grounds_the_text_the_loop_will_emit(self, tmp_path): |
| 1232 | + # #2789: grounding used to run only on process_query's return value, |
| 1233 | + # which the SSE/TUI stream never re-reads. finalize_answer is the |
| 1234 | + # hook the agent LOOP calls before it emits anything, so this must |
| 1235 | + # ground on its own, with no process_query involved at all. |
| 1236 | + agent = _build_agent(tmp_path) |
| 1237 | + try: |
| 1238 | + conversation = [{"role": "user", "content": "archive it"}] |
| 1239 | + out = agent.finalize_answer("The message has been archived.", conversation) |
| 1240 | + assert out == UNGROUNDED_SUCCESS_FALLBACK |
| 1241 | + assert agent._grounded_answer == UNGROUNDED_SUCCESS_FALLBACK |
| 1242 | + finally: |
| 1243 | + agent.close_db() |
| 1244 | + |
| 1245 | + def test_process_query_does_not_re_ground_what_finalize_answer_already_did( |
| 1246 | + self, tmp_path |
| 1247 | + ): |
| 1248 | + # An append-style guard (attention-card) makes a double-fire visible: if |
| 1249 | + # process_query's fallback re-ran ground_final_answer on text |
| 1250 | + # finalize_answer already corrected, the correction would appear twice. |
| 1251 | + _store_attention_view(items=[_attention_item("action_item")], scanned=7) |
| 1252 | + agent = _build_agent(tmp_path) |
| 1253 | + try: |
| 1254 | + conversation = [{"role": "user", "content": "anything need my attention?"}] |
| 1255 | + grounded_by_loop = agent.finalize_answer( |
| 1256 | + "No urgent or actionable items found.", conversation |
| 1257 | + ) |
| 1258 | + assert grounded_by_loop.count("attention card") == 1 |
| 1259 | + |
| 1260 | + canned = { |
| 1261 | + "status": "success", |
| 1262 | + "result": grounded_by_loop, |
| 1263 | + "conversation": conversation, |
| 1264 | + "steps_taken": 1, |
| 1265 | + } |
| 1266 | + with patch( |
| 1267 | + "gaia.agents.base.agent.Agent.process_query", return_value=canned |
| 1268 | + ): |
| 1269 | + out = agent.process_query("anything need my attention?") |
| 1270 | + assert out["result"] == grounded_by_loop |
| 1271 | + assert out["result"].count("attention card") == 1 |
| 1272 | + finally: |
| 1273 | + agent.close_db() |
| 1274 | + |
1083 | 1275 | def test_attention_card_contradiction_is_appended_by_the_real_override( |
1084 | 1276 | self, tmp_path |
1085 | 1277 | ): |
|
0 commit comments