personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / tests / test_think_cadence.py
8.1 kB 258 lines
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