atlas-chat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. atlas/__init__.py +40 -0
  2. atlas/application/__init__.py +7 -0
  3. atlas/application/chat/__init__.py +7 -0
  4. atlas/application/chat/agent/__init__.py +10 -0
  5. atlas/application/chat/agent/act_loop.py +179 -0
  6. atlas/application/chat/agent/factory.py +142 -0
  7. atlas/application/chat/agent/protocols.py +46 -0
  8. atlas/application/chat/agent/react_loop.py +338 -0
  9. atlas/application/chat/agent/think_act_loop.py +171 -0
  10. atlas/application/chat/approval_manager.py +151 -0
  11. atlas/application/chat/elicitation_manager.py +191 -0
  12. atlas/application/chat/events/__init__.py +1 -0
  13. atlas/application/chat/events/agent_event_relay.py +112 -0
  14. atlas/application/chat/modes/__init__.py +1 -0
  15. atlas/application/chat/modes/agent.py +125 -0
  16. atlas/application/chat/modes/plain.py +74 -0
  17. atlas/application/chat/modes/rag.py +81 -0
  18. atlas/application/chat/modes/tools.py +179 -0
  19. atlas/application/chat/orchestrator.py +213 -0
  20. atlas/application/chat/policies/__init__.py +1 -0
  21. atlas/application/chat/policies/tool_authorization.py +99 -0
  22. atlas/application/chat/preprocessors/__init__.py +1 -0
  23. atlas/application/chat/preprocessors/message_builder.py +92 -0
  24. atlas/application/chat/preprocessors/prompt_override_service.py +104 -0
  25. atlas/application/chat/service.py +454 -0
  26. atlas/application/chat/utilities/__init__.py +6 -0
  27. atlas/application/chat/utilities/error_handler.py +367 -0
  28. atlas/application/chat/utilities/event_notifier.py +546 -0
  29. atlas/application/chat/utilities/file_processor.py +613 -0
  30. atlas/application/chat/utilities/tool_executor.py +789 -0
  31. atlas/atlas_chat_cli.py +347 -0
  32. atlas/atlas_client.py +238 -0
  33. atlas/core/__init__.py +0 -0
  34. atlas/core/auth.py +205 -0
  35. atlas/core/authorization_manager.py +27 -0
  36. atlas/core/capabilities.py +123 -0
  37. atlas/core/compliance.py +215 -0
  38. atlas/core/domain_whitelist.py +147 -0
  39. atlas/core/domain_whitelist_middleware.py +82 -0
  40. atlas/core/http_client.py +28 -0
  41. atlas/core/log_sanitizer.py +102 -0
  42. atlas/core/metrics_logger.py +59 -0
  43. atlas/core/middleware.py +131 -0
  44. atlas/core/otel_config.py +242 -0
  45. atlas/core/prompt_risk.py +200 -0
  46. atlas/core/rate_limit.py +0 -0
  47. atlas/core/rate_limit_middleware.py +64 -0
  48. atlas/core/security_headers_middleware.py +51 -0
  49. atlas/domain/__init__.py +37 -0
  50. atlas/domain/chat/__init__.py +1 -0
  51. atlas/domain/chat/dtos.py +85 -0
  52. atlas/domain/errors.py +96 -0
  53. atlas/domain/messages/__init__.py +12 -0
  54. atlas/domain/messages/models.py +160 -0
  55. atlas/domain/rag_mcp_service.py +664 -0
  56. atlas/domain/sessions/__init__.py +7 -0
  57. atlas/domain/sessions/models.py +36 -0
  58. atlas/domain/unified_rag_service.py +371 -0
  59. atlas/infrastructure/__init__.py +10 -0
  60. atlas/infrastructure/app_factory.py +135 -0
  61. atlas/infrastructure/events/__init__.py +1 -0
  62. atlas/infrastructure/events/cli_event_publisher.py +140 -0
  63. atlas/infrastructure/events/websocket_publisher.py +140 -0
  64. atlas/infrastructure/sessions/in_memory_repository.py +56 -0
  65. atlas/infrastructure/transport/__init__.py +7 -0
  66. atlas/infrastructure/transport/websocket_connection_adapter.py +33 -0
  67. atlas/init_cli.py +226 -0
  68. atlas/interfaces/__init__.py +15 -0
  69. atlas/interfaces/events.py +134 -0
  70. atlas/interfaces/llm.py +54 -0
  71. atlas/interfaces/rag.py +40 -0
  72. atlas/interfaces/sessions.py +75 -0
  73. atlas/interfaces/tools.py +57 -0
  74. atlas/interfaces/transport.py +24 -0
  75. atlas/main.py +564 -0
  76. atlas/mcp/api_key_demo/README.md +76 -0
  77. atlas/mcp/api_key_demo/main.py +172 -0
  78. atlas/mcp/api_key_demo/run.sh +56 -0
  79. atlas/mcp/basictable/main.py +147 -0
  80. atlas/mcp/calculator/main.py +149 -0
  81. atlas/mcp/code-executor/execution_engine.py +98 -0
  82. atlas/mcp/code-executor/execution_environment.py +95 -0
  83. atlas/mcp/code-executor/main.py +528 -0
  84. atlas/mcp/code-executor/result_processing.py +276 -0
  85. atlas/mcp/code-executor/script_generation.py +195 -0
  86. atlas/mcp/code-executor/security_checker.py +140 -0
  87. atlas/mcp/corporate_cars/main.py +437 -0
  88. atlas/mcp/csv_reporter/main.py +545 -0
  89. atlas/mcp/duckduckgo/main.py +182 -0
  90. atlas/mcp/elicitation_demo/README.md +171 -0
  91. atlas/mcp/elicitation_demo/main.py +262 -0
  92. atlas/mcp/env-demo/README.md +158 -0
  93. atlas/mcp/env-demo/main.py +199 -0
  94. atlas/mcp/file_size_test/main.py +284 -0
  95. atlas/mcp/filesystem/main.py +348 -0
  96. atlas/mcp/image_demo/main.py +113 -0
  97. atlas/mcp/image_demo/requirements.txt +4 -0
  98. atlas/mcp/logging_demo/README.md +72 -0
  99. atlas/mcp/logging_demo/main.py +103 -0
  100. atlas/mcp/many_tools_demo/main.py +50 -0
  101. atlas/mcp/order_database/__init__.py +0 -0
  102. atlas/mcp/order_database/main.py +369 -0
  103. atlas/mcp/order_database/signal_data.csv +1001 -0
  104. atlas/mcp/pdfbasic/main.py +394 -0
  105. atlas/mcp/pptx_generator/main.py +760 -0
  106. atlas/mcp/pptx_generator/requirements.txt +13 -0
  107. atlas/mcp/pptx_generator/run_test.sh +1 -0
  108. atlas/mcp/pptx_generator/test_pptx_generator_security.py +169 -0
  109. atlas/mcp/progress_demo/main.py +167 -0
  110. atlas/mcp/progress_updates_demo/QUICKSTART.md +273 -0
  111. atlas/mcp/progress_updates_demo/README.md +120 -0
  112. atlas/mcp/progress_updates_demo/main.py +497 -0
  113. atlas/mcp/prompts/main.py +222 -0
  114. atlas/mcp/public_demo/main.py +189 -0
  115. atlas/mcp/sampling_demo/README.md +169 -0
  116. atlas/mcp/sampling_demo/main.py +234 -0
  117. atlas/mcp/thinking/main.py +77 -0
  118. atlas/mcp/tool_planner/main.py +240 -0
  119. atlas/mcp/ui-demo/badmesh.png +0 -0
  120. atlas/mcp/ui-demo/main.py +383 -0
  121. atlas/mcp/ui-demo/templates/button_demo.html +32 -0
  122. atlas/mcp/ui-demo/templates/data_visualization.html +32 -0
  123. atlas/mcp/ui-demo/templates/form_demo.html +28 -0
  124. atlas/mcp/username-override-demo/README.md +320 -0
  125. atlas/mcp/username-override-demo/main.py +308 -0
  126. atlas/modules/__init__.py +0 -0
  127. atlas/modules/config/__init__.py +34 -0
  128. atlas/modules/config/cli.py +231 -0
  129. atlas/modules/config/config_manager.py +1096 -0
  130. atlas/modules/file_storage/__init__.py +22 -0
  131. atlas/modules/file_storage/cli.py +330 -0
  132. atlas/modules/file_storage/content_extractor.py +290 -0
  133. atlas/modules/file_storage/manager.py +295 -0
  134. atlas/modules/file_storage/mock_s3_client.py +402 -0
  135. atlas/modules/file_storage/s3_client.py +417 -0
  136. atlas/modules/llm/__init__.py +19 -0
  137. atlas/modules/llm/caller.py +287 -0
  138. atlas/modules/llm/litellm_caller.py +675 -0
  139. atlas/modules/llm/models.py +19 -0
  140. atlas/modules/mcp_tools/__init__.py +17 -0
  141. atlas/modules/mcp_tools/client.py +2123 -0
  142. atlas/modules/mcp_tools/token_storage.py +556 -0
  143. atlas/modules/prompts/prompt_provider.py +130 -0
  144. atlas/modules/rag/__init__.py +24 -0
  145. atlas/modules/rag/atlas_rag_client.py +336 -0
  146. atlas/modules/rag/client.py +129 -0
  147. atlas/routes/admin_routes.py +865 -0
  148. atlas/routes/config_routes.py +484 -0
  149. atlas/routes/feedback_routes.py +361 -0
  150. atlas/routes/files_routes.py +274 -0
  151. atlas/routes/health_routes.py +40 -0
  152. atlas/routes/mcp_auth_routes.py +223 -0
  153. atlas/server_cli.py +164 -0
  154. atlas/tests/conftest.py +20 -0
  155. atlas/tests/integration/test_mcp_auth_integration.py +152 -0
  156. atlas/tests/manual_test_sampling.py +87 -0
  157. atlas/tests/modules/mcp_tools/test_client_auth.py +226 -0
  158. atlas/tests/modules/mcp_tools/test_client_env.py +191 -0
  159. atlas/tests/test_admin_mcp_server_management_routes.py +141 -0
  160. atlas/tests/test_agent_roa.py +135 -0
  161. atlas/tests/test_app_factory_smoke.py +47 -0
  162. atlas/tests/test_approval_manager.py +439 -0
  163. atlas/tests/test_atlas_client.py +188 -0
  164. atlas/tests/test_atlas_rag_client.py +447 -0
  165. atlas/tests/test_atlas_rag_integration.py +224 -0
  166. atlas/tests/test_attach_file_flow.py +287 -0
  167. atlas/tests/test_auth_utils.py +165 -0
  168. atlas/tests/test_backend_public_url.py +185 -0
  169. atlas/tests/test_banner_logging.py +287 -0
  170. atlas/tests/test_capability_tokens_and_injection.py +203 -0
  171. atlas/tests/test_compliance_level.py +54 -0
  172. atlas/tests/test_compliance_manager.py +253 -0
  173. atlas/tests/test_config_manager.py +617 -0
  174. atlas/tests/test_config_manager_paths.py +12 -0
  175. atlas/tests/test_core_auth.py +18 -0
  176. atlas/tests/test_core_utils.py +190 -0
  177. atlas/tests/test_docker_env_sync.py +202 -0
  178. atlas/tests/test_domain_errors.py +329 -0
  179. atlas/tests/test_domain_whitelist.py +359 -0
  180. atlas/tests/test_elicitation_manager.py +408 -0
  181. atlas/tests/test_elicitation_routing.py +296 -0
  182. atlas/tests/test_env_demo_server.py +88 -0
  183. atlas/tests/test_error_classification.py +113 -0
  184. atlas/tests/test_error_flow_integration.py +116 -0
  185. atlas/tests/test_feedback_routes.py +333 -0
  186. atlas/tests/test_file_content_extraction.py +1134 -0
  187. atlas/tests/test_file_extraction_routes.py +158 -0
  188. atlas/tests/test_file_library.py +107 -0
  189. atlas/tests/test_file_manager_unit.py +18 -0
  190. atlas/tests/test_health_route.py +49 -0
  191. atlas/tests/test_http_client_stub.py +8 -0
  192. atlas/tests/test_imports_smoke.py +30 -0
  193. atlas/tests/test_interfaces_llm_response.py +9 -0
  194. atlas/tests/test_issue_access_denied_fix.py +136 -0
  195. atlas/tests/test_llm_env_expansion.py +836 -0
  196. atlas/tests/test_log_level_sensitive_data.py +285 -0
  197. atlas/tests/test_mcp_auth_routes.py +341 -0
  198. atlas/tests/test_mcp_client_auth.py +331 -0
  199. atlas/tests/test_mcp_data_injection.py +270 -0
  200. atlas/tests/test_mcp_get_authorized_servers.py +95 -0
  201. atlas/tests/test_mcp_hot_reload.py +512 -0
  202. atlas/tests/test_mcp_image_content.py +424 -0
  203. atlas/tests/test_mcp_logging.py +172 -0
  204. atlas/tests/test_mcp_progress_updates.py +313 -0
  205. atlas/tests/test_mcp_prompt_override_system_prompt.py +102 -0
  206. atlas/tests/test_mcp_prompts_server.py +39 -0
  207. atlas/tests/test_mcp_tool_result_parsing.py +296 -0
  208. atlas/tests/test_metrics_logger.py +56 -0
  209. atlas/tests/test_middleware_auth.py +379 -0
  210. atlas/tests/test_prompt_risk_and_acl.py +141 -0
  211. atlas/tests/test_rag_mcp_aggregator.py +204 -0
  212. atlas/tests/test_rag_mcp_service.py +224 -0
  213. atlas/tests/test_rate_limit_middleware.py +45 -0
  214. atlas/tests/test_routes_config_smoke.py +60 -0
  215. atlas/tests/test_routes_files_download_token.py +41 -0
  216. atlas/tests/test_routes_files_health.py +18 -0
  217. atlas/tests/test_runtime_imports.py +53 -0
  218. atlas/tests/test_sampling_integration.py +482 -0
  219. atlas/tests/test_security_admin_routes.py +61 -0
  220. atlas/tests/test_security_capability_tokens.py +65 -0
  221. atlas/tests/test_security_file_stats_scope.py +21 -0
  222. atlas/tests/test_security_header_injection.py +191 -0
  223. atlas/tests/test_security_headers_and_filename.py +63 -0
  224. atlas/tests/test_shared_session_repository.py +101 -0
  225. atlas/tests/test_system_prompt_loading.py +181 -0
  226. atlas/tests/test_token_storage.py +505 -0
  227. atlas/tests/test_tool_approval_config.py +93 -0
  228. atlas/tests/test_tool_approval_utils.py +356 -0
  229. atlas/tests/test_tool_authorization_group_filtering.py +223 -0
  230. atlas/tests/test_tool_details_in_config.py +108 -0
  231. atlas/tests/test_tool_planner.py +300 -0
  232. atlas/tests/test_unified_rag_service.py +398 -0
  233. atlas/tests/test_username_override_in_approval.py +258 -0
  234. atlas/tests/test_websocket_auth_header.py +168 -0
  235. atlas/version.py +6 -0
  236. atlas_chat-0.1.0.data/data/.env.example +253 -0
  237. atlas_chat-0.1.0.data/data/config/defaults/compliance-levels.json +44 -0
  238. atlas_chat-0.1.0.data/data/config/defaults/domain-whitelist.json +123 -0
  239. atlas_chat-0.1.0.data/data/config/defaults/file-extractors.json +74 -0
  240. atlas_chat-0.1.0.data/data/config/defaults/help-config.json +198 -0
  241. atlas_chat-0.1.0.data/data/config/defaults/llmconfig-buggy.yml +11 -0
  242. atlas_chat-0.1.0.data/data/config/defaults/llmconfig.yml +19 -0
  243. atlas_chat-0.1.0.data/data/config/defaults/mcp.json +138 -0
  244. atlas_chat-0.1.0.data/data/config/defaults/rag-sources.json +17 -0
  245. atlas_chat-0.1.0.data/data/config/defaults/splash-config.json +16 -0
  246. atlas_chat-0.1.0.dist-info/METADATA +236 -0
  247. atlas_chat-0.1.0.dist-info/RECORD +250 -0
  248. atlas_chat-0.1.0.dist-info/WHEEL +5 -0
  249. atlas_chat-0.1.0.dist-info/entry_points.txt +4 -0
  250. atlas_chat-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,408 @@
