"""Manual handoff preserves uncertain delivery evidence without real sends.""" import json import unittest from unittest import mock import test_visual_review_visibility as fixtures class VisualHandoffTest(unittest.TestCase): setUp = fixtures.VisualReviewVisibilityTest.setUp def uncertain(self): self.state.update(stage='uncertain', send_state='uncertain', send_dispatched_at=123.0, generation_surface_signature=b'surface', send_baseline_blocks=[['客户甲 9/16 10:00:00', '旧问题']], last_lines=['客户甲 9/16 10:00:00', '旧问题'], customer_speaker='客户甲') return self.state def handoff(self): self.uncertain() result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=True) self.assertEqual(result['handed_off'], [self.key]) return result def test_uncertain_requires_confirmation_and_keeps_evidence(self): self.uncertain() for confirm in (False, None, 'true', 1): result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=confirm) self.assertEqual(result['requires_confirmation'], [self.key]) self.assertEqual(result['protected'], [self.key]) self.assertFalse(result['deleted']) self.assertIs(self.bot._pending_reply_sessions[self.key], self.state) def test_every_sending_stage_is_protected_even_when_confirmed(self): for stage, send_state in [('sending', ''), ('sending', 'uncertain'), ('queued', 'sending')]: self.state.update(stage=stage, send_state=send_state) result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=True) self.assertEqual(result['protected'], [self.key]) self.assertFalse(result['deleted']) def test_receipt_unknown_states_always_require_confirmation(self): for stage, send_state in [('receipt_check', ''), ('unknown', ''), ('queued', 'unknown'), ('queued', 'sent_uncommitted')]: self.state.update(stage=stage, send_state=send_state) result = self.bot.handoff_pending_replies([self.key]) self.assertEqual(result['requires_confirmation'], [self.key]) def test_confirmed_handoff_preserves_serialized_evidence_not_delivery(self): self.handoff() saved = json.loads(self.path.read_text(encoding='utf-8')) self.assertEqual(saved['pending'], {}) entry = saved['manual_handoffs'][self.key] self.assertEqual(entry['state']['send_state'], 'uncertain') self.assertEqual(entry['state']['generation_surface_signature'], b'surface'.hex()) self.assertTrue(entry['receipt_checked_by_user']) self.assertIs(entry['delivery_confirmed'], False) self.assertEqual(entry['state']['staged_reply_text'], '合成待审草稿') self.assertTrue(self.bot._reply_task_cancelled(self.fp)) def test_restart_and_unread_scan_cannot_resend_original(self): self.handoff() self.bot._cancelled_reply_sessions = set() self.bot._pending_reply_sessions = self.bot._load_pending_replies() self.assertEqual(self.bot._pending_reply_sessions, {}) self.assertTrue(self.bot._reply_task_cancelled(self.fp)) self.assertFalse(self.bot._begin_reply_pending_from_unread(self.fp, confirmed_unread=True)) self.assertEqual(self.bot._pending_reply_sessions, {}) self.assertTrue(self.bot._persist_pending_replies()) self.assertIn(self.key, json.loads(self.path.read_text(encoding='utf-8'))['manual_handoffs']) def test_failed_handoff_save_rolls_back_pending_tombstone_and_evidence(self): self.uncertain() self.bot._cancelled_reply_sessions = {'other'} with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False): result = self.bot.handoff_pending_replies([self.key], confirm_uncertain=True) self.assertIn('error', result) self.assertFalse(result['deleted']) self.assertIs(self.bot._pending_reply_sessions[self.key], self.state) self.assertEqual(self.bot._cancelled_reply_sessions, {'other'}) self.assertEqual(self.bot._manual_handoffs, {}) def test_generating_work_is_cancelled_without_approving_or_sending(self): self.state.update(stage='generating', awaiting_review=False) self.bot._pending_exchanges = {self.key: {'reply': '尚未生成完成'}} result = self.bot.handoff_pending_replies([self.key]) self.assertEqual(result['handed_off'], [self.key]) self.assertTrue(self.bot._reply_task_cancelled(self.fp)) self.assertNotIn(self.key, self.bot._pending_exchanges) self.assertFalse(self.bot._mark_reply_pending(self.fp)) self.assertFalse(self.state.get('review_approved')) def test_old_transcript_or_staff_reply_does_not_unlock_handoff(self): self.handoff() self.bot._known_agent_speakers = {'客服甲'} for text in ('客户甲 9/16 10:00:00\n旧问题', '客户甲 9/16 10:00:00\n旧问题\n客服甲 9/16 10:01:00\n人工回答', '客户甲 9/16 10:02:00\n无旧基线的新片段'): self.assertFalse(self.bot._resume_handoff_for_new_message(self.fp, text)) self.assertTrue(self.bot._reply_task_cancelled(self.fp)) def test_proven_new_customer_block_resumes_but_retains_old_evidence(self): self.handoff() text = '客户甲 9/16 10:00:00\n旧问题\n客户甲 9/16 10:02:00\n追加的新问题' self.assertTrue(self.bot._resume_handoff_for_new_message(self.fp, text)) self.assertFalse(self.bot._reply_task_cancelled(self.fp)) entry = json.loads(self.path.read_text(encoding='utf-8'))['manual_handoffs'][self.key] self.assertGreater(entry['resumed_at'], 0) self.assertEqual(entry['state']['send_state'], 'uncertain') self.assertFalse(entry['delivery_confirmed']) def test_resume_does_not_take_effect_if_disk_save_fails(self): self.handoff() text = '客户甲 9/16 10:00:00\n旧问题\n客户甲 9/16 10:02:00\n追加的新问题' with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False): self.assertFalse(self.bot._resume_handoff_for_new_message(self.fp, text)) self.assertTrue(self.bot._reply_task_cancelled(self.fp)) def test_db_message_must_be_newer_and_same_conversation(self): self.state['database_event'] = {'account': 'A', 'conv_id': 'C', 'message_id': 'old', 'send_time': 100} self.handoff() old = dict(self.state['database_event']) for event in (old, {**old, 'message_id': 'new', 'send_time': 99}, {**old, 'message_id': 'new', 'send_time': 101, 'conv_id': 'other'}): self.assertFalse(self.bot._resume_handoff_for_new_message(self.fp, database_event=event)) self.assertTrue(self.bot._resume_handoff_for_new_message( self.fp, database_event={**old, 'message_id': 'new', 'send_time': 101})) self.assertFalse(self.bot._reply_task_cancelled(self.fp)) def test_old_database_event_is_not_requeued_after_restart(self): event = {'account': 'A', 'conv_id': 'C', 'message_id': 'old', 'send_time': 100} self.state['database_event'] = event self.handoff() self.bot._cancelled_reply_sessions = set() self.bot._pending_reply_sessions = self.bot._load_pending_replies() okay, reason = self.bot.enqueue_detected(self.key, display_name='客户甲', database_event=event) self.assertFalse(okay) self.assertIn('转人工', reason) self.assertEqual(self.bot._pending_reply_sessions, {}) def test_offline_flat_handoff_metadata_loads_without_becoming_a_task(self): entry = {'state': dict(self.state), 'handed_off_at': 1, 'delivery_confirmed': False} self.path.write_text(json.dumps({'manual_handoffs': {self.key: entry}}), encoding='utf-8') self.assertEqual(self.bot._load_pending_replies(), {}) self.assertTrue(self.bot._reply_task_cancelled(self.fp)) def test_plain_cancel_cannot_bypass_receipt_or_sending_guard(self): for stage, send_state in [('receipt_check', ''), ('uncertain', ''), ('unknown', ''), ('sending', ''), ('queued', 'unknown')]: self.state.update(stage=stage, send_state=send_state) result = self.bot.cancel_pending_replies([self.key]) self.assertEqual(result['protected'], [self.key]) self.assertFalse(result['deleted']) def test_new_task_cannot_receive_an_old_model_result_after_handoff(self): self.state.update(stage='generating', awaiting_review=False) self.bot._consume_database_update = mock.Mock() self.bot.report_progress = mock.Mock() def old_model_returns(*args, **kwargs): self.bot.handoff_pending_replies([self.key]) # A subsequent proven customer message is a different task object. self.bot._manual_handoffs[self.key]['resumed_at'] = 1 self.bot._cancelled_reply_sessions.clear() self.bot._pending_reply_sessions[self.key] = {'stage': 'queued', 'staged_user_text': '后续新问题'} return '旧模型回复' self.bot._generate_ai_reply_impl = mock.Mock(side_effect=old_model_returns) self.assertIsNone(self.bot._generate_ai_reply(self.fp, '旧问题')) self.assertNotIn('staged_reply_text', self.bot._pending_reply_sessions[self.key]) if __name__ == '__main__': unittest.main()