"""Asking for a human: the one path that must work when the model does not.""" from __future__ import annotations import pytest from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.assistant.feishu_events import handle_message_event from app.assistant.models import STATUS_PENDING, AssistantActionProposal from app.domains.support.models import SupportTicket from tests.support.card_shape import ( buttons_of, callback_buttons, callback_value, ) from tests.test_assistant.conftest import OPEN_ID, message_event pytestmark = pytest.mark.asyncio async def test_asking_for_a_human_offers_a_card_and_files_nothing_yet( db_session: AsyncSession, enabled_gate, feishu_person, sent, cards, kernel, ): await handle_message_event( db_session, message_event(text="我的电脑开不了机", event_id="e1"), trace_id="t-1", ) outcome = await handle_message_event( db_session, message_event(text="转人工", event_id="e2"), trace_id="t-2", ) assert outcome == "offered" assert len(cards) == 1 assert cards[0]["open_id"] == OPEN_ID proposals = ( await db_session.execute(select(AssistantActionProposal)) ).scalars().all() assert len(proposals) == 1 assert proposals[0].status == STATUS_PENDING assert proposals[0].person_id == feishu_person.id # Nothing is filed until the employee confirms. assert (await db_session.execute(select(SupportTicket))).scalars().first() is None async def test_the_draft_is_built_from_the_conversation_not_the_model( db_session: AsyncSession, enabled_gate, feishu_person, sent, cards, kernel, ): await handle_message_event( db_session, message_event(text="投影仪连不上会议室的屏", event_id="e1"), trace_id="t-1", ) kernel["state"]["raises"] = True # the model is down from here on outcome = await handle_message_event( db_session, message_event(text="转人工", event_id="e2"), trace_id="t-2", ) assert outcome == "offered" proposal = ( await db_session.execute(select(AssistantActionProposal)) ).scalars().one() assert "投影仪连不上会议室的屏" in str(proposal.params) async def test_a_failed_card_send_tells_the_employee_where_to_go( db_session: AsyncSession, enabled_gate, feishu_person, sent, kernel, monkeypatch, ): from app.assistant import channel async def _fail(db, *, to, card, idempotency_key=None): return False monkeypatch.setattr(channel, "send_card", _fail) # 先说一句 —— 2026-09-09 起,**空对话**里的「转人工」不再草拟工单(那种工单 # 标题是「AI 求助升级」、正文只有一行,IT 拿到手还得回头再问一遍),而是先 # 请员工用一句话说明。这个用例要验的是「卡片发不出去时怎么办」,所以线程里 # 得先有点东西,否则它验的是另一条分支。 await handle_message_event( db_session, message_event(text="我的电脑开不了机", event_id="e0"), trace_id="t-0", ) outcome = await handle_message_event( db_session, message_event(text="转人工", event_id="e1"), trace_id="t-1", ) # Distinct from a model failure: an auditor reading outcomes later needs to # tell "the card never arrived" from "the turn blew up". assert outcome == "card_undelivered" assert sent assert "helix.pingcap.net" in sent[-1]["text"] async def test_the_escalation_card_carries_the_native_button( db_session: AsyncSession, enabled_gate, feishu_person, sent, cards, kernel, ): """The 转人工 card must be confirmable without leaving Feishu. ``propose_tools`` passes ``proposal_id`` and gets a native ``card.action.trigger`` button; this call site did not, so its card had only the URL — and once the confirm page required a session, this became the one flow in the product that cost an SSO round trip, on the path an employee reaches AFTER self-service already failed them. Deleting that one argument left 397/397 tests green. """ await handle_message_event( db_session, message_event(text="投影仪连不上会议室的屏", event_id="e-native-1"), trace_id="t-native-1", ) await handle_message_event( db_session, message_event(text="转人工", event_id="e-native-2"), trace_id="t-native-2", ) assert cards, "no confirmation card was sent" card = cards[-1]["card"] callbacks = callback_buttons(card) assert callbacks, f"card has no native callback button: {buttons_of(card)}" assert callback_value(callbacks[0]).get("action") == "assistant.confirm_proposal"