import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, Mock from models import AssistantConfig, RuntimeTaskBackend from services.brains.prompt_tasks import PromptTaskBridge from services.tool_executor import ToolExecutor from services.runtime_variables import DynamicVariableStore class PromptTaskBridgeTest(unittest.IsolatedAsyncioTestCase): def config(self, **kwargs): return AssistantConfig(task_backend=RuntimeTaskBackend(id="backend_1", type="opencode"), **kwargs) async def test_receipt_and_single_terminal_notification(self): cfg = self.config(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"}) runtime = SimpleNamespace(session_id="conv_1", transport=object(), tool_executor_factory=None, call_end=SimpleNamespace(ending=False), queue_frame=AsyncMock(), llm=SimpleNamespace(queue_frame=AsyncMock())) detach = Mock() service = SimpleNamespace(subscribe=Mock(return_value=detach), submit=AsyncMock(return_value={"id": "task_1", "status": "queued"})) bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service, AsyncMock(return_value=cfg.task_backend)) functions = bridge.functions() self.assertEqual(functions[0][0].name, "delegate_task") callback = AsyncMock() await functions[0][1](SimpleNamespace(arguments={"instruction": "修复测试"}, tool_call_id="call_1", result_callback=callback)) self.assertEqual(callback.await_args.args[0]["status"], "accepted") task = {"id": "task_1", "sequence": 3, "updatedAt": "2026-10-23", "inputRequest": {}, "status": "succeeded", "summary": "完成", "result": "测试通过"} await bridge.on_task(task) await bridge.on_task(task) runtime.llm.queue_frame.assert_awaited_once() self.assertFalse(runtime.llm.queue_frame.await_args.args[0].interruptible) bridge.close() detach.assert_called_once() async def test_test_runtime_never_submits_a_real_task(self): runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=object()) service = SimpleNamespace(submit=AsyncMock()) bridge = PromptTaskBridge(self.config(), runtime, ToolExecutor(DynamicVariableStore({})), service) callback = AsyncMock() await bridge.functions()[0][1](SimpleNamespace(result_callback=callback)) service.submit.assert_not_awaited() self.assertEqual(callback.await_args.args[0]["status"], "error") async def test_test_runtime_never_controls_an_existing_real_task(self): runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=object()) service = SimpleNamespace(get=AsyncMock(), cancel=AsyncMock(), send_input=AsyncMock()) bridge = PromptTaskBridge(self.config(), runtime, ToolExecutor(DynamicVariableStore({})), service) for schema, handler in bridge.functions()[1:]: callback = AsyncMock() await handler(SimpleNamespace(function_name=schema.name, arguments={"task_id": "real_task", "message": "yes"}, result_callback=callback)) self.assertEqual(callback.await_args.args[0]["status"], "error") service.get.assert_not_awaited() service.cancel.assert_not_awaited() service.send_input.assert_not_awaited() async def test_bound_instruction_sources_and_snapshot_ignore_model_overrides(self): cases = [({"source": "literal", "value": "固定任务"}, "固定任务"), ({"source": "template", "value": "检查 {{project}}"}, "检查 demo"), ({"source": "variable", "variable_name": "project"}, "demo")] for source, expected in cases: with self.subTest(source=source): cfg = self.config(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"}) cfg.task_backend.instruction_source = source runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None) service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"})) store = DynamicVariableStore({"project": "demo"}) bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(store), service, AsyncMock(return_value=cfg.task_backend)) schema, handler = bridge.functions()[0] self.assertEqual(schema.to_default_dict()["parameters"]["properties"], {}) callback = AsyncMock() await handler(SimpleNamespace(arguments={"instruction": "unauthorized override"}, tool_call_id="call_1", result_callback=callback)) self.assertEqual(service.submit.await_args.kwargs["instruction"], expected) self.assertEqual(service.submit.await_args.kwargs["variable_state"]["values"], store.values) async def test_each_delegation_uses_refreshed_backend_and_rejects_disabled_resources(self): cfg = self.config(conversation_id="conv_1", dynamic_variables={"system__agent_id": "asst_1"}) runtime = SimpleNamespace(session_id="conv_1", transport=None, tool_executor_factory=None) service = SimpleNamespace(submit=AsyncMock(return_value={"id": "task_1"})) updated = cfg.task_backend.model_copy(deep=True) updated.secrets = {"password": "rotated"} loader = AsyncMock(side_effect=[updated, ValueError("任务后端不存在或未启用")]) bridge = PromptTaskBridge(cfg, runtime, ToolExecutor(DynamicVariableStore({})), service, loader) handler = bridge.functions()[0][1] callback = AsyncMock() params = SimpleNamespace(arguments={"instruction": "检查项目"}, tool_call_id="call_1", result_callback=callback) await handler(params) self.assertIs(service.submit.await_args.kwargs["backend"], updated) await handler(params) self.assertEqual(loader.await_count, 2) service.submit.assert_awaited_once() self.assertEqual(callback.await_args.args[0]["status"], "error") async def test_live_loader_rejects_binding_changes_and_revalidates_resources(self): from unittest.mock import MagicMock, patch from services.brains.prompt_tasks import load_current_backend cfg = self.config() assistant = SimpleNamespace(dynamic_variable_definitions={}) session = SimpleNamespace(get=AsyncMock(return_value=assistant)) database = MagicMock() database.__aenter__ = AsyncMock(return_value=session) database.__aexit__ = AsyncMock(return_value=False) updated = cfg.task_backend.model_copy(update={"id": "different_backend"}) with patch("db.session.SessionLocal", return_value=database), patch("services.task_backend_resources.resolve_task_backend", AsyncMock(return_value=updated)) as resolve: with self.assertRaisesRegex(ValueError, "绑定已更新"): await load_current_backend("asst_1", cfg.task_backend, {}) resolve.assert_awaited_once_with(session, assistant) resolve.side_effect = ValueError("模型已停用") with self.assertRaisesRegex(ValueError, "模型已停用"): await load_current_backend("asst_1", cfg.task_backend, {})