1
+ """Tests for the elicitation manager."""
2
+
3
+ import asyncio
4
+
5
+ import pytest
6
+
7
+ from atlas.application.chat.elicitation_manager import ElicitationManager, ElicitationRequest, get_elicitation_manager
8
+
9
+
10
+ class TestElicitationRequest:
11
+ """Test ElicitationRequest class."""
12
+
13
+ @pytest.mark.asyncio
14
+ async def test_create_elicitation_request(self):
15
+ """Test creating an elicitation request."""
16
+ request = ElicitationRequest(
17
+ elicitation_id="elicit_123",
18
+ tool_call_id="tool_456",
19
+ tool_name="test_tool",
20
+ message="Please provide your name",
21
+ response_schema={"type": "object", "properties": {"value": {"type": "string"}}}
22
+ )
23
+ assert request.elicitation_id == "elicit_123"
24
+ assert request.tool_call_id == "tool_456"
25
+ assert request.tool_name == "test_tool"
26
+ assert request.message == "Please provide your name"
27
+ assert "properties" in request.response_schema
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_wait_for_response_accept(self):
31
+ """Test waiting for an accept response."""
32
+ request = ElicitationRequest(
33
+ elicitation_id="elicit_123",
34
+ tool_call_id="tool_456",
35
+ tool_name="test_tool",
36
+ message="Enter your name",
37
+ response_schema={"type": "object"}
38
+ )
39
+
40
+ # Simulate setting a response
41
+ response_data = {"action": "accept", "data": {"name": "John"}}
42
+ request.future.set_result(response_data)
43
+
44
+ # Wait for the response (should be immediate since we already set it)
45
+ response = await request.wait_for_response(timeout=1.0)
46
+
47
+ assert response["action"] == "accept"
48
+ assert response["data"] == {"name": "John"}
49
+
50
+ @pytest.mark.asyncio
51
+ async def test_wait_for_response_decline(self):
52
+ """Test waiting for a decline response."""
53
+ request = ElicitationRequest(
54
+ elicitation_id="elicit_123",
55
+ tool_call_id="tool_456",
56
+ tool_name="test_tool",
57
+ message="Enter your name",
58
+ response_schema={"type": "object"}
59
+ )
60
+
61
+ # Simulate decline response
62
+ response_data = {"action": "decline", "data": None}
63
+ request.future.set_result(response_data)
64
+
65
+ response = await request.wait_for_response(timeout=1.0)
66
+
67
+ assert response["action"] == "decline"
68
+ assert response["data"] is None
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_wait_for_response_cancel(self):
72
+ """Test waiting for a cancel response."""
73
+ request = ElicitationRequest(
74
+ elicitation_id="elicit_123",
75
+ tool_call_id="tool_456",
76
+ tool_name="test_tool",
77
+ message="Enter your name",
78
+ response_schema={"type": "object"}
79
+ )
80
+
81
+ # Simulate cancel response
82
+ response_data = {"action": "cancel", "data": None}
83
+ request.future.set_result(response_data)
84
+
85
+ response = await request.wait_for_response(timeout=1.0)
86
+
87
+ assert response["action"] == "cancel"
88
+ assert response["data"] is None
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_timeout(self):
92
+ """Test that timeout works correctly."""
93
+ request = ElicitationRequest(
94
+ elicitation_id="elicit_123",
95
+ tool_call_id="tool_456",
96
+ tool_name="test_tool",
97
+ message="Enter your name",
98
+ response_schema={"type": "object"}
99
+ )
100
+
101
+ # Should timeout since we don't set a response
102
+ with pytest.raises(asyncio.TimeoutError):
103
+ await request.wait_for_response(timeout=0.1)
104
+
105
+
106
+ class TestElicitationManager:
107
+ """Test ElicitationManager class."""
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_create_elicitation_request(self):
111
+ """Test creating an elicitation request via manager."""
112
+ manager = ElicitationManager()
113
+ manager.create_elicitation_request(
114
+ elicitation_id="elicit_123",
115
+ tool_call_id="tool_456",
116
+ tool_name="test_tool",
117
+ message="Enter your name",
118
+ response_schema={"type": "object"}
119
+ )
120
+
121
+ assert "elicit_123" in manager.get_all_pending_requests()
122
+
123
+ @pytest.mark.asyncio
124
+ async def test_handle_elicitation_response_accept(self):
125
+ """Test handling an accept response."""
126
+ manager = ElicitationManager()
127
+ manager.create_elicitation_request(
128
+ elicitation_id="elicit_123",
129
+ tool_call_id="tool_456",
130
+ tool_name="test_tool",
131
+ message="Enter your name",
132
+ response_schema={"type": "object"}
133
+ )
134
+
135
+ # Handle the response
136
+ result = manager.handle_elicitation_response(
137
+ elicitation_id="elicit_123",
138
+ action="accept",
139
+ data={"name": "John"}
140
+ )
141
+
142
+ assert result is True
143
+
144
+ @pytest.mark.asyncio
145
+ async def test_handle_elicitation_response_decline(self):
146
+ """Test handling a decline response."""
147
+ manager = ElicitationManager()
148
+ manager.create_elicitation_request(
149
+ elicitation_id="elicit_123",
150
+ tool_call_id="tool_456",
151
+ tool_name="test_tool",
152
+ message="Enter your name",
153
+ response_schema={"type": "object"}
154
+ )
155
+
156
+ # Handle decline response
157
+ result = manager.handle_elicitation_response(
158
+ elicitation_id="elicit_123",
159
+ action="decline",
160
+ data=None
161
+ )
162
+
163
+ assert result is True
164
+
165
+ @pytest.mark.asyncio
166
+ async def test_handle_elicitation_response_cancel(self):
167
+ """Test handling a cancel response."""
168
+ manager = ElicitationManager()
169
+ manager.create_elicitation_request(
170
+ elicitation_id="elicit_123",
171
+ tool_call_id="tool_456",
172
+ tool_name="test_tool",
173
+ message="Enter your name",
174
+ response_schema={"type": "object"}
175
+ )
176
+
177
+ # Handle cancel response
178
+ result = manager.handle_elicitation_response(
179
+ elicitation_id="elicit_123",
180
+ action="cancel",
181
+ data=None
182
+ )
183
+
184
+ assert result is True
185
+
186
+ def test_handle_unknown_elicitation(self):
187
+ """Test handling response for unknown elicitation."""
188
+ manager = ElicitationManager()
189
+
190
+ # Try to handle response for non-existent elicitation
191
+ result = manager.handle_elicitation_response(
192
+ elicitation_id="unknown_123",
193
+ action="accept",
194
+ data={"name": "John"}
195
+ )
196
+
197
+ assert result is False
198
+
199
+ @pytest.mark.asyncio
200
+ async def test_cleanup_request(self):
201
+ """Test cleaning up an elicitation request."""
202
+ manager = ElicitationManager()
203
+ manager.create_elicitation_request(
204
+ elicitation_id="elicit_123",
205
+ tool_call_id="tool_456",
206
+ tool_name="test_tool",
207
+ message="Enter your name",
208
+ response_schema={"type": "object"}
209
+ )
210
+
211
+ # Verify request exists
212
+ assert "elicit_123" in manager.get_all_pending_requests()
213
+
214
+ # Cleanup the request
215
+ manager.cleanup_request("elicit_123")
216
+
217
+ # Verify request is removed
218
+ assert "elicit_123" not in manager.get_all_pending_requests()
219
+
220
+ @pytest.mark.asyncio
221
+ async def test_get_pending_request(self):
222
+ """Test retrieving a pending request."""
223
+ manager = ElicitationManager()
224
+ manager.create_elicitation_request(
225
+ elicitation_id="elicit_123",
226
+ tool_call_id="tool_456",
227
+ tool_name="test_tool",
228
+ message="Enter your name",
229
+ response_schema={"type": "object"}
230
+ )
231
+
232
+ # Retrieve the request
233
+ retrieved = manager.get_pending_request("elicit_123")
234
+
235
+ assert retrieved is not None
236
+ assert retrieved.elicitation_id == "elicit_123"
237
+ assert retrieved.tool_call_id == "tool_456"
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_get_pending_request_not_found(self):
241
+ """Test retrieving non-existent request."""
242
+ manager = ElicitationManager()
243
+
244
+ # Try to get non-existent request
245
+ retrieved = manager.get_pending_request("unknown_123")
246
+
247
+ assert retrieved is None
248
+
249
+ @pytest.mark.asyncio
250
+ async def test_cancel_all_requests(self):
251
+ """Test cancelling all pending requests."""
252
+ manager = ElicitationManager()
253
+
254
+ # Create multiple requests
255
+ manager.create_elicitation_request(
256
+ elicitation_id="elicit_1",
257
+ tool_call_id="tool_1",
258
+ tool_name="test_tool",
259
+ message="Request 1",
260
+ response_schema={"type": "object"}
261
+ )
262
+ manager.create_elicitation_request(
263
+ elicitation_id="elicit_2",
264
+ tool_call_id="tool_2",
265
+ tool_name="test_tool",
266
+ message="Request 2",
267
+ response_schema={"type": "object"}
268
+ )
269
+
270
+ # Verify both exist
271
+ assert len(manager.get_all_pending_requests()) == 2
272
+
273
+ # Cancel all requests
274
+ manager.cancel_all_requests()
275
+
276
+ # Verify all are removed
277
+ assert len(manager.get_all_pending_requests()) == 0
278
+
279
+ def test_get_elicitation_manager_singleton(self):
280
+ """Test that get_elicitation_manager returns singleton."""
281
+ manager1 = get_elicitation_manager()
282
+ manager2 = get_elicitation_manager()
283
+
284
+ assert manager1 is manager2
285
+
286
+
287
+ class TestElicitationManagerIntegration:
288
+ """Integration tests for ElicitationManager."""
289
+
290
+ @pytest.mark.asyncio
291
+ async def test_full_elicitation_flow(self):
292
+ """Test complete elicitation flow from request to response."""
293
+ manager = ElicitationManager()
294
+
295
+ # Create request
296
+ request = manager.create_elicitation_request(
297
+ elicitation_id="elicit_123",
298
+ tool_call_id="tool_456",
299
+ tool_name="test_tool",
300
+ message="Enter your information",
301
+ response_schema={
302
+ "type": "object",
303
+ "properties": {
304
+ "name": {"type": "string"},
305
+ "age": {"type": "integer"}
306
+ }
307
+ }
308
+ )
309
+
310
+ # Simulate async handling
311
+ async def simulate_user_response():
312
+ # Wait a bit to simulate user thinking
313
+ await asyncio.sleep(0.1)
314
+ # User responds
315
+ manager.handle_elicitation_response(
316
+ elicitation_id="elicit_123",
317
+ action="accept",
318
+ data={"name": "Alice", "age": 30}
319
+ )
320
+
321
+ # Start the simulation
322
+ asyncio.create_task(simulate_user_response())
323
+
324
+ # Wait for response
325
+ response = await request.wait_for_response(timeout=2.0)
326
+
327
+ assert response["action"] == "accept"
328
+ assert response["data"]["name"] == "Alice"
329
+ assert response["data"]["age"] == 30
330
+
331
+ # Cleanup
332
+ manager.cleanup_request("elicit_123")
333
+ assert "elicit_123" not in manager.get_all_pending_requests()
334
+
335
+ @pytest.mark.asyncio
336
+ async def test_multi_turn_elicitation(self):
337
+ """Test handling multiple sequential elicitation requests."""
338
+ manager = ElicitationManager()
339
+
340
+ # First elicitation
341
+ request1 = manager.create_elicitation_request(
342
+ elicitation_id="elicit_1",
343
+ tool_call_id="tool_456",
344
+ tool_name="test_tool",
345
+ message="Enter your name",
346
+ response_schema={"type": "object"}
347
+ )
348
+
349
+ # Immediately respond to first
350
+ manager.handle_elicitation_response(
351
+ elicitation_id="elicit_1",
352
+ action="accept",
353
+ data={"name": "Bob"}
354
+ )
355
+
356
+ response1 = await request1.wait_for_response(timeout=1.0)
357
+ assert response1["action"] == "accept"
358
+
359
+ manager.cleanup_request("elicit_1")
360
+
361
+ # Second elicitation
362
+ request2 = manager.create_elicitation_request(
363
+ elicitation_id="elicit_2",
364
+ tool_call_id="tool_456",
365
+ tool_name="test_tool",
366
+ message="Enter your age",
367
+ response_schema={"type": "object"}
368
+ )
369
+
370
+ # Respond to second
371
+ manager.handle_elicitation_response(
372
+ elicitation_id="elicit_2",
373
+ action="accept",
374
+ data={"age": 25}
375
+ )
376
+
377
+ response2 = await request2.wait_for_response(timeout=1.0)
378
+ assert response2["action"] == "accept"
379
+ assert response2["data"]["age"] == 25
380
+
381
+ manager.cleanup_request("elicit_2")
382
+
383
+ @pytest.mark.asyncio
384
+ async def test_elicitation_with_decline(self):
385
+ """Test elicitation flow when user declines."""
386
+ manager = ElicitationManager()
387
+
388
+ request = manager.create_elicitation_request(
389
+ elicitation_id="elicit_123",
390
+ tool_call_id="tool_456",
391
+ tool_name="test_tool",
392
+ message="Enter optional information",
393
+ response_schema={"type": "object"}
394
+ )
395
+
396
+ # User declines
397
+ manager.handle_elicitation_response(
398
+ elicitation_id="elicit_123",
399
+ action="decline",
400
+ data=None
401
+ )
402
+
403
+ response = await request.wait_for_response(timeout=1.0)
404
+
405
+ assert response["action"] == "decline"
406
+ assert response["data"] is None
407
+
408
+ manager.cleanup_request("elicit_123")
@@ -0,0 +1,296 @@
1
+ """
2
+ Tests for MCP elicitation routing functionality.
3
+
4
+ Tests the dictionary-based routing system that allows elicitation requests
5
+ from MCP tools to reach the correct WebSocket connection across async tasks.
6
+ """
7
+
8
+ from unittest.mock import AsyncMock, Mock, patch
9
+
10
+ import pytest
11
+
12
+ from atlas.domain.messages.models import ToolCall
13
+
14
+
15
+ class TestElicitationRouting:
16
+ """Test elicitation routing context management."""
17
+
18
+ @pytest.fixture
19
+ def mock_tool_call(self):
20
+ """Create a mock ToolCall object."""
21
+ return ToolCall(
22
+ id="test_call_123",
23
+ name="elicitation_demo_get_user_name",
24
+ arguments={}
25
+ )
26
+
27
+ @pytest.fixture
28
+ def mock_update_callback(self):
29
+ """Create a mock update callback."""
30
+ return AsyncMock()
31
+
32
+ @pytest.fixture
33
+ def manager(self):
34
+ """Create a MCPToolManager instance for testing."""
35
+ from atlas.modules.mcp_tools.client import MCPToolManager
36
+ return MCPToolManager(config_path="/tmp/nonexistent_mcp_test.json")
37
+
38
+ @pytest.mark.asyncio
39
+ async def test_elicitation_context_sets_routing(self, manager, mock_tool_call, mock_update_callback):
40
+ """Test that elicitation context correctly sets routing in dictionary."""
41
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING
42
+
43
+ # Clear routing before test
44
+ _ELICITATION_ROUTING.clear()
45
+
46
+ server_name = "test_server"
47
+ routing_key = (server_name, mock_tool_call.id)
48
+
49
+ # Use the context manager
50
+ async with manager._use_elicitation_context(server_name, mock_tool_call, mock_update_callback):
51
+ # Inside context: routing should exist with composite key
52
+ assert routing_key in _ELICITATION_ROUTING
53
+ routing = _ELICITATION_ROUTING[routing_key]
54
+ assert routing.server_name == server_name
55
+ assert routing.tool_call == mock_tool_call
56
+ assert routing.update_cb == mock_update_callback
57
+
58
+ # After context: routing should be cleaned up
59
+ assert routing_key not in _ELICITATION_ROUTING
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_elicitation_routing_cleanup_on_error(self, manager, mock_tool_call, mock_update_callback):
63
+ """Test that routing is cleaned up even if error occurs."""
64
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING
65
+
66
+ _ELICITATION_ROUTING.clear()
67
+
68
+ server_name = "test_server"
69
+ routing_key = (server_name, mock_tool_call.id)
70
+
71
+ # Simulate an error inside the context
72
+ with pytest.raises(RuntimeError):
73
+ async with manager._use_elicitation_context(server_name, mock_tool_call, mock_update_callback):
74
+ assert routing_key in _ELICITATION_ROUTING
75
+ raise RuntimeError("Simulated error")
76
+
77
+ # Routing should still be cleaned up
78
+ assert routing_key not in _ELICITATION_ROUTING
79
+
80
+ @pytest.mark.asyncio
81
+ async def test_multiple_servers_routing(self, manager, mock_update_callback):
82
+ """Test that multiple servers can have separate routing contexts."""
83
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING
84
+
85
+ _ELICITATION_ROUTING.clear()
86
+
87
+ tool_call_1 = ToolCall(id="call_1", name="tool_1", arguments={})
88
+ tool_call_2 = ToolCall(id="call_2", name="tool_2", arguments={})
89
+ routing_key_1 = ("server_1", "call_1")
90
+ routing_key_2 = ("server_2", "call_2")
91
+
92
+ # Create contexts for two different servers
93
+ async with manager._use_elicitation_context("server_1", tool_call_1, mock_update_callback):
94
+ async with manager._use_elicitation_context("server_2", tool_call_2, mock_update_callback):
95
+ # Both should exist simultaneously
96
+ assert routing_key_1 in _ELICITATION_ROUTING
97
+ assert routing_key_2 in _ELICITATION_ROUTING
98
+ assert _ELICITATION_ROUTING[routing_key_1].tool_call == tool_call_1
99
+ assert _ELICITATION_ROUTING[routing_key_2].tool_call == tool_call_2
100
+
101
+ # server_2 cleaned up, server_1 still exists
102
+ assert routing_key_1 in _ELICITATION_ROUTING
103
+ assert routing_key_2 not in _ELICITATION_ROUTING
104
+
105
+ # Both cleaned up
106
+ assert routing_key_1 not in _ELICITATION_ROUTING
107
+ assert routing_key_2 not in _ELICITATION_ROUTING
108
+
109
+ @pytest.mark.asyncio
110
+ async def test_elicitation_with_none_callback(self, manager, mock_tool_call):
111
+ """Test elicitation context with None callback (should still work)."""
112
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING
113
+
114
+ _ELICITATION_ROUTING.clear()
115
+
116
+ server_name = "test_server"
117
+ routing_key = (server_name, mock_tool_call.id)
118
+
119
+ # Use context with None callback
120
+ async with manager._use_elicitation_context(server_name, mock_tool_call, None):
121
+ routing = _ELICITATION_ROUTING[routing_key]
122
+ assert routing.update_cb is None
123
+
124
+ assert routing_key not in _ELICITATION_ROUTING
125
+
126
+
127
+ class TestElicitationHandler:
128
+ """Test per-server elicitation handler creation."""
129
+
130
+ @pytest.fixture
131
+ def manager(self):
132
+ """Create a MCPToolManager instance for testing."""
133
+ from atlas.modules.mcp_tools.client import MCPToolManager
134
+ return MCPToolManager(config_path="/tmp/nonexistent_mcp_test.json")
135
+
136
+ @pytest.mark.asyncio
137
+ async def test_handler_creation_captures_server_name(self, manager):
138
+ """Test that handler closure captures the correct server_name."""
139
+
140
+ # Create handlers for different servers
141
+ handler_1 = manager._create_elicitation_handler("server_1")
142
+ handler_2 = manager._create_elicitation_handler("server_2")
143
+
144
+ # Handlers should be different functions (different closures)
145
+ assert handler_1 != handler_2
146
+ assert callable(handler_1)
147
+ assert callable(handler_2)
148
+
149
+ @pytest.mark.asyncio
150
+ async def test_handler_returns_cancel_when_no_routing(self, manager):
151
+ """Test that handler returns cancel when routing not found."""
152
+ from fastmcp.client.elicitation import ElicitResult
153
+
154
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING
155
+
156
+ _ELICITATION_ROUTING.clear()
157
+
158
+ handler = manager._create_elicitation_handler("test_server")
159
+
160
+ # Call handler with no routing set
161
+ result = await handler("Test message", str, None, None)
162
+
163
+ assert isinstance(result, ElicitResult)
164
+ assert result.action == "cancel"
165
+ assert result.content is None
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_handler_returns_cancel_when_no_update_cb(self, manager):
169
+ """Test that handler returns cancel when update_cb is None."""
170
+ from fastmcp.client.elicitation import ElicitResult
171
+
172
+ from atlas.domain.messages.models import ToolCall
173
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING, _ElicitationRoutingContext
174
+
175
+ _ELICITATION_ROUTING.clear()
176
+
177
+ server_name = "test_server"
178
+ tool_call = ToolCall(id="call_123", name="test_tool", arguments={})
179
+ routing_key = (server_name, tool_call.id)
180
+
181
+ # Set routing with None callback using composite key
182
+ _ELICITATION_ROUTING[routing_key] = _ElicitationRoutingContext(
183
+ server_name=server_name,
184
+ tool_call=tool_call,
185
+ update_cb=None
186
+ )
187
+
188
+ handler = manager._create_elicitation_handler(server_name)
189
+ result = await handler("Test message", str, None, None)
190
+
191
+ assert isinstance(result, ElicitResult)
192
+ assert result.action == "cancel"
193
+ assert result.content is None
194
+
195
+
196
+ class TestElicitationIntegration:
197
+ """Integration tests for elicitation workflow."""
198
+
199
+ @pytest.fixture
200
+ def manager(self):
201
+ """Create a MCPToolManager instance for testing."""
202
+ from atlas.modules.mcp_tools.client import MCPToolManager
203
+ return MCPToolManager(config_path="/tmp/nonexistent_mcp_test.json")
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_elicitation_request_sent_to_callback(self, manager):
207
+ """Test that elicitation request is sent to update callback."""
208
+ from atlas.domain.messages.models import ToolCall
209
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING, _ElicitationRoutingContext
210
+
211
+ _ELICITATION_ROUTING.clear()
212
+
213
+ server_name = "test_server"
214
+ tool_call = ToolCall(id="call_123", name="test_tool", arguments={})
215
+ mock_callback = AsyncMock()
216
+ routing_key = (server_name, tool_call.id)
217
+
218
+ # Set routing with mock callback using composite key
219
+ _ELICITATION_ROUTING[routing_key] = _ElicitationRoutingContext(
220
+ server_name=server_name,
221
+ tool_call=tool_call,
222
+ update_cb=mock_callback
223
+ )
224
+
225
+ handler = manager._create_elicitation_handler(server_name)
226
+
227
+ # Mock elicitation manager
228
+ with patch('atlas.application.chat.elicitation_manager.get_elicitation_manager') as mock_get_mgr:
229
+ mock_elicit_mgr = Mock()
230
+ mock_request = AsyncMock()
231
+ mock_request.wait_for_response = AsyncMock(return_value={
232
+ "action": "accept",
233
+ "data": "test_value"
234
+ })
235
+ mock_elicit_mgr.create_elicitation_request = Mock(return_value=mock_request)
236
+ mock_elicit_mgr.cleanup_request = Mock()
237
+ mock_get_mgr.return_value = mock_elicit_mgr
238
+
239
+ result = await handler("What's your name?", str, None, None)
240
+
241
+ # Verify callback was called with elicitation_request
242
+ mock_callback.assert_called_once()
243
+ call_args = mock_callback.call_args[0][0]
244
+ assert call_args["type"] == "elicitation_request"
245
+ assert call_args["message"] == "What's your name?"
246
+ assert call_args["tool_call_id"] == "call_123"
247
+
248
+ # Verify result
249
+ assert result.action == "accept"
250
+ assert result.content == {"value": "test_value"}
251
+
252
+ @pytest.mark.asyncio
253
+ async def test_elicitation_accept_no_data_returns_empty_object(self, manager):
254
+ """Test approval-only elicitation returns empty object on accept.
255
+
256
+ FastMCP validation for response_type=None expects an empty response object.
257
+ Some UIs send placeholder payloads like {'none': ''}; we must not forward them.
258
+ """
259
+ from atlas.domain.messages.models import ToolCall
260
+ from atlas.modules.mcp_tools.client import _ELICITATION_ROUTING, _ElicitationRoutingContext
261
+
262
+ _ELICITATION_ROUTING.clear()
263
+
264
+ server_name = "test_server"
265
+ tool_call = ToolCall(id="call_123", name="test_tool", arguments={})
266
+ mock_callback = AsyncMock()
267
+ routing_key = (server_name, tool_call.id)
268
+
269
+ _ELICITATION_ROUTING[routing_key] = _ElicitationRoutingContext(
270
+ server_name=server_name,
271
+ tool_call=tool_call,
272
+ update_cb=mock_callback,
273
+ )
274
+
275
+ handler = manager._create_elicitation_handler(server_name)
276
+
277
+ with patch('atlas.application.chat.elicitation_manager.get_elicitation_manager') as mock_get_mgr:
278
+ mock_elicit_mgr = Mock()
279
+ mock_request = AsyncMock()
280
+ mock_request.wait_for_response = AsyncMock(return_value={
281
+ "action": "accept",
282
+ "data": {"none": ""},
283
+ })
284
+ mock_elicit_mgr.create_elicitation_request = Mock(return_value=mock_request)
285
+ mock_elicit_mgr.cleanup_request = Mock()
286
+ mock_get_mgr.return_value = mock_elicit_mgr
287
+
288
+ result = await handler(
289
+ "Are you sure you want to delete this item?",
290
+ None,
291
+ None,
292
+ None,
293
+ )
294
+
295
+ assert result.action == "accept"
296
+ assert result.content == {}