mito-ai 0.1.45__py3-none-any.whl → 0.1.47__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.

Potentially problematic release.


This version of mito-ai might be problematic. Click here for more details.

Files changed (82) hide show
  1. mito_ai/__init__.py +10 -1
  2. mito_ai/_version.py +1 -1
  3. mito_ai/anthropic_client.py +90 -5
  4. mito_ai/app_deploy/handlers.py +97 -77
  5. mito_ai/app_deploy/models.py +16 -12
  6. mito_ai/chat_history/handlers.py +63 -0
  7. mito_ai/chat_history/urls.py +32 -0
  8. mito_ai/completions/handlers.py +18 -20
  9. mito_ai/completions/models.py +4 -1
  10. mito_ai/completions/prompt_builders/agent_execution_prompt.py +6 -1
  11. mito_ai/completions/prompt_builders/agent_system_message.py +63 -4
  12. mito_ai/completions/prompt_builders/chat_system_message.py +4 -0
  13. mito_ai/completions/prompt_builders/prompt_constants.py +1 -0
  14. mito_ai/completions/prompt_builders/utils.py +14 -0
  15. mito_ai/constants.py +3 -0
  16. mito_ai/path_utils.py +56 -0
  17. mito_ai/streamlit_conversion/agent_utils.py +27 -106
  18. mito_ai/streamlit_conversion/prompts/prompt_constants.py +166 -53
  19. mito_ai/streamlit_conversion/prompts/streamlit_app_creation_prompt.py +2 -1
  20. mito_ai/streamlit_conversion/prompts/streamlit_error_correction_prompt.py +3 -3
  21. mito_ai/streamlit_conversion/prompts/streamlit_finish_todo_prompt.py +4 -3
  22. mito_ai/streamlit_conversion/{streamlit_system_prompt.py → prompts/streamlit_system_prompt.py} +1 -0
  23. mito_ai/streamlit_conversion/prompts/update_existing_app_prompt.py +50 -0
  24. mito_ai/streamlit_conversion/search_replace_utils.py +93 -0
  25. mito_ai/streamlit_conversion/streamlit_agent_handler.py +103 -119
  26. mito_ai/streamlit_conversion/streamlit_utils.py +18 -68
  27. mito_ai/streamlit_conversion/validate_streamlit_app.py +78 -96
  28. mito_ai/streamlit_preview/handlers.py +44 -85
  29. mito_ai/streamlit_preview/manager.py +6 -6
  30. mito_ai/streamlit_preview/utils.py +19 -18
  31. mito_ai/tests/chat_history/test_chat_history.py +211 -0
  32. mito_ai/tests/message_history/test_message_history_utils.py +43 -19
  33. mito_ai/tests/providers/test_anthropic_client.py +178 -6
  34. mito_ai/tests/streamlit_conversion/test_apply_search_replace.py +226 -0
  35. mito_ai/tests/streamlit_conversion/test_streamlit_agent_handler.py +87 -114
  36. mito_ai/tests/streamlit_conversion/test_streamlit_utils.py +42 -45
  37. mito_ai/tests/streamlit_conversion/test_validate_streamlit_app.py +20 -14
  38. mito_ai/tests/streamlit_preview/test_streamlit_preview_handler.py +13 -16
  39. mito_ai/tests/streamlit_preview/test_streamlit_preview_manager.py +22 -26
  40. mito_ai/tests/user/__init__.py +2 -0
  41. mito_ai/tests/user/test_user.py +120 -0
  42. mito_ai/user/handlers.py +45 -0
  43. mito_ai/user/urls.py +21 -0
  44. mito_ai/utils/anthropic_utils.py +8 -6
  45. mito_ai/utils/create.py +17 -1
  46. mito_ai/utils/error_classes.py +42 -0
  47. mito_ai/utils/message_history_utils.py +7 -4
  48. mito_ai/utils/telemetry_utils.py +79 -11
  49. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/build_log.json +1 -1
  50. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/package.json +2 -2
  51. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/package.json.orig +1 -1
  52. mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.0c3368195d954d2ed033.js → mito_ai-0.1.47.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.2db61d2b629817845901.js +2126 -363
  53. mito_ai-0.1.47.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.2db61d2b629817845901.js.map +1 -0
  54. mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.684f82575fcc2e3b350c.js → mito_ai-0.1.47.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.e22c6cd4e56c32116daa.js +9 -9
  55. mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.684f82575fcc2e3b350c.js.map → mito_ai-0.1.47.data/data/share/jupyter/labextensions/mito_ai/static/remoteEntry.e22c6cd4e56c32116daa.js.map +1 -1
  56. {mito_ai-0.1.45.dist-info → mito_ai-0.1.47.dist-info}/METADATA +1 -1
  57. {mito_ai-0.1.45.dist-info → mito_ai-0.1.47.dist-info}/RECORD +81 -69
  58. mito_ai-0.1.45.data/data/share/jupyter/labextensions/mito_ai/static/lib_index_js.0c3368195d954d2ed033.js.map +0 -1
  59. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/etc/jupyter/jupyter_server_config.d/mito_ai.json +0 -0
  60. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/schemas/mito_ai/toolbar-buttons.json +0 -0
  61. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js +0 -0
  62. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/node_modules_process_browser_js.4b128e94d31a81ebd209.js.map +0 -0
  63. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/style.js +0 -0
  64. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js +0 -0
  65. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/style_index_js.5876024bb17dbd6a3ee6.js.map +0 -0
  66. {mito_ai-0.1.45.data → mito_ai-0.1.47.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
  67. {mito_ai-0.1.45.data → mito_ai-0.1.47.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
  68. {mito_ai-0.1.45.data → mito_ai-0.1.47.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
  69. {mito_ai-0.1.45.data → mito_ai-0.1.47.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
  70. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js +0 -0
  71. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_dist_esm_index_mjs.6bac1a8c4cc93f15f6b7.js.map +0 -0
  72. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js +0 -0
  73. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_aws-amplify_ui-react_dist_esm_index_mjs.4fcecd65bef9e9847609.js.map +0 -0
  74. {mito_ai-0.1.45.data → mito_ai-0.1.47.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
  75. {mito_ai-0.1.45.data → mito_ai-0.1.47.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
  76. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js +0 -0
  77. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_semver_index_js.3f6754ac5116d47de76b.js.map +0 -0
  78. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js +0 -0
  79. {mito_ai-0.1.45.data → mito_ai-0.1.47.data}/data/share/jupyter/labextensions/mito_ai/static/vendors-node_modules_vscode-diff_dist_index_js.ea55f1f9346638aafbcf.js.map +0 -0
  80. {mito_ai-0.1.45.dist-info → mito_ai-0.1.47.dist-info}/WHEEL +0 -0
  81. {mito_ai-0.1.45.dist-info → mito_ai-0.1.47.dist-info}/entry_points.txt +0 -0
  82. {mito_ai-0.1.45.dist-info → mito_ai-0.1.47.dist-info}/licenses/LICENSE +0 -0
@@ -2,17 +2,17 @@
2
2
  # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
3
 
4
4
  import os
5
- import tempfile
5
+ from typing import Literal, TypedDict
6
6
  import uuid
7
- from mito_ai.streamlit_conversion.streamlit_utils import get_app_path
8
7
  from mito_ai.streamlit_preview.utils import ensure_app_exists, validate_request_body
9
8
  import tornado
10
9
  from jupyter_server.base.handlers import APIHandler
11
- from mito_ai.streamlit_conversion.streamlit_agent_handler import streamlit_handler
12
10
  from mito_ai.streamlit_preview.manager import get_preview_manager
13
- from mito_ai.utils.create import initialize_user
14
- from typing import Tuple, Optional
15
-
11
+ from mito_ai.path_utils import AbsoluteNotebookPath, get_absolute_notebook_dir_path, get_absolute_notebook_path
12
+ from mito_ai.utils.telemetry_utils import log_streamlit_app_conversion_error, log_streamlit_app_preview_failure, log_streamlit_app_preview_success
13
+ from mito_ai.completions.models import MessageType
14
+ from mito_ai.utils.error_classes import StreamlitConversionError, StreamlitPreviewError
15
+ import traceback
16
16
 
17
17
 
18
18
  class StreamlitPreviewHandler(APIHandler):
@@ -22,103 +22,62 @@ class StreamlitPreviewHandler(APIHandler):
22
22
  """Initialize the handler."""
23
23
  self.preview_manager = get_preview_manager()
24
24
 
25
- def _resolve_notebook_path(self, notebook_path: str) -> str:
26
- """
27
- Resolve the notebook path to an absolute path that can be found by the backend.
28
-
29
- This method handles path resolution issues that can occur in different environments:
30
-
31
- 1. **Test Environment**: Playwright tests create temporary directories with complex
32
- paths like 'mitoai_ui_tests-app_builde-ab3a5-n-Test-Preview-as-Streamlit-chromium/'
33
- that the backend can't directly access.
34
-
35
- 2. **JupyterHub/Cloud Deployments**: In cloud environments, users may have notebooks
36
- in subdirectories that aren't immediately accessible from the server root.
37
-
38
- 3. **Docker Containers**: When running in containers, the working directory and
39
- file paths may not align with what the frontend reports.
40
-
41
- 4. **Multi-user Environments**: In enterprise deployments, users may have notebooks
42
- in user-specific directories that require path resolution.
43
-
44
- The method tries multiple strategies:
45
- 1. If the path is already absolute, return it as-is
46
- 2. Try to resolve relative to the Jupyter server's root directory
47
- 3. Search recursively through subdirectories for a file with the same name
48
- 4. Return the original path if not found (will cause a clear error message)
49
-
50
- Args:
51
- notebook_path (str): The notebook path from the frontend (may be relative or absolute)
52
-
53
- Returns:
54
- str: The resolved absolute path to the notebook file
55
- """
56
- # If the path is already absolute, return it
57
- if os.path.isabs(notebook_path):
58
- return notebook_path
59
-
60
- # Get the Jupyter server's root directory
61
- server_root = self.settings.get("server_root_dir", os.getcwd())
62
-
63
- # Try to find the notebook file in the server root
64
- resolved_path = os.path.join(server_root, notebook_path)
65
- if os.path.exists(resolved_path):
66
- return resolved_path
67
-
68
- # If not found, try to find it in subdirectories
69
- # This handles cases where the notebook is in a subdirectory that the frontend
70
- # doesn't know about, or where the path structure differs between frontend and backend
71
- for root, dirs, files in os.walk(server_root):
72
- if os.path.basename(notebook_path) in files:
73
- return os.path.join(root, os.path.basename(notebook_path))
74
-
75
- # If still not found, return the original path (will cause a clear error)
76
- # This ensures we get a meaningful error message rather than a generic "file not found"
77
- return os.path.join(os.getcwd(), notebook_path)
78
-
79
25
  @tornado.web.authenticated
80
26
  async def post(self) -> None:
81
27
  """Start a new streamlit preview."""
82
28
  try:
83
29
  # Parse and validate request
84
30
  body = self.get_json_body()
85
- is_valid, error_msg, notebook_path, force_recreate = validate_request_body(body)
86
- if not is_valid or notebook_path is None:
87
- self.set_status(400)
88
- self.finish({"error": error_msg})
89
- return
90
-
31
+ notebook_path, force_recreate, edit_prompt = validate_request_body(body)
91
32
 
92
33
  # Ensure app exists
93
- resolved_notebook_path = self._resolve_notebook_path(notebook_path)
94
-
95
- success, error_msg = await ensure_app_exists(resolved_notebook_path, force_recreate)
96
-
97
- if not success:
98
- self.set_status(500)
99
- self.finish({"error": error_msg})
100
- return
34
+ absolute_notebook_path = get_absolute_notebook_path(notebook_path)
35
+ await ensure_app_exists(absolute_notebook_path, force_recreate, edit_prompt)
101
36
 
102
37
  # Start preview
103
38
  # TODO: There's a bug here where when the user rebuilds and already running app. Instead of
104
39
  # creating a new process, we should update the existing process. The app displayed to the user
105
40
  # does update, but that's just because of hot reloading when we overwrite the app.py file.
106
41
  preview_id = str(uuid.uuid4())
107
- resolved_app_directory = os.path.dirname(resolved_notebook_path)
108
- success, message, port = self.preview_manager.start_streamlit_preview(resolved_app_directory, preview_id)
109
-
110
- if not success:
111
- self.set_status(500)
112
- self.finish({"error": f"Failed to start preview: {message}"})
113
- return
42
+ absolute_app_directory = get_absolute_notebook_dir_path(absolute_notebook_path)
43
+ port = self.preview_manager.start_streamlit_preview(absolute_app_directory, preview_id)
114
44
 
115
45
  # Return success response
116
- self.finish({"id": preview_id, "port": port, "url": f"http://localhost:{port}"})
117
-
46
+ self.finish({
47
+ "type": 'success',
48
+ "id": preview_id,
49
+ "port": port,
50
+ "url": f"http://localhost:{port}"
51
+ })
52
+ log_streamlit_app_preview_success('mito_server_key', MessageType.STREAMLIT_CONVERSION, edit_prompt)
53
+
54
+ except StreamlitConversionError as e:
55
+ print(e)
56
+ self.set_status(e.error_code)
57
+ error_message = str(e)
58
+ formatted_traceback = traceback.format_exc()
59
+ self.finish({"error": error_message})
60
+ log_streamlit_app_conversion_error(
61
+ 'mito_server_key',
62
+ MessageType.STREAMLIT_CONVERSION,
63
+ error_message,
64
+ formatted_traceback,
65
+ edit_prompt,
66
+ )
67
+ except StreamlitPreviewError as e:
68
+ print(e)
69
+ error_message = str(e)
70
+ formatted_traceback = traceback.format_exc()
71
+ self.set_status(e.error_code)
72
+ self.finish({"error": error_message})
73
+ log_streamlit_app_preview_failure('mito_server_key', MessageType.STREAMLIT_CONVERSION, error_message, formatted_traceback, edit_prompt)
118
74
  except Exception as e:
119
- print(f"Error in streamlit preview handler: {e}")
75
+ print(f"Exception in streamlit preview handler: {e}")
120
76
  self.set_status(500)
121
- self.finish({"error": str(e)})
77
+ error_message = str(e)
78
+ formatted_traceback = traceback.format_exc()
79
+ self.finish({"error": error_message})
80
+ log_streamlit_app_preview_failure('mito_server_key', MessageType.STREAMLIT_CONVERSION, error_message, formatted_traceback, "")
122
81
 
123
82
  @tornado.web.authenticated
124
83
  def delete(self, preview_id: str) -> None:
@@ -11,6 +11,7 @@ import requests
11
11
  from typing import Dict, Optional, Tuple
12
12
  from dataclasses import dataclass
13
13
  from mito_ai.logger import get_logger
14
+ from mito_ai.utils.error_classes import StreamlitPreviewError
14
15
 
15
16
 
16
17
  @dataclass
@@ -37,7 +38,7 @@ class StreamlitPreviewManager:
37
38
 
38
39
  return port
39
40
 
40
- def start_streamlit_preview(self, app_directory: str, preview_id: str) -> Tuple[bool, str, Optional[int]]:
41
+ def start_streamlit_preview(self, app_directory: str, preview_id: str) -> int:
41
42
  """Start a streamlit preview process.
42
43
 
43
44
  Args:
@@ -80,7 +81,7 @@ class StreamlitPreviewManager:
80
81
  if not ready:
81
82
  proc.terminate()
82
83
  proc.wait()
83
- return False, "Streamlit app failed to start", None
84
+ raise StreamlitPreviewError("Streamlit app failed to start as app is not ready", 500)
84
85
 
85
86
  # Register the process
86
87
  with self._lock:
@@ -90,11 +91,11 @@ class StreamlitPreviewManager:
90
91
  )
91
92
 
92
93
  self.log.info(f"Started streamlit preview {preview_id} on port {port}")
93
- return True, "Preview started successfully", port
94
+ return port
94
95
 
95
96
  except Exception as e:
96
97
  self.log.error(f"Error starting streamlit preview: {e}")
97
- return False, f"Failed to start preview: {str(e)}", None
98
+ raise StreamlitPreviewError(f"Failed to start preview: {str(e)}", 500)
98
99
 
99
100
  def _wait_for_app_ready(self, port: int, timeout: int = 30) -> bool:
100
101
  """Wait for streamlit app to be ready on the given port."""
@@ -106,7 +107,7 @@ class StreamlitPreviewManager:
106
107
  if response.status_code == 200:
107
108
  return True
108
109
  except requests.exceptions.RequestException as e:
109
- print(f"Error waiting for app to be ready: {e}")
110
+ print(f"Waiting for app to be ready...")
110
111
  pass
111
112
 
112
113
  time.sleep(1)
@@ -153,7 +154,6 @@ class StreamlitPreviewManager:
153
154
  # Global instance
154
155
  _preview_manager = StreamlitPreviewManager()
155
156
 
156
-
157
157
  def get_preview_manager() -> StreamlitPreviewManager:
158
158
  """Get the global preview manager instance."""
159
159
  return _preview_manager
@@ -2,40 +2,41 @@
2
2
  # Distributed under the terms of the GNU Affero General Public License v3.0 License.
3
3
 
4
4
  from typing import Tuple, Optional
5
- import os
6
- from mito_ai.streamlit_conversion.streamlit_utils import get_app_path
7
5
  from mito_ai.streamlit_conversion.streamlit_agent_handler import streamlit_handler
6
+ from mito_ai.path_utils import AbsoluteNotebookPath, does_app_path_exists, get_absolute_app_path, get_absolute_notebook_dir_path
7
+ from mito_ai.utils.error_classes import StreamlitPreviewError
8
8
 
9
9
 
10
- def validate_request_body(body: Optional[dict]) -> Tuple[bool, str, Optional[str], bool]:
10
+ def validate_request_body(body: Optional[dict]) -> Tuple[str, bool, str]:
11
11
  """Validate the request body and extract notebook_path and force_recreate."""
12
12
  if body is None:
13
- return False, "Invalid or missing JSON body", None, False
13
+ raise StreamlitPreviewError("Invalid or missing JSON body", 400)
14
14
 
15
15
  notebook_path = body.get("notebook_path")
16
16
  if not notebook_path:
17
- return False, "Missing notebook_path parameter", None, False
17
+ raise StreamlitPreviewError("Missing notebook_path parameter", 400)
18
18
 
19
19
  force_recreate = body.get("force_recreate", False)
20
20
  if not isinstance(force_recreate, bool):
21
- return False, "force_recreate must be a boolean", None, False
21
+ raise StreamlitPreviewError("force_recreate must be a boolean", 400)
22
+
23
+ edit_prompt = body.get("edit_prompt", "")
24
+ if not isinstance(edit_prompt, str):
25
+ raise StreamlitPreviewError("edit_prompt must be a string", 400)
22
26
 
23
- return True, "", notebook_path, force_recreate
27
+ return notebook_path, force_recreate, edit_prompt
24
28
 
25
- async def ensure_app_exists(resolved_notebook_path: str, force_recreate: bool = False) -> Tuple[bool, str]:
29
+ async def ensure_app_exists(absolute_notebook_path: AbsoluteNotebookPath, force_recreate: bool = False, edit_prompt: str = "") -> None:
26
30
  """Ensure app.py exists, generating it if necessary or if force_recreate is True."""
27
- # Check if the app already exists
28
- app_path = get_app_path(os.path.dirname(resolved_notebook_path))
31
+
32
+ absolute_notebook_dir_path = get_absolute_notebook_dir_path(absolute_notebook_path)
33
+ absolute_app_path = get_absolute_app_path(absolute_notebook_dir_path)
34
+ app_path_exists = does_app_path_exists(absolute_app_path)
29
35
 
30
- if app_path is None or force_recreate:
31
- if app_path is None:
36
+ if not app_path_exists or force_recreate:
37
+ if not app_path_exists:
32
38
  print("[Mito AI] App path not found, generating streamlit code")
33
39
  else:
34
40
  print("[Mito AI] Force recreating streamlit app")
35
41
 
36
- success, app_path, message = await streamlit_handler(resolved_notebook_path)
37
-
38
- if not success or app_path is None:
39
- return False, f"Failed to generate streamlit code: {message}"
40
-
41
- return True, ""
42
+ await streamlit_handler(absolute_notebook_path, edit_prompt)
@@ -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
@@ -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
- # Keep only the most recent message
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 message should remain unchanged
269
- recent_content = result[3].get("content")
270
- assert isinstance(recent_content, str)
271
- assert recent_content == "Recent user message"
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 keep_recent parameter."""
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
- # Keep the 2 most recent messages
303
- result = trim_old_messages(messages, keep_recent=2)
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, keep_recent=2)
352
+ result = trim_old_messages(messages)
333
353
  assert result == []
334
354
 
335
355
 
336
- def test_trim_old_messages_fewer_than_keep_recent() -> None:
337
- """Test that trim_old_messages doesn't modify messages if there are fewer than keep_recent."""
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, keep_recent=3)
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 one recent message (to not be trimmed)
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, keep_recent=2)
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: