personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Tests for cadence-scheduled think dispatch."""
5
6from __future__ import annotations
7
8import importlib
9import json
10import time
11from pathlib import Path
12
13import pytest
14
15from solstone.think.pipeline_health import CompletionsSince
16
17DAY = "20990302"
18NOW = 1_800_000_000_000
19
20
21@pytest.fixture
22def cadence_runtime(tmp_path, monkeypatch):
23 journal = tmp_path / "journal"
24 (journal / "health").mkdir(parents=True)
25 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal))
26 mod = importlib.import_module("solstone.think.thinking")
27 monkeypatch.setattr(mod, "emit", lambda *args, **kwargs: None)
28 monkeypatch.setattr(mod, "_jsonl_log", lambda *args, **kwargs: None)
29 return mod, journal
30
31
32def _cadence_config(**extra) -> dict:
33 config = {
34 "type": "generate",
35 "priority": 1,
36 "output": "md",
37 "schedule": "cadence",
38 }
39 config.update(extra)
40 return config
41
42
43def _window(
44 *,
45 segments: tuple[dict, ...] = (
46 {"stream": "default", "segment": "090000_300", "ts": NOW - 120_000},
47 ),
48 activities: tuple[dict, ...] = (),
49) -> CompletionsSince:
50 return CompletionsSince(segments=segments, activities=activities)
51
52
53def _write_cadence_state(journal: Path, state: dict[str, int]) -> None:
54 path = journal / "health" / "cadence.json"
55 path.write_text(json.dumps(state), encoding="utf-8")
56
57
58def test_run_cadence_prompts_zero_talents_exits_without_state_write(
59 cadence_runtime, monkeypatch
60):
61 mod, journal = cadence_runtime
62 monkeypatch.setattr(mod, "get_talent_configs", lambda schedule: {})
63 monkeypatch.setattr(
64 mod,
65 "_dispatch_cortex_request",
66 lambda **kwargs: pytest.fail("cadence should not dispatch"),
67 )
68
69 assert mod.run_cadence_prompts(DAY, refresh=False, verbose=False) == (0, 0, [])
70 assert not (journal / "health" / "cadence.json").exists()
71
72
73def test_run_cadence_prompts_fires_when_window_has_new_work(
74 cadence_runtime, monkeypatch
75):
76 mod, journal = cadence_runtime
77 last = NOW - 600_000
78 requests: list[dict] = []
79 _write_cadence_state(journal, {"talentA": last})
80 monkeypatch.setattr(
81 mod,
82 "get_talent_configs",
83 lambda schedule: {"talentA": _cadence_config()},
84 )
85 monkeypatch.setattr(mod, "now_ms", lambda: NOW)
86
87 def fake_completed_since(day: str, since_ms: int) -> CompletionsSince:
88 assert day == DAY
89 assert since_ms == last
90 return _window()
91
92 def fake_request(**kwargs):
93 requests.append(kwargs)
94 return "use-1"
95
96 monkeypatch.setattr(mod, "read_completed_since", fake_completed_since)
97 monkeypatch.setattr(mod, "_dispatch_cortex_request", fake_request)
98 monkeypatch.setattr(
99 mod,
100 "_drain_priority_batch",
101 lambda spawned, *_args: (1, 0, []),
102 )
103
104 assert mod.run_cadence_prompts(DAY, refresh=False, verbose=False) == (1, 0, [])
105
106 assert len(requests) == 1
107 request_config = requests[0]["config"]
108 assert request_config["schedule"] == "cadence"
109 assert request_config["cadence_window"]["since_ms"] == last
110 assert request_config["cadence_window"]["segments"]
111 assert mod.load_cadence_state()["talentA"] == NOW
112
113
114def test_run_cadence_prompts_noops_without_new_work(cadence_runtime, monkeypatch):
115 mod, journal = cadence_runtime
116 last = NOW - 600_000
117 save_calls: list[dict] = []
118 _write_cadence_state(journal, {"talentA": last})
119 monkeypatch.setattr(
120 mod,
121 "get_talent_configs",
122 lambda schedule: {"talentA": _cadence_config()},
123 )
124 monkeypatch.setattr(mod, "now_ms", lambda: NOW)
125 monkeypatch.setattr(
126 mod,
127 "read_completed_since",
128 lambda day, since_ms: CompletionsSince((), ()),
129 )
130 monkeypatch.setattr(
131 mod,
132 "_dispatch_cortex_request",
133 lambda **kwargs: pytest.fail("cadence should not dispatch"),
134 )
135 monkeypatch.setattr(
136 mod, "save_cadence_state", lambda state: save_calls.append(state)
137 )
138
139 assert mod.run_cadence_prompts(DAY, refresh=False, verbose=False) == (0, 0, [])
140 assert save_calls == []
141 assert json.loads((journal / "health" / "cadence.json").read_text()) == {
142 "talentA": last
143 }
144
145
146def test_run_cadence_prompts_respects_per_talent_interval(cadence_runtime, monkeypatch):
147 mod, journal = cadence_runtime
148 _write_cadence_state(journal, {"talentA": NOW - 600_000})
149 monkeypatch.setattr(
150 mod,
151 "get_talent_configs",
152 lambda schedule: {"talentA": _cadence_config(cadence_minutes=30)},
153 )
154 monkeypatch.setattr(mod, "now_ms", lambda: NOW)
155 monkeypatch.setattr(
156 mod,
157 "read_completed_since",
158 lambda day, since_ms: pytest.fail("interval gate should run first"),
159 )
160 monkeypatch.setattr(
161 mod,
162 "_dispatch_cortex_request",
163 lambda **kwargs: pytest.fail("cadence should not dispatch"),
164 )
165
166 assert mod.run_cadence_prompts(DAY, refresh=False, verbose=False) == (0, 0, [])
167
168
169def test_run_cadence_prompts_writes_back_only_on_success(cadence_runtime, monkeypatch):
170 mod, journal = cadence_runtime
171 last = NOW - 600_000
172 save_calls: list[dict] = []
173 _write_cadence_state(journal, {"talentA": last})
174 monkeypatch.setattr(
175 mod,
176 "get_talent_configs",
177 lambda schedule: {"talentA": _cadence_config()},
178 )
179 monkeypatch.setattr(mod, "now_ms", lambda: NOW)
180 monkeypatch.setattr(mod, "read_completed_since", lambda day, since_ms: _window())
181 monkeypatch.setattr(mod, "_dispatch_cortex_request", lambda **kwargs: "use-fail")
182 monkeypatch.setattr(
183 mod,
184 "_drain_priority_batch",
185 lambda spawned, *_args: (0, 1, ["talentA (error)"]),
186 )
187 monkeypatch.setattr(
188 mod, "save_cadence_state", lambda state: save_calls.append(state)
189 )
190
191 assert mod.run_cadence_prompts(DAY, refresh=False, verbose=False) == (
192 0,
193 1,
194 ["talentA (error)"],
195 )
196 assert save_calls == []
197 assert json.loads((journal / "health" / "cadence.json").read_text()) == {
198 "talentA": last
199 }
200
201
202def test_run_cadence_prompts_missing_state_treats_talent_as_never_run(
203 cadence_runtime, monkeypatch
204):
205 mod, journal = cadence_runtime
206 requests: list[dict] = []
207 monkeypatch.setattr(
208 mod,
209 "get_talent_configs",
210 lambda schedule: {"talentA": _cadence_config()},
211 )
212 monkeypatch.setattr(mod, "now_ms", lambda: NOW)
213 monkeypatch.setattr(mod, "read_completed_since", lambda day, since_ms: _window())
214 monkeypatch.setattr(
215 mod,
216 "_dispatch_cortex_request",
217 lambda **kwargs: requests.append(kwargs) or "use-1",
218 )
219 monkeypatch.setattr(
220 mod,
221 "_drain_priority_batch",
222 lambda spawned, *_args: (1, 0, []),
223 )
224
225 assert mod.run_cadence_prompts(DAY, refresh=False, verbose=False) == (1, 0, [])
226 assert requests[0]["config"]["cadence_window"]["since_ms"] == 0
227 assert mod.load_cadence_state()["talentA"] == NOW
228 assert (journal / "health" / "cadence.json").exists()
229
230
231def test_run_cadence_prompts_writeback_uses_window_read_time(
232 cadence_runtime, monkeypatch
233):
234 mod, _journal = cadence_runtime
235 calls = 0
236 monkeypatch.setattr(
237 mod,
238 "get_talent_configs",
239 lambda schedule: {"talentA": _cadence_config()},
240 )
241
242 def fake_now_ms() -> int:
243 nonlocal calls
244 calls += 1
245 return NOW if calls == 1 else NOW + 999_999
246
247 def fake_drain(spawned, *_args):
248 assert mod.now_ms() > NOW
249 assert time.time() > 0
250 return (1, 0, [])
251
252 monkeypatch.setattr(mod, "now_ms", fake_now_ms)
253 monkeypatch.setattr(mod, "read_completed_since", lambda day, since_ms: _window())
254 monkeypatch.setattr(mod, "_dispatch_cortex_request", lambda **kwargs: "use-1")
255 monkeypatch.setattr(mod, "_drain_priority_batch", fake_drain)
256
257 assert mod.run_cadence_prompts(DAY, refresh=False, verbose=False) == (1, 0, [])
258 assert mod.load_cadence_state()["talentA"] == NOW