personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4from __future__ import annotations
5
6import json
7from collections.abc import Iterator
8from contextlib import contextmanager
9from pathlib import Path
10from typing import Any
11
12import pytest
13
14import solstone.think.entities.observations as observations
15
16
17@pytest.fixture(autouse=True)
18def _fast_observation_writes(monkeypatch: pytest.MonkeyPatch) -> None:
19 def fast_atomic_replace(
20 path: Path, data: str | bytes, *, mode: int | None = None
21 ) -> None:
22 path.parent.mkdir(parents=True, exist_ok=True)
23 if isinstance(data, bytes):
24 path.write_bytes(data)
25 else:
26 path.write_text(data, encoding="utf-8")
27 if mode is not None:
28 path.chmod(mode)
29
30 monkeypatch.setattr(observations, "atomic_replace", fast_atomic_replace)
31
32
33def _set_journal(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
34 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
35
36
37def _row(
38 content: str,
39 observed_at: int,
40 source_day: str = "20260301",
41) -> dict[str, Any]:
42 return {
43 "content": content,
44 "observed_at": observed_at,
45 "source_day": source_day,
46 }
47
48
49def _write_observations(facet: str, name: str, rows: list[dict[str, Any]]) -> Path:
50 observations.save_observations(facet, name, rows)
51 return observations.observations_file_path(facet, name)
52
53
54def _read_raw(path: Path) -> str:
55 return path.read_text(encoding="utf-8")
56
57
58def _write_raw_observation_lines(facet: str, name: str, lines: list[str]) -> Path:
59 path = observations.observations_file_path(facet, name)
60 path.parent.mkdir(parents=True, exist_ok=True)
61 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
62 return path
63
64
65def _install_clock(monkeypatch: pytest.MonkeyPatch, *ticks: int) -> None:
66 values = iter(ticks)
67 monkeypatch.setattr(observations, "now_ms", lambda: next(values))
68
69
70def test_observation_day_counts_counts_only_valid_source_days(
71 tmp_path: Path,
72 monkeypatch: pytest.MonkeyPatch,
73):
74 _set_journal(tmp_path, monkeypatch)
75 _write_raw_observation_lines(
76 "work",
77 "Alice Example",
78 [
79 json.dumps({"content": "First", "source_day": "20260401"}),
80 json.dumps({"content": "Second", "source_day": "20260401"}),
81 json.dumps({"content": "Third", "source_day": "20260402"}),
82 json.dumps({"content": "Undated"}),
83 json.dumps({"content": "Integer day", "source_day": 1}),
84 json.dumps({"content": "Dashed day", "source_day": "2026-04-03"}),
85 json.dumps("not a dict"),
86 "{malformed",
87 "",
88 ],
89 )
90
91 assert observations.observation_day_counts("work", "Alice Example") == {
92 "20260401": 2,
93 "20260402": 1,
94 }
95
96
97def test_observation_day_counts_empty_for_missing_file_and_empty_slug(
98 tmp_path: Path,
99 monkeypatch: pytest.MonkeyPatch,
100):
101 _set_journal(tmp_path, monkeypatch)
102
103 assert observations.observation_day_counts("work", "Missing Entity") == {}
104 assert observations.observation_day_counts("work", "") == {}
105
106
107def test_observation_count_predicates_can_diverge(
108 tmp_path: Path,
109 monkeypatch: pytest.MonkeyPatch,
110):
111 _set_journal(tmp_path, monkeypatch)
112 facet = "work"
113 name = "Predicate Divergence"
114 d1 = "20260401"
115 d2 = "20260402"
116 _write_raw_observation_lines(
117 facet,
118 name,
119 [
120 json.dumps({"content": "First dated", "source_day": d1}),
121 json.dumps({"content": "Second dated", "source_day": d1}),
122 json.dumps({"content": "Third dated", "source_day": d2}),
123 json.dumps({"content": "Parseable undated"}),
124 "{malformed",
125 ],
126 )
127
128 assert observations.count_observations(facet, name) == 5
129 assert len(observations.load_observations(facet, name)) == 4
130 assert observations.observation_day_counts(facet, name) == {
131 d1: 2,
132 d2: 1,
133 }
134
135
136def test_record_observation_ops_consolidates_remote_control_project(
137 tmp_path: Path,
138 monkeypatch: pytest.MonkeyPatch,
139):
140 _set_journal(tmp_path, monkeypatch)
141 _install_clock(monkeypatch, 1000, 1001)
142 facet = "work"
143 name = "remote_control_project"
144 initial = [
145 _row(
146 "Remote Control Project supports browser automation for remote support.",
147 1,
148 ),
149 _row(
150 "Remote Control Project requires operator confirmation before connecting.",
151 2,
152 ),
153 _row(
154 "Remote Control Project supports browser automation for remote support.",
155 3,
156 ),
157 _row("Remote Control Project stores session audit records.", 4),
158 _row(
159 "Remote Control Project supports browser automation for remote support.",
160 5,
161 ),
162 _row("Remote Control Project uses a relay for live control sessions.", 6),
163 ]
164 _write_observations(facet, name, initial)
165
166 counts = observations.record_observation_ops(
167 facet,
168 name,
169 [
170 {
171 "op": "drop",
172 "target_index": 2,
173 "target_quote": "browser automation",
174 },
175 {
176 "op": "update",
177 "target_index": 0,
178 "content": (
179 "Remote Control Project supports browser automation for remote "
180 "support with operator confirmation."
181 ),
182 "target_quote": "SUPPORTS BROWSER AUTOMATION",
183 },
184 {
185 "op": "drop",
186 "target_index": 4,
187 "target_quote": "browser automation",
188 },
189 {
190 "op": "add",
191 "content": (
192 "Remote Control Project treats remote access as an "
193 "operator-owned workflow."
194 ),
195 },
196 ],
197 "20260401",
198 )
199
200 assert counts == {"update": 1, "add": 1, "drop": 2, "keep": 0, "skipped": 0}
201 assert observations.load_observations(facet, name) == [
202 {
203 "content": (
204 "Remote Control Project supports browser automation for remote "
205 "support with operator confirmation."
206 ),
207 "observed_at": 1000,
208 "source_day": "20260401",
209 },
210 initial[1],
211 initial[3],
212 initial[5],
213 {
214 "content": (
215 "Remote Control Project treats remote access as an "
216 "operator-owned workflow."
217 ),
218 "observed_at": 1001,
219 "source_day": "20260401",
220 },
221 ]
222
223
224def test_record_observation_ops_skips_invalid_inputs_without_raising(
225 tmp_path: Path,
226 monkeypatch: pytest.MonkeyPatch,
227):
228 _set_journal(tmp_path, monkeypatch)
229 facet = "work"
230 name = "remote_control_project"
231 initial = [_row("Remote Control Project requires operator confirmation.", 1)]
232 path = _write_observations(facet, name, initial)
233 before = _read_raw(path)
234
235 counts = observations.record_observation_ops(
236 facet,
237 name,
238 [
239 {
240 "op": "drop",
241 "target_index": 99,
242 "target_quote": "operator confirmation",
243 },
244 {
245 "op": "update",
246 "target_index": 0,
247 "content": "Remote Control Project requires two-person approval.",
248 "target_quote": "missing quote",
249 },
250 {"op": "add", "content": " "},
251 ],
252 "20260401",
253 )
254
255 assert counts == {"update": 0, "add": 0, "drop": 0, "keep": 0, "skipped": 3}
256 assert observations.load_observations(facet, name) == initial
257 assert _read_raw(path) == before
258
259
260def test_record_observation_ops_uses_original_snapshot_indices(
261 tmp_path: Path,
262 monkeypatch: pytest.MonkeyPatch,
263):
264 _set_journal(tmp_path, monkeypatch)
265 _install_clock(monkeypatch, 2000)
266 facet = "work"
267 name = "remote_control_project"
268 initial = [
269 _row("Index zero should be dropped.", 1),
270 _row("Index one should survive unchanged.", 2),
271 _row("Index two should be updated.", 3),
272 ]
273 _write_observations(facet, name, initial)
274
275 counts = observations.record_observation_ops(
276 facet,
277 name,
278 [
279 {"op": "drop", "target_index": 0, "target_quote": "dropped"},
280 {
281 "op": "update",
282 "target_index": 2,
283 "content": "Index two was updated using the original index.",
284 "target_quote": "Index two",
285 },
286 ],
287 "20260402",
288 )
289
290 assert counts == {"update": 1, "add": 0, "drop": 1, "keep": 0, "skipped": 0}
291 assert observations.load_observations(facet, name) == [
292 initial[1],
293 {
294 "content": "Index two was updated using the original index.",
295 "observed_at": 2000,
296 "source_day": "20260402",
297 },
298 ]
299
300
301def test_record_observation_ops_holds_lock_across_read_and_write(
302 tmp_path: Path,
303 monkeypatch: pytest.MonkeyPatch,
304):
305 _set_journal(tmp_path, monkeypatch)
306 _install_clock(monkeypatch, 3000)
307 facet = "work"
308 name = "remote_control_project"
309 initial = [_row("Remote Control Project has a lock-sensitive row.", 1)]
310 _write_observations(facet, name, initial)
311 expected_path = observations.observations_file_path(facet, name)
312 lock_active = False
313 entered_paths: list[Path] = []
314 read_inside_lock: list[bool] = []
315 write_inside_lock: list[bool] = []
316 real_load = observations.load_observations
317 real_save = observations.save_observations
318
319 @contextmanager
320 def spy_lock(path: Path) -> Iterator[None]:
321 nonlocal lock_active
322 entered_paths.append(path)
323 lock_active = True
324 try:
325 yield
326 finally:
327 lock_active = False
328
329 def spy_load(facet_arg: str, name_arg: str) -> list[dict[str, Any]]:
330 read_inside_lock.append(lock_active)
331 return real_load(facet_arg, name_arg)
332
333 def spy_save(facet_arg: str, name_arg: str, rows: list[dict[str, Any]]) -> None:
334 write_inside_lock.append(lock_active)
335 real_save(facet_arg, name_arg, rows)
336
337 monkeypatch.setattr(observations, "hold_lock", spy_lock)
338 monkeypatch.setattr(observations, "load_observations", spy_load)
339 monkeypatch.setattr(observations, "save_observations", spy_save)
340
341 counts = observations.record_observation_ops(
342 facet,
343 name,
344 [
345 {
346 "op": "update",
347 "target_index": 0,
348 "content": "Remote Control Project updates while locked.",
349 }
350 ],
351 "20260403",
352 )
353
354 assert counts == {"update": 1, "add": 0, "drop": 0, "keep": 0, "skipped": 0}
355 assert entered_paths == [expected_path]
356 assert read_inside_lock == [True]
357 assert write_inside_lock == [True]
358
359
360def test_record_observation_ops_on_zero_observations_skips_indexed_ops_and_adds(
361 tmp_path: Path,
362 monkeypatch: pytest.MonkeyPatch,
363):
364 _set_journal(tmp_path, monkeypatch)
365 _install_clock(monkeypatch, 4000)
366 facet = "work"
367 name = "remote_control_project"
368
369 counts = observations.record_observation_ops(
370 facet,
371 name,
372 [
373 {"op": "drop", "target_index": 0},
374 {
375 "op": "update",
376 "target_index": 0,
377 "content": "This update has no target.",
378 },
379 {
380 "op": "add",
381 "content": "Remote Control Project starts with an added observation.",
382 },
383 ],
384 "20260404",
385 )
386
387 assert counts == {"update": 0, "add": 1, "drop": 0, "keep": 0, "skipped": 2}
388 assert observations.load_observations(facet, name) == [
389 {
390 "content": "Remote Control Project starts with an added observation.",
391 "observed_at": 4000,
392 "source_day": "20260404",
393 }
394 ]
395
396
397def test_record_observation_ops_empty_ops_do_not_churn_files(
398 tmp_path: Path,
399 monkeypatch: pytest.MonkeyPatch,
400):
401 _set_journal(tmp_path, monkeypatch)
402 facet = "work"
403 absent_name = "remote_control_project"
404 absent_path = observations.observations_file_path(facet, absent_name)
405
406 absent_counts = observations.record_observation_ops(facet, absent_name, [])
407
408 assert absent_counts == {
409 "update": 0,
410 "add": 0,
411 "drop": 0,
412 "keep": 0,
413 "skipped": 0,
414 }
415 assert not absent_path.exists()
416
417 existing_name = "existing_remote_control_project"
418 existing_rows = [_row("Existing observation remains unchanged.", 1)]
419 existing_path = _write_observations(facet, existing_name, existing_rows)
420 before = _read_raw(existing_path)
421
422 existing_counts = observations.record_observation_ops(facet, existing_name, [])
423
424 assert existing_counts == {
425 "update": 0,
426 "add": 0,
427 "drop": 0,
428 "keep": 0,
429 "skipped": 0,
430 }
431 assert _read_raw(existing_path) == before
432
433
434def test_record_observation_ops_keep_only_does_not_churn_file(
435 tmp_path: Path,
436 monkeypatch: pytest.MonkeyPatch,
437):
438 _set_journal(tmp_path, monkeypatch)
439 facet = "work"
440 name = "remote_control_project"
441 initial = [_row("Remote Control Project keeps this observation.", 1)]
442 path = _write_observations(facet, name, initial)
443 before = _read_raw(path)
444
445 counts = observations.record_observation_ops(
446 facet,
447 name,
448 [{"op": "keep", "target_index": 0, "target_quote": "keeps this"}],
449 )
450
451 assert counts == {"update": 0, "add": 0, "drop": 0, "keep": 1, "skipped": 0}
452 assert observations.load_observations(facet, name) == initial
453 assert _read_raw(path) == before