personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4import importlib
5import os
6import uuid
7from pathlib import Path
8
9import pytest
10
11
12def test_get_talent_configs_generators():
13 """Test that system generators are discovered with source field."""
14 talent = importlib.import_module("solstone.think.talent")
15 generators = talent.get_talent_configs(type="generate")
16 assert "schedule" in generators
17 info = generators["schedule"]
18 assert os.path.basename(info["path"]) == "schedule.md"
19 assert isinstance(info["color"], str)
20 assert isinstance(info["mtime"], int)
21 assert "title" in info
22 assert "occurrences" not in info
23 # New: check source field
24 assert info.get("source") == "system"
25
26
27def test_get_output_name():
28 """Test generator key to filename conversion."""
29 talent = importlib.import_module("solstone.think.talent")
30
31 # System generators: key unchanged
32 assert talent.get_output_name("activity") == "activity"
33 assert talent.get_output_name("flow") == "flow"
34
35 # App generators: _app_name format
36 assert talent.get_output_name("chat:sentiment") == "_chat_sentiment"
37 assert talent.get_output_name("my_app:weekly_summary") == "_my_app_weekly_summary"
38
39
40def test_get_talent_configs_app_discovery(tmp_path, monkeypatch):
41 """Test that app generators are discovered from apps/*/talent/."""
42 talent = importlib.import_module("solstone.think.talent")
43
44 # Create a fake app with a generator
45 app_dir = tmp_path / "apps" / "test_app" / "talent"
46 app_dir.mkdir(parents=True)
47
48 # Create generator files with frontmatter
49 (app_dir / "custom_generator.md").write_text(
50 '{\n "title": "Custom Generator",\n "color": "#ff0000"\n}\n\nTest prompt'
51 )
52
53 # Also create workspace.html to make it a valid app (not strictly required for generators)
54 (tmp_path / "apps" / "test_app" / "workspace.html").write_text("<h1>Test</h1>")
55
56 # For now, just verify system generators have correct source
57 generators = talent.get_talent_configs(type="generate")
58 for key, info in generators.items():
59 if ":" not in key:
60 assert info.get("source") == "system", f"{key} should have source=system"
61
62
63def test_get_talent_configs_by_schedule():
64 """Test filtering generators by schedule."""
65 talent = importlib.import_module("solstone.think.talent")
66
67 # Get daily generators
68 daily = talent.get_talent_configs(type="generate", schedule="daily")
69 assert len(daily) > 0
70 for key, meta in daily.items():
71 assert meta.get("schedule") == "daily", f"{key} should have schedule=daily"
72
73 # Get segment generators
74 segment = talent.get_talent_configs(type="generate", schedule="segment")
75 assert len(segment) > 0
76 for key, meta in segment.items():
77 assert meta.get("schedule") == "segment", f"{key} should have schedule=segment"
78
79 # Verify no overlap
80 assert not set(daily.keys()) & set(segment.keys()), (
81 "daily and segment should not overlap"
82 )
83
84 # Unknown schedule returns empty dict
85 assert talent.get_talent_configs(type="generate", schedule="hourly") == {}
86 assert talent.get_talent_configs(type="generate", schedule="") == {}
87
88
89def test_get_talent_configs_include_disabled(monkeypatch):
90 """Test include_disabled parameter."""
91 talent = importlib.import_module("solstone.think.talent")
92
93 # Get generators without disabled (default)
94 without_disabled = talent.get_talent_configs(type="generate", schedule="daily")
95
96 # Get generators with disabled included
97 with_disabled = talent.get_talent_configs(
98 type="generate", schedule="daily", include_disabled=True
99 )
100
101 # Should have at least as many with disabled included
102 assert len(with_disabled) >= len(without_disabled)
103
104
105def test_scheduled_generators_have_valid_schedule():
106 """Test that scheduled generators have valid schedule field.
107
108 Generators with a schedule field must have valid values
109 ('segment', 'daily', 'weekly', 'cadence', or 'activity'). Some generators
110 (like importer) have output but no schedule - they're used for ad-hoc
111 processing, not scheduled runs.
112 """
113 talent = importlib.import_module("solstone.think.talent")
114
115 generators = talent.get_talent_configs(type="generate")
116 valid_schedules = ("segment", "daily", "activity", "weekly", "cadence")
117
118 for key, meta in generators.items():
119 sched = meta.get("schedule")
120 if sched is not None:
121 assert sched in valid_schedules, (
122 f"Generator '{key}' has invalid schedule '{sched}'"
123 )
124
125
126def test_all_talent_configs_declare_non_empty_title():
127 talent = importlib.import_module("solstone.think.talent")
128
129 configs = talent.get_talent_configs(include_disabled=True)
130 missing = [
131 key
132 for key, meta in configs.items()
133 if not isinstance(meta.get("title"), str) or not meta["title"].strip()
134 ]
135
136 assert not missing, f"talent configs missing non-empty title: {missing}"
137
138
139def test_sense_in_segment_schedule():
140 """Test that sense generator exists in segment schedule at priority 5."""
141 talent = importlib.import_module("solstone.think.talent")
142
143 generators = talent.get_talent_configs(type="generate", schedule="segment")
144 assert "sense" in generators
145
146 sense = generators["sense"]
147 assert sense.get("priority") == 5, "sense should be at priority 5"
148
149 sources = sense.get("load", {})
150
151 assert sources.get("transcripts") is True, "sense should include transcripts"
152 assert sources.get("percepts") is True, "sense should include percepts"
153
154
155def _write_temp_talent_prompt(talent_dir: Path, stem: str, frontmatter: str) -> Path:
156 talent_dir.mkdir(parents=True, exist_ok=True)
157 prompt_path = talent_dir / f"{stem}.md"
158 prompt_path.write_text(
159 f"{frontmatter}\n\nTemporary test prompt\n", encoding="utf-8"
160 )
161 return prompt_path
162
163
164def test_get_talent_configs_raises_on_missing_type_with_output(tmp_path, monkeypatch):
165 talent = importlib.import_module("solstone.think.talent")
166 monkeypatch.setattr(talent, "TALENT_DIR", tmp_path)
167 stem = f"test_missing_type_output_{uuid.uuid4().hex}"
168 prompt_path = _write_temp_talent_prompt(
169 tmp_path,
170 stem,
171 '{\n "schedule": "daily",\n "priority": 10,\n "output": "md"\n}',
172 )
173 try:
174 with pytest.raises(
175 ValueError, match=rf"Prompt '{stem}'.*missing required 'type'"
176 ):
177 talent.get_talent_configs(include_disabled=True)
178 finally:
179 prompt_path.unlink(missing_ok=True)
180
181
182def test_get_talent_configs_allows_missing_type_with_tools(tmp_path, monkeypatch):
183 talent = importlib.import_module("solstone.think.talent")
184 monkeypatch.setattr(talent, "TALENT_DIR", tmp_path)
185 stem = f"test_missing_type_tools_{uuid.uuid4().hex}"
186 prompt_path = _write_temp_talent_prompt(
187 tmp_path,
188 stem,
189 '{\n "schedule": "daily",\n "priority": 10,\n "tools": "journal"\n}',
190 )
191 try:
192 configs = talent.get_talent_configs(include_disabled=True)
193 assert stem in configs
194 assert configs[stem].get("type") is None
195 finally:
196 prompt_path.unlink(missing_ok=True)
197
198
199def test_get_talent_configs_raises_when_generate_missing_output(tmp_path, monkeypatch):
200 talent = importlib.import_module("solstone.think.talent")
201 monkeypatch.setattr(talent, "TALENT_DIR", tmp_path)
202 stem = f"test_generate_missing_output_{uuid.uuid4().hex}"
203 prompt_path = _write_temp_talent_prompt(
204 tmp_path,
205 stem,
206 '{\n "type": "generate",\n "schedule": "daily",\n "priority": 10\n}',
207 )
208 try:
209 with pytest.raises(
210 ValueError,
211 match=rf"Prompt '{stem}'.*type='generate'.*missing required 'output'",
212 ):
213 talent.get_talent_configs(include_disabled=True)
214 finally:
215 prompt_path.unlink(missing_ok=True)
216
217
218def test_get_talent_configs_allows_cogitate_without_tools(tmp_path, monkeypatch):
219 talent = importlib.import_module("solstone.think.talent")
220 monkeypatch.setattr(talent, "TALENT_DIR", tmp_path)
221 stem = f"test_cogitate_missing_tools_{uuid.uuid4().hex}"
222 prompt_path = _write_temp_talent_prompt(
223 tmp_path,
224 stem,
225 '{\n "type": "cogitate",\n "schedule": "daily",\n "priority": 10\n}',
226 )
227 try:
228 configs = talent.get_talent_configs(include_disabled=True)
229 assert stem in configs
230 assert configs[stem]["type"] == "cogitate"
231 finally:
232 prompt_path.unlink(missing_ok=True)
233
234
235def test_get_talent_configs_type_generate_returns_only_generate():
236 talent = importlib.import_module("solstone.think.talent")
237 generators = talent.get_talent_configs(type="generate")
238 assert generators, "Expected at least one generate prompt"
239 assert all(meta.get("type") == "generate" for meta in generators.values())
240
241
242def test_get_talent_configs_type_cogitate_returns_only_cogitate():
243 talent = importlib.import_module("solstone.think.talent")
244 cogitate_prompts = talent.get_talent_configs(type="cogitate")
245 assert cogitate_prompts, "Expected at least one cogitate prompt"
246 assert all(meta.get("type") == "cogitate" for meta in cogitate_prompts.values())