deepanalysts 0.7.2__tar.gz → 0.7.3__tar.gz

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 (63) hide show
  1. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/PKG-INFO +1 -1
  2. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/composite.py +39 -9
  3. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/supabase_storage.py +8 -2
  4. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/filesystem.py +10 -4
  5. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/PKG-INFO +1 -1
  6. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/pyproject.toml +1 -1
  7. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_composite_backend.py +89 -4
  8. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_filesystem_middleware.py +35 -0
  9. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_supabase_storage_backend.py +35 -0
  10. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/README.md +0 -0
  11. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/__init__.py +0 -0
  12. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/_messages_reducer.py +0 -0
  13. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/__init__.py +0 -0
  14. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/basement.py +0 -0
  15. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/e2b.py +0 -0
  16. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/filesystem.py +0 -0
  17. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/protocol.py +0 -0
  18. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/sandbox.py +0 -0
  19. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/state.py +0 -0
  20. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/store.py +0 -0
  21. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/backends/utils.py +0 -0
  22. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/clients/__init__.py +0 -0
  23. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/clients/basement.py +0 -0
  24. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/__init__.py +0 -0
  25. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/_state.py +0 -0
  26. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/_utils.py +0 -0
  27. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/memory.py +0 -0
  28. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/patch_tool_calls.py +0 -0
  29. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/skills.py +0 -0
  30. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/subagents.py +0 -0
  31. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/summarization.py +0 -0
  32. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/middleware/tool_errors.py +0 -0
  33. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/state.py +0 -0
  34. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/streaming.py +0 -0
  35. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/utils/__init__.py +0 -0
  36. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts/utils/retry.py +0 -0
  37. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/SOURCES.txt +0 -0
  38. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/dependency_links.txt +0 -0
  39. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/requires.txt +0 -0
  40. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/deepanalysts.egg-info/top_level.txt +0 -0
  41. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/setup.cfg +0 -0
  42. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_basement.py +0 -0
  43. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_e2b_backend.py +0 -0
  44. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_e2b_integration.py +0 -0
  45. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_messages_reducer.py +0 -0
  46. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_prompt_sections.py +0 -0
  47. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_protocol_bridge.py +0 -0
  48. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_files_delta.py +0 -0
  49. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_fs_backport.py +0 -0
  50. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_middleware_tools.py +0 -0
  51. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_new_api.py +0 -0
  52. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_subagents.py +0 -0
  53. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_regression_tier1_backports.py +0 -0
  54. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_sandbox_backend.py +0 -0
  55. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_sandbox_hardening.py +0 -0
  56. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_sandbox_hardening_integration.py +0 -0
  57. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_skills_middleware.py +0 -0
  58. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_store_backend.py +0 -0
  59. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_streaming_transformer.py +0 -0
  60. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_summarization_middleware.py +0 -0
  61. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_supabase_storage_integration.py +0 -0
  62. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_tool_errors.py +0 -0
  63. {deepanalysts-0.7.2 → deepanalysts-0.7.3}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -9,7 +9,8 @@ from __future__ import annotations
9
9
 
10
10
  import asyncio
11
11
  from collections import defaultdict
12
- from typing import cast
12
+ from dataclasses import replace
13
+ from typing import TypeVar, cast
13
14
 
14
15
  from deepanalysts.backends.protocol import (
15
16
  BackendProtocol,
@@ -49,6 +50,29 @@ def _remap_file_info_path(fi: FileInfo, route_prefix: str) -> FileInfo:
49
50
  )
50
51
 
51
52
 
53
+ _ResultT = TypeVar("_ResultT", WriteResult, EditResult)
54
+
55
+
56
+ def _remap_result_paths(res: _ResultT, route_prefix: str | None) -> _ResultT:
57
+ """Re-prepend a matched route prefix to a write/edit result's paths.
58
+
59
+ Child backends receive route-stripped paths, so the ``path`` (and
60
+ ``files_update`` keys) they return are relative to the route root. Tool
61
+ confirmations echo ``res.path`` back to the model — leaving it stripped
62
+ hands the model a path that routes to a *different* backend on the next
63
+ call (e.g. ``/shared/x.md`` written, ``/x.md`` echoed, follow-up read of
64
+ ``/x.md`` hits the default sandbox and fails).
65
+ """
66
+ if route_prefix is None:
67
+ return res
68
+ prefix = route_prefix.rstrip("/")
69
+ path = f"{prefix}{res.path}" if res.path else res.path
70
+ files_update = res.files_update
71
+ if files_update is not None:
72
+ files_update = {f"{prefix}{k}": v for k, v in files_update.items()}
73
+ return replace(res, path=path, files_update=files_update)
74
+
75
+
52
76
  def _strip_route_from_pattern(pattern: str, route_prefix: str) -> str:
