personal memory agent
0

Configure Feed

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

solstone / tests / test_pulse.py
7.4 kB 242 lines
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4from __future__ import annotations 5 6import json 7 8import pytest 9 10from solstone.talent import pulse 11from solstone.think.day_accumulator import read_latest 12 13 14@pytest.fixture 15def journal(tmp_path, monkeypatch): 16 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path)) 17 return tmp_path 18 19 20def _select_local_provider(journal_path): 21 config_dir = journal_path / "config" 22 config_dir.mkdir(parents=True, exist_ok=True) 23 (config_dir / "journal.json").write_text( 24 json.dumps( 25 { 26 "providers": { 27 "active": {"provider": "local", "model": "local/qwen3.5-4b"}, 28 } 29 } 30 ), 31 encoding="utf-8", 32 ) 33 34 35def _pulse_payload(**overrides): 36 payload = { 37 "title": "Focused morning", 38 "one_sentence": "The morning is centered on a launch review.", 39 "full_details": "The owner has a coherent launch-review block in motion.", 40 "needs_you": ["Review the launch checklist."], 41 } 42 payload.update(overrides) 43 return payload 44 45 46def test_post_process_persists_valid_pulse_record(journal): 47 config = { 48 "day": "20260611", 49 "model": "test-model", 50 "_pulse_window_note": { 51 "segments": 1, 52 "activities": 0, 53 "input_segments": 1, 54 "input_activities": 0, 55 "since_ms": 123, 56 "gaps": [], 57 }, 58 } 59 60 returned = pulse.post_process(json.dumps(_pulse_payload()), config) 61 62 summary = json.loads(returned) 63 assert summary == _pulse_payload() 64 record = read_latest("20260611", "pulse", lookback_days=0) 65 assert record is not None 66 for key, value in _pulse_payload().items(): 67 assert record[key] == value 68 assert record["model"] == "test-model" 69 assert record["generated_at"].endswith("Z") 70 assert isinstance(record["ts"], int) 71 assert record["window"] == config["_pulse_window_note"] 72 73 74@pytest.mark.parametrize("result", ["not json", json.dumps({"title": "Incomplete"})]) 75def test_post_process_falls_back_for_malformed_model_output(journal, result): 76 default = _pulse_payload( 77 title="Fallback", 78 one_sentence="Fallback sentence.", 79 full_details="Fallback details.", 80 needs_you=["Use the deterministic fallback."], 81 ) 82 config = {"day": "20260611", "model": "test-model", "_pulse_default": default} 83 84 returned = pulse.post_process(result, config) 85 86 summary = json.loads(returned) 87 assert summary == default 88 record = read_latest("20260611", "pulse", lookback_days=0) 89 assert record is not None 90 for key, value in default.items(): 91 assert record[key] == value 92 assert record["model"] == "test-model" 93 assert "generated_at" in record 94 assert "ts" in record 95 assert "window" in record 96 97 98def test_normalize_pulse_coerces_and_clamps_fields(): 99 default = _pulse_payload( 100 title="Fallback", 101 one_sentence="Fallback sentence.", 102 full_details="Fallback details.", 103 needs_you=["Fallback need."], 104 ) 105 raw = { 106 "title": "T" * 100, 107 "one_sentence": "S" * 260, 108 "full_details": "D" * 1900, 109 "needs_you": [ 110 "one", 111 42, 112 None, 113 "", 114 "x" * 300, 115 "five", 116 "six", 117 "seven", 118 "eight", 119 "nine", 120 ], 121 "ignored": "dropped", 122 } 123 124 summary = pulse._normalize_pulse(raw, default) 125 126 assert set(summary) == {"title", "one_sentence", "full_details", "needs_you"} 127 assert summary["title"] == "T" * pulse._TITLE_MAX 128 assert summary["one_sentence"] == "S" * pulse._SENTENCE_MAX 129 assert summary["full_details"] == "D" * pulse._DETAILS_MAX 130 assert summary["needs_you"] == [ 131 "one", 132 "42", 133 "x" * pulse._NEED_MAX, 134 "five", 135 "six", 136 "seven", 137 "eight", 138 ] 139 140 assert ( 141 pulse._normalize_pulse( 142 { 143 "title": "", 144 "one_sentence": " ", 145 "full_details": "", 146 "needs_you": ["ignored"], 147 }, 148 default, 149 ) 150 == default 151 ) 152 153 154def test_pre_process_includes_segment_timeline_and_missing_gap(journal, monkeypatch): 155 day = "20260611" 156 segment = "101500_300" 157 seg_dir = journal / "chronicle" / day / "desktop" / segment 158 seg_dir.mkdir(parents=True) 159 (seg_dir / "timeline.json").write_text( 160 json.dumps( 161 { 162 "title": "Launch review", 163 "description": "The segment focused on launch readiness.", 164 } 165 ), 166 encoding="utf-8", 167 ) 168 (journal / "identity").mkdir() 169 (journal / "identity" / "partner.md").write_text( 170 "Partner context", encoding="utf-8" 171 ) 172 173 monkeypatch.setattr(pulse, "get_current", lambda: {"attention": "clear"}) 174 monkeypatch.setattr(pulse, "get_imports", lambda: {"pending": []}) 175 monkeypatch.setattr(pulse, "get_facets", lambda: []) 176 monkeypatch.setattr(pulse, "load_recent_entity_names", lambda limit=12: ["Alice"]) 177 178 result = pulse.pre_process( 179 { 180 "day": day, 181 "cadence_window": { 182 "since_ms": 1000, 183 "segments": [ 184 {"stream": "desktop", "segment": segment, "ts": 2000}, 185 {"stream": None, "segment": "missing_300", "ts": 1000}, 186 ], 187 "activities": [], 188 }, 189 } 190 ) 191 192 assert result is not None 193 template_vars = result["template_vars"] 194 assert "Launch review" in template_vars["completed_since"] 195 assert ( 196 "The segment focused on launch readiness." in template_vars["completed_since"] 197 ) 198 assert "no timeline.json found for segment missing_300" in template_vars["gaps"] 199 assert "Partner context" == template_vars["partner_profile"] 200 assert pulse._compact_json(["Alice"]) == template_vars["recent_entities"] 201 202 203def test_pre_process_total_failure_returns_skip_reason(monkeypatch): 204 monkeypatch.setattr( 205 pulse, 206 "_completed_since", 207 lambda day, config, gaps: (_ for _ in ()).throw(RuntimeError("boom")), 208 ) 209 210 result = pulse.pre_process({"day": "20260611"}) 211 212 assert result is not None 213 assert result["skip_reason"] == "pulse pre-hook failed: boom" 214 215 216def test_accumulate_suppresses_single_file_output_path(journal): 217 from solstone.think.talent import get_talent, get_talent_configs 218 from solstone.think.talents import prepare_config 219 from solstone.think.thinking import _apply_output_persistence 220 221 raw_config = get_talent_configs()["pulse"] 222 pulse_config = get_talent("pulse") 223 assert raw_config["output"] == "json" 224 assert raw_config["schema"] == "pulse.schema.json" 225 assert pulse_config["output"] == "json" 226 assert pulse_config["json_schema"]["required"] == [ 227 "title", 228 "one_sentence", 229 "full_details", 230 "needs_you", 231 ] 232 assert pulse_config["accumulate"] is True 233 234 request_config = {} 235 _apply_output_persistence(request_config, pulse_config, force_refresh=False) 236 237 assert "output" not in request_config 238 assert "refresh" not in request_config 239 240 _select_local_provider(journal) 241 prepared = prepare_config({"name": "pulse", "day": "20260611"}) 242 assert "output_path" not in prepared