personal memory agent
at main 447 lines 15 kB view raw
1# SPDX-License-Identifier: AGPL-3.0-only 2# Copyright (c) 2026 sol pbc 3 4import importlib 5import sys 6import types 7from unittest.mock import Mock 8 9import numpy as np 10import pytest 11 12from think.utils import now_ms 13 14 15@pytest.fixture(autouse=True) 16def set_test_journal_path(request, monkeypatch): 17 """Set _SOLSTONE_JOURNAL_OVERRIDE to tests/fixtures/journal for all unit tests. 18 19 This ensures all tests have a valid _SOLSTONE_JOURNAL_OVERRIDE without needing 20 to explicitly set it in each test. Integration tests are excluded. 21 """ 22 # Skip for integration tests - they may have different requirements 23 if "integration" in request.node.keywords: 24 return 25 26 # Set _SOLSTONE_JOURNAL_OVERRIDE to tests/fixtures/journal for all unit tests 27 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", "tests/fixtures/journal") 28 29 30@pytest.fixture(autouse=True) 31def add_module_stubs(request, monkeypatch): 32 # Skip stubbing for integration tests 33 if "integration" in request.node.keywords: 34 return 35 36 # stub heavy modules used by think.indexer 37 if "usearch.index" not in sys.modules: 38 usearch = types.ModuleType("usearch") 39 index_mod = types.ModuleType("usearch.index") 40 41 class DummyIndex: 42 def __init__(self, *a, **k): 43 pass 44 45 def save(self, *a, **k): 46 pass 47 48 @classmethod 49 def restore(cls, *a, **k): 50 return cls() 51 52 def remove(self, *a, **k): 53 pass 54 55 def add(self, *a, **k): 56 pass 57 58 def search(self, *a, **k): 59 class Res: 60 keys = [1] 61 distances = [0.0] 62 63 return Res() 64 65 index_mod.Index = DummyIndex 66 usearch.index = index_mod 67 sys.modules["usearch"] = usearch 68 sys.modules["usearch.index"] = index_mod 69 if "sentence_transformers" not in sys.modules: 70 st_mod = types.ModuleType("sentence_transformers") 71 72 class DummyST: 73 def __init__(self, *a, **k): 74 pass 75 76 def get_sentence_embedding_dimension(self): 77 return 384 78 79 def encode(self, texts): 80 if isinstance(texts, str): 81 texts = [texts] 82 return [([0.0] * 384) for _ in texts] 83 84 st_mod.SentenceTransformer = DummyST 85 sys.modules["sentence_transformers"] = st_mod 86 if "sklearn.metrics.pairwise" not in sys.modules: 87 pairwise = types.ModuleType("pairwise") 88 89 def cosine_similarity(a, b): 90 return [[1.0]] 91 92 pairwise.cosine_similarity = cosine_similarity 93 metrics = types.ModuleType("metrics") 94 metrics.pairwise = pairwise 95 96 cluster = types.ModuleType("sklearn.cluster") 97 98 class DummyHDBSCAN: 99 def __init__(self, **k): 100 pass 101 102 def fit(self, X): 103 self.labels_ = np.full(len(X), -1, dtype=int) 104 return self 105 106 cluster.HDBSCAN = DummyHDBSCAN 107 108 sklearn = types.ModuleType("sklearn") 109 sklearn.metrics = metrics 110 sklearn.cluster = cluster 111 sys.modules["sklearn"] = sklearn 112 sys.modules["sklearn.metrics"] = metrics 113 sys.modules["sklearn.metrics.pairwise"] = pairwise 114 sys.modules["sklearn.cluster"] = cluster 115 if "dotenv" not in sys.modules: 116 dotenv_mod = types.ModuleType("dotenv") 117 118 def load_dotenv(*a, **k): 119 return True 120 121 def dotenv_values(*a, **k): 122 return {} 123 124 dotenv_mod.load_dotenv = load_dotenv 125 dotenv_mod.dotenv_values = dotenv_values 126 sys.modules["dotenv"] = dotenv_mod 127 # Import real observe package first to avoid shadowing with stubs 128 if "observe" not in sys.modules: 129 importlib.import_module("observe") 130 if "observe.detect" not in sys.modules: 131 detect_mod = types.ModuleType("observe.detect") 132 133 def input_detect(): 134 return None, None 135 136 detect_mod.input_detect = input_detect 137 sys.modules["observe.detect"] = detect_mod 138 observe_pkg = sys.modules.get("observe") 139 setattr(observe_pkg, "detect", detect_mod) 140 if "observe.hear" not in sys.modules: 141 # Import the real module for format_audio and load_transcript 142 hear_mod = importlib.import_module("observe.hear") 143 sys.modules["observe.hear"] = hear_mod 144 observe_pkg = sys.modules.get("observe") 145 setattr(observe_pkg, "hear", hear_mod) 146 if "observe.sense" not in sys.modules: 147 # Import the real module - it has minimal dependencies 148 sense_mod = importlib.import_module("observe.sense") 149 sys.modules["observe.sense"] = sense_mod 150 observe_pkg = sys.modules.get("observe") 151 setattr(observe_pkg, "sense", sense_mod) 152 if "observe.utils" not in sys.modules: 153 # Import the real module 154 utils_mod = importlib.import_module("observe.utils") 155 sys.modules["observe.utils"] = utils_mod 156 observe_pkg = sys.modules.get("observe") 157 setattr(observe_pkg, "utils", utils_mod) 158 if "observe.screen" not in sys.modules: 159 # Import the real module for format_screen 160 screen_mod = importlib.import_module("observe.screen") 161 sys.modules["observe.screen"] = screen_mod 162 observe_pkg = sys.modules.get("observe") 163 setattr(observe_pkg, "screen", screen_mod) 164 if "gi" not in sys.modules: 165 gi_mod = types.ModuleType("gi") 166 gi_mod.require_version = lambda *a, **k: None 167 168 class Dummy(types.ModuleType): 169 pass 170 171 repo = types.ModuleType("gi.repository") 172 repo.Gdk = Dummy("Gdk") 173 repo.Gtk = Dummy("Gtk") 174 gi_mod.repository = repo 175 sys.modules["gi"] = gi_mod 176 sys.modules["gi.repository"] = repo 177 sys.modules["Gdk"] = repo.Gdk 178 sys.modules["Gtk"] = repo.Gtk 179 google_mod = sys.modules.get("google", types.ModuleType("google")) 180 genai_mod = types.ModuleType("google.genai") 181 182 class DummyModels: 183 def generate_content(self, *, model, contents, config=None): 184 return types.SimpleNamespace(text="[]", candidates=[], usage_metadata=None) 185 186 class DummyClient: 187 def __init__(self, *a, **k): 188 self.models = DummyModels() 189 190 genai_mod.Client = DummyClient 191 192 # Mock Content type for type hints 193 class MockContent: 194 pass 195 196 # Mock config builders 197 class MockHttpOptions: 198 def __init__(self, **k): 199 self.timeout = k.get("timeout") 200 201 class MockThinkingConfig: 202 def __init__(self, **k): 203 self.thinking_budget = k.get("thinking_budget") 204 205 class MockGenerateContentConfig: 206 def __init__(self, **k): 207 for key, value in k.items(): 208 setattr(self, key, value) 209 210 class MockHttpRetryOptions: 211 def __init__(self, **k): 212 pass 213 214 genai_mod.types = types.SimpleNamespace( 215 GenerateContentConfig=MockGenerateContentConfig, 216 Content=MockContent, 217 HttpOptions=MockHttpOptions, 218 HttpRetryOptions=MockHttpRetryOptions, 219 ThinkingConfig=MockThinkingConfig, 220 ) 221 google_mod.genai = genai_mod 222 sys.modules["google"] = google_mod 223 sys.modules["google.genai"] = genai_mod 224 if "cv2" not in sys.modules: 225 cv2_mod = types.ModuleType("cv2") 226 cv2_mod.COLOR_RGB2LAB = 0 227 228 def cvtColor(arr, code): 229 arr = np.asarray(arr) 230 gray = arr.mean(axis=2) 231 return np.stack([gray, gray, gray], axis=2) 232 233 cv2_mod.cvtColor = cvtColor 234 sys.modules["cv2"] = cv2_mod 235 if "soundfile" not in sys.modules: 236 sf_mod = types.ModuleType("soundfile") 237 238 def write(buf, data, samplerate, format=None): 239 buf.write(b"fLaCfake") 240 241 sf_mod.write = write 242 sys.modules["soundfile"] = sf_mod 243 for name in [ 244 "noisereduce", 245 ]: 246 if name not in sys.modules: 247 sys.modules[name] = types.ModuleType(name) 248 249 250@pytest.fixture(autouse=True) 251def reset_supervisor_state(): 252 """Reset supervisor module state before/after tests to prevent cross-test pollution.""" 253 try: 254 import think.supervisor as mod 255 256 # Reset before test 257 mod._daily_state["last_day"] = None 258 mod._is_remote_mode = False 259 # Create fresh task queue 260 mod._task_queue = mod.TaskQueue(on_queue_change=None) 261 except ImportError: 262 pass # supervisor not loaded yet 263 yield 264 try: 265 import think.supervisor as mod 266 267 # Reset after test 268 mod._daily_state["last_day"] = None 269 mod._is_remote_mode = False 270 mod._observer_health = {} 271 mod._enabled_observers = set() 272 # Create fresh task queue 273 mod._task_queue = mod.TaskQueue(on_queue_change=None) 274 except ImportError: 275 pass 276 277 278@pytest.fixture 279def mock_callosum(monkeypatch): 280 """Mock Callosum connections to capture emitted events without real I/O. 281 282 This fixture provides a MockCallosumConnection class that: 283 - Enforces the start-before-emit requirement 284 - Broadcasts events to all listeners (like the real Callosum) 285 - Works without real socket connections 286 287 Usage: 288 def test_example(mock_callosum): 289 from think.callosum import CallosumConnection 290 291 received = [] 292 listener = CallosumConnection() 293 listener.start(callback=lambda msg: received.append(msg)) 294 295 # Now emit events and they'll be captured in received 296 """ 297 all_listeners = [] 298 299 class MockCallosumConnection: 300 def __init__(self, socket_path=None): 301 self.socket_path = socket_path 302 self.callback = None 303 self.thread = None 304 305 def start(self, callback=None): 306 """Simulate starting the background thread.""" 307 self.callback = callback 308 self.thread = Mock() 309 self.thread.is_alive.return_value = True 310 if callback: 311 all_listeners.append(self) 312 313 def emit(self, tract, event, **kwargs): 314 """Emit event and broadcast to all listeners.""" 315 # Return False if not started yet (matches real behavior) 316 if self.thread is None or not self.thread.is_alive(): 317 return False 318 319 # Build message 320 msg = {"tract": tract, "event": event, **kwargs} 321 if "ts" not in msg: 322 msg["ts"] = now_ms() 323 324 # Broadcast to all listeners 325 for listener in all_listeners: 326 if listener.callback: 327 listener.callback(msg) 328 329 return True 330 331 def stop(self): 332 """Stop connection and remove from listeners.""" 333 if self in all_listeners: 334 all_listeners.remove(self) 335 self.thread = None 336 self.callback = None 337 338 # Patch both import locations 339 monkeypatch.setattr("think.runner.CallosumConnection", MockCallosumConnection) 340 monkeypatch.setattr("think.callosum.CallosumConnection", MockCallosumConnection) 341 monkeypatch.setattr("think.supervisor.CallosumConnection", MockCallosumConnection) 342 343 344def setup_google_genai_stub(monkeypatch, *, with_thinking=False): 345 """Set up a complete Google GenAI stub for testing. 346 347 Args: 348 monkeypatch: pytest monkeypatch fixture 349 with_thinking: If True, mock responses include thinking parts 350 351 Returns: 352 The DummyChat class for inspection if needed 353 """ 354 from types import SimpleNamespace 355 356 google_mod = types.ModuleType("google") 357 genai_mod = types.ModuleType("google.genai") 358 errors_mod = types.ModuleType("google.genai.errors") 359 360 # Error classes matching actual SDK structure 361 class APIError(Exception): 362 pass 363 364 class ServerError(APIError): 365 pass 366 367 class ClientError(APIError): 368 pass 369 370 errors_mod.APIError = APIError 371 errors_mod.ServerError = ServerError 372 errors_mod.ClientError = ClientError 373 374 class DummyChat: 375 """Mock chat that optionally returns thinking parts.""" 376 377 kwargs = None # Class var to capture last call for inspection 378 379 def __init__(self, model, history=None, config=None): 380 self.model = model 381 self.history = list(history or []) 382 self.config = config 383 384 def get_history(self): 385 return list(self.history) 386 387 def record_history(self, content): 388 self.history.append(content) 389 390 async def send_message(self, message, config=None): 391 DummyChat.kwargs = { 392 "message": message, 393 "config": config, 394 "model": self.model, 395 } 396 if with_thinking: 397 # Response with thinking parts matching actual SDK structure 398 thinking_part = SimpleNamespace( 399 thought=True, 400 text="I need to analyze this step by step.", 401 ) 402 answer_part = SimpleNamespace( 403 thought=False, 404 text="ok", 405 ) 406 candidate = SimpleNamespace( 407 content=SimpleNamespace(parts=[thinking_part, answer_part]), 408 ) 409 return SimpleNamespace(text="ok", candidates=[candidate]) 410 else: 411 # Simple response without thinking 412 return SimpleNamespace(text="ok") 413 414 class DummyChats: 415 def create(self, *, model, config=None, history=None): 416 return DummyChat(model, history=history, config=config) 417 418 class DummyModels: 419 """Mock for client.models.generate_content (non-chat generate API).""" 420 421 def generate_content(self, *, model, contents, config=None): 422 return SimpleNamespace(text="[]", candidates=[], usage_metadata=None) 423 424 class DummyClient: 425 def __init__(self, *a, **k): 426 self.chats = DummyChats() 427 self.models = DummyModels() 428 self.aio = SimpleNamespace(chats=DummyChats(), models=DummyModels()) 429 430 genai_mod.Client = DummyClient 431 genai_mod.errors = errors_mod 432 genai_mod.types = SimpleNamespace( 433 GenerateContentConfig=lambda **k: SimpleNamespace(**k), 434 ToolConfig=lambda **k: SimpleNamespace(**k), 435 FunctionCallingConfig=lambda **k: SimpleNamespace(**k), 436 ThinkingConfig=lambda **k: SimpleNamespace(**k), 437 Content=lambda **k: SimpleNamespace(**k), 438 Part=lambda **k: SimpleNamespace(**k), 439 HttpOptions=lambda **k: SimpleNamespace(**k), 440 HttpRetryOptions=lambda **k: SimpleNamespace(**k), 441 ) 442 google_mod.genai = genai_mod 443 monkeypatch.setitem(sys.modules, "google", google_mod) 444 monkeypatch.setitem(sys.modules, "google.genai", genai_mod) 445 monkeypatch.setitem(sys.modules, "google.genai.errors", errors_mod) 446 447 return DummyChat