personal memory agent
1# SPDX-License-Identifier: AGPL-3.0-only
2# Copyright (c) 2026 sol pbc
3
4"""Tests for template variable substitution in load_prompt."""
5
6import json
7
8import pytest
9
10from solstone.think.prompts import _flatten_identity_to_template_vars, load_prompt
11
12
13@pytest.fixture
14def mock_journal_with_config(tmp_path, monkeypatch):
15 """Create a temporary journal with config."""
16 # Create config directory and journal.json
17 config_dir = tmp_path / "config"
18 config_dir.mkdir()
19
20 config = {
21 "identity": {
22 "name": "Test User",
23 "preferred": "Testy",
24 "bio": "a curious software engineer interested in AI",
25 "pronouns": {
26 "subject": "they",
27 "object": "them",
28 "possessive": "their",
29 "reflexive": "themselves",
30 },
31 "aliases": ["test", "tester"],
32 "email_addresses": ["test@example.com"],
33 "timezone": "America/Los_Angeles",
34 },
35 "agent": {
36 "name": "sol",
37 "name_status": "default",
38 "named_date": None,
39 },
40 }
41
42 with open(config_dir / "journal.json", "w") as f:
43 json.dump(config, f)
44
45 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
46
47 yield tmp_path
48
49
50@pytest.fixture
51def mock_prompt_dir(tmp_path):
52 """Create a temporary directory with test prompt files."""
53 prompts_dir = tmp_path / "prompts"
54 prompts_dir.mkdir()
55
56 # Create a prompt with template variables
57 template_prompt = """Hello $name, also known as $preferred!
58
59You use $pronouns_subject/$pronouns_object/$pronouns_possessive/$pronouns_reflexive pronouns.
60
61Capitalized: $Pronouns_subject will do it $Pronouns_reflexive.
62
63Bio: $bio
64
65Timezone: $timezone
66"""
67 (prompts_dir / "test_template.md").write_text(template_prompt)
68
69 # Create a prompt without template variables
70 plain_prompt = "This is a plain prompt without any variables."
71 (prompts_dir / "plain.md").write_text(plain_prompt)
72
73 return prompts_dir
74
75
76def test_flatten_identity_basic_fields():
77 """Test flattening of basic identity fields."""
78 identity = {"name": "Alice Smith", "preferred": "Alice", "timezone": "UTC"}
79
80 result = _flatten_identity_to_template_vars(identity)
81
82 assert result["name"] == "Alice Smith"
83 assert result["Name"] == "Alice smith" # Capitalized
84 assert result["preferred"] == "Alice"
85 assert result["Preferred"] == "Alice"
86 assert result["timezone"] == "UTC"
87
88
89def test_flatten_identity_nested_pronouns():
90 """Test flattening of nested pronoun fields."""
91 identity = {
92 "pronouns": {
93 "subject": "she",
94 "object": "her",
95 "possessive": "her",
96 "reflexive": "herself",
97 }
98 }
99
100 result = _flatten_identity_to_template_vars(identity)
101
102 assert result["pronouns_subject"] == "she"
103 assert result["Pronouns_subject"] == "She"
104 assert result["pronouns_object"] == "her"
105 assert result["pronouns_possessive"] == "her"
106 assert result["pronouns_reflexive"] == "herself"
107 assert result["Pronouns_reflexive"] == "Herself"
108
109
110def test_flatten_identity_with_bio(mock_journal_with_config):
111 """Test bio field extraction."""
112 from solstone.think.utils import get_config
113
114 config = get_config()
115 identity = config["identity"]
116
117 result = _flatten_identity_to_template_vars(identity)
118
119 assert result["bio"] == "a curious software engineer interested in AI"
120 assert result["Bio"] == "A curious software engineer interested in ai"
121
122
123def test_load_prompt_with_substitution(mock_journal_with_config, mock_prompt_dir):
124 """Test that load_prompt performs template substitution."""
125 result = load_prompt("test_template", base_dir=mock_prompt_dir)
126
127 # Check that variables were substituted
128 assert "Test User" in result.text
129 assert "Testy" in result.text
130 assert "they/them/their/themselves" in result.text
131 assert "They will do it Themselves" in result.text
132 assert "a curious software engineer interested in AI" in result.text
133 assert "America/Los_Angeles" in result.text
134
135 # Ensure no template variables remain
136 assert "$name" not in result.text
137 assert "$pronouns_subject" not in result.text
138 assert "$bio" not in result.text
139
140
141def test_load_prompt_without_substitution(mock_journal_with_config, mock_prompt_dir):
142 """Test that prompts without variables work normally."""
143 result = load_prompt("plain", base_dir=mock_prompt_dir)
144
145 assert result.text == "This is a plain prompt without any variables."
146
147
148def test_load_prompt_missing_config_graceful(tmp_path, mock_prompt_dir, monkeypatch):
149 """Test that load_prompt works even without config (safe_substitute)."""
150 # Point to a journal without config
151 monkeypatch.setenv("SOLSTONE_JOURNAL", str(tmp_path))
152
153 result = load_prompt("test_template", base_dir=mock_prompt_dir)
154
155 # When config exists but has empty values, safe_substitute replaces them with empty strings
156 # Variables that are not set will remain empty in the output (replaced with "")
157 # The prompt should still load without errors
158 assert result.path.exists()
159
160
161def test_load_prompt_with_custom_context(mock_journal_with_config, mock_prompt_dir):
162 """Test that custom context variables are substituted."""
163 # Create a prompt with context variables
164 context_prompt = """Day: $day
165Segment: $segment
166Custom value: $custom_value"""
167 (mock_prompt_dir / "context_test.md").write_text(context_prompt)
168
169 result = load_prompt(
170 "context_test",
171 base_dir=mock_prompt_dir,
172 context={"day": "20250110", "segment": "143022_300", "custom_value": "hello"},
173 )
174
175 assert "Day: 20250110" in result.text
176 assert "Segment: 143022_300" in result.text
177 assert "Custom value: hello" in result.text
178
179
180def test_load_prompt_context_uppercase_versions(
181 mock_journal_with_config, mock_prompt_dir
182):
183 """Test that uppercase-first versions are created for context variables."""
184 context_prompt = """lowercase: $agent
185Uppercase: $Agent"""
186 (mock_prompt_dir / "uppercase_test.md").write_text(context_prompt)
187
188 result = load_prompt(
189 "uppercase_test",
190 base_dir=mock_prompt_dir,
191 context={"agent": "meetings"},
192 )
193
194 assert "lowercase: meetings" in result.text
195 assert "Uppercase: Meetings" in result.text
196
197
198def test_load_prompt_context_overrides_identity(
199 mock_journal_with_config, mock_prompt_dir
200):
201 """Test that context variables override identity variables."""
202 override_prompt = "Name: $name"
203 (mock_prompt_dir / "override_test.md").write_text(override_prompt)
204
205 # Without context, should use identity name
206 result_default = load_prompt("override_test", base_dir=mock_prompt_dir)
207 assert "Name: Test User" in result_default.text
208
209 # With context, should override
210 result_override = load_prompt(
211 "override_test",
212 base_dir=mock_prompt_dir,
213 context={"name": "Custom Name"},
214 )
215 assert "Name: Custom Name" in result_override.text
216
217
218def test_load_prompt_context_stringifies_values(
219 mock_journal_with_config, mock_prompt_dir
220):
221 """Test that non-string context values are converted to strings."""
222 stringify_prompt = "Number: $count, Bool: $flag"
223 (mock_prompt_dir / "stringify_test.md").write_text(stringify_prompt)
224
225 result = load_prompt(
226 "stringify_test",
227 base_dir=mock_prompt_dir,
228 context={"count": 42, "flag": True},
229 )
230
231 assert "Number: 42" in result.text
232 assert "Bool: True" in result.text
233
234
235def test_load_prompt_empty_context(mock_journal_with_config, mock_prompt_dir):
236 """Test that empty context dict behaves same as None.
237
238 Note: mock_journal_with_config needed for get_config() call in load_prompt.
239 """
240 result_none = load_prompt("plain", base_dir=mock_prompt_dir, context=None)
241 result_empty = load_prompt("plain", base_dir=mock_prompt_dir, context={})
242
243 assert result_none.text == result_empty.text
244
245
246def test_load_prompt_identity_vars_follow_journal_override(monkeypatch, tmp_path):
247 """Journal identity/ content should not leak across journal overrides."""
248
249 def write_journal(journal_dir, awareness_text):
250 config_dir = journal_dir / "config"
251 config_dir.mkdir(parents=True)
252 (config_dir / "journal.json").write_text(
253 json.dumps(
254 {
255 "identity": {"name": "Test User"},
256 "agent": {"name": "sol", "name_status": "default"},
257 }
258 )
259 )
260 identity_dir = journal_dir / "identity"
261 identity_dir.mkdir()
262 (identity_dir / "awareness.md").write_text(awareness_text)
263
264 prompt_dir = tmp_path / "prompts"
265 prompt_dir.mkdir()
266 (prompt_dir / "identity_vars.md").write_text("Awareness:\n$identity_awareness\n")
267
268 journal_one = tmp_path / "journal-one"
269 journal_two = tmp_path / "journal-two"
270 write_journal(journal_one, "first awareness")
271 write_journal(journal_two, "second awareness")
272
273 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_one))
274 first = load_prompt("identity_vars", base_dir=prompt_dir)
275 assert "first awareness" in first.text
276
277 monkeypatch.setenv("SOLSTONE_JOURNAL", str(journal_two))
278 second = load_prompt("identity_vars", base_dir=prompt_dir)
279 assert "second awareness" in second.text
280 assert "first awareness" not in second.text