diff --git a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py index a086357af..1706e3705 100644 --- a/surfsense_backend/app/agents/new_chat/middleware/filesystem.py +++ b/surfsense_backend/app/agents/new_chat/middleware/filesystem.py @@ -787,17 +787,20 @@ class SurfSenseFilesystemMiddleware(FilesystemMiddleware): ) -> str: backend = self._get_backend(runtime) mount_prefix = self._default_mount_prefix(runtime) + normalized_candidate = re.sub(r"/+", "/", candidate.strip().replace("\\", "/")) if not mount_prefix or not isinstance(backend, MultiRootLocalFolderBackend): - return candidate if candidate.startswith("/") else f"/{candidate.lstrip('/')}" + if normalized_candidate.startswith("/"): + return normalized_candidate + return f"/{normalized_candidate.lstrip('/')}" mount_names = set(backend.list_mounts()) - if candidate.startswith("/"): - first_segment = candidate.lstrip("/").split("/", 1)[0] + if normalized_candidate.startswith("/"): + first_segment = normalized_candidate.lstrip("/").split("/", 1)[0] if first_segment in mount_names: - return candidate - return f"{mount_prefix}{candidate}" + return normalized_candidate + return f"{mount_prefix}{normalized_candidate}" - relative = candidate.lstrip("/") + relative = normalized_candidate.lstrip("/") first_segment = relative.split("/", 1)[0] if first_segment in mount_names: return f"/{relative}" diff --git a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py index f42e836cb..c0281fa29 100644 --- a/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py +++ b/surfsense_backend/tests/unit/middleware/test_file_intent_middleware.py @@ -4,6 +4,7 @@ from langchain_core.messages import AIMessage, HumanMessage from app.agents.new_chat.middleware.file_intent import ( FileIntentMiddleware, FileOperationIntent, + _fallback_path, ) pytestmark = pytest.mark.unit @@ -171,3 +172,43 @@ async def test_file_write_infers_directory_from_user_text_when_missing(): assert contract["intent"] == FileOperationIntent.FILE_WRITE.value assert contract["suggested_path"] == "/pc_backups/random.md" + +def test_fallback_path_normalizes_windows_slashes() -> None: + resolved = _fallback_path( + suggested_filename="summary.md", + suggested_path=r"\reports\q2\summary.md", + user_text="create report", + ) + + assert resolved == "/reports/q2/summary.md" + + +def test_fallback_path_normalizes_windows_drive_path() -> None: + resolved = _fallback_path( + suggested_filename=None, + suggested_path=r"C:\Users\anish\notes\todo.md", + user_text="create note", + ) + + assert resolved == "/C/Users/anish/notes/todo.md" + + +def test_fallback_path_normalizes_mixed_separators_and_duplicate_slashes() -> None: + resolved = _fallback_path( + suggested_filename="summary.md", + suggested_path=r"\\reports\\q2//summary.md", + user_text="create report", + ) + + assert resolved == "/reports/q2/summary.md" + + +def test_fallback_path_keeps_posix_style_absolute_path_for_linux_and_macos() -> None: + resolved = _fallback_path( + suggested_filename=None, + suggested_path="/var/log/surfsense/notes.md", + user_text="create note", + ) + + assert resolved == "/var/log/surfsense/notes.md" + diff --git a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py index 89a6e2b4c..7b4119bb5 100644 --- a/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py +++ b/surfsense_backend/tests/unit/middleware/test_filesystem_verification.py @@ -97,3 +97,68 @@ def test_normalize_local_mount_path_keeps_explicit_mount(tmp_path: Path) -> None ) assert resolved == "/pc_backups/notes/random-note.md" + + +def test_normalize_local_mount_path_windows_backslashes(tmp_path: Path) -> None: + root = tmp_path / "PC Backups" + root.mkdir() + backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),)) + runtime = _RuntimeNoSuggestedPath() + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type] + r"\notes\random-note.md", + runtime, + ) + + assert resolved == "/pc_backups/notes/random-note.md" + + +def test_normalize_local_mount_path_normalizes_mixed_separators(tmp_path: Path) -> None: + root = tmp_path / "PC Backups" + root.mkdir() + backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),)) + runtime = _RuntimeNoSuggestedPath() + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type] + r"\\notes//nested\\random-note.md", + runtime, + ) + + assert resolved == "/pc_backups/notes/nested/random-note.md" + + +def test_normalize_local_mount_path_keeps_explicit_mount_with_backslashes( + tmp_path: Path, +) -> None: + root = tmp_path / "PC Backups" + root.mkdir() + backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),)) + runtime = _RuntimeNoSuggestedPath() + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path( # type: ignore[arg-type] + r"\pc_backups\notes\random-note.md", + runtime, + ) + + assert resolved == "/pc_backups/notes/random-note.md" + + +def test_normalize_local_mount_path_prefixes_posix_absolute_path_for_linux_and_macos( + tmp_path: Path, +) -> None: + root = tmp_path / "PC Backups" + root.mkdir() + backend = MultiRootLocalFolderBackend((("pc_backups", str(root)),)) + runtime = _RuntimeNoSuggestedPath() + middleware = SurfSenseFilesystemMiddleware.__new__(SurfSenseFilesystemMiddleware) + middleware._get_backend = lambda _runtime: backend # type: ignore[method-assign] + + resolved = middleware._normalize_local_mount_path("/var/log/app.log", runtime) # type: ignore[arg-type] + + assert resolved == "/pc_backups/var/log/app.log" diff --git a/surfsense_web/contracts/enums/toolIcons.tsx b/surfsense_web/contracts/enums/toolIcons.tsx index fd12aaa9c..3bc639d33 100644 --- a/surfsense_web/contracts/enums/toolIcons.tsx +++ b/surfsense_web/contracts/enums/toolIcons.tsx @@ -1,6 +1,7 @@ import { BookOpen, Brain, + FileUser, FileText, Film, Globe, @@ -15,6 +16,7 @@ const TOOL_ICONS: Record = { generate_podcast: Podcast, generate_video_presentation: Film, generate_report: FileText, + generate_resume: FileUser, generate_image: ImageIcon, scrape_webpage: ScanLine, web_search: Globe,