53
77
  """Strip a route prefix from a glob pattern when the pattern targets that route.
54
78
 
@@ -163,8 +187,8 @@ class CompositeBackend(SandboxBackendProtocol):
163
187
  return backend.read(backend_path, offset=offset, limit=limit)
164
188
 
165
189
  def write(self, file_path: str, content: str) -> WriteResult:
166
- backend, backend_path, _rp = self._route(file_path)
167
- return backend.write(backend_path, content)
190
+ backend, backend_path, route_prefix = self._route(file_path)
191
+ return _remap_result_paths(backend.write(backend_path, content), route_prefix)
168
192
 
169
193
  def edit(
170
194
  self,
@@ -173,8 +197,11 @@ class CompositeBackend(SandboxBackendProtocol):
173
197
  new_string: str,
174
198
  replace_all: bool = False,
175
199
  ) -> EditResult:
176
- backend, backend_path, _rp = self._route(file_path)
177
- return backend.edit(backend_path, old_string, new_string, replace_all=replace_all)
200
+ backend, backend_path, route_prefix = self._route(file_path)
201
+ return _remap_result_paths(
202
+ backend.edit(backend_path, old_string, new_string, replace_all=replace_all),
203
+ route_prefix,
204
+ )
178
205
 
179
206
  def grep(
180
207
  self,
@@ -309,8 +336,8 @@ class CompositeBackend(SandboxBackendProtocol):
309
336
  return await backend.aread_result(backend_path, offset=offset, limit=limit)
310
337
 
311
338
  async def awrite(self, file_path: str, content: str) -> WriteResult:
312
- backend, backend_path, _rp = self._route(file_path)
313
- return await backend.awrite(backend_path, content)
339
+ backend, backend_path, route_prefix = self._route(file_path)
340
+ return _remap_result_paths(await backend.awrite(backend_path, content), route_prefix)
314
341
 
315
342
  async def aedit(
316
343
  self,
@@ -319,8 +346,11 @@ class CompositeBackend(SandboxBackendProtocol):
319
346
  new_string: str,
320
347
  replace_all: bool = False,
321
348
  ) -> EditResult:
322
- backend, backend_path, _rp = self._route(file_path)
323
- return await backend.aedit(backend_path, old_string, new_string, replace_all=replace_all)
349
+ backend, backend_path, route_prefix = self._route(file_path)
350
+ return _remap_result_paths(
351
+ await backend.aedit(backend_path, old_string, new_string, replace_all=replace_all),
352
+ route_prefix,
353
+ )
324
354
 
325
355
  async def agrep(
326
356
  self,
@@ -337,6 +337,12 @@ class SupabaseStorageBackend(BackendProtocol):
337
337
  except StorageListError as e:
338
338
  return LsResult(error=f"Error: {e}")
339
339
 
340
+ # Entries must carry the full virtual path, not the bare object name —
341
+ # listing "/research/x/" with entries rendered as "/README.md" reads as
342
+ # a root-level file and the model then read_files the wrong path.
343
+ base = "/" + path.strip("/")
344
+ base = base if base == "/" else f"{base}/"
345
+
340
346
  entries: list[FileInfo] = []
341
347
  for item in items:
342
348
  name = item.get("name", "")
@@ -344,12 +350,12 @@ class SupabaseStorageBackend(BackendProtocol):
344
350
  continue
345
351
 
346
352
  if item.get("id") is None:
347
- entries.append(FileInfo(path=f"/{name}/", is_dir=True, size=0, modified_at=""))
353
+ entries.append(FileInfo(path=f"{base}{name}/", is_dir=True, size=0, modified_at=""))
348
354
  else:
349
355
  metadata = item.get("metadata") or {}
350
356
  entries.append(
351
357
  FileInfo(
352
- path=f"/{name}",
358
+ path=f"{base}{name}",
353
359
  is_dir=False,
354
360
  size=metadata.get("size", 0),
355
361
  modified_at=item.get("updated_at", ""),
@@ -736,19 +736,22 @@ class FilesystemMiddleware(AgentMiddleware):
736
736
  res: WriteResult = resolved_backend.write(file_path, content)
737
737
  if res.error:
738
738
  return res.error
739
+ # Byte count lets the model verify a large write completed even
740
+ # after history compaction truncates the content argument.
741
+ confirmation = f"Updated file {res.path} ({len(content.encode('utf-8'))} bytes written)"
739
742
  if res.files_update is not None:
740
743
  return Command(
741
744
  update={
742
745
  "files": res.files_update,
743
746
  "messages": [
744
747
  ToolMessage(
745
- content=f"Updated file {res.path}",
748
+ content=confirmation,
746
749
  tool_call_id=runtime.tool_call_id,
747
750
  )
748
751
  ],
749
752
  }
750
753
  )
751
- return f"Updated file {res.path}"
754
+ return confirmation
752
755
 
753
756
  async def async_write_file(
754
757
  file_path: Annotated[str, "Absolute path to the file to write."],
@@ -763,19 +766,22 @@ class FilesystemMiddleware(AgentMiddleware):
763
766
  res: WriteResult = await resolved_backend.awrite(file_path, content)
764
767
  if res.error:
765
768
  return res.error
769
+ # Byte count lets the model verify a large write completed even
770
+ # after history compaction truncates the content argument.
771
+ confirmation = f"Updated file {res.path} ({len(content.encode('utf-8'))} bytes written)"
766
772
  if res.files_update is not None:
767
773
  return Command(
768
774
  update={
769
775
  "files": res.files_update,
770
776
  "messages": [
771
777
  ToolMessage(
772
- content=f"Updated file {res.path}",
778
+ content=confirmation,
773
779
  tool_call_id=runtime.tool_call_id,
774
780
  )
775
781
  ],
776
782
  }
777
783
  )
778
- return f"Updated file {res.path}"
784
+ return confirmation
779
785
 
780
786
  return StructuredTool.from_function(
781
787
  name="write_file",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deepanalysts
3
- Version: 0.7.2
3
+ Version: 0.7.3
4
4
  Summary: LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support
5
5
  Author-email: Ganchuluun Narantsatsralt <tsatsralt@swifttech.cloud>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepanalysts"
3
- version = "0.7.2"
3
+ version = "0.7.3"
4
4
  description = "LangChain/LangGraph middleware for building AI agents with memory, skills, and filesystem support"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -44,7 +44,7 @@ class TestCompositeBackendRouting:
44
44
  )
45
45
  result = composite.write("/memories/user.md", "preferences")
46
46
  assert result.error is None
47
- assert result.path == "/user.md" # Prefix stripped
47
+ assert result.path == "/memories/user.md" # Full virtual path echoed
48
48
 
49
49
  def test_route_to_default(self):
50
50
  """Test unmatched paths use default backend."""
@@ -79,8 +79,8 @@ class TestCompositeBackendRouting:
79
79
  # The /skills/trading/ should match before /skills/
80
80
  result = composite.write("/skills/trading/fib.md", "content")
81
81
  assert result.error is None
82
- # Path stripped to /fib.md (not /trading/fib.md)
83
- assert result.path == "/fib.md"
82
+ # Result path is the full virtual path, whichever route matched
83
+ assert result.path == "/skills/trading/fib.md"
84
84
 
85
85
 
86
86
  class TestCompositeBackendExecution:
@@ -217,7 +217,7 @@ class TestCompositeBackendAsync:
217
217
 
218
218
  result = await composite.awrite("/memories/new.md", "new content")
219
219
  assert result.error is None
220
- assert result.path == "/new.md" # Prefix stripped
220
+ assert result.path == "/memories/new.md" # Full virtual path echoed
221
221
 
222
222
  asyncio.run(run_test())
223
223
 
@@ -475,3 +475,88 @@ class TestPathHelpers:
475
475
  def test_strip_route_leading_slash_agnostic(self):
476
476
  result = _strip_route_from_pattern("memories/**/*.md", "/memories/")
477
477
  assert result == "**/*.md"
478
+
479
+
480
+ # ---------------------------------------------------------------------------
481
+ # Regression: write/edit results must echo the full virtual path
482
+ # ---------------------------------------------------------------------------
483
+
484
+
485
+ class TestWriteEditResultPathRemap:
486
+ """Regression for the /shared/ path-stripping bug.
487
+
488
+ Routed backends see route-stripped paths, so their results carried e.g.
489
+ ``/research/x.md`` for a write to ``/shared/research/x.md``. The tool
490
+ confirmation echoed that stripped path, and a follow-up read of it routed
491
+ to the *default* backend and failed. Write/edit results must round-trip.
492
+ """
493
+
494
+ def test_write_result_path_roundtrips_through_read(self):
495
+ composite = CompositeBackend(
496
+ default=make_store_backend(),
497
+ routes={"/shared/": make_store_backend()},
498
+ )
499
+ result = composite.write("/shared/research/x/README.md", "# hi")
500
+ assert result.error is None
501
+ assert result.path == "/shared/research/x/README.md"
502
+ # The echoed path must be readable as-is (this failed pre-fix: the
503
+ # stripped path routed to the default backend).
504
+ assert "hi" in composite.read(result.path)
505
+
506
+ def test_edit_result_path_is_full_virtual_path(self):
507
+ store = make_store_backend()
508
+ composite = CompositeBackend(
509
+ default=make_store_backend(),
510
+ routes={"/shared/": store},
511
+ )
512
+ store.write("/note.md", "hello world")
513
+ result = composite.edit("/shared/note.md", "hello", "goodbye")
514
+ assert result.error is None
515
+ assert result.path == "/shared/note.md"
516
+
517
+ def test_awrite_files_update_keys_are_remapped(self):
518
+ async def run_test():
519
+ composite = CompositeBackend(
520
+ default=make_store_backend(),
521
+ routes={"/memories/": make_store_backend()},
522
+ )
523
+ result = await composite.awrite("/memories/a/b.md", "content")
524
+ assert result.error is None
525
+ assert result.path == "/memories/a/b.md"
526
+ if result.files_update is not None:
527
+ assert all(k.startswith("/memories/") for k in result.files_update)
528
+
529
+ asyncio.run(run_test())
530
+
531
+ def test_aedit_result_path_is_full_virtual_path(self):
532
+ async def run_test():
533
+ store = make_store_backend()
534
+ composite = CompositeBackend(
535
+ default=make_store_backend(),
536
+ routes={"/shared/": store},
537
+ )
538
+ store.write("/x.md", "old text")
539
+ result = await composite.aedit("/shared/x.md", "old", "new")
540
+ assert result.error is None
541
+ assert result.path == "/shared/x.md"
542
+
543
+ asyncio.run(run_test())
544
+
545
+ def test_default_route_write_path_unchanged(self):
546
+ composite = CompositeBackend(
547
+ default=make_store_backend(),
548
+ routes={"/memories/": make_store_backend()},
549
+ )
550
+ result = composite.write("/scratch.txt", "tmp")
551
+ assert result.error is None
552
+ assert result.path == "/scratch.txt"
553
+
554
+ def test_error_result_passes_through(self):
555
+ store = make_store_backend()
556
+ composite = CompositeBackend(
557
+ default=make_store_backend(),
558
+ routes={"/shared/": store},
559
+ )
560
+ result = composite.edit("/shared/missing.md", "a", "b")
561
+ assert result.error is not None
562
+ assert result.path is None
@@ -29,6 +29,14 @@ def _make_store_backend() -> StoreBackend:
29
29
  return StoreBackend(_make_mock_runtime())
30
30
 
31
31
 
32
+ def _invoke_write_file(middleware: FilesystemMiddleware, file_path: str, content: str):
33
+ """Invoke the write_file tool's sync function directly (see _invoke_read_file)."""
34
+ write_file_tool = next(t for t in middleware.tools if t.name == "write_file")
35
+ runtime = MagicMock()
36
+ runtime.tool_call_id = "test-call-id"
37
+ return write_file_tool.func(file_path=file_path, content=content, runtime=runtime)
38
+
39
+
32
40
  def _invoke_read_file(middleware: FilesystemMiddleware, file_path: str, offset: int = 0, limit: int = 500) -> str:
33
41
  """Invoke the read_file tool's sync function directly.
34
42
 
@@ -44,6 +52,33 @@ def _invoke_read_file(middleware: FilesystemMiddleware, file_path: str, offset:
44
52
  return sync_fn(file_path=file_path, runtime=runtime, offset=offset, limit=limit)
45
53
 
46
54
 
55
+ class TestWriteFileConfirmation:
56
+ """Regression: the confirmation must state how many bytes were written.
57
+
58
+ History compaction later truncates the write's content argument to a
59
+ 20-char preview ("...(argument truncated)"), so without a byte count the
60
+ model cannot tell whether a large write completed and tends to retry it.
61
+ """
62
+
63
+ def test_confirmation_reports_byte_count(self):
64
+ backend = _make_store_backend()
65
+ middleware = FilesystemMiddleware(backend=backend)
66
+ content = "x" * 1234
67
+ result = _invoke_write_file(middleware, "/report.md", content)
68
+ # StoreBackend returns files_update, so the confirmation rides a Command
69
+ message = result.update["messages"][0].content if hasattr(result, "update") else result
70
+ assert "/report.md" in message
71
+ assert "1234 bytes written" in message
72
+
73
+ def test_byte_count_is_utf8_bytes_not_chars(self):
74
+ backend = _make_store_backend()
75
+ middleware = FilesystemMiddleware(backend=backend)
76
+ content = "é" * 10 # 10 chars, 20 UTF-8 bytes
77
+ result = _invoke_write_file(middleware, "/unicode.md", content)
78
+ message = result.update["messages"][0].content if hasattr(result, "update") else result
79
+ assert "20 bytes written" in message
80
+
81
+
47
82
  class TestReadFileTruncation:
48
83
  """Tests for read_file tool truncation behavior."""
49
84
 
@@ -258,6 +258,41 @@ class TestAls:
258
258
 
259
259
  asyncio.run(run())
260
260
 
261
+ def test_list_subdirectory_entries_carry_full_path(self):
262
+ """Regression: entries were rendered as bare names ("/README.md"), so
263
+ listing a subdirectory looked like a listing of the tree root and the
264
+ model then read the wrong path."""
265
+
266
+ async def run():
267
+ b = make_backend()
268
+ items = [
269
+ {"name": "README.md", "id": "abc", "metadata": {"size": 10}, "updated_at": "2024-01-01"},
270
+ {"name": "data", "id": None}, # folder
271
+ ]
272
+ with patch.object(b, "_list_objects", new_callable=AsyncMock, return_value=items):
273
+ result = await b.als("/research/ai-supply-chain/")
274
+ assert result.error is None
275
+ paths = [fi["path"] for fi in (result.entries or [])]
276
+ assert "/research/ai-supply-chain/README.md" in paths
277
+ assert "/research/ai-supply-chain/data/" in paths
278
+ assert "/README.md" not in paths
279
+
280
+ asyncio.run(run())
281
+
282
+ def test_list_subdirectory_without_trailing_slash(self):
283
+ async def run():
284
+ b = make_backend()
285
+ items = [
286
+ {"name": "notes.md", "id": "x", "metadata": {"size": 5}, "updated_at": "2024-01-01"},
287
+ ]
288
+ with patch.object(b, "_list_objects", new_callable=AsyncMock, return_value=items):
289
+ result = await b.als("/research")
290
+ assert result.error is None
291
+ paths = [fi["path"] for fi in (result.entries or [])]
292
+ assert paths == ["/research/notes.md"]
293
+
294
+ asyncio.run(run())
295
+
261
296
 
262
297
  class TestAgrep:
263
298
  def test_grep_finds_matches(self):
File without changes
File without changes