"""Protocol review integration with isolated queues and an inert coordinator.""" import json from pathlib import Path import tempfile import unittest from unittest import mock from protocol_engine import ProtocolBot, session_key from test_native_protocol_pipeline import ACCOUNT, CONV, Database, Sender, message from wecom_native_sender import DeliveryUnknown ASSISTANT = 'M:assistant-fixture' class Coordinator: def __init__(self): self.active = True self.pending = {} self.reviews = {} self.completed = {} self.resume = set() self.own = set() self.notifications = 0 self.closed = False self.error = None def enabled(self): return self.active def is_recipient(self, conv): return conv == ASSISTANT def has_pending(self, conv): return conv in self.pending def resumed(self, conv): return conv in self.resume def enqueue_notifications(self): self.notifications += 1 def close(self): self.closed = True def cancel_review(self, state): conv = state.get('conv_id') if self.pending.get(conv) == state.get('assistant_review_id'): self.pending.pop(conv, None) return True def ensure_review(self, state, context): existing = state.get('assistant_review_id') if existing in self.completed: return existing conv = state['conv_id'] if conv not in self.pending: key = 'review-' + str(len(self.reviews) + 1) trigger = state.get('database_event') or next((m for m in context['messages'] if m['dedup_key'] == state['dedup_key']), {}) self.reviews[key] = {'account': state['account'], 'conv': conv, 'rowid': trigger.get('rowid', 0), 'send_time': trigger.get('send_time', 0)} self.pending[conv] = key return self.pending[conv] def reconcile(self, state, context): if self.error: raise self.error key = state.get('assistant_review_id') or self.pending.get(state['conv_id']) if key in self.completed: return self.completed[key] baseline = self.reviews.get(key) if not baseline: return None for m in context.get('messages', []): if (m.get('account') == baseline['account'] and m.get('conv_id') == baseline['conv'] and m.get('sender_id') == baseline['account'] and m.get('is_self') and str(m.get('server_id')) not in ('', '0', 'None') and m.get('rowid', 0) > baseline['rowid'] and m.get('send_time', 0) >= baseline['send_time'] and (m['conv_id'], str(m['server_id'])) not in self.own): evidence = {'review_id': key, 'server_id': m['server_id'], 'rowid': m['rowid'], 'send_time': m['send_time']} self.completed[key] = evidence self.pending.pop(state['conv_id'], None) self.resume.add(state['conv_id']) return evidence return None def record_automated_send(self, state, receipt): self.own.add((state['conv_id'], str(receipt['serverId']))) class ProtocolReviewAssistantTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory(prefix='protocol-review-assistant-') self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) self.db, self.coordinator = Database(), Coordinator() self.sender = Sender(self.db) self.generate = mock.Mock(return_value='synthetic draft') self.logs = [] for patcher in ( mock.patch('protocol_engine.application_data_dir', return_value=self.root), mock.patch('queue_log.application_data_dir', return_value=self.root), mock.patch('ai_config.AI_REVIEW_RULES', []), mock.patch('review_policy.current_judge_mode', return_value='shadow'), mock.patch('send_lock.try_acquire', return_value=True), mock.patch('send_lock.release'), mock.patch('protocol_engine.NativeSender', side_effect=AssertionError('real send forbidden')), ): self.enterContext(patcher) self.bot = self.make_bot() self.key = session_key(ACCOUNT, CONV) def make_bot(self): bot = ProtocolBot(db=self.db, sender=self.sender, identity={'accountId': ACCOUNT, 'pid': 1}, path=self.root/'pending.json', generate=self.generate, log=self.logs.append, review_assistant=self.coordinator) bot.message_batch_window_seconds = bot.send_delay_seconds = 0 bot.send_mode = 'review' return bot def review(self): self.db.add(message(2)) self.bot.poll_once() return self.bot._pending[self.key] def human(self, **changes): self.db.add({**message(3, self=True, text='manual reply'), **changes}) self.bot.poll_once() def test_registers_one_review_with_persisted_id_and_trigger(self): state = self.review() self.bot.poll_once() self.assertEqual(len(self.coordinator.reviews), 1) saved = json.loads(self.bot.path.read_text(encoding='utf-8'))['pending'][self.key] self.assertEqual(saved['assistant_review_id'], state['assistant_review_id']) self.assertEqual(saved['database_event']['rowid'], 2) self.assertTrue(state['awaiting_review']) self.assertFalse(self.sender.calls) self.assertGreaterEqual(self.coordinator.notifications, 1) def test_private_assistant_chat_is_never_auto_replied_to(self): self.db.add(message(2, conv=ASSISTANT)) self.bot.poll_once() self.generate.assert_not_called() self.assertFalse(self.bot._pending) self.assertFalse(self.sender.calls) def test_human_reply_completes_review_discards_draft_and_keeps_seen(self): old = self.review() self.human() self.assertNotIn(self.key, self.bot._pending) self.assertEqual(self.bot._seen[self.key], old['dedup_key']) self.assertTrue(self.coordinator.resumed(CONV)) self.assertFalse(self.sender.calls) self.assertTrue(any('已审核' in entry for entry in self.logs)) def test_next_message_resumes_automatic_reply_despite_global_review_mode(self): self.review(); self.human() self.db.add(message(4, text='new safe question')) self.bot.poll_once() self.assertEqual(len(self.sender.calls), 1) self.assertEqual(self.sender.calls[0][0], message(4)['dedup_key']) self.assertIn((CONV, '999'), self.coordinator.own) def test_new_model_risk_still_requires_a_new_review_after_resumption(self): self.review(); self.human() def risky(_): self.bot._pending[self.key].update(judge_risk='high', judge_mode='arbitrate') return 'new high risk draft' self.bot.generate = risky self.db.add(message(4)) self.bot.poll_once() state = self.bot._pending[self.key] self.assertTrue(state['awaiting_review']) self.assertFalse(state.get('approved')) self.assertEqual(state['review_reason'], '模型裁判评估为高风险') self.assertEqual(len(self.coordinator.reviews), 2) self.assertFalse(self.sender.calls) def test_selective_review_always_precedes_resumed_global_mode(self): self.coordinator.resume.add(CONV) with mock.patch('review_policy.selective_review_reason', return_value='new rule hit'): state = self.review() self.assertTrue(state['awaiting_review']) self.assertEqual(state['review_reason'], 'new rule hit') self.assertFalse(self.sender.calls) def test_automated_outbound_does_not_complete_pending_review(self): self.review() self.coordinator.own.add((CONV, '3')) self.human() self.assertIn(self.key, self.bot._pending) self.assertFalse(self.coordinator.resumed(CONV)) self.assertFalse(self.sender.calls) def test_old_synced_outbound_and_missing_server_receipt_do_not_complete(self): for changes in ({'send_time': 1}, {'server_id': ''}, {'sender_id': 'other-account'}): with self.subTest(changes=changes): self.review() self.human(**changes) self.assertIn(self.key, self.bot._pending) self.assertFalse(self.coordinator.resumed(CONV)) self.db.messages = [message()] self.db.events.clear() def test_new_inbound_while_old_review_is_pending_keeps_review_and_new_draft(self): previous = self.review() self.bot.send_mode = 'auto' self.db.add(message(3, text='additional customer message')) self.bot.poll_once() state = self.bot._pending[self.key] self.assertIsNot(state, previous) self.assertFalse(state.get('approved')) self.assertTrue(state['awaiting_review']) self.assertEqual(state['assistant_review_id'], previous['assistant_review_id']) self.assertFalse(self.sender.calls) def test_restart_reconciles_human_reply_then_keeps_subsequent_customer_message(self): self.review() self.db.messages.extend([message(3,self=True), message(4)]) self.db.events.clear() restored = self.make_bot() restored.poll_once() self.assertEqual(restored._pending[self.key]['dedup_key'], message(4)['dedup_key']) restored.poll_once() self.assertEqual(len(self.sender.calls), 1) self.assertEqual(self.sender.calls[0][0], message(4)['dedup_key']) def test_late_phone_sync_does_not_swallow_newer_already_queued_question(self): self.review() self.db.add(message(4,text='new question after manual reply time')) self.bot.poll_once() self.assertTrue(self.bot._pending[self.key]['awaiting_review']) self.db.add({**message(5,self=True,text='manual reply'), 'send_time':3}) self.bot.poll_once() self.assertEqual(len(self.sender.calls),1) self.assertEqual(self.sender.calls[0][0],message(4)['dedup_key']) self.assertEqual(self.generate.call_args.args[0]['last_message']['rowid'],4) def test_sending_unknown_and_failed_evidence_are_never_auto_completed(self): for status in ('sending', 'uncertain', 'failed'): with self.subTest(status=status): state = self.review() state['send_state'] = status self.human() self.assertIs(self.bot._pending[self.key], state) self.assertNotIn(state['assistant_review_id'], self.coordinator.completed) state['send_state'] = '' self.db.messages = [message()] self.db.events.clear() def test_coordination_read_error_cannot_clear_a_registered_review(self): self.review() self.coordinator.error = OSError('synthetic unavailable store') self.human() self.assertIn(self.key, self.bot._pending) self.assertFalse(self.sender.calls) self.assertFalse(self.bot.stop_event.is_set()) def test_completion_save_failure_rolls_back_queue_and_can_retry(self): state = self.review() self.db.messages.append(message(3, self=True)) with mock.patch.object(self.bot, '_save', side_effect=OSError('synthetic full disk')): with self.assertRaises(OSError): self.bot._reconcile_assistant_review(self.key, state) self.assertIs(self.bot._pending[self.key], state) self.assertTrue(self.bot._reconcile_assistant_review(self.key, state)) self.assertNotIn(self.key, self.bot._pending) def test_busy_send_lock_yields_without_error_then_sends_next_round(self): self.bot.send_mode = 'auto' self.db.add(message(2)) with mock.patch('send_lock.try_acquire', return_value=False), mock.patch('send_lock.release') as release: self.bot.poll_once() release.assert_not_called() state = self.bot._pending[self.key] self.assertNotIn(state['stage'], ('error','failed','receipt_check','sending')) self.assertEqual(state['send_state'], '') self.assertFalse(self.sender.calls) with mock.patch('send_lock.release') as release: self.bot.poll_once() release.assert_called_once() self.assertEqual(len(self.sender.calls), 1) def test_uncertain_send_releases_shared_lock_and_preserves_receipt_check(self): self.bot.send_mode = 'auto' self.sender.error = DeliveryUnknown('synthetic unconfirmed delivery') self.db.add(message(2)) with mock.patch('send_lock.release') as release: self.bot.poll_once() release.assert_called_once() self.assertEqual(self.bot._pending[self.key]['send_state'], 'uncertain') def test_disabled_coordinator_cannot_override_global_review(self): self.coordinator.active = False self.coordinator.resume.add(CONV) self.assertTrue(self.review()['awaiting_review']) self.assertFalse(self.coordinator.reviews) self.assertFalse(self.sender.calls) def test_close_stops_coordinator(self): self.bot.close() self.assertTrue(self.coordinator.closed) class ProtocolSharedCoordinatorTests(unittest.TestCase): """Actual durable coordinator + protocol queue, with no notification worker.""" make_bot = ProtocolReviewAssistantTests.make_bot def setUp(self): import time from review_assistant import ReviewAssistantCoordinator ProtocolReviewAssistantTests.setUp(self) self.base_time = time.time() self.db.messages[0]['send_time'] = self.base_time - 10 settings = {'review_assistant_accounts': {ACCOUNT: { 'enabled': True, 'recipient_conv_id': 'M:300'}}} self.settings = settings self.coordinator = ReviewAssistantCoordinator( root=self.root, account=ACCOUNT, pid=1, db=self.db, settings_loader=lambda: settings, log=self.logs.append) self.addCleanup(self.coordinator.close) self.enterContext(mock.patch.object(self.coordinator, 'enqueue_notifications')) self.bot = self.make_bot() def add(self, rowid, *, self_reply=False, seconds=None, text='synthetic question'): item = message(rowid, self=self_reply, text=text) item['send_time'] = self.base_time + (rowid if seconds is None else seconds) self.db.add(item) return item def review(self): self.add(2) self.bot.poll_once() state = self.bot._pending[self.key] self.assertTrue(state['assistant_review_id']) return state def test_durable_manual_resolution_and_new_risk_get_distinct_episode(self): original_id = self.review()['assistant_review_id'] self.add(3, self_reply=True, text='phone manual answer') self.bot.poll_once() self.assertFalse(self.coordinator.has_pending(CONV)) self.assertTrue(self.coordinator.resumed(CONV)) self.assertNotIn(self.key, self.bot._pending) def risky(context): self.bot._pending[self.key].update(judge_risk='high', judge_mode='arbitrate') return 'new risky answer' self.bot.generate = risky self.add(4) self.bot.poll_once() state = self.bot._pending[self.key] self.assertTrue(state['awaiting_review']) self.assertFalse(state.get('approved')) self.assertNotEqual(state['assistant_review_id'], original_id) self.assertTrue(self.coordinator.has_pending(CONV)) self.assertFalse(self.coordinator.resumed(CONV)) self.assertFalse(self.sender.calls) def test_durable_completion_survives_protocol_save_failure_without_new_episode(self): import sqlite3 state = self.review() original_id = state['assistant_review_id'] self.add(3, self_reply=True, text='phone manual answer') self.db.events.clear() with mock.patch.object(self.bot, '_save', side_effect=OSError('synthetic full disk')): with self.assertRaises(OSError): self.bot._reconcile_assistant_review(self.key, state) self.assertIs(self.bot._pending[self.key], state) self.assertTrue(self.coordinator.resumed(CONV)) restored = self.make_bot() restored.poll_once() self.assertNotIn(self.key, restored._pending) from contextlib import closing with closing(sqlite3.connect(self.coordinator.path)) as conn: rows = conn.execute('SELECT id,status FROM episodes').fetchall() self.assertEqual(rows, [(original_id, 'completed')]) self.assertFalse(self.sender.calls) def test_actual_store_late_phone_sync_preserves_newer_question(self): self.review() newer = self.add(4, text='newer customer question') self.bot.poll_once() self.assertTrue(self.bot._pending[self.key]['awaiting_review']) self.add(5, self_reply=True, seconds=3, text='late synced phone reply') self.bot.poll_once() self.assertEqual(len(self.sender.calls), 1) self.assertEqual(self.sender.calls[0][0], newer['dedup_key']) self.assertEqual(self.generate.call_args.args[0]['last_message']['rowid'], 4) self.assertTrue(self.coordinator.resumed(CONV)) def test_local_approval_closes_pending_without_future_global_review_bypass(self): self.review() self.bot.approve_pending_replies([self.key]) self.bot.poll_once() self.assertEqual(len(self.sender.calls), 1) self.assertFalse(self.coordinator.has_pending(CONV)) self.assertFalse(self.coordinator.resumed(CONV)) self.add(1000) self.bot.poll_once() self.assertTrue(self.bot._pending[self.key]['awaiting_review']) self.assertEqual(len(self.sender.calls), 1) def test_real_store_bot_receipt_is_not_a_manual_approval(self): state = self.review() self.coordinator.record_automated_send(state, {'status': 'confirmed', 'serverId': '3'}) self.add(3, self_reply=True, text='synthetic draft') self.bot.poll_once() self.assertTrue(self.coordinator.has_pending(CONV)) self.assertFalse(self.coordinator.resumed(CONV)) self.assertIs(self.bot._pending[self.key], state) self.assertFalse(self.sender.calls) def test_explicit_cancel_closes_old_reminder_without_auto_resume(self): state = self.review() old_id = state['assistant_review_id'] result = self.bot.cancel_pending_replies([self.key]) self.assertEqual(result['deleted'], [self.key]) self.assertFalse(self.coordinator.has_pending(CONV)) self.assertFalse(self.coordinator.resumed(CONV)) self.assertFalse(self.bot._assistant_cancellations) self.add(3) self.bot.poll_once() self.assertTrue(self.bot._pending[self.key]['awaiting_review']) self.assertNotEqual(self.bot._pending[self.key]['assistant_review_id'], old_id) self.assertFalse(self.sender.calls) def test_explicit_handoff_closes_reminder_and_preserves_evidence(self): state = self.review() result = self.bot.handoff_pending_replies([self.key]) self.assertEqual(result['handed_off'], [self.key]) self.assertFalse(self.coordinator.has_pending(CONV)) self.assertFalse(self.coordinator.resumed(CONV)) self.assertEqual(self.bot._manual_handoffs[self.key]['state']['assistant_review_id'], state['assistant_review_id']) self.assertFalse(self.sender.calls) def test_cancel_queue_save_failure_does_not_cancel_existing_review(self): state = self.review() with mock.patch.object(self.bot, '_save', side_effect=OSError('synthetic full disk')): with self.assertRaises(OSError): self.bot.cancel_pending_replies([self.key]) self.assertIs(self.bot._pending[self.key], state) self.assertTrue(self.coordinator.has_pending(CONV)) self.assertFalse(self.bot._assistant_cancellations) def test_cancel_store_failure_retries_after_restart_before_new_question(self): state = self.review() self.bot.send_mode = 'auto' with mock.patch.object(self.coordinator, 'cancel_review', return_value=False): self.bot.cancel_pending_replies([self.key]) self.assertIn(state['assistant_review_id'], self.bot._assistant_cancellations) self.add(3, text='customer after explicit cancellation') self.bot.poll_once() self.assertEqual(self.bot._pending[self.key]['stage'], 'queued') self.assertFalse(self.sender.calls) restored = self.make_bot() restored.send_mode = 'auto' restored.poll_once() self.assertFalse(restored._assistant_cancellations) self.assertFalse(self.coordinator.has_pending(CONV)) self.assertFalse(self.coordinator.resumed(CONV)) self.assertEqual(len(self.sender.calls), 1) def test_cancel_works_after_configuration_is_disabled(self): self.review() self.settings['review_assistant_accounts'][ACCOUNT]['enabled'] = False self.bot.cancel_pending_replies([self.key]) self.assertFalse(self.bot._assistant_cancellations) self.settings['review_assistant_accounts'][ACCOUNT]['enabled'] = True self.assertFalse(self.coordinator.has_pending(CONV)) def test_recovered_confirmed_local_approval_closes_old_reminder(self): state = self.review() self.bot.approve_pending_replies([self.key]) state['send_state'] = 'sending' self.bot._save() self.sender.lookup = mock.Mock(return_value={'status': 'confirmed', 'result': { 'status': 'confirmed', 'serverId': '789'}}) restored = self.make_bot() self.assertNotIn(self.key, restored._pending) self.assertFalse(self.coordinator.has_pending(CONV)) self.assertFalse(self.coordinator.resumed(CONV)) self.assertFalse(self.sender.calls) if __name__ == '__main__': unittest.main()