test_robustness_timeout.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import asyncio
  2. import threading
  3. import unittest
  4. from unittest.mock import MagicMock, patch, AsyncMock
  5. import asyncio
  6. from app.services.forum_scheduler import ForumScheduler
  7. from app.agent.agent import ParticipantAgent, ModeratorAgent
  8. class TestRobustnessTimeout(unittest.IsolatedAsyncioTestCase):
  9. async def test_agent_speak_timeout_handling(self):
  10. """
  11. Test that _agent_speak handles LLM timeout (returning None) gracefully.
  12. """
  13. scheduler = ForumScheduler()
  14. mock_db = MagicMock()
  15. forum_id = 1
  16. # Mock agent
  17. agent = ParticipantAgent("Test Agent", {"system_prompt": "test"}, 1, "test")
  18. agent.persona_id = 123
  19. # Mock agent.speak to return None (simulating timeout/failure after retries)
  20. # The native HelloAgents stream can return no tokens.
  21. # Then agent.speak generator loop probably yields nothing or raises if not handled.
  22. # But here we mock agent.speak to return None directly (not a generator)
  23. # Our updated code checks `if gen:`.
  24. agent.speak = MagicMock(return_value=None)
  25. # Mock dependencies
  26. with patch('app.services.forum_scheduler.create_message') as mock_create_msg, \
  27. patch('app.services.forum_scheduler.get_forum_participants', return_value=[]), \
  28. patch('app.services.forum_scheduler.manager.broadcast', new_callable=AsyncMock) as mock_broadcast, \
  29. patch('app.services.forum_scheduler.ForumScheduler._broadcast_system_log', new_callable=AsyncMock) as mock_log, \
  30. patch.object(scheduler, '_is_forum_running', return_value=True), \
  31. patch('app.services.forum_scheduler.update_forum_participant') as mock_update_p:
  32. # Run _agent_speak
  33. # We must mock asyncio.to_thread because we mock agent.speak to be sync function
  34. # Or make agent.speak async if we don't mock to_thread?
  35. # It's easier to mock to_thread to return agent.speak()
  36. with patch('asyncio.to_thread', side_effect=lambda func, *args: func(*args)):
  37. await scheduler._agent_speak(forum_id, agent, {}, "context")
  38. # Verify:
  39. # It should handle None generator by logging warning and setting content to "(沉默)"
  40. # Then call create_message
  41. mock_create_msg.assert_called_once()
  42. args, kwargs = mock_create_msg.call_args
  43. # Args are (db, MessageCreate(...))
  44. # Check content inside MessageCreate
  45. msg_create = args[1]
  46. self.assertEqual(msg_create.content, "(沉默)")
  47. async def test_moderator_speak_timeout_handling(self):
  48. """
  49. Test that _moderator_speak handles LLM timeout gracefully.
  50. """
  51. scheduler = ForumScheduler()
  52. mock_db = MagicMock()
  53. forum_id = 1
  54. # Mock moderator
  55. mock_mod = MagicMock()
  56. mock_mod.name = "Moderator"
  57. # Mock opening to return None
  58. mock_mod.opening.return_value = None
  59. with patch('app.services.forum_scheduler.get_forum') as mock_get_forum, \
  60. patch('app.services.forum_scheduler.create_message') as mock_create_msg, \
  61. patch('app.services.forum_scheduler.manager.broadcast', new_callable=AsyncMock), \
  62. patch('app.services.forum_scheduler.ForumScheduler._broadcast_system_log', new_callable=AsyncMock), \
  63. patch('app.services.forum_scheduler.update_forum') as mock_update_f:
  64. mock_get_forum.return_value.moderator_id = 999
  65. with patch('asyncio.to_thread', side_effect=lambda func, *args: func(*args)):
  66. # Run
  67. await scheduler._moderator_speak(forum_id, mock_mod, "opening", [])
  68. # In our implementation for moderator:
  69. # if gen is None: logger.warning...
  70. # content remains ""
  71. # if content: create_message...
  72. # So create_message should NOT be called
  73. mock_create_msg.assert_not_called()
  74. async def test_agent_speak_exception_handling(self):
  75. """
  76. Test that _agent_speak handles generator exception gracefully.
  77. """
  78. scheduler = ForumScheduler()
  79. mock_db = MagicMock()
  80. forum_id = 1
  81. agent = ParticipantAgent("Test Agent", {"system_prompt": "test"}, 1, "test")
  82. agent.persona_id = 123
  83. # Mock generator that raises
  84. def faulty_generator(*args):
  85. yield "Hello"
  86. raise ValueError("Stream broken")
  87. agent.speak = MagicMock(return_value=faulty_generator())
  88. with patch('app.services.forum_scheduler.create_message') as mock_create_msg, \
  89. patch('app.services.forum_scheduler.get_forum_participants', return_value=[]), \
  90. patch('app.services.forum_scheduler.manager.broadcast', new_callable=AsyncMock), \
  91. patch('app.services.forum_scheduler.ForumScheduler._broadcast_system_log', new_callable=AsyncMock), \
  92. patch.object(scheduler, '_is_forum_running', return_value=True), \
  93. patch('app.services.forum_scheduler.update_forum_participant'), \
  94. patch('asyncio.to_thread', side_effect=lambda func, *args: func(*args)):
  95. await scheduler._agent_speak(forum_id, agent, {}, "context")
  96. # It should catch the exception inside the loop and proceed with partial content
  97. mock_create_msg.assert_called_once()
  98. msg_create = mock_create_msg.call_args[0][1]
  99. self.assertEqual(msg_create.content, "Hello")
  100. async def test_stopped_forum_discards_late_agent_output(self):
  101. scheduler = ForumScheduler()
  102. agent = ParticipantAgent("Test Agent", {"system_prompt": "test"}, 1, "test")
  103. agent.speak = MagicMock(return_value=iter(["late output"]))
  104. with patch('app.services.forum_scheduler.create_message') as mock_create_msg, \
  105. patch('app.services.forum_scheduler.get_forum_participants', return_value=[]), \
  106. patch.object(scheduler, '_is_forum_running', return_value=False), \
  107. patch('asyncio.to_thread', side_effect=lambda func, *args: func(*args)):
  108. await scheduler._agent_speak(1, agent, {}, "context")
  109. mock_create_msg.assert_not_called()
  110. async def test_running_only_log_is_dropped_after_stop(self):
  111. scheduler = ForumScheduler()
  112. with patch.object(scheduler, '_is_forum_running', return_value=False), \
  113. patch('app.services.forum_scheduler.manager.broadcast', new_callable=AsyncMock) as broadcast, \
  114. patch.object(scheduler, '_spawn_forum_task') as spawn_task:
  115. await scheduler._broadcast_system_log(
  116. 1,
  117. "主持人正在构思",
  118. "thought",
  119. require_running=True,
  120. )
  121. broadcast.assert_not_awaited()
  122. spawn_task.assert_not_called()
  123. async def test_stop_waits_for_inflight_log_persistence(self):
  124. scheduler = ForumScheduler()
  125. persistence_started = threading.Event()
  126. allow_persistence_to_finish = threading.Event()
  127. persistence_finished = threading.Event()
  128. def push_message(*args, **kwargs):
  129. return False
  130. def create_system_log(*args, **kwargs):
  131. persistence_started.set()
  132. allow_persistence_to_finish.wait(timeout=5)
  133. persistence_finished.set()
  134. child = scheduler._spawn_forum_task(
  135. 1,
  136. scheduler._persist_log_bg(
  137. 1,
  138. "主持人正在构思",
  139. "thought",
  140. "System",
  141. "2026-08-12T12:00:00+08:00",
  142. require_running=True,
  143. ),
  144. )
  145. running_states = iter([True, False])
  146. with patch.object(scheduler, '_is_forum_running', side_effect=lambda forum_id: next(running_states)), \
  147. patch.object(scheduler, '_get_db') as get_db, \
  148. patch('app.services.forum_scheduler.get_forum', return_value=MagicMock(status='running')), \
  149. patch('app.services.forum_scheduler.update_forum'), \
  150. patch('app.services.forum_scheduler.manager.broadcast', new_callable=AsyncMock), \
  151. patch('app.core.cache.cache_service.push_message', side_effect=push_message), \
  152. patch('app.crud.crud_system_log.create_system_log', side_effect=create_system_log):
  153. get_db.return_value.__enter__.return_value = MagicMock()
  154. await asyncio.to_thread(persistence_started.wait, 5)
  155. stop_task = asyncio.create_task(scheduler.stop_forum(1))
  156. await asyncio.sleep(0.05)
  157. self.assertFalse(stop_task.done())
  158. self.assertFalse(persistence_finished.is_set())
  159. allow_persistence_to_finish.set()
  160. await asyncio.wait_for(stop_task, timeout=5)
  161. self.assertTrue(child.cancelled())
  162. self.assertTrue(persistence_finished.is_set())
  163. self.assertNotIn(1, scheduler.child_tasks)
  164. async def test_moderator_thinking_log_is_managed_and_running_only(self):
  165. scheduler = ForumScheduler()
  166. moderator = MagicMock(name="主持人")
  167. moderator.name = "主持人"
  168. moderator.opening.return_value = iter(())
  169. spawned = []
  170. def capture_task(forum_id, coroutine):
  171. spawned.append((forum_id, dict(coroutine.cr_frame.f_locals)))
  172. coroutine.close()
  173. return MagicMock()
  174. with patch.object(scheduler, '_get_db') as get_db, \
  175. patch('app.services.forum_scheduler.get_forum', return_value=MagicMock(moderator_id=2)), \
  176. patch.object(scheduler, '_spawn_forum_task', side_effect=capture_task), \
  177. patch('asyncio.to_thread', side_effect=lambda func, *args: func(*args)):
  178. get_db.return_value.__enter__.return_value = MagicMock()
  179. await scheduler._moderator_speak(1, moderator, "opening", guests=[])
  180. self.assertEqual(len(spawned), 1)
  181. self.assertEqual(spawned[0][0], 1)
  182. self.assertTrue(spawned[0][1]["require_running"])
  183. async def test_all_failed_thinks_close_forum_without_exposing_provider_error(self):
  184. scheduler = ForumScheduler()
  185. db = MagicMock()
  186. with patch.object(scheduler, '_get_db') as get_db, \
  187. patch('app.services.forum_scheduler.get_forum', return_value=MagicMock()), \
  188. patch('app.services.forum_scheduler.update_forum') as update_forum, \
  189. patch('app.services.forum_scheduler.manager.broadcast', new_callable=AsyncMock) as broadcast, \
  190. patch.object(scheduler, '_broadcast_system_log', new_callable=AsyncMock) as system_log:
  191. get_db.return_value.__enter__.return_value = db
  192. await scheduler._close_for_unavailable_agents(1)
  193. update_forum.assert_called_once_with(db, 1, status='closed')
  194. broadcast.assert_awaited_once_with(1, {'type': 'status_update', 'status': 'closed'})
  195. system_log.assert_awaited_once()
  196. assert '模型配置' in system_log.await_args.args[1]
  197. assert '401' not in system_log.await_args.args[1]
  198. if __name__ == '__main__':
  199. unittest.main()