# -*- coding: utf-8 -*- # pylint: disable=redefined-outer-name,protected-access,unused-argument """Unit tests for :class:`MemorySpace` — the model's SQLite recall surface. The security-critical guarantee is that the model, which runs arbitrary SQL here, cannot escape the read-only attach of durable history. These tests pin the SQLite-authorizer contract plus the recall ``scope`` semantics. """ import sqlite3 from pathlib import Path import pytest from pineagents.agents.context.scroll.history import HistoryStore from pineagents.agents.context.scroll.memoryspace import ( MemorySpace, fts_match_query, sanitize_suffix, ) from pineagents.agents.context.types import LogEntry @pytest.fixture def history_db(tmp_path: Path) -> Path: """A durable store with two agents across two sessions.""" h = HistoryStore(tmp_path / "history.db") h.append( session_id="s1", agent_id="ag1", dedup_key="m1", entry=LogEntry( kind="model_turn", role="assistant", content="tanks rolled in", headline="battle", ), ) h.append( session_id="s2", agent_id="ag1", dedup_key="m2", entry=LogEntry( kind="model_turn", role="assistant", content="tanks regrouped later", ), ) h.append( session_id="s3", agent_id="ag2", dedup_key="m3", entry=LogEntry( kind="model_turn", role="assistant", content="tanks of another agent", ), ) h.close() return tmp_path / "history.db" @pytest.fixture def ms(history_db: Path) -> MemorySpace: space = MemorySpace( history_db_path=str(history_db), session_id="s1", agent_id="ag1", ) yield space space.close() # -- the read-only-attach contract ------------------------------------------ @pytest.mark.parametrize( "sql", [ "ATTACH DATABASE ':memory:' AS other", "DETACH DATABASE hist", "INSERT INTO hist.conversation_history(session_id, kind) " "VALUES ('x', 'k')", "UPDATE hist.conversation_history SET content = 'tampered'", "DELETE FROM hist.conversation_history", "DROP TABLE hist.conversation_history", ], ) def test_authorizer_blocks_escape_attempts(ms: MemorySpace, sql: str): with pytest.raises(sqlite3.Error): ms.sql_exec(sql) # And the durable data is untouched. assert ( ms.sql_query( "SELECT COUNT(*) AS n FROM hist.conversation_history", )[ 0 ]["n"] == 3 ) def test_scratch_is_read_write(ms: MemorySpace): ms.sql_exec("CREATE TABLE notes(x INTEGER)") ms.sql_exec("INSERT INTO notes VALUES (42)") assert ms.sql_query("SELECT x FROM notes")[0]["x"] == 42 assert "notes" in ms.tables() def test_hist_is_readable(ms: MemorySpace): rows = ms.sql_query( "SELECT content FROM hist.conversation_history ORDER BY seq", ) assert rows[0]["content"] == "tanks rolled in" # -- recall scope semantics -------------------------------------------------- def test_search_default_is_this_agent_cross_session(ms: MemorySpace): contents = {r["content"] for r in ms.search("tanks")} # Both of ag1's turns (s1 + s2), none of ag2's — isolation by default. assert "tanks rolled in" in contents assert "tanks regrouped later" in contents assert "tanks of another agent" not in contents def test_search_uses_seq_as_stable_bm25_tie_breaker(tmp_path: Path): h = HistoryStore(tmp_path / "history.db") expected_seqs = [] for index in range(3): expected_seqs.append( h.append( session_id="archive", agent_id="ag1", dedup_key=f"equal-{index}", entry=LogEntry( kind="model_turn", role="assistant", content="identical ranking text", ), ), ) h.close() space = MemorySpace( history_db_path=str(tmp_path / "history.db"), session_id="current", agent_id="ag1", ) try: first = space.search("identical ranking", k=3) second = space.search("identical ranking", k=3) finally: space.close() assert [row["seq"] for row in first] == expected_seqs assert [row["seq"] for row in second] == expected_seqs def test_search_excludes_recall_tool_own_turns(tmp_path: Path): """The recall tool's own source/output must not surface as search hits, or a query matches the agent's earlier queries (self-pollution).""" h = HistoryStore(tmp_path / "history.db") h.append( session_id="s1", agent_id="ag1", dedup_key="real", entry=LogEntry( kind="model_turn", role="assistant", content="the car needs service after 10000 miles", ), ) # The agent's own recall call (its Python source) and its printed output — # both carry the searched keywords and both must be excluded. h.append( session_id="s1", agent_id="ag1", dedup_key="recall_call", entry=LogEntry( kind="model_turn", role="assistant", name="recall_history_python", content='ms.search("car service")', ), ) h.append( session_id="s1", agent_id="ag1", dedup_key="recall_out", entry=LogEntry( kind="tool_result", role="assistant", name="recall_history", content="stdout: searching for car service ...", tool_call_id="t1", ), ) h.close() space = MemorySpace( history_db_path=str(tmp_path / "history.db"), session_id="s1", agent_id="ag1", ) try: hits = space.search("car service") contents = [r["content"] for r in hits] assert contents == ["the car needs service after 10000 miles"] finally: space.close() def test_search_excludes_the_active_turn(tmp_path: Path): """The current request and its in-progress reply must not surface as hits: they are already in the live window, and a second recall round would otherwise top-k-match the previous round's quoted findings (echo loop). Earlier turns of the SAME session stay searchable.""" h = HistoryStore(tmp_path / "history.db") rows = [ ("old_u", "context_msg", "user", "tanks question from earlier"), ("old_a", "model_turn", "assistant", "tanks were parked at base"), # The ACTIVE turn: the latest user request + the reply being written. ("cur_u", "context_msg", "user", "tanks question retried"), ("cur_a", "model_turn", "assistant", "tanks quote from last recall"), ] for key, kind, role, content in rows: h.append( session_id="s1", agent_id="ag1", dedup_key=key, entry=LogEntry(kind=kind, role=role, content=content), ) h.append( # another session is untouched by the exclusion session_id="s2", agent_id="ag1", dedup_key="other", entry=LogEntry( kind="model_turn", role="assistant", content="tanks moved in another session", ), ) h.close() space = MemorySpace( history_db_path=str(tmp_path / "history.db"), session_id="s1", agent_id="ag1", ) try: expected = { "tanks question from earlier", "tanks were parked at base", "tanks moved in another session", } assert {r["content"] for r in space.search("tanks", k=10)} == expected # The LIKE fallback applies the same exclusion. like = space._search_like("tanks", [("agent_id", "ag1")], None, 10) got = {r["content"] for r in like if r["kind"] != "_notice"} assert got == expected finally: space.close() def test_active_turn_floor_is_computed_once_per_instance( ms: MemorySpace, monkeypatch, ): """The MAX(seq) scan behind the active-turn exclusion is memoized: a single search consults the floor twice (FTS path + LIKE fallback) and the read-only history can't change under the instance, so it must run at most once — the cost that accrues on large histories in the recall subprocess. """ calls = {"n": 0} real = ms._compute_active_turn_floor def counting(): calls["n"] += 1 return real() monkeypatch.setattr(ms, "_compute_active_turn_floor", counting) # Consult it across every path that would otherwise re-query. ms.search("tanks", k=5) ms._search_like("tanks", [("agent_id", "ag1")], None, 5) ms._active_turn_floor() assert calls["n"] == 1 def test_active_turn_floor_ignores_continuation_stubs(tmp_path: Path): """A loop-continuation stub row (user-role, tagged) must not move the active-turn floor: the floor anchors on the REAL request that started the turn, so the whole still-live extended turn stays excluded from search instead of leaking back in as echo.""" h = HistoryStore(tmp_path / "history.db") rows = [ ("old", "model_turn", "assistant", "tanks parked at base", None), ("req", "context_msg", "user", "tanks question", None), ("a1", "model_turn", "assistant", "tanks quote from recall", None), ( "stub", "context_msg", "user", "Continue working on the task.", {"qwenpaw_tag": "loop_continuation"}, ), ("a2", "model_turn", "assistant", "tanks continued reply", None), ] for key, kind, role, content, metadata in rows: h.append( session_id="s1", agent_id="ag1", dedup_key=key, entry=LogEntry( kind=kind, role=role, content=content, metadata=metadata or {}, ), ) h.close() space = MemorySpace( history_db_path=str(tmp_path / "history.db"), session_id="s1", agent_id="ag1", ) try: # Floor = the real request's seq (NOT the stub's): everything from # the request onward is active-turn and excluded from search. hits = {r["content"] for r in space.search("tanks", k=10)} assert hits == {"tanks parked at base"} finally: space.close() def test_search_rows_carry_session_id(ms: MemorySpace): # Cross-session/agent search is only useful if a hit says which session it # came from — the model needs ``session_id`` to follow up (it used to guess # the key and crash with KeyError). rows = {r["content"]: r for r in ms.search("tanks", all_agents=True)} assert rows["tanks rolled in"]["session_id"] == "s1" assert rows["tanks regrouped later"]["session_id"] == "s2" def test_fts_match_query_passes_boolean_operators(): # Bare UPPERCASE AND/OR/NOT are FTS5 operators (so the model can cast a # wide net); every other token is a quoted literal; a plain query is AND. assert fts_match_query("tank OR aquarium") == '"tank" OR "aquarium"' assert fts_match_query("plain words") == '"plain" "words"' # lowercase 'or' is a search term, not an operator assert fts_match_query("salt or pepper") == '"salt" "or" "pepper"' # punctuation operators are still neutralised assert fts_match_query("F-15") == '"F" "15"' def test_search_or_widens_beyond_a_single_term(tmp_path: Path): h = HistoryStore(tmp_path / "history.db") h.append( session_id="s1", agent_id="ag1", dedup_key="a", entry=LogEntry( kind="model_turn", role="user", content="cleaned the goldfish tank", ), ) h.append( session_id="s1", agent_id="ag1", dedup_key="b", entry=LogEntry( kind="model_turn", role="user", content="bought an aquarium filter", ), ) h.close() space = MemorySpace( history_db_path=str(tmp_path / "history.db"), session_id="s1", agent_id="ag1", ) try: # OR matches EITHER term (2 rows); the AND form would match neither. assert len(space.search("tank OR aquarium")) == 2 assert len(space.search("tank aquarium")) == 0 finally: space.close() def test_search_all_agents_spans_the_workspace(ms: MemorySpace): contents = {r["content"] for r in ms.search("tanks", all_agents=True)} assert "tanks of another agent" in contents assert len(contents) == 3 def test_search_pins_to_an_explicit_session(ms: MemorySpace): # ms is on s1, but an explicit session_id targets a different one. contents = {r["content"] for r in ms.search("tanks", session_id="s2")} assert contents == {"tanks regrouped later"} def test_search_pins_to_an_explicit_agent(ms: MemorySpace): # The default agent scope hides ag2; pin to it to read its history. contents = {r["content"] for r in ms.search("tanks", agent_id="ag2")} assert contents == {"tanks of another agent"} def test_explicit_target_takes_precedence(ms: MemorySpace): # An explicit session_id wins even against all_agents=True. contents = { r["content"] for r in ms.search("tanks", all_agents=True, session_id="s1") } assert contents == {"tanks rolled in"} def test_row_cap_truncates_with_marker(history_db: Path): space = MemorySpace( history_db_path=str(history_db), session_id="s1", agent_id="ag1", row_cap=2, ) try: rows = space.sql_query("SELECT seq FROM hist.conversation_history") assert rows[-1].get("_truncated") is True assert len([r for r in rows if "_truncated" not in r]) == 2 finally: space.close() def _hits(rows: list[dict]) -> set: """Result contents minus the LIKE-degraded notice row.""" return {r["content"] for r in rows if r["kind"] != "_notice"} def test_like_fallback_respects_scope(ms: MemorySpace): """Force the no-FTS path; scope + explicit targeting still hold.""" ms._fts_ok = False contents = _hits(ms.search("tanks")) assert "tanks of another agent" not in contents assert "tanks rolled in" in contents # explicit targeting works on the LIKE path too pinned = _hits(ms.search("tanks", agent_id="ag2")) assert pinned == {"tanks of another agent"} def test_like_fallback_emits_degradation_notice(ms: MemorySpace): """Without FTS5 the model must be told search degraded to a LIKE scan, so it stops using OR/boolean grammar that silently matches nothing.""" ms._fts_ok = False rows = ms.search("tanks") assert rows[0]["kind"] == "_notice" assert "FTS5" in rows[0]["content"] # The notice shares the row schema, so a content-iterating loop is safe. assert set(rows[0].keys()) >= {"seq", "kind", "role", "content"} def test_no_notice_when_fts_available(ms: MemorySpace): """The notice is FTS-unavailable-only — a normal FTS build never sees it, even when a query degrades to LIKE for lack of word tokens.""" # All-punctuation query falls back to LIKE, but FTS5 *is* available here. rows = ms.search("!!!") assert all(r["kind"] != "_notice" for r in rows) # -- intent-named recall helpers -------------------------------------------- def test_expand_returns_full_turns_in_span(ms: MemorySpace): rows = ms.expand(1, 99) # Globally-unique seq spans every session/agent, so expand is unscoped. assert {r["content"] for r in rows} == { "tanks rolled in", "tanks regrouped later", "tanks of another agent", } def test_recall_tool_is_agent_scoped_by_default(history_db: Path, tmp_path): h = HistoryStore(history_db) h.append( session_id="s9", agent_id="ag2", dedup_key="tcX", entry=LogEntry( kind="tool_result", role="assistant", content="other agent tool", tool_call_id="shared", ), ) h.close() space = MemorySpace( history_db_path=str(history_db), session_id="s1", agent_id="ag1", ) try: # ag1 has no 'shared' tcid → empty; widening reaches ag2's row. assert not space.recall_tool("shared") assert len(space.recall_tool("shared", all_agents=True)) == 1 finally: space.close() # -- session / agent discovery ---------------------------------------------- def test_sessions_lists_this_agents_conversations(ms: MemorySpace): rows = {r["session_id"]: r for r in ms.sessions()} # ag1 ran in s1 and s2; ag2's s3 is hidden by the default agent scope. assert set(rows) == {"s1", "s2"} assert rows["s1"]["turns"] == 1 def test_sessions_all_agents_spans_the_workspace(ms: MemorySpace): ids = {r["session_id"] for r in ms.sessions(all_agents=True)} assert ids == {"s1", "s2", "s3"} def test_session_is_agent_scoped_by_default(ms: MemorySpace): # ag2's s3 is hidden by the default agent scope: session ids are not # globally unique (main/local/cron: recur across agents), so the # default must not leak another agent's conversation. all_agents widens. assert ms.session("s3") == [] rows = ms.session("s3", all_agents=True) assert [r["content"] for r in rows] == ["tanks of another agent"] def test_agents_is_workspace_wide(ms: MemorySpace): rows = {r["agent_id"]: r for r in ms.agents()} assert set(rows) == {"ag1", "ag2"} assert rows["ag1"]["sessions"] == 2 # s1 + s2 def test_sanitize_suffix(): assert sanitize_suffix(None) == "scratch" assert sanitize_suffix("a-b.c/d") == "a_b_c_d" assert sanitize_suffix("ok_123") == "ok_123" # -- SQL values are bound, not f-string-concatenated ------------------------ def test_recall_values_with_sql_metacharacters_are_bound(tmp_path: Path): """Recall must bind ``session_id``/``agent_id``/``tool_call_id`` as SQL parameters, never f-string them in. A value carrying a single quote (e.g. ``O'Brien's task``) would otherwise break the WHERE clause or open an injection; here it must round-trip cleanly and match only its own row.""" quoted_session = "O'Brien's task" quoted_agent = "ag'1" quoted_tcid = "tc'1" h = HistoryStore(tmp_path / "history.db") h.append( session_id=quoted_session, agent_id=quoted_agent, dedup_key="m1", entry=LogEntry( kind="tool_result", role="assistant", content="briefing for the quoted session", tool_call_id=quoted_tcid, ), ) # A decoy under a different agent the scoped recall must NOT return. h.append( session_id=quoted_session, agent_id="ag2", dedup_key="m2", entry=LogEntry( kind="model_turn", role="assistant", content="other agent same session name", ), ) h.close() space = MemorySpace( history_db_path=str(tmp_path / "history.db"), session_id=quoted_session, agent_id=quoted_agent, ) try: # session(): the path ekzhu flagged — agent-scoped, value bound. rows = space.session(quoted_session) assert [r["content"] for r in rows] == [ "briefing for the quoted session", ] # recall_tool(): tool_call_id bound, not concatenated. rows = space.recall_tool(quoted_tcid) assert [r["content"] for r in rows] == [ "briefing for the quoted session", ] # search() with an explicit quoted agent_id pin — both MATCH arg and # the lineage filter are bound. rows = space.search("briefing", agent_id=quoted_agent) assert [r["content"] for r in rows] == [ "briefing for the quoted session", ] # LIKE fallback path takes the same bound (col, value) filters. # (Drop the leading FTS-unavailable notice row the LIKE path adds.) space._fts_ok = False rows = space.search("briefing", agent_id=quoted_agent) assert [r["content"] for r in rows if r["kind"] != "_notice"] == [ "briefing for the quoted session", ] finally: space.close() # -- saved tool-output search ----------------------------------------------- def _saved_tool_notice(path: Path, *, quoted: bool = False) -> str: rendered_path = f'"{path}"' if quoted else str(path) return ( "[tool output truncated]\n" "If more content is needed, call `read_file` with " f"file_path={rendered_path} start_line=1 to read more." ) def test_saved_tool_paths_accept_quoted_and_legacy_paths_with_spaces( tmp_path: Path, ): artifact_dir = tmp_path / "tool results with spaces" artifact_dir.mkdir() quoted_file = artifact_dir / "quoted result.txt" quoted_file.write_text("quoted\n", encoding="utf-8") legacy_file = artifact_dir / "legacy result.txt" legacy_file.write_text("legacy\n", encoding="utf-8") history = HistoryStore(tmp_path / "history.db") history.close() space = MemorySpace( history_db_path=tmp_path / "history.db", session_id="current", agent_id="ag1", ) try: paths = space._saved_tool_paths( _saved_tool_notice(quoted_file, quoted=True) + "\n" + _saved_tool_notice(legacy_file), ) finally: space.close() assert paths == [quoted_file.resolve(), legacy_file.resolve()] def test_saved_tool_paths_prefer_structured_artifact_metadata(tmp_path: Path): artifact = tmp_path / "metadata-only-result.txt" artifact.write_text("structured artifact\n", encoding="utf-8") history = HistoryStore(tmp_path / "history.db") history.close() space = MemorySpace( history_db_path=tmp_path / "history.db", session_id="current", agent_id="ag1", ) try: paths = space._saved_tool_paths( "preview without a legacy path notice", { "qwenpaw_truncation": { "0": { "file_path": str(artifact), }, }, }, ) finally: space.close() assert paths == [artifact.resolve()] def test_saved_tool_search_checks_each_multiblock_artifact(tmp_path: Path): decoy_file = tmp_path / "first-block.txt" decoy_file.write_text("nothing relevant\n", encoding="utf-8") target_file = tmp_path / "second-block.txt" target_file.write_text("the deepneedle is here\n", encoding="utf-8") history = HistoryStore(tmp_path / "history.db") history.append( session_id="archive", agent_id="ag1", dedup_key="multi-block-result", entry=LogEntry( kind="tool_result", role="assistant", content=( _saved_tool_notice(decoy_file) + "\n\n" + _saved_tool_notice(target_file) ), tool_call_id="multi-block-call", ), ) history.close() space = MemorySpace( history_db_path=tmp_path / "history.db", session_id="current", agent_id="ag1", ) try: rows = space.search("deepneedle", k=1) finally: space.close() assert len(rows) == 1 assert rows[0]["kind"] == "tool_result" assert f"file_path={target_file}" in rows[0]["content"] assert "deepneedle" in rows[0]["content"] def test_recall_tool_annotates_each_multiblock_artifact(tmp_path: Path): first_file = tmp_path / "first-block.txt" first_file.write_text("first block\n", encoding="utf-8") second_file = tmp_path / "second-block.txt" second_file.write_text("second block\n", encoding="utf-8") history = HistoryStore(tmp_path / "history.db") history.append( session_id="archive", agent_id="ag1", dedup_key="multi-block-result", entry=LogEntry( kind="tool_result", role="assistant", content=( _saved_tool_notice(first_file) + "\n\n" + _saved_tool_notice(second_file) ), tool_call_id="multi-block-call", ), ) history.close() space = MemorySpace( history_db_path=tmp_path / "history.db", session_id="current", agent_id="ag1", ) try: rows = space.recall_tool("multi-block-call") finally: space.close() artifacts = [ row["content"] for row in rows if row["kind"] == "_saved_tool_output" ] assert artifacts == [ "Full saved tool output is available at " f"file_path={str(first_file)!r} start_line=1.", "Full saved tool output is available at " f"file_path={str(second_file)!r} start_line=1.", ] def test_recall_tool_returns_preview_when_artifact_expired(tmp_path: Path): artifact = tmp_path / "expired-result.txt" artifact.write_text("complete output\n", encoding="utf-8") history = HistoryStore(tmp_path / "history.db") history.append( session_id="archive", agent_id="ag1", dedup_key="expired-result", entry=LogEntry( kind="tool_result", role="assistant", content="bounded preview", tool_call_id="expired-call", metadata={ "qwenpaw_truncation": { "0": { "file_path": str(artifact), "start_line": 1, }, }, }, ), ) history.close() artifact.unlink() space = MemorySpace( history_db_path=tmp_path / "history.db", session_id="current", agent_id="ag1", ) try: rows = space.recall_tool("expired-call") finally: space.close() assert rows[0]["kind"] == "_saved_tool_output_unavailable" assert "ARTIFACT_UNAVAILABLE" in rows[0]["content"] assert rows[1]["kind"] == "tool_result" assert rows[1]["content"] == "bounded preview" def test_saved_tool_search_pages_past_first_200_candidates(tmp_path: Path): target_file = tmp_path / "target.txt" target_file.write_text("the deepneedle is here\n", encoding="utf-8") decoy_file = tmp_path / "decoy.txt" decoy_file.write_text("nothing relevant\n", encoding="utf-8") history = HistoryStore(tmp_path / "history.db") history.append( session_id="archive", agent_id="ag1", dedup_key="oldest-target", entry=LogEntry( kind="tool_result", role="assistant", content=_saved_tool_notice(target_file), tool_call_id="target-call", ), ) for index in range(200): history.append( session_id="archive", agent_id="ag1", dedup_key=f"newer-decoy-{index}", entry=LogEntry( kind="tool_result", role="assistant", content=_saved_tool_notice(decoy_file), tool_call_id=f"decoy-{index}", ), ) history.close() space = MemorySpace( history_db_path=tmp_path / "history.db", session_id="current", agent_id="ag1", ) try: rows = space.search("deepneedle", k=1) finally: space.close() assert len(rows) == 1 assert rows[0]["kind"] == "tool_result" assert "tool_call_id=target-call" in rows[0]["content"] assert "deepneedle" in rows[0]["content"] def test_saved_tool_file_search_streams_without_read_text( tmp_path: Path, monkeypatch, ): artifact = tmp_path / "large.txt" artifact.write_text( "before\nneedle match\nafter\n", encoding="utf-8", ) def fail_read_text(*args, **kwargs): raise AssertionError("saved artifact search must stream") monkeypatch.setattr(Path, "read_text", fail_read_text) matches = MemorySpace._file_line_matches(artifact, ["needle"]) assert matches == [ { "line": 2, "excerpt": "1: before\n2: needle match\n3: after", }, ] def test_attach_saved_tool_preserves_preview_when_scan_budget_exhausts( tmp_path: Path, ): artifact = tmp_path / "large.txt" artifact.write_text("x" * 100 + " needle\n", encoding="utf-8") history = HistoryStore(tmp_path / "history.db") history.close() space = MemorySpace( history_db_path=tmp_path / "history.db", session_id="current", agent_id="ag1", saved_tool_scan_max_bytes=32, ) try: rows = space._attach_saved_tool_file_matches( [ { "seq": 1, "kind": "tool_result", "role": "assistant", "name": "read_file", "content": ( "bounded preview retained in history\n" + _saved_tool_notice(artifact) ), }, ], "needle", ) finally: space.close() notices = [row for row in rows if row["kind"] == "_notice"] assert len(notices) == 1 assert "Results are partial" in notices[0]["content"] previews = [row for row in rows if row["kind"] == "tool_result"] assert len(previews) == 1 assert "bounded preview retained in history" in previews[0]["content"]