personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4from __future__ import annotations
5
6from pathlib import Path
7
8import pytest
9
10from solstone.think.cogitate_read_tools import (
11 glob,
12 grep_search,
13 list_directory,
14 read_file,
15)
16from solstone.think.providers import read_tools
17from tests.openhands_fakes import install_fake_openhands
18
19
20@pytest.fixture
21def fake_openhands(monkeypatch):
22 return install_fake_openhands(monkeypatch)
23
24
25def _build_journal(tmp_path: Path) -> Path:
26 journal = tmp_path / "journal"
27 (journal / "notes").mkdir(parents=True)
28 (journal / "notes" / "a.txt").write_text("hello x\n", encoding="utf-8")
29 (journal / ".git").mkdir()
30 (journal / ".git" / "config").write_text("[core]\n", encoding="utf-8")
31 (journal / "bin.dat").write_bytes(b"hello\x00x")
32 (tmp_path / "outside.txt").write_text("outside\n", encoding="utf-8")
33 (journal / "outside-link.txt").symlink_to(tmp_path / "outside.txt")
34 return journal
35
36
37def _build_tools(journal: Path, read_call_budget: int = 20) -> dict[str, object]:
38 read_tools._READ_TYPES.clear()
39 tools = read_tools.build_read_tools(
40 journal=journal,
41 read_call_budget=read_call_budget,
42 )
43 return {tool.name: tool for tool in tools}
44
45
46def _read_file_args(path: str) -> dict[str, object]:
47 return {"path": path, "start_line": 1}
48
49
50def _list_directory_args(path: str = ".") -> dict[str, object]:
51 return {
52 "path": path,
53 "recursive": False,
54 "include_hidden": False,
55 "pattern": None,
56 }
57
58
59def test_read_tools_names_and_promoted_qualnames(fake_openhands, tmp_path):
60 journal = _build_journal(tmp_path)
61 tools = read_tools.build_read_tools(journal=journal, read_call_budget=20)
62
63 assert [tool.name for tool in tools] == [
64 read_file.__name__,
65 list_directory.__name__,
66 glob.__name__,
67 grep_search.__name__,
68 ]
69 for tool in tools:
70 assert "<locals>" not in type(tool).__qualname__
71
72
73def test_read_file_allowed_returns_file_content(fake_openhands, tmp_path):
74 journal = _build_journal(tmp_path)
75 tools = _build_tools(journal)
76 tool = tools[read_file.__name__]
77
78 observation = tool(tool.action_from_arguments(_read_file_args("notes/a.txt")))
79
80 assert observation.is_error is False
81 assert "hello x" in observation.text
82
83
84def test_read_file_denies_blocked_component(fake_openhands, tmp_path):
85 journal = _build_journal(tmp_path)
86 tools = _build_tools(journal)
87 tool = tools[read_file.__name__]
88
89 observation = tool(tool.action_from_arguments(_read_file_args(".git/config")))
90
91 assert observation.is_error is True
92 assert observation.text.startswith("denied_component:")
93
94
95def test_read_file_denies_path_escape_outside_journal(fake_openhands, tmp_path):
96 journal = _build_journal(tmp_path)
97 tools = _build_tools(journal)
98 tool = tools[read_file.__name__]
99
100 observation = tool(tool.action_from_arguments(_read_file_args("outside-link.txt")))
101
102 assert observation.is_error is True
103 assert observation.text.startswith("path_escape:")
104
105
106def test_read_file_denies_binary_file(fake_openhands, tmp_path):
107 journal = _build_journal(tmp_path)
108 tools = _build_tools(journal)
109 tool = tools[read_file.__name__]
110
111 observation = tool(tool.action_from_arguments(_read_file_args("bin.dat")))
112
113 assert observation.is_error is True
114 assert observation.text.startswith("binary_file:")
115
116
117def test_read_tools_share_one_cross_tool_budget(fake_openhands, tmp_path):
118 journal = _build_journal(tmp_path)
119 tools = _build_tools(journal, read_call_budget=1)
120 read_tool = tools[read_file.__name__]
121 list_tool = tools[list_directory.__name__]
122
123 first = read_tool(read_tool.action_from_arguments(_read_file_args("notes/a.txt")))
124 second = list_tool(list_tool.action_from_arguments(_list_directory_args()))
125
126 assert first.is_error is False
127 assert second.is_error is True
128 assert second.text.startswith("budget_exhausted:")
129
130
131def test_list_directory_renders_newline_joined_paths(fake_openhands, tmp_path):
132 journal = _build_journal(tmp_path)
133 tools = _build_tools(journal)
134 tool = tools[list_directory.__name__]
135
136 observation = tool(tool.action_from_arguments(_list_directory_args()))
137
138 assert observation.is_error is False
139 assert "notes/" in observation.text
140 assert "Entry(" not in observation.text