mito-ai 0.1.44__py3-none-any.whl → 0.1.46__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.
- mito_ai/__init__.py +10 -1
- mito_ai/_version.py +1 -1
- mito_ai/anthropic_client.py +92 -8
- mito_ai/app_deploy/app_deploy_utils.py +25 -0
- mito_ai/app_deploy/handlers.py +9 -12
- mito_ai/app_deploy/models.py +4 -1
- mito_ai/chat_history/handlers.py +63 -0
- mito_ai/chat_history/urls.py +32 -0
- mito_ai/completions/handlers.py +44 -20
- mito_ai/completions/models.py +1 -0
- mito_ai/completions/prompt_builders/prompt_constants.py +22 -4
- mito_ai/constants.py +3 -0
- mito_ai/streamlit_conversion/agent_utils.py +148 -30
- mito_ai/streamlit_conversion/prompts/prompt_constants.py +147 -24
- mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +2 -1
- mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +2 -2
- mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +4 -3
- mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
- mito_ai/streamlit_conversion/streamlit_agent_handler.py +101 -104
- mito_ai/streamlit_conversion/streamlit_system_prompt.py +1 -0
- mito_ai/streamlit_conversion/streamlit_utils.py +18 -17
- mito_ai/streamlit_conversion/validate_streamlit_app.py +66 -62
- mito_ai/streamlit_preview/handlers.py +5 -3
- mito_ai/streamlit_preview/utils.py +11 -7
- mito_ai/tests/chat_history/test_chat_history.py +211 -0
- mito_ai/tests/deploy_app/test_app_deploy_utils.py +71 -0
- mito_ai/tests/message_history/test_message_history_utils.py +43 -19
- mito_ai/tests/providers/test_anthropic_client.py +180 -8
- mito_ai/tests/streamlit_conversion/test_apply_patch_to_text.py +368 -0
- mito_ai/tests/streamlit_conversion/test_fix_diff_headers.py +533 -0
- mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +71 -158
- mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +16 -16
- mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +16 -28
- mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +2 -2
- mito_ai/tests/user/__init__.py +2 -0
- mito_ai/tests/user/test_user.py +120 -0
- mito_ai/tests/utils/test_anthropic_utils.py +4 -4
- mito_ai/user/handlers.py +33 -0
- mito_ai/user/urls.py +21 -0
- mito_ai/utils/anthropic_utils.py +15 -21
- mito_ai/utils/message_history_utils.py +4 -3
- mito_ai/utils/telemetry_utils.py +7 -4
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +100 -100
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +1 -1
- mito_ai-0.1.44.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.cf2e3ad2797fbb53826b.js → mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.20f12766ecd3d430568e.js +1520 -300
- mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.20f12766ecd3d430568e.js.map +1 -0
- mito_ai-0.1.44.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.5482493d1270f55b7283.js → mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.54126ab6511271265443.js +18 -18
- mito_ai-0.1.44.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.5482493d1270f55b7283.js.map → mito_ai-0.1.46.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.54126ab6511271265443.js.map +1 -1
- {mito_ai-0.1.44.dist-info → mito_ai-0.1.46.dist-info}/METADATA +2 -2
- {mito_ai-0.1.44.dist-info → mito_ai-0.1.46.dist-info}/RECORD +75 -63
- mito_ai-0.1.44.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.cf2e3ad2797fbb53826b.js.map +0 -1
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_apis_signOut_mjs-node_module-75790d.688c25857e7b81b1740f.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_auth_dist_esm_providers_cognito_tokenProvider_tokenProvider_-72f1c8.a917210f057fcfe224ad.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_react-dom_client_js-node_modules_aws-amplify_ui-react_dist_styles_css.b43d4249e4d3dac9ad7b.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
- {mito_ai-0.1.44.data → mito_ai-0.1.46.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +0 -0
- {mito_ai-0.1.44.dist-info → mito_ai-0.1.46.dist-info}/WHEEL +0 -0
- {mito_ai-0.1.44.dist-info → mito_ai-0.1.46.dist-info}/entry_points.txt +0 -0
- {mito_ai-0.1.44.dist-info → mito_ai-0.1.46.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
import requests
|
|
6
|
+
import time
|
|
7
|
+
from unittest.mock import patch, MagicMock
|
|
8
|
+
from mito_ai.tests.conftest import TOKEN
|
|
9
|
+
from mito_ai.completions.message_history import GlobalMessageHistory, ChatThread
|
|
10
|
+
from mito_ai.completions.models import ThreadID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@pytest.fixture
|
|
14
|
+
def mock_chat_threads():
|
|
15
|
+
"""Fixture that creates mock chat threads for testing"""
|
|
16
|
+
thread_id_1 = ThreadID("test-thread-1")
|
|
17
|
+
thread_id_2 = ThreadID("test-thread-2")
|
|
18
|
+
|
|
19
|
+
# Create mock threads with different timestamps
|
|
20
|
+
thread_1 = ChatThread(
|
|
21
|
+
thread_id=thread_id_1,
|
|
22
|
+
creation_ts=time.time() - 3600, # 1 hour ago
|
|
23
|
+
last_interaction_ts=time.time() - 1800, # 30 minutes ago
|
|
24
|
+
name="Test Chat 1",
|
|
25
|
+
ai_optimized_history=[
|
|
26
|
+
{"role": "user", "content": "Hello"},
|
|
27
|
+
{"role": "assistant", "content": "Hi there!"},
|
|
28
|
+
],
|
|
29
|
+
display_history=[
|
|
30
|
+
{"role": "user", "content": "Hello"},
|
|
31
|
+
{"role": "assistant", "content": "Hi there!"},
|
|
32
|
+
],
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
thread_2 = ChatThread(
|
|
36
|
+
thread_id=thread_id_2,
|
|
37
|
+
creation_ts=time.time() - 7200, # 2 hours ago
|
|
38
|
+
last_interaction_ts=time.time() - 900, # 15 minutes ago (more recent)
|
|
39
|
+
name="Test Chat 2",
|
|
40
|
+
ai_optimized_history=[
|
|
41
|
+
{"role": "user", "content": "How are you?"},
|
|
42
|
+
{"role": "assistant", "content": "I'm doing well, thanks!"},
|
|
43
|
+
],
|
|
44
|
+
display_history=[
|
|
45
|
+
{"role": "user", "content": "How are you?"},
|
|
46
|
+
{"role": "assistant", "content": "I'm doing well, thanks!"},
|
|
47
|
+
],
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return {thread_id_1: thread_1, thread_id_2: thread_2}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@pytest.fixture
|
|
54
|
+
def mock_message_history(mock_chat_threads):
|
|
55
|
+
"""Fixture that mocks the GlobalMessageHistory with test data"""
|
|
56
|
+
mock_history = MagicMock(spec=GlobalMessageHistory)
|
|
57
|
+
mock_history._chat_threads = mock_chat_threads
|
|
58
|
+
|
|
59
|
+
# Mock the get_threads method to return sorted threads
|
|
60
|
+
def mock_get_threads():
|
|
61
|
+
from mito_ai.completions.models import ChatThreadMetadata
|
|
62
|
+
|
|
63
|
+
threads = []
|
|
64
|
+
for thread in mock_chat_threads.values():
|
|
65
|
+
threads.append(
|
|
66
|
+
ChatThreadMetadata(
|
|
67
|
+
thread_id=thread.thread_id,
|
|
68
|
+
name=thread.name,
|
|
69
|
+
creation_ts=thread.creation_ts,
|
|
70
|
+
last_interaction_ts=thread.last_interaction_ts,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
# Sort by last_interaction_ts (newest first)
|
|
74
|
+
threads.sort(key=lambda x: x.last_interaction_ts, reverse=True)
|
|
75
|
+
return threads
|
|
76
|
+
|
|
77
|
+
mock_history.get_threads = mock_get_threads
|
|
78
|
+
return mock_history
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# --- GET ALL THREADS ---
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_get_all_threads_success(jp_base_url: str, mock_message_history):
|
|
85
|
+
"""Test successful GET all threads endpoint"""
|
|
86
|
+
# Since the server extension is already loaded, we need to work with the actual instance
|
|
87
|
+
# Let's just test that the endpoint works and returns the expected structure
|
|
88
|
+
response = requests.get(
|
|
89
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
90
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
91
|
+
)
|
|
92
|
+
assert response.status_code == 200
|
|
93
|
+
|
|
94
|
+
response_json = response.json()
|
|
95
|
+
assert "threads" in response_json
|
|
96
|
+
# The actual number of threads will depend on what's in the .mito/ai-chats directory
|
|
97
|
+
# So we'll just check that it's a list
|
|
98
|
+
assert isinstance(response_json["threads"], list)
|
|
99
|
+
|
|
100
|
+
# Check thread structure for any threads that exist
|
|
101
|
+
for thread in response_json["threads"]:
|
|
102
|
+
assert "thread_id" in thread
|
|
103
|
+
assert "name" in thread
|
|
104
|
+
assert "creation_ts" in thread
|
|
105
|
+
assert "last_interaction_ts" in thread
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_get_all_threads_empty(jp_base_url: str):
|
|
109
|
+
"""Test GET all threads endpoint when no threads exist"""
|
|
110
|
+
# This test will work with whatever threads exist in the actual .mito/ai-chats directory
|
|
111
|
+
# We'll just verify the endpoint works and returns the expected structure
|
|
112
|
+
response = requests.get(
|
|
113
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
114
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
115
|
+
)
|
|
116
|
+
assert response.status_code == 200
|
|
117
|
+
|
|
118
|
+
response_json = response.json()
|
|
119
|
+
assert "threads" in response_json
|
|
120
|
+
assert isinstance(response_json["threads"], list)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_get_all_threads_with_no_auth(jp_base_url: str):
|
|
124
|
+
"""Test GET all threads endpoint without authentication"""
|
|
125
|
+
response = requests.get(
|
|
126
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
127
|
+
)
|
|
128
|
+
assert response.status_code == 403 # Forbidden
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_get_all_threads_with_incorrect_auth(jp_base_url: str):
|
|
132
|
+
"""Test GET all threads endpoint with incorrect authentication"""
|
|
133
|
+
response = requests.get(
|
|
134
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
135
|
+
headers={"Authorization": f"token incorrect-token"},
|
|
136
|
+
)
|
|
137
|
+
assert response.status_code == 403 # Forbidden
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# --- GET SPECIFIC THREAD ---
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def test_get_specific_thread_success(jp_base_url: str, mock_message_history):
|
|
144
|
+
"""Test successful GET specific thread endpoint"""
|
|
145
|
+
# First, get all threads to see what's available
|
|
146
|
+
response = requests.get(
|
|
147
|
+
jp_base_url + "/mito-ai/chat-history/threads",
|
|
148
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
149
|
+
)
|
|
150
|
+
assert response.status_code == 200
|
|
151
|
+
|
|
152
|
+
threads = response.json()["threads"]
|
|
153
|
+
if not threads:
|
|
154
|
+
# If no threads exist, skip this test
|
|
155
|
+
pytest.skip("No threads available for testing")
|
|
156
|
+
|
|
157
|
+
# Use the first available thread
|
|
158
|
+
thread_id = threads[0]["thread_id"]
|
|
159
|
+
|
|
160
|
+
response = requests.get(
|
|
161
|
+
jp_base_url + f"/mito-ai/chat-history/threads/{thread_id}",
|
|
162
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
163
|
+
)
|
|
164
|
+
assert response.status_code == 200
|
|
165
|
+
|
|
166
|
+
response_json = response.json()
|
|
167
|
+
assert response_json["thread_id"] == thread_id
|
|
168
|
+
assert "name" in response_json
|
|
169
|
+
assert "creation_ts" in response_json
|
|
170
|
+
assert "last_interaction_ts" in response_json
|
|
171
|
+
assert "display_history" in response_json
|
|
172
|
+
assert "ai_optimized_history" in response_json
|
|
173
|
+
|
|
174
|
+
# Check message history structure
|
|
175
|
+
display_history = response_json["display_history"]
|
|
176
|
+
assert isinstance(display_history, list)
|
|
177
|
+
ai_optimized_history = response_json["ai_optimized_history"]
|
|
178
|
+
assert isinstance(ai_optimized_history, list)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def test_get_specific_thread_not_found(jp_base_url: str, mock_message_history):
|
|
182
|
+
"""Test GET specific thread endpoint with non-existent thread ID"""
|
|
183
|
+
# Use a clearly non-existent thread ID
|
|
184
|
+
fake_thread_id = "non-existent-thread-12345"
|
|
185
|
+
|
|
186
|
+
response = requests.get(
|
|
187
|
+
jp_base_url + f"/mito-ai/chat-history/threads/{fake_thread_id}",
|
|
188
|
+
headers={"Authorization": f"token {TOKEN}"},
|
|
189
|
+
)
|
|
190
|
+
assert response.status_code == 404
|
|
191
|
+
|
|
192
|
+
response_json = response.json()
|
|
193
|
+
assert "error" in response_json
|
|
194
|
+
assert fake_thread_id in response_json["error"]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_get_specific_thread_with_no_auth(jp_base_url: str):
|
|
198
|
+
"""Test GET specific thread endpoint without authentication"""
|
|
199
|
+
response = requests.get(
|
|
200
|
+
jp_base_url + "/mito-ai/chat-history/threads/test-thread-1",
|
|
201
|
+
)
|
|
202
|
+
assert response.status_code == 403 # Forbidden
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_get_specific_thread_with_incorrect_auth(jp_base_url: str):
|
|
206
|
+
"""Test GET specific thread endpoint with incorrect authentication"""
|
|
207
|
+
response = requests.get(
|
|
208
|
+
jp_base_url + "/mito-ai/chat-history/threads/test-thread-1",
|
|
209
|
+
headers={"Authorization": f"token incorrect-token"},
|
|
210
|
+
)
|
|
211
|
+
assert response.status_code == 403 # Forbidden
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Copyright (c) Saga Inc.
|
|
2
|
+
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
|
+
|
|
4
|
+
import zipfile
|
|
5
|
+
import logging
|
|
6
|
+
from mito_ai.app_deploy.app_deploy_utils import add_files_to_zip
|
|
7
|
+
|
|
8
|
+
class TestAddFilesToZip:
|
|
9
|
+
"""Test cases for add_files_to_zip helper function"""
|
|
10
|
+
|
|
11
|
+
def test_files_added_correctly(self, tmp_path):
|
|
12
|
+
"""Ensure individual files are added correctly to the zip"""
|
|
13
|
+
# Create files
|
|
14
|
+
f1 = tmp_path / "file1.txt"
|
|
15
|
+
f1.write_text("file1 content")
|
|
16
|
+
f2 = tmp_path / "file2.txt"
|
|
17
|
+
f2.write_text("file2 content")
|
|
18
|
+
|
|
19
|
+
zip_path = tmp_path / "test.zip"
|
|
20
|
+
add_files_to_zip(str(zip_path), str(tmp_path), ["file1.txt", "file2.txt"])
|
|
21
|
+
|
|
22
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
23
|
+
names = zf.namelist()
|
|
24
|
+
assert "file1.txt" in names
|
|
25
|
+
assert "file2.txt" in names
|
|
26
|
+
assert len(names) == 2
|
|
27
|
+
|
|
28
|
+
def test_directories_added_recursively(self, tmp_path):
|
|
29
|
+
"""Ensure directories are added recursively with correct relative paths"""
|
|
30
|
+
nested = tmp_path / "folder"
|
|
31
|
+
nested.mkdir()
|
|
32
|
+
(nested / "nested1.txt").write_text("nested1 content")
|
|
33
|
+
subfolder = nested / "sub"
|
|
34
|
+
subfolder.mkdir()
|
|
35
|
+
(subfolder / "nested2.txt").write_text("nested2 content")
|
|
36
|
+
|
|
37
|
+
zip_path = tmp_path / "test.zip"
|
|
38
|
+
add_files_to_zip(str(zip_path), str(tmp_path), ["folder"])
|
|
39
|
+
|
|
40
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
41
|
+
names = zf.namelist()
|
|
42
|
+
assert "folder/nested1.txt" in names
|
|
43
|
+
assert "folder/sub/nested2.txt" in names
|
|
44
|
+
|
|
45
|
+
def test_missing_files_skipped(self, tmp_path, caplog):
|
|
46
|
+
"""Ensure missing files do not break the function and warning is logged"""
|
|
47
|
+
caplog.set_level(logging.WARNING)
|
|
48
|
+
zip_path = tmp_path / "test.zip"
|
|
49
|
+
add_files_to_zip(str(zip_path), str(tmp_path), ["does_not_exist.txt"], logger=logging.getLogger())
|
|
50
|
+
|
|
51
|
+
# Zip should exist but be empty
|
|
52
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
53
|
+
assert zf.namelist() == []
|
|
54
|
+
|
|
55
|
+
# Check warning was logged
|
|
56
|
+
assert any("Skipping missing file" in record.message for record in caplog.records)
|
|
57
|
+
|
|
58
|
+
def test_arcname_paths_correct(self, tmp_path):
|
|
59
|
+
"""Ensure arcname paths inside zip preserve relative paths to base_path"""
|
|
60
|
+
(tmp_path / "file.txt").write_text("content")
|
|
61
|
+
folder = tmp_path / "folder"
|
|
62
|
+
folder.mkdir()
|
|
63
|
+
(folder / "nested.txt").write_text("nested content")
|
|
64
|
+
|
|
65
|
+
zip_path = tmp_path / "test.zip"
|
|
66
|
+
add_files_to_zip(str(zip_path), str(tmp_path), ["file.txt", "folder"])
|
|
67
|
+
|
|
68
|
+
with zipfile.ZipFile(zip_path, "r") as zf:
|
|
69
|
+
names = zf.namelist()
|
|
70
|
+
assert "file.txt" in names
|
|
71
|
+
assert "folder/nested.txt" in names
|
|
@@ -233,16 +233,17 @@ file1.csv
|
|
|
233
233
|
file2.txt
|
|
234
234
|
"""
|
|
235
235
|
|
|
236
|
-
# Create test messages with proper typing
|
|
236
|
+
# Create test messages with proper typing
|
|
237
237
|
messages: List[ChatCompletionMessageParam] = [
|
|
238
238
|
{"role": "system", "content": system_message_with_sections},
|
|
239
239
|
{"role": "user", "content": user_message_with_sections},
|
|
240
240
|
{"role": "assistant", "content": assistant_message_with_sections},
|
|
241
|
-
{"role": "user", "content": "Recent user message"},
|
|
241
|
+
{"role": "user", "content": "Recent user message 1"},
|
|
242
|
+
{"role": "user", "content": "Recent user message 2"},
|
|
243
|
+
{"role": "user", "content": "Recent user message 3"},
|
|
242
244
|
]
|
|
243
245
|
|
|
244
|
-
|
|
245
|
-
result = trim_old_messages(messages, keep_recent=1)
|
|
246
|
+
result = trim_old_messages(messages)
|
|
246
247
|
|
|
247
248
|
# System message should remain unchanged even though it's old
|
|
248
249
|
system_content = result[0].get("content")
|
|
@@ -265,14 +266,22 @@ file2.txt
|
|
|
265
266
|
assert FILES_SECTION_HEADING in assistant_content
|
|
266
267
|
assert "file1.csv" in assistant_content
|
|
267
268
|
|
|
268
|
-
# Recent user
|
|
269
|
-
|
|
270
|
-
assert isinstance(
|
|
271
|
-
assert
|
|
269
|
+
# Recent user messages should remain unchanged
|
|
270
|
+
recent_content_1 = result[3].get("content")
|
|
271
|
+
assert isinstance(recent_content_1, str)
|
|
272
|
+
assert recent_content_1 == "Recent user message 1"
|
|
273
|
+
|
|
274
|
+
recent_content_2 = result[4].get("content")
|
|
275
|
+
assert isinstance(recent_content_2, str)
|
|
276
|
+
assert recent_content_2 == "Recent user message 2"
|
|
277
|
+
|
|
278
|
+
recent_content_3 = result[5].get("content")
|
|
279
|
+
assert isinstance(recent_content_3, str)
|
|
280
|
+
assert recent_content_3 == "Recent user message 3"
|
|
272
281
|
|
|
273
282
|
|
|
274
283
|
def test_trim_old_messages_preserves_recent_messages() -> None:
|
|
275
|
-
"""Test that trim_old_messages preserves the most recent messages based on
|
|
284
|
+
"""Test that trim_old_messages preserves the most recent messages based on MESSAGE_HISTORY_TRIM_THRESHOLD."""
|
|
276
285
|
# Create test messages
|
|
277
286
|
old_message_1 = f"""Old message 1.
|
|
278
287
|
{FILES_SECTION_HEADING}
|
|
@@ -289,6 +298,10 @@ file3.csv
|
|
|
289
298
|
recent_message_2 = f"""Recent message 2.
|
|
290
299
|
{FILES_SECTION_HEADING}
|
|
291
300
|
file4.csv
|
|
301
|
+
"""
|
|
302
|
+
recent_message_3 = f"""Recent message 3.
|
|
303
|
+
{FILES_SECTION_HEADING}
|
|
304
|
+
file5.csv
|
|
292
305
|
"""
|
|
293
306
|
|
|
294
307
|
# Create test messages with proper typing
|
|
@@ -297,10 +310,11 @@ file4.csv
|
|
|
297
310
|
{"role": "user", "content": old_message_2},
|
|
298
311
|
{"role": "user", "content": recent_message_1},
|
|
299
312
|
{"role": "user", "content": recent_message_2},
|
|
313
|
+
{"role": "user", "content": recent_message_3},
|
|
300
314
|
]
|
|
301
315
|
|
|
302
|
-
#
|
|
303
|
-
result = trim_old_messages(messages
|
|
316
|
+
# Test with MESSAGE_HISTORY_TRIM_THRESHOLD (3) - only the first 2 messages should be trimmed
|
|
317
|
+
result = trim_old_messages(messages)
|
|
304
318
|
|
|
305
319
|
# Old messages should be trimmed
|
|
306
320
|
old_content_1 = result[0].get("content")
|
|
@@ -325,24 +339,30 @@ file4.csv
|
|
|
325
339
|
assert recent_content_2 == recent_message_2
|
|
326
340
|
assert FILES_SECTION_HEADING in recent_content_2
|
|
327
341
|
assert "file4.csv" in recent_content_2
|
|
342
|
+
|
|
343
|
+
recent_content_3 = result[4].get("content")
|
|
344
|
+
assert isinstance(recent_content_3, str)
|
|
345
|
+
assert recent_content_3 == recent_message_3
|
|
346
|
+
assert FILES_SECTION_HEADING in recent_content_3
|
|
347
|
+
assert "file5.csv" in recent_content_3
|
|
328
348
|
|
|
329
349
|
def test_trim_old_messages_empty_list() -> None:
|
|
330
350
|
"""Test that trim_old_messages handles empty message lists correctly."""
|
|
331
351
|
messages: List[ChatCompletionMessageParam] = []
|
|
332
|
-
result = trim_old_messages(messages
|
|
352
|
+
result = trim_old_messages(messages)
|
|
333
353
|
assert result == []
|
|
334
354
|
|
|
335
355
|
|
|
336
|
-
def
|
|
337
|
-
"""Test that trim_old_messages doesn't modify messages if there are fewer than
|
|
356
|
+
def test_trim_old_messages_fewer_than_threshold() -> None:
|
|
357
|
+
"""Test that trim_old_messages doesn't modify messages if there are fewer than MESSAGE_HISTORY_TRIM_THRESHOLD."""
|
|
338
358
|
messages: List[ChatCompletionMessageParam] = [
|
|
339
359
|
{"role": "user", "content": "User message 1"},
|
|
340
360
|
{"role": "assistant", "content": "Assistant message 1"},
|
|
341
361
|
]
|
|
342
362
|
|
|
343
|
-
result = trim_old_messages(messages
|
|
363
|
+
result = trim_old_messages(messages)
|
|
344
364
|
|
|
345
|
-
# Messages should remain unchanged
|
|
365
|
+
# Messages should remain unchanged since we have fewer than MESSAGE_HISTORY_TRIM_THRESHOLD (3) messages
|
|
346
366
|
user_content = result[0].get("content")
|
|
347
367
|
assert isinstance(user_content, str)
|
|
348
368
|
assert user_content == "User message 1"
|
|
@@ -373,15 +393,17 @@ def test_trim_mixed_content_messages() -> None:
|
|
|
373
393
|
})
|
|
374
394
|
|
|
375
395
|
# Create sample message list with one old message (the mixed content)
|
|
376
|
-
# and
|
|
396
|
+
# and enough recent messages to exceed MESSAGE_HISTORY_TRIM_THRESHOLD (3)
|
|
377
397
|
message_list: List[ChatCompletionMessageParam] = [
|
|
378
398
|
mixed_content_message, # This should get trimmed
|
|
379
399
|
{"role": "assistant", "content": "That's a chart showing data trends"},
|
|
380
|
-
{"role": "user", "content": "Can you explain more?"} # Recent message, should not be trimmed
|
|
400
|
+
{"role": "user", "content": "Can you explain more?"}, # Recent message, should not be trimmed
|
|
401
|
+
{"role": "user", "content": "Another recent message"}, # Recent message, should not be trimmed
|
|
402
|
+
{"role": "user", "content": "Yet another recent message"} # Recent message, should not be trimmed
|
|
381
403
|
]
|
|
382
404
|
|
|
383
405
|
# Apply the trimming function
|
|
384
|
-
trimmed_messages = trim_old_messages(message_list
|
|
406
|
+
trimmed_messages = trim_old_messages(message_list)
|
|
385
407
|
|
|
386
408
|
# Verify that the first message has been trimmed properly
|
|
387
409
|
assert trimmed_messages[0]["role"] == "user"
|
|
@@ -390,6 +412,8 @@ def test_trim_mixed_content_messages() -> None:
|
|
|
390
412
|
# Verify that the recent messages are untouched
|
|
391
413
|
assert trimmed_messages[1] == message_list[1]
|
|
392
414
|
assert trimmed_messages[2] == message_list[2]
|
|
415
|
+
assert trimmed_messages[3] == message_list[3]
|
|
416
|
+
assert trimmed_messages[4] == message_list[4]
|
|
393
417
|
|
|
394
418
|
|
|
395
419
|
def test_get_display_history_calls_update_last_interaction() -> None:
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
# Distributed under the terms of the GNU Affero General Public License v3.0 License.
|
|
3
3
|
|
|
4
4
|
import pytest
|
|
5
|
-
from mito_ai.anthropic_client import get_anthropic_system_prompt_and_messages, extract_and_parse_anthropic_json_response, AnthropicClient
|
|
6
|
-
from mito_ai.utils.anthropic_utils import
|
|
5
|
+
from mito_ai.anthropic_client import get_anthropic_system_prompt_and_messages, get_anthropic_system_prompt_and_messages_with_caching, add_cache_control_to_message, extract_and_parse_anthropic_json_response, AnthropicClient
|
|
6
|
+
from mito_ai.utils.anthropic_utils import FAST_ANTHROPIC_MODEL
|
|
7
7
|
from anthropic.types import Message, TextBlock, ToolUseBlock, Usage, ToolUseBlock, Message, Usage, TextBlock
|
|
8
8
|
from openai.types.chat import ChatCompletionMessageParam, ChatCompletionUserMessageParam, ChatCompletionAssistantMessageParam, ChatCompletionSystemMessageParam
|
|
9
|
-
from mito_ai.completions.models import MessageType
|
|
10
|
-
from unittest.mock import
|
|
9
|
+
from mito_ai.completions.models import MessageType
|
|
10
|
+
from unittest.mock import patch
|
|
11
11
|
import anthropic
|
|
12
|
-
from typing import List, Dict,
|
|
12
|
+
from typing import List, Dict, cast
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
# Dummy base64 image (1x1 PNG)
|
|
@@ -53,7 +53,7 @@ def test_no_system_instructions_only_content():
|
|
|
53
53
|
]
|
|
54
54
|
system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages(messages)
|
|
55
55
|
|
|
56
|
-
assert isinstance(system_prompt, anthropic.
|
|
56
|
+
assert isinstance(system_prompt, anthropic.Omit)
|
|
57
57
|
assert len(anthropic_messages) == 2
|
|
58
58
|
assert anthropic_messages[0]["role"] == "user"
|
|
59
59
|
assert anthropic_messages[0]["content"] == "Hello!"
|
|
@@ -93,7 +93,7 @@ def test_empty_message_content():
|
|
|
93
93
|
]
|
|
94
94
|
system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages(messages)
|
|
95
95
|
|
|
96
|
-
assert isinstance(system_prompt, anthropic.
|
|
96
|
+
assert isinstance(system_prompt, anthropic.Omit)
|
|
97
97
|
assert len(anthropic_messages) == 1 # Should skip the message with missing content
|
|
98
98
|
assert anthropic_messages[0]["role"] == "assistant"
|
|
99
99
|
assert anthropic_messages[0]["content"] == "Hi!"
|
|
@@ -272,4 +272,176 @@ async def test_model_selection_based_on_message_type(message_type, expected_mode
|
|
|
272
272
|
# Verify that create was called with the expected model
|
|
273
273
|
mock_create.assert_called_once()
|
|
274
274
|
call_args = mock_create.call_args
|
|
275
|
-
assert call_args[1]['model'] == expected_model
|
|
275
|
+
assert call_args[1]['model'] == expected_model
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# Caching Tests
|
|
279
|
+
|
|
280
|
+
@pytest.mark.parametrize("message,expected_role,expected_content_type,expected_content_length,expected_cache_control", [
|
|
281
|
+
# String content message
|
|
282
|
+
(
|
|
283
|
+
{"role": "user", "content": "Hello world"},
|
|
284
|
+
"user",
|
|
285
|
+
list,
|
|
286
|
+
1,
|
|
287
|
+
True
|
|
288
|
+
),
|
|
289
|
+
# List content message
|
|
290
|
+
(
|
|
291
|
+
{
|
|
292
|
+
"role": "user",
|
|
293
|
+
"content": [
|
|
294
|
+
{"type": "text", "text": "First part"},
|
|
295
|
+
{"type": "text", "text": "Second part"}
|
|
296
|
+
]
|
|
297
|
+
},
|
|
298
|
+
"user",
|
|
299
|
+
list,
|
|
300
|
+
2,
|
|
301
|
+
True
|
|
302
|
+
),
|
|
303
|
+
# Empty content message
|
|
304
|
+
(
|
|
305
|
+
{"role": "user", "content": []},
|
|
306
|
+
"user",
|
|
307
|
+
list,
|
|
308
|
+
0,
|
|
309
|
+
False
|
|
310
|
+
),
|
|
311
|
+
# Assistant message with string content
|
|
312
|
+
(
|
|
313
|
+
{"role": "assistant", "content": "I can help you with that."},
|
|
314
|
+
"assistant",
|
|
315
|
+
list,
|
|
316
|
+
1,
|
|
317
|
+
True
|
|
318
|
+
),
|
|
319
|
+
])
|
|
320
|
+
def test_add_cache_control_to_message(message, expected_role, expected_content_type, expected_content_length, expected_cache_control):
|
|
321
|
+
"""Test adding cache control to different types of messages."""
|
|
322
|
+
result = add_cache_control_to_message(message)
|
|
323
|
+
|
|
324
|
+
assert result["role"] == expected_role
|
|
325
|
+
assert isinstance(result["content"], expected_content_type)
|
|
326
|
+
assert len(result["content"]) == expected_content_length
|
|
327
|
+
|
|
328
|
+
if expected_cache_control and expected_content_length > 0:
|
|
329
|
+
# Should have cache_control on the last content block
|
|
330
|
+
last_block = result["content"][-1]
|
|
331
|
+
assert last_block["cache_control"] == {"type": "ephemeral"}
|
|
332
|
+
|
|
333
|
+
# If there are multiple blocks, earlier blocks should not have cache_control
|
|
334
|
+
if expected_content_length > 1:
|
|
335
|
+
for i in range(expected_content_length - 1):
|
|
336
|
+
assert "cache_control" not in result["content"][i]
|
|
337
|
+
elif expected_content_length == 0:
|
|
338
|
+
# Empty content should return unchanged
|
|
339
|
+
assert result == message
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.mark.parametrize("messages,expected_system_type,expected_system_content", [
|
|
343
|
+
# With system prompt
|
|
344
|
+
(
|
|
345
|
+
[
|
|
346
|
+
ChatCompletionSystemMessageParam(role="system", content="You are a helpful assistant."),
|
|
347
|
+
ChatCompletionUserMessageParam(role="user", content="Hello!")
|
|
348
|
+
],
|
|
349
|
+
list,
|
|
350
|
+
"You are a helpful assistant.",
|
|
351
|
+
),
|
|
352
|
+
# Without system prompt
|
|
353
|
+
(
|
|
354
|
+
[
|
|
355
|
+
ChatCompletionUserMessageParam(role="user", content="Hello!"),
|
|
356
|
+
ChatCompletionAssistantMessageParam(role="assistant", content="Hi there!")
|
|
357
|
+
],
|
|
358
|
+
anthropic.Omit,
|
|
359
|
+
None,
|
|
360
|
+
),
|
|
361
|
+
# Multiple system messages (should take last one)
|
|
362
|
+
(
|
|
363
|
+
[
|
|
364
|
+
ChatCompletionSystemMessageParam(role="system", content="First system message."),
|
|
365
|
+
ChatCompletionSystemMessageParam(role="system", content="Second system message."),
|
|
366
|
+
ChatCompletionUserMessageParam(role="user", content="Hello!"),
|
|
367
|
+
ChatCompletionUserMessageParam(role="user", content="Hello!"),
|
|
368
|
+
ChatCompletionUserMessageParam(role="user", content="Hello!")
|
|
369
|
+
],
|
|
370
|
+
list,
|
|
371
|
+
"Second system message.",
|
|
372
|
+
),
|
|
373
|
+
])
|
|
374
|
+
def test_caching_system_prompt_scenarios(messages, expected_system_type, expected_system_content):
|
|
375
|
+
"""Test caching with different system prompt scenarios."""
|
|
376
|
+
system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages_with_caching(messages)
|
|
377
|
+
|
|
378
|
+
# Check system prompt
|
|
379
|
+
assert isinstance(system_prompt, expected_system_type)
|
|
380
|
+
if expected_system_content:
|
|
381
|
+
assert system_prompt[0]["text"] == expected_system_content
|
|
382
|
+
assert system_prompt[0]["cache_control"] == {"type": "ephemeral"}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
@pytest.mark.parametrize("message_count,expected_cache_boundary", [
|
|
386
|
+
(1, None), # 1 message, No cache boundary
|
|
387
|
+
(3, None), # 3 messages, No cache boundary
|
|
388
|
+
(5, 1), # 5 messages, cache at index 2
|
|
389
|
+
(10, 6), # 10 messages, cache at index 6
|
|
390
|
+
])
|
|
391
|
+
def test_caching_conversation_history(message_count, expected_cache_boundary):
|
|
392
|
+
"""Test that conversation history is cached at the keep_recent boundary for different message counts."""
|
|
393
|
+
|
|
394
|
+
# Create messages based on the parameter
|
|
395
|
+
messages: List[ChatCompletionMessageParam] = [
|
|
396
|
+
ChatCompletionSystemMessageParam(role="system", content="You are helpful.")
|
|
397
|
+
]
|
|
398
|
+
|
|
399
|
+
# Add message pairs
|
|
400
|
+
for i in range(message_count):
|
|
401
|
+
messages.append(ChatCompletionUserMessageParam(role="user", content=f"Message {i+1}"))
|
|
402
|
+
|
|
403
|
+
system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages_with_caching(messages)
|
|
404
|
+
|
|
405
|
+
# System prompt should have cache control
|
|
406
|
+
assert isinstance(system_prompt, list)
|
|
407
|
+
assert system_prompt[0]["cache_control"] == {"type": "ephemeral"}
|
|
408
|
+
|
|
409
|
+
print(anthropic_messages)
|
|
410
|
+
|
|
411
|
+
if expected_cache_boundary is None:
|
|
412
|
+
# Verify no cache boundry
|
|
413
|
+
assert all("cache_control" not in str(message) for message in anthropic_messages)
|
|
414
|
+
else:
|
|
415
|
+
# Other messages should not have cache control
|
|
416
|
+
for i, message in enumerate(anthropic_messages):
|
|
417
|
+
if i == expected_cache_boundary:
|
|
418
|
+
assert anthropic_messages[expected_cache_boundary]["content"][0]["cache_control"] == {"type": "ephemeral"}
|
|
419
|
+
else:
|
|
420
|
+
assert "cache_control" not in str(message)
|
|
421
|
+
|
|
422
|
+
def test_caching_with_mixed_content():
|
|
423
|
+
"""Test caching with mixed text and image content."""
|
|
424
|
+
messages: List[ChatCompletionMessageParam] = [
|
|
425
|
+
ChatCompletionSystemMessageParam(role="system", content="You are a helpful assistant."),
|
|
426
|
+
ChatCompletionUserMessageParam(role="user", content=[
|
|
427
|
+
{"type": "text", "text": "Here is an image:"},
|
|
428
|
+
{"type": "image_url", "image_url": {"url": DUMMY_IMAGE_DATA_URL}}
|
|
429
|
+
])
|
|
430
|
+
]
|
|
431
|
+
system_prompt, anthropic_messages = get_anthropic_system_prompt_and_messages_with_caching(messages)
|
|
432
|
+
|
|
433
|
+
# System prompt should have cache control
|
|
434
|
+
assert isinstance(system_prompt, list)
|
|
435
|
+
assert system_prompt[0]["cache_control"] == {"type": "ephemeral"}
|
|
436
|
+
|
|
437
|
+
# User message should NOT have cache control (only 1 message, so boundary is invalid)
|
|
438
|
+
user_message = anthropic_messages[0]
|
|
439
|
+
assert user_message["role"] == "user"
|
|
440
|
+
assert isinstance(user_message["content"], list)
|
|
441
|
+
assert len(user_message["content"]) == 2
|
|
442
|
+
|
|
443
|
+
# No content blocks should have cache control (too few messages to cache)
|
|
444
|
+
assert user_message["content"][0]["type"] == "text"
|
|
445
|
+
assert "cache_control" not in user_message["content"][0]
|
|
446
|
+
assert user_message["content"][1]["type"] == "image"
|
|
447
|
+
assert "cache_control" not in user_message["content"][1]
|