diff --git a/src/bub/store.py b/src/bub/store.py index 06184baf..cad85ac9 100644 --- a/src/bub/store.py +++ b/src/bub/store.py @@ -390,7 +390,7 @@ def fetch_all(self, query: TapeQuery) -> Iterable[TapeEntry]: if not query._query: result: Iterable[TapeEntry] = super().fetch_all(query) return result - unlimited_query = replace(query, _limit=None) + unlimited_query = replace(query, _query=None, _limit=None) entries: Iterable[TapeEntry] = super().fetch_all(unlimited_query) return self._filter_entries(list(entries), query._query, query._limit or 20) diff --git a/src/bub/utils.py b/src/bub/utils.py index aab1af4a..d60708de 100644 --- a/src/bub/utils.py +++ b/src/bub/utils.py @@ -37,7 +37,7 @@ def workspace_from_state(state: TurnState) -> Path: def get_entry_text(entry: TapeEntry) -> str: import yaml - return yaml.safe_dump(entry.payload) + return yaml.safe_dump(entry.payload, allow_unicode=True) async def maybe_context_manager(obj: Any, stack: AsyncExitStack) -> Any: diff --git a/tests/test_file_tape_store_search.py b/tests/test_file_tape_store_search.py new file mode 100644 index 00000000..8839638e --- /dev/null +++ b/tests/test_file_tape_store_search.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from bub.store import FileTapeStore, TapeQuery +from bub.tape import TapeEntry + + +@pytest.mark.parametrize( + ("content", "query", "matches"), + [ + ("deployment configuration", "deployment", True), + ("Deployment configuration", "DEPLOYMENT", True), + ("deployment configuration", "deploymnt", True), + ("项目配置和部署说明", "项目配置", True), + ("café configuration", "CAFÉ", True), + ("deployment 🚀 ready", "🚀", True), + ("deployment configuration", "zqxjkv", False), + ("deployment configuration", "xy", False), + ("deployment configuration", " ", False), + ], +) +def test_file_search_matches_persisted_payload(tmp_path: Path, content: str, query: str, matches: bool) -> None: + store = FileTapeStore(tmp_path) + store.append("session", TapeEntry.message({"role": "user", "content": content})) + + # Reopen the store so the query exercises persisted data rather than its write cache. + reopened = FileTapeStore(tmp_path) + search = TapeQuery("session", reopened).query(query) + entries = list(search.all()) + + assert [entry.payload["content"] for entry in entries] == ([content] if matches else []) + assert search._query == query + + +@pytest.mark.parametrize("limit", [1, 2, 10]) +def test_fuzzy_search_preserves_filters_order_deduplication_and_limit(tmp_path: Path, limit: int) -> None: + store = FileTapeStore(tmp_path) + entries = [ + TapeEntry.message({"content": "deployment before anchor"}), + TapeEntry.anchor("start"), + TapeEntry(0, "message", {"content": "deployment alpha"}, date="2026-01-02T00:00:00Z"), + TapeEntry(0, "message", {"content": "deployment alpha"}, date="2026-01-02T12:00:00Z"), + TapeEntry(0, "message", {"content": "deployment beta"}, date="2026-01-03T23:59:59Z"), + TapeEntry(0, "system", {"content": "deployment system"}, date="2026-01-03T00:00:00Z"), + TapeEntry(0, "message", {"content": "deployment outside dates"}, date="2026-01-01T00:00:00Z"), + TapeEntry.anchor("end"), + TapeEntry.message({"content": "deployment after anchor"}), + ] + for entry in entries: + store.append("session", entry) + search = ( + TapeQuery("session", store) + .between_anchors("start", "end") + .between_dates("2026-01-02", "2026-01-03") + .kinds("message") + .query("deploymnt") + .limit(limit) + ) + + assert [entry.id for entry in search.all()] == [5, 4][:limit] + assert search._query == "deploymnt" + assert search._limit == limit + + +@pytest.mark.parametrize("last", [False, True]) +def test_fuzzy_search_preserves_after_anchor_selection(tmp_path: Path, last: bool) -> None: + store = FileTapeStore(tmp_path) + store.append("session", TapeEntry.message({"content": "deployment before"})) + store.append("session", TapeEntry.anchor("checkpoint")) + store.append("session", TapeEntry.message({"content": "deployment after"})) + search = TapeQuery("session", store).query("deploymnt") + search = search.last_anchor() if last else search.after_anchor("checkpoint") + + assert [entry.id for entry in search.all()] == [3] + + +def test_unqueried_file_search_retains_chronological_order(tmp_path: Path) -> None: + store = FileTapeStore(tmp_path) + for content in ("first", "second", "third"): + store.append("session", TapeEntry.message({"content": content})) + + assert [entry.id for entry in TapeQuery("session", store).limit(2).all()] == [1, 2]