personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Tests for journal identity — identity directory read/write commands."""
5
6import json
7import re
8
9import pytest
10from typer.testing import CliRunner
11
12from solstone.think.tools.sol import app
13
14runner = CliRunner()
15_HISTORY_FIELDS = [
16 "ts",
17 "file",
18 "actor",
19 "op",
20 "section",
21 "reason",
22 "before_hash",
23 "after_hash",
24 "bytes_before",
25 "bytes_after",
26]
27
28
29def _read_history(journal_path):
30 history = journal_path / "identity" / "history.jsonl"
31 return [json.loads(line) for line in history.read_text().splitlines()]
32
33
34def _assert_history_record(record, *, file_name, actor, op, section, reason):
35 assert list(record) == _HISTORY_FIELDS
36 assert re.fullmatch(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z", record["ts"])
37 assert record["file"] == file_name
38 assert record["actor"] == actor
39 assert record["op"] == op
40 assert record["section"] == section
41 assert record["reason"] == reason
42 assert isinstance(record["before_hash"], str)
43 assert isinstance(record["after_hash"], str)
44 assert isinstance(record["bytes_before"], int)
45 assert isinstance(record["bytes_after"], int)
46
47
48@pytest.fixture
49def journal_with_identity(tmp_path, monkeypatch):
50 """Set up a journal with identity/ containing partner.md."""
51 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
52
53 # Provide minimal config for ensure_identity_directory
54 config_dir = tmp_path / "config"
55 config_dir.mkdir()
56 (config_dir / "journal.json").write_text(
57 json.dumps({"identity": {"name": "Test User"}})
58 )
59
60 identity_dir = tmp_path / "identity"
61 identity_dir.mkdir()
62
63 partner_md = """\
64# partner
65
66Behavioral profile of the journal owner — observed patterns that help sol
67adapt its responses, timing, and initiative to how this person actually works.
68
69## work patterns
70[observing]
71
72## communication style
73[observing]
74
75## relationship priorities
76[observing]
77
78## decision style
79[observing]
80
81## expertise domains
82[observing]
83"""
84 (identity_dir / "partner.md").write_text(partner_md)
85 (identity_dir / "health.md").write_text(
86 "## Status\n\n"
87 "not yet generated\n\n"
88 "## Needs your attention\n\n"
89 "## Auto-repairs (last 7d)\n",
90 encoding="utf-8",
91 )
92
93 return tmp_path
94
95
96class TestSolPartnerRead:
97 def test_read_partner(self, journal_with_identity):
98 result = runner.invoke(app, ["partner"])
99 assert result.exit_code == 0
100 assert "# partner" in result.output
101 assert "## work patterns" in result.output
102
103 def test_read_partner_missing(self, tmp_path, monkeypatch):
104 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
105 config_dir = tmp_path / "config"
106 config_dir.mkdir()
107 (config_dir / "journal.json").write_text(json.dumps({}))
108 # ensure_identity_directory creates partner.md
109 result = runner.invoke(app, ["partner"])
110 assert result.exit_code == 0
111
112
113class TestSolPartnerWrite:
114 def test_write_partner(self, journal_with_identity):
115 new_content = "# partner\n\n## work patterns\nPrefers mornings for deep work.\n"
116 result = runner.invoke(app, ["partner", "--write"], input=new_content)
117 assert result.exit_code == 0
118 assert "partner.md updated" in result.output
119
120 partner_path = journal_with_identity / "identity" / "partner.md"
121 assert partner_path.read_text() == new_content
122
123 def test_write_partner_empty_stdin(self, journal_with_identity):
124 result = runner.invoke(app, ["partner", "--write"], input="")
125 assert result.exit_code == 1
126 assert "no content" in result.output
127
128
129class TestSolPartnerUpdateSection:
130 def test_update_section_work_patterns(self, journal_with_identity):
131 result = runner.invoke(
132 app,
133 ["partner", "--update-section", "work patterns"],
134 input="Prefers async communication and morning deep work.",
135 )
136 assert result.exit_code == 0
137 assert "Updated ## work patterns" in result.output
138
139 partner_path = journal_with_identity / "identity" / "partner.md"
140 content = partner_path.read_text()
141 assert "Prefers async communication" in content
142 assert "## communication style" in content
143 assert "## decision style" in content
144
145 def test_update_section_not_found(self, journal_with_identity):
146 result = runner.invoke(
147 app,
148 ["partner", "--update-section", "nonexistent"],
149 input="content",
150 )
151 assert result.exit_code == 1
152 assert "not found" in result.output
153
154 def test_update_section_empty_stdin(self, journal_with_identity):
155 result = runner.invoke(
156 app,
157 ["partner", "--update-section", "work patterns"],
158 input="",
159 )
160 assert result.exit_code == 1
161 assert "no content" in result.output
162
163
164class TestSolWriteDoesNotEscapeIdentityDir:
165 """Verify that journal identity only writes to identity/ files."""
166
167 def test_partner_write_stays_in_identity_dir(self, journal_with_identity):
168 """Write to partner.md goes to identity/partner.md, not anywhere else."""
169 result = runner.invoke(app, ["partner", "--write"], input="test content\n")
170 assert result.exit_code == 0
171 partner_path = journal_with_identity / "identity" / "partner.md"
172 assert partner_path.read_text() == "test content\n"
173 journal_files = set(
174 f.name for f in journal_with_identity.iterdir() if f.is_file()
175 )
176 assert "partner.md" not in journal_files
177
178
179class TestSolPartnerValueOption:
180 def test_write_partner_with_value(self, journal_with_identity):
181 new_content = "# partner\n\n## work patterns\nMorning person.\n"
182 result = runner.invoke(app, ["partner", "--write", "--value", new_content])
183 assert result.exit_code == 0
184 assert "partner.md updated" in result.output
185 partner_path = journal_with_identity / "identity" / "partner.md"
186 assert partner_path.read_text() == new_content
187
188 def test_update_section_with_value(self, journal_with_identity):
189 result = runner.invoke(
190 app,
191 [
192 "partner",
193 "--update-section",
194 "work patterns",
195 "--value",
196 "Prefers mornings",
197 ],
198 )
199 assert result.exit_code == 0
200 assert "Updated ## work patterns" in result.output
201 content = (journal_with_identity / "identity" / "partner.md").read_text()
202 assert "Prefers mornings" in content
203
204 def test_value_empty_string_errors(self, journal_with_identity):
205 result = runner.invoke(app, ["partner", "--write", "--value", " "])
206 assert result.exit_code == 1
207 assert "no content" in result.output
208
209
210class TestSolHistoryLogging:
211 def test_partner_write_logs_history(self, journal_with_identity):
212 runner.invoke(app, ["partner", "--write", "--value", "# partner\n\nProfile.\n"])
213 records = _read_history(journal_with_identity)
214 assert len(records) == 1
215 _assert_history_record(
216 records[0],
217 file_name="partner.md",
218 actor="journal identity partner --write",
219 op="replace",
220 section=None,
221 reason="manual replace",
222 )
223
224 def test_multiple_writes_append(self, journal_with_identity):
225 runner.invoke(app, ["partner", "--write", "--value", "# partner\n\nFirst.\n"])
226 runner.invoke(app, ["partner", "--write", "--value", "# partner\n\nSecond.\n"])
227 records = _read_history(journal_with_identity)
228 assert len(records) == 2
229
230 def test_partner_update_section_logs_history(self, journal_with_identity):
231 runner.invoke(
232 app,
233 [
234 "partner",
235 "--update-section",
236 "work patterns",
237 "--value",
238 "Morning focus",
239 ],
240 )
241 records = _read_history(journal_with_identity)
242 assert len(records) == 1
243 _assert_history_record(
244 records[0],
245 file_name="partner.md",
246 actor="journal identity partner --update-section <heading>",
247 op="update_section",
248 section="work patterns",
249 reason="manual section update",
250 )