from __future__ import annotations import unittest from types import SimpleNamespace from fastapi import HTTPException from routes.assistants import _validate_startup_actions from models import RuntimeToolBinding from schemas import AssistantUpsert from services.config_resolver import ( _implicit_startup_tool_names, _runtime_tool_ids, _tools_for, ) def startup_body( *, phase: str = "opening", bind_tool: bool = False, ) -> AssistantUpsert: return AssistantUpsert( name="启动动作测试", type="prompt", runtimeMode="pipeline", toolBindings=( [{"toolId": "tool_message", "argumentBindings": {}}] if bind_tool else [] ), startup={ "executionMode": "sequential", "actions": [ { "id": "opening_message", "phase": phase, "toolBinding": { "toolId": "tool_message", "argumentBindings": { "title": {"source": "literal", "value": "重要提示"}, "message": {"source": "literal", "value": "请确认已阅读。"}, "actions": { "source": "literal", "value": [{"id": "confirmed", "label": "确认", "style": "primary"}], }, "dismissible": {"source": "literal", "value": False}, }, }, "required": True, } ], }, ) class FakeSession: def __init__(self, tool): self.tool = tool async def get(self, _model, tool_id): return self.tool if self.tool is not None and tool_id == self.tool.id else None class FakeToolQueryResult: def __init__(self, tools): self.tools = tools def scalars(self): return self def all(self): return self.tools class FakeToolCatalogSession: def __init__(self, tools): self.tools = tools async def execute(self, _statement): return FakeToolQueryResult(self.tools) class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase): def test_runtime_pool_includes_bound_and_startup_only_tools(self): self.assertEqual( _runtime_tool_ids( [ RuntimeToolBinding(tool_id="conversation_tool"), RuntimeToolBinding(tool_id="shared_tool"), ], { "actions": [ {"toolBinding": {"toolId": "opening_tool"}}, {"toolBinding": {"toolId": "shared_tool"}}, ] }, ), ["conversation_tool", "shared_tool", "opening_tool"], ) def test_confirmation_opening_declares_implicit_show_message_tool(self): self.assertEqual( _implicit_startup_tool_names( { "opening_mode": "confirmation", "opening_message": { "title": "重要提示", "message": "请确认已阅读。", }, } ), ["show_message"], ) self.assertEqual( _implicit_startup_tool_names( { "openingMode": "playback", "openingMessage": {"message": "无需确认"}, } ), [], ) async def test_implicit_show_message_enters_runtime_pool_without_binding(self): tool = SimpleNamespace( id="tool_show_message_default", name="显示确认消息", function_name="show_message", type="client", description="显示确认弹窗", definition={"config": {}}, secrets={}, mcp_server_id=None, ) runtime_tools = await _tools_for( FakeToolCatalogSession([tool]), SimpleNamespace(type="prompt"), [], ["show_message"], ) self.assertEqual([item.id for item in runtime_tools], [tool.id]) self.assertEqual(runtime_tools[0].function_name, "show_message") def test_realtime_rejects_startup_actions(self): with self.assertRaisesRegex(ValueError, "Realtime"): AssistantUpsert( name="Realtime 启动动作", type="prompt", runtimeMode="realtime", startup=startup_body().startup, ) def test_realtime_rejects_builtin_opening_message(self): with self.assertRaisesRegex(ValueError, "Realtime"): AssistantUpsert( name="Realtime 开场消息", type="prompt", runtimeMode="realtime", startup={ "openingMode": "confirmation", "entryMode": "generate", "openingMessage": { "title": "重要提示", "message": "请确认已阅读。", "confirmLabel": "确认", } }, ) async def test_builtin_opening_message_does_not_reference_a_tool(self): body = AssistantUpsert( name="内置开场消息", type="prompt", startup={ "openingMode": "confirmation", "entryMode": "generate", "openingMessage": { "title": "重要提示", "message": "请确认已阅读。", "confirmLabel": "确认", } }, ) await _validate_startup_actions(FakeSession(None), body) self.assertEqual(body.startup.actions, []) self.assertEqual(body.startup.opening_message.confirm_label, "确认") self.assertEqual(body.startup.opening_mode, "confirmation") self.assertEqual(body.startup.entry_mode, "generate") async def test_startup_tool_does_not_need_conversation_binding(self): tool = SimpleNamespace( id="tool_message", name="重要提示", function_name="show_message", type="client", status="active", definition={ "config": { "allow_interruptions": True, "execution_mode": "async", "wait_for_response": True, "response_wait_mode": "session", } }, ) body = startup_body(bind_tool=False) await _validate_startup_actions(FakeSession(tool), body) self.assertEqual(body.tool_bindings, []) async def test_show_message_requires_session_wait(self): tool = SimpleNamespace( id="tool_message", name="重要提示", function_name="show_message", type="client", status="active", definition={ "config": { "allow_interruptions": True, "execution_mode": "async", "wait_for_response": True, "response_wait_mode": "timeout", } }, ) with self.assertRaisesRegex(HTTPException, "会话内等待"): await _validate_startup_actions(FakeSession(tool), startup_body()) tool.definition["config"]["response_wait_mode"] = "session" await _validate_startup_actions(FakeSession(tool), startup_body()) async def test_preflight_rejects_client_tools(self): tool = SimpleNamespace( id="tool_message", name="重要提示", function_name="show_message", type="client", status="active", definition={ "config": { "allow_interruptions": True, "execution_mode": "async", "wait_for_response": True, "response_wait_mode": "session", } }, ) with self.assertRaisesRegex(HTTPException, "preflight"): await _validate_startup_actions( FakeSession(tool), startup_body(phase="preflight"), ) if __name__ == "__main__": unittest.main()