479 lines
26 KiB
Python
479 lines
26 KiB
Python
"""Synthetic assistant-review integration; no GUI actions, network or real queues."""
|
|
import json
|
|
from pathlib import Path
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
from unittest import mock
|
|
|
|
from wechat_bot import WeChatBot
|
|
|
|
|
|
class VisualAssistantTests(unittest.TestCase):
|
|
def setUp(self):
|
|
temporary = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary.cleanup)
|
|
self.root = Path(temporary.name)
|
|
self.bot = WeChatBot.__new__(WeChatBot)
|
|
self.fp = b'v' * 40
|
|
self.key = self.fp.hex()
|
|
self.bot._pending_lock = threading.RLock()
|
|
self.bot._pending_reply_path = str(self.root / 'pending.json')
|
|
self.bot._review_assistant_identity = ('account-one', 42)
|
|
self.bot._review_assistant_discarded_drafts = {}
|
|
self.bot._pending_scan_progress = {self.key: {'old': True}}
|
|
self.bot._pending_exchanges = {self.key: ('old question', 'old draft')}
|
|
self.bot._queue_log_instance = mock.Mock()
|
|
self.bot._stop_check = threading.Event()
|
|
self.bot.enqueue_detected = mock.Mock(return_value=(True, 'ok'))
|
|
self.bot._db_source = mock.Mock()
|
|
self.bot._review_assistant_settings_loader = lambda: {}
|
|
self.assistant = mock.Mock()
|
|
self.assistant.enabled.return_value = True
|
|
self.assistant.is_recipient.return_value = False
|
|
self.assistant.ensure_review.return_value = 'review-one'
|
|
self.assistant.reconcile.return_value = {
|
|
'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2',
|
|
'server_id': '11', 'rowid': 12, 'send_time': time.time(), 'dedup_key': 'human-event', 'completed_by_human': True}
|
|
self.assistant.resumed.return_value = True
|
|
self.assistant.has_pending.return_value = False
|
|
self.bot._get_review_assistant = mock.Mock(return_value=self.assistant)
|
|
self.state = {
|
|
'created_at': time.time(), 'updated_at': time.time(), 'display_name': 'Synthetic customer',
|
|
'account': 'account-one', 'conv_id': 'S:1_2', 'awaiting_review': True,
|
|
'stage': 'manual_review', 'review_origin': 'global', 'reply_text': 'old AI draft',
|
|
'staged_reply_text': 'old AI draft', 'staged_user_text': 'old question',
|
|
'last_pasted_draft': 'old AI draft', 'review_requested_at': time.time()-2,
|
|
}
|
|
self.bot._pending_reply_sessions = {self.key: self.state}
|
|
human = {'is_self': True, 'sender_id': 'account-one', 'dedup_key': 'human-event', 'server_id': '11',
|
|
'rowid': 12, 'send_time': time.time(), 'content': 'human answer'}
|
|
self.context = {'account': 'account-one', 'conv_id': 'S:1_2', 'display_name': 'Synthetic customer',
|
|
'messages': [human], 'last_message': human, 'text': 'Synthetic transcript'}
|
|
self.bot._db_source.get_conversation_context_by_id.side_effect = lambda *a, **kw: self.context
|
|
self.bot._db_source.get_conversation_context.side_effect = lambda *a, **kw: self.context
|
|
for operation in ('click', 'press', 'hotkey', 'write'):
|
|
self.enterContext(mock.patch('wechat_bot.pyautogui.' + operation,
|
|
side_effect=AssertionError('No desktop actions in assistant reconciliation')))
|
|
|
|
def poll(self):
|
|
self.bot._review_assistant_poll_at = 0
|
|
self.bot.poll_review_assistant()
|
|
|
|
def test_proven_human_reply_discards_old_task_without_approving_or_sending(self):
|
|
self.poll()
|
|
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertNotIn(self.key, self.bot._pending_exchanges)
|
|
self.assertNotIn(self.key, self.bot._pending_scan_progress)
|
|
self.assertFalse(self.state.get('review_approved'))
|
|
self.assertEqual(self.bot._queue_log_instance.append.call_args.args[2], '人工已完成')
|
|
self.bot.enqueue_detected.assert_not_called()
|
|
data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8'))
|
|
self.assertEqual(data['pending'], {})
|
|
self.assertEqual(data['assistant_discarded_drafts']['account-one\0S:1_2']['draft'], 'old AI draft')
|
|
tombstone = next(iter(data['manual_handoffs'].values()))
|
|
self.assertTrue(tombstone['completed_by_human'])
|
|
self.assertTrue(tombstone['evidence']['completed_by_human'])
|
|
self.assertEqual(tombstone['state']['stage'], 'manual_review')
|
|
self.assertTrue(tombstone['resumed_at'])
|
|
self.assertEqual(self.bot._manual_handoff_key(self.fp), '')
|
|
|
|
def test_no_evidence_preserves_review_and_persists_review_id(self):
|
|
self.assistant.reconcile.return_value = None
|
|
self.poll()
|
|
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
|
|
self.assertTrue(self.state['awaiting_review'])
|
|
data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8'))
|
|
self.assertEqual(data[self.key]['assistant_review_id'], 'review-one')
|
|
self.assistant.enqueue_notifications.assert_called_once()
|
|
|
|
def test_wrong_account_or_conversation_or_review_evidence_cannot_clear_task(self):
|
|
for field in ('account', 'conv_id', 'review_id'):
|
|
with self.subTest(field=field):
|
|
evidence = dict(self.assistant.reconcile.return_value)
|
|
self.assistant.reconcile.return_value = {**evidence, field: 'different'}
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assistant.reconcile.return_value = evidence
|
|
|
|
def test_active_sending_cancel_or_unrecognized_send_state_cannot_complete(self):
|
|
for overrides in ({'send_state': 'sending'}, {'stage': 'sending'},
|
|
{'send_state': 'unexpected'}, {'stage': 'receipt_check'},
|
|
{'cancel_requested': True}, {'stage': 'cancelled'}):
|
|
with self.subTest(overrides=overrides):
|
|
original = dict(self.state)
|
|
self.state.update(overrides)
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.state.clear()
|
|
self.state.update(original)
|
|
self.assistant.reconcile.assert_not_called()
|
|
|
|
def test_terminal_states_require_explicit_human_completion_evidence(self):
|
|
for value in ('uncertain', 'unknown', 'failed', 'sent_uncommitted'):
|
|
with self.subTest(value=value):
|
|
self.state.update(send_state=value, stage='receipt_check')
|
|
self.assistant.reconcile.return_value.pop('completed_by_human', None)
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertEqual(self.assistant.reconcile.call_count, 4)
|
|
|
|
def test_all_ui_attention_states_register_and_notify(self):
|
|
from review_state import task_review_state
|
|
cases = [{'manual_required': True}, {'stage': 'error', 'task_error': 'no model answer'},
|
|
{'foreign_draft_text': 'human draft'}, {'stage': 'manual_takeover'},
|
|
{'stage': 'failed'}, {'send_state': 'failed'},
|
|
{'stage': 'receipt_check', 'send_state': 'unknown'},
|
|
{'stage': 'manual_review', 'awaiting_review': True}]
|
|
for extra in cases:
|
|
with self.subTest(extra=extra):
|
|
self.state.clear()
|
|
self.state.update(account='account-one', conv_id='S:1_2', **extra)
|
|
flags = task_review_state(self.state)
|
|
self.assertTrue(flags['awaitingReview'] or flags['needsHuman'])
|
|
self.bot._register_assistant_review(self.fp)
|
|
self.assertEqual(self.state['assistant_review_id'], 'review-one')
|
|
self.assertEqual(self.assistant.ensure_review.call_count, len(cases))
|
|
self.assertEqual(self.assistant.enqueue_notifications.call_count, len(cases))
|
|
|
|
def test_failed_review_id_save_rolls_back_and_does_not_enqueue(self):
|
|
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]):
|
|
self.bot._register_assistant_review(self.fp)
|
|
self.assertNotIn('assistant_review_id', self.state)
|
|
self.assistant.enqueue_notifications.assert_not_called()
|
|
|
|
def test_completed_review_does_not_erase_a_concurrent_replacement(self):
|
|
replacement = {**self.state, 'model_task_id': 'new-task', 'reply_text': 'new-draft'}
|
|
def reconcile(*args):
|
|
self.bot._pending_reply_sessions[self.key] = replacement
|
|
return {'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2', 'completed_by_human': True}
|
|
self.assistant.reconcile.side_effect = reconcile
|
|
self.poll()
|
|
self.assertIs(self.bot._pending_reply_sessions[self.key], replacement)
|
|
|
|
def test_new_database_event_arriving_during_read_is_retained(self):
|
|
def reconcile(*args):
|
|
self.state['database_event'] = {'message_id': 'new-unseen-event'}
|
|
return {'review_id': 'review-one', 'account': 'account-one', 'conv_id': 'S:1_2', 'completed_by_human': True}
|
|
self.assistant.reconcile.side_effect = reconcile
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
|
|
def test_persistence_failure_restores_review_and_old_exchange(self):
|
|
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, True, False]):
|
|
self.poll()
|
|
self.assertIs(self.bot._pending_reply_sessions[self.key], self.state)
|
|
self.assertIn(self.key, self.bot._pending_exchanges)
|
|
self.assertEqual(self.bot._review_assistant_discarded_drafts, {})
|
|
self.assertEqual(self.bot._manual_handoffs, {})
|
|
self.bot._queue_log_instance.append.assert_not_called()
|
|
|
|
def test_new_customer_message_after_human_reply_starts_fresh_task(self):
|
|
newer = {'is_self': False, 'dedup_key': 'next-question', 'content': 'new question', 'send_time': time.time()+1, 'rowid': 13}
|
|
self.context['messages'].append(newer)
|
|
self.context['last_message'] = newer
|
|
self.poll()
|
|
self.bot.enqueue_detected.assert_called_once()
|
|
self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['message_id'], 'next-question')
|
|
self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['rowid'], 13)
|
|
self.assertNotIn('review_approved', self.bot.enqueue_detected.call_args.kwargs)
|
|
|
|
def test_retired_draft_only_matches_exact_original_and_verified_identity(self):
|
|
self.poll()
|
|
next_state = {'account': 'account-one', 'conv_id': 'S:1_2'}
|
|
self.assertTrue(self.bot._is_discarded_review_draft(next_state, 'old AI draft'))
|
|
self.assertFalse(self.bot._is_discarded_review_draft(next_state, 'edited human draft'))
|
|
self.assertFalse(self.bot._is_discarded_review_draft(next_state, ' old AI draft'))
|
|
self.assertFalse(self.bot._is_discarded_review_draft({**next_state, 'account': 'other'}, 'old AI draft'))
|
|
self.assertFalse(self.bot._is_discarded_review_draft({**next_state, 'send_state': 'uncertain'}, 'old AI draft'))
|
|
|
|
def test_retired_draft_and_review_binding_survive_restart(self):
|
|
self.assistant.reconcile.return_value = None
|
|
self.poll()
|
|
restored = self.bot._load_pending_replies()[self.key]
|
|
self.assertEqual(restored['assistant_review_id'], 'review-one')
|
|
self.assertEqual(restored['account'], 'account-one')
|
|
self.assertEqual(restored['conv_id'], 'S:1_2')
|
|
self.bot._review_assistant_discarded_drafts = {'account-one\0S:1_2': {
|
|
'account': 'account-one', 'conv_id': 'S:1_2', 'draft': 'old AI draft'}}
|
|
self.bot._persist_pending_replies()
|
|
self.bot._load_pending_replies()
|
|
self.assertTrue(self.bot._review_assistant_discarded_drafts)
|
|
|
|
def test_resumed_skips_blanket_only_and_new_risk_still_requires_review(self):
|
|
self.bot.send_mode = 'review'
|
|
self.bot._reply_needs_review = mock.Mock(return_value=(True, 'new high risk'))
|
|
decision = self.bot._reply_review_decision(self.state, 'new question', 'new answer')
|
|
self.assertEqual(decision, (False, True, 'new high risk'))
|
|
self.bot._reply_needs_review.assert_called_once_with(self.state, 'new question', 'new answer')
|
|
self.assistant.resumed.return_value = False
|
|
self.assertEqual(self.bot._reply_review_decision(self.state, 'q', 'a'), (True, True, 'new high risk'))
|
|
|
|
def test_assistant_contact_is_filtered_by_exact_conversation(self):
|
|
self.assistant.is_recipient.return_value = True
|
|
self.assertTrue(self.bot._review_assistant_recipient(self.state))
|
|
self.assistant.is_recipient.assert_called_once_with('S:1_2')
|
|
self.context['account'] = 'wrong'
|
|
self.assertFalse(self.bot._review_assistant_recipient(self.state))
|
|
|
|
def test_missing_database_and_cross_account_context_cannot_complete(self):
|
|
self.context['account'] = 'different'
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.bot._db_source = None
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assistant.ensure_review.assert_not_called()
|
|
|
|
def test_disabled_config_never_imports_native_or_starts_coordinator(self):
|
|
del self.bot._get_review_assistant
|
|
self.bot.hwnd = 123
|
|
with mock.patch('wechat_bot.win32process.GetWindowThreadProcessId', side_effect=AssertionError('No native probe')):
|
|
self.assertIsNone(self.bot._get_review_assistant(verify=True))
|
|
|
|
def test_stop_suppresses_read_and_notification(self):
|
|
self.bot._stop_check.set()
|
|
self.poll()
|
|
self.bot._get_review_assistant.assert_not_called()
|
|
self.assistant.enqueue_notifications.assert_not_called()
|
|
|
|
def test_notification_registration_requires_durable_state(self):
|
|
with mock.patch.object(self.bot, '_persist_pending_replies', return_value=False):
|
|
self.bot._register_assistant_review(self.fp)
|
|
self.assistant.ensure_review.assert_not_called()
|
|
self.assistant.enqueue_notifications.assert_not_called()
|
|
|
|
|
|
def test_pending_review_is_not_bypassed_by_new_safe_message(self):
|
|
self.bot.send_mode = 'auto'
|
|
self.assistant.has_pending.return_value = True
|
|
self.bot._reply_needs_review = mock.Mock(return_value=(False, ''))
|
|
blanket, required, reason = self.bot._reply_review_decision({}, 'new question', 'safe answer')
|
|
self.assertFalse(blanket)
|
|
self.assertTrue(required)
|
|
self.assertIn('待审核', reason)
|
|
self.bot._reply_needs_review.assert_called_once()
|
|
|
|
def test_late_mobile_sync_does_not_hide_new_customer_message(self):
|
|
human = dict(self.context['last_message'])
|
|
human.update(send_time=3, rowid=5)
|
|
newer = {'is_self': False, 'dedup_key': 'next-question', 'content': 'new question',
|
|
'send_time': 4, 'rowid': 4, 'account': 'account-one', 'conv_id': 'S:1_2'}
|
|
self.context['messages'] = [newer, human]
|
|
self.context['last_message'] = human
|
|
self.assistant.reconcile.return_value.update(send_time=3, rowid=5)
|
|
self.poll()
|
|
self.assertEqual(self.bot.enqueue_detected.call_args.kwargs['database_event']['message_id'], 'next-question')
|
|
|
|
def test_send_attempt_failure_prevents_dispatch_and_local_completion_keeps_approval(self):
|
|
self.assistant.record_automated_attempt.side_effect = OSError('synthetic disk failure')
|
|
self.assertFalse(self.bot._record_assistant_automated_attempt(self.state, 'new draft'))
|
|
self.state['review_approved'] = True
|
|
self.bot._record_assistant_automated_send(self.state)
|
|
self.assistant.complete_local_review.assert_called_once_with(self.state)
|
|
self.assistant.record_automated_send.assert_not_called() # No real server receipt.
|
|
|
|
|
|
def test_discarded_draft_marker_is_consumed_once(self):
|
|
self.poll()
|
|
current = {'account': 'account-one', 'conv_id': 'S:1_2'}
|
|
self.assertTrue(self.bot._is_discarded_review_draft(current, 'old AI draft'))
|
|
self.assertTrue(self.bot._retire_discarded_review_marker(current))
|
|
self.assertFalse(self.bot._is_discarded_review_draft(current, 'old AI draft'))
|
|
self.bot._load_pending_replies()
|
|
self.assertEqual(self.bot._review_assistant_discarded_drafts, {})
|
|
|
|
def test_draft_marker_retirement_failure_retains_protection(self):
|
|
self.poll()
|
|
current = {'account': 'account-one', 'conv_id': 'S:1_2'}
|
|
with mock.patch.object(self.bot, '_persist_pending_replies', return_value=False):
|
|
self.assertFalse(self.bot._retire_discarded_review_marker(current))
|
|
self.assertTrue(self.bot._is_discarded_review_draft(current, 'old AI draft'))
|
|
|
|
|
|
class SharedVisualAssistantTests(unittest.TestCase):
|
|
"""Use the real durable coordinator, keeping all transports mocked."""
|
|
setUp = VisualAssistantTests.setUp
|
|
poll = VisualAssistantTests.poll
|
|
|
|
def use_real_coordinator(self):
|
|
from review_assistant import ReviewAssistantCoordinator
|
|
self.bot._review_assistant_identity = ('123', 42)
|
|
self.state['account'] = '123'
|
|
self.context['account'] = '123'
|
|
incoming = {'account': '123', 'conv_id': 'S:1_2', 'is_self': False, 'sender_id': '2',
|
|
'dedup_key': 'incoming-one', 'server_id': '10', 'rowid': 1,
|
|
'send_time': time.time()-1, 'content': 'original question'}
|
|
self.context['messages'] = [incoming]
|
|
self.context['last_message'] = incoming
|
|
coordinator = ReviewAssistantCoordinator(root=self.root, account='123', pid=42,
|
|
settings_loader=lambda: {'review_assistant_accounts': {'123': {
|
|
'enabled': True, 'recipient_conv_id': 'M:9'}}})
|
|
coordinator.enqueue_notifications = mock.Mock()
|
|
self.addCleanup(coordinator.close)
|
|
self.bot._get_review_assistant.return_value = coordinator
|
|
self.bot._register_assistant_review(self.fp)
|
|
self.assertTrue(self.state.get('assistant_review_id'))
|
|
return coordinator
|
|
|
|
def add_human(self, *, content='human phone reply'):
|
|
outgoing = {'account': '123', 'conv_id': 'S:1_2', 'is_self': True, 'sender_id': '123',
|
|
'dedup_key': 'human-two', 'server_id': '11', 'rowid': 2,
|
|
'send_time': time.time()+.1, 'content': content}
|
|
self.context['messages'].append(outgoing)
|
|
self.context['last_message'] = outgoing
|
|
|
|
def test_real_shared_human_completion_then_new_risk(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.add_human()
|
|
self.poll()
|
|
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertTrue(coordinator.resumed('S:1_2'))
|
|
self.bot.send_mode = 'review'
|
|
self.bot._reply_needs_review = mock.Mock(return_value=(True, 'new risk'))
|
|
self.assertEqual(self.bot._reply_review_decision({'account':'123', 'conv_id':'S:1_2'}, 'q', 'a'),
|
|
(False, True, 'new risk'))
|
|
|
|
def test_real_shared_automated_attempt_is_not_human_reply(self):
|
|
coordinator = self.use_real_coordinator()
|
|
coordinator.record_automated_attempt(self.state)
|
|
self.add_human(content='old AI draft')
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertFalse(coordinator.resumed('S:1_2'))
|
|
|
|
def test_real_shared_local_approval_closes_reminder_without_resuming(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.state['review_approved'] = True
|
|
self.bot._record_assistant_automated_send(self.state)
|
|
self.assertFalse(coordinator.has_pending('S:1_2'))
|
|
self.assertFalse(coordinator.resumed('S:1_2'))
|
|
|
|
def test_real_shared_completion_retries_original_episode_after_disk_failure(self):
|
|
coordinator = self.use_real_coordinator()
|
|
review_id = self.state['assistant_review_id']
|
|
self.add_human()
|
|
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]):
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertTrue(coordinator.resumed('S:1_2'))
|
|
self.poll()
|
|
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertEqual(self.state['assistant_review_id'], review_id)
|
|
|
|
|
|
def test_real_manual_task_without_draft_completes_after_human_reply(self):
|
|
self.state.update(awaiting_review=False, manual_required=True, stage='error',
|
|
reply_text='', staged_reply_text='', last_pasted_draft='',
|
|
task_error='voice transcription unavailable')
|
|
coordinator = self.use_real_coordinator()
|
|
self.assertTrue(coordinator.has_pending('S:1_2'))
|
|
self.add_human()
|
|
self.poll()
|
|
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertTrue(coordinator.resumed('S:1_2'))
|
|
self.assertEqual(self.bot._queue_log_instance.append.call_args.args[2], '人工已完成')
|
|
self.bot.enqueue_detected.assert_not_called()
|
|
|
|
def test_real_terminal_send_requires_distinct_human_and_keeps_original_evidence(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.state.update(awaiting_review=False, stage='receipt_check', send_state='unknown',
|
|
send_dispatched_at=time.time()-2, send_id='original-send-id')
|
|
self.add_human(content='old AI draft')
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertFalse(coordinator.resumed('S:1_2'))
|
|
human = dict(self.context['messages'][-1])
|
|
human.update(content='different verified human response', rowid=3,
|
|
server_id='12', dedup_key='human-three', send_time=time.time()+.2)
|
|
self.context['messages'].append(human)
|
|
self.context['last_message'] = human
|
|
self.poll()
|
|
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
|
|
data = json.loads(Path(self.bot._pending_reply_path).read_text(encoding='utf-8'))
|
|
tombstone = next(iter(data['manual_handoffs'].values()))
|
|
self.assertEqual(tombstone['state']['send_state'], 'unknown')
|
|
self.assertEqual(tombstone['state']['send_id'], 'original-send-id')
|
|
self.assertEqual(tombstone['evidence']['server_id'], '12')
|
|
self.assertFalse(tombstone['delivery_confirmed'])
|
|
self.bot._load_pending_replies()
|
|
self.assertEqual(self.bot._manual_handoff_key(self.fp), '')
|
|
self.bot.enqueue_detected.assert_not_called()
|
|
|
|
def test_real_failed_send_and_local_persistence_failure_keep_task_until_durable(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.state.update(awaiting_review=False, send_state='failed', stage='failed',
|
|
manual_required=True)
|
|
self.add_human()
|
|
with mock.patch.object(self.bot, '_persist_pending_replies', side_effect=[True, False]):
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertEqual(self.bot._manual_handoffs, {})
|
|
self.assertEqual(self.bot._review_assistant_discarded_drafts, {})
|
|
self.poll()
|
|
self.assertNotIn(self.key, self.bot._pending_reply_sessions)
|
|
tombstone = next(iter(self.bot._manual_handoffs.values()))
|
|
self.assertEqual(tombstone['state']['send_state'], 'failed')
|
|
self.assertTrue(tombstone['evidence']['completed_by_human'])
|
|
self.assertTrue(coordinator.resumed('S:1_2'))
|
|
|
|
def test_real_terminal_automated_reply_never_completes(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.state.update(awaiting_review=False, send_state='uncertain', stage='receipt_check')
|
|
coordinator.record_automated_attempt({**self.state, 'reply_text': 'automated different response'})
|
|
self.add_human(content='automated different response')
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertFalse(coordinator.resumed('S:1_2'))
|
|
|
|
def test_real_assistant_conversation_cannot_complete_customer_task(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.add_human()
|
|
self.context['messages'][-1]['conv_id'] = 'M:9'
|
|
self.poll()
|
|
self.assertIn(self.key, self.bot._pending_reply_sessions)
|
|
self.assertFalse(coordinator.resumed('S:1_2'))
|
|
|
|
def test_explicit_cancel_closes_shared_episode_without_native_io(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.bot._forget_uncertain_tracking = mock.Mock()
|
|
result = self.bot.cancel_pending_replies([self.key])
|
|
self.assertEqual(result['deleted'], [self.key])
|
|
self.assertFalse(coordinator.has_pending('S:1_2'))
|
|
self.assertFalse(coordinator.resumed('S:1_2'))
|
|
|
|
def test_explicit_handoff_closes_shared_episode_without_approval(self):
|
|
coordinator = self.use_real_coordinator()
|
|
result = self.bot.handoff_pending_replies([self.key])
|
|
self.assertEqual(result['handed_off'], [self.key])
|
|
self.assertFalse(coordinator.has_pending('S:1_2'))
|
|
self.assertFalse(coordinator.resumed('S:1_2'))
|
|
|
|
def test_failed_queue_cancel_keeps_shared_episode(self):
|
|
coordinator = self.use_real_coordinator()
|
|
self.bot._forget_uncertain_tracking = mock.Mock()
|
|
with mock.patch.object(self.bot, '_persist_pending_replies_unlocked', return_value=False):
|
|
result = self.bot.cancel_pending_replies([self.key])
|
|
self.assertFalse(result['deleted'])
|
|
self.assertTrue(coordinator.has_pending('S:1_2'))
|
|
|
|
def test_stopped_client_cancel_closes_reminder_in_queue_data_directory(self):
|
|
from gui_runtime import delete_pending_reply_file
|
|
coordinator = self.use_real_coordinator()
|
|
with mock.patch('queue_log.QueueLog'):
|
|
result = delete_pending_reply_file([self.key], self.bot._pending_reply_path)
|
|
self.assertEqual(result['deleted'], [self.key])
|
|
self.assertFalse(coordinator.has_pending('S:1_2'))
|
|
|
|
def test_failed_reminder_cancel_is_reported_without_claiming_success(self):
|
|
from gui_runtime import cancel_assistant_review_for_state
|
|
with mock.patch('review_assistant.ReviewAssistantCoordinator') as factory, mock.patch('builtins.print') as log:
|
|
factory.return_value.cancel_review.return_value = False
|
|
self.assertFalse(cancel_assistant_review_for_state({'account':'123', 'assistant_review_id':'r'}, self.root))
|
|
self.assertTrue(log.called)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|