#!/usr/bin/env python3 # ruff: noqa: E701, E702 """ Memory System Integration Tests — Full Suite. Launches kigi agent stdio in isolated environments ($HOME override) with pre-populated memory files. Tests the entire memory lifecycle: indexing, search, embeddings, flush, session-end, compaction, pruning. The script auto-builds the binary (release + dev features) before running. Requires: cargo, rg (ripgrep). Usage: # Run from the repo root: python3 crates/codegen/kigi-shell/tests/memory_integration/run_tests.py # Run only fast (no-model) tests: python3 crates/codegen/kigi-shell/tests/memory_integration/run_tests.py --fast # Run a single test by name: python3 crates/codegen/kigi-shell/tests/memory_integration/run_tests.py test_fts_search_quality # Skip build (use pre-built binary): KIGI_BINARY=/path/to/kigi-tui python3 run_tests.py --fast """ import json import os import select import shutil import sqlite3 import subprocess import sys import tempfile import time import uuid # ── Colours ────────────────────────────────────────────────────────────── R, G, Y, B, N = "\033[31m", "\033[32m", "\033[33m", "\033[1m", "\033[0m" PASS = FAIL = SKIP = 0 def ok(m): global PASS PASS += 1 print(f" {G}✓{N} {m}") def fail(m, d=""): global FAIL FAIL += 1 print(f" {R}✗{N} {m}") d and print(f" {d}") def skip(m): global SKIP SKIP += 1 print(f" {Y}⊘{N} {m} (skipped)") def section(t): print(f"\n{B}── {t} ──{N}") # ── ACP Client (NDJSON over stdin/stdout) ──────────────────────────────── class AcpClient: """Talks ACP (JSON-RPC over NDJSON) to a kigi agent stdio subprocess.""" def __init__(self, proc, cwd=None): self.proc = proc self._id = 0 self.notifications = [] self.cwd = cwd self.session_id = None def _nid(self): self._id += 1 return self._id def send(self, method, params=None, timeout=30): mid = self._nid() msg = {"jsonrpc": "2.0", "id": mid, "method": method} if params: msg["params"] = params try: self.proc.stdin.write((json.dumps(msg) + "\n").encode()) self.proc.stdin.flush() except BrokenPipeError: return {"error": "broken pipe"} return self._read(mid, timeout) def notify(self, method, params=None): msg = {"jsonrpc": "2.0", "method": method} if params: msg["params"] = params try: self.proc.stdin.write((json.dumps(msg) + "\n").encode()) self.proc.stdin.flush() except Exception: pass def _read(self, eid, timeout=30): deadline = time.time() + timeout while time.time() < deadline: r, _, _ = select.select([self.proc.stdout], [], [], min(deadline - time.time(), 1)) if not r: continue line = self.proc.stdout.readline() if not line: break try: msg = json.loads(line) except Exception: continue if "id" not in msg: self.notifications.append(msg) continue if msg.get("id") == eid: return msg return {"error": f"timeout id={eid}"} def drain_notifications(self, duration=1): """Read any pending notifications for a short time.""" deadline = time.time() + duration while time.time() < deadline: r, _, _ = select.select([self.proc.stdout], [], [], 0.1) if not r: continue line = self.proc.stdout.readline() if not line: break try: msg = json.loads(line) if "id" not in msg: self.notifications.append(msg) except Exception: pass def prompt(self, text, timeout=15): """Send session/prompt with the stored session_id.""" params = {"prompt": [{"type": "text", "text": text}]} if self.session_id: params["sessionId"] = self.session_id return self.send("session/prompt", params, timeout=timeout) def close(self): self.notify("shutdown") self.notify("exit") try: self.proc.stdin.close() except Exception: pass try: self.proc.wait(timeout=10) except Exception: self.proc.kill() # ── Isolated Environment ───────────────────────────────────────────────── class IsolatedEnv: """Creates a fully isolated kigi environment with custom $HOME. The agent subprocess gets a fake $HOME with: ~/.kigi/auth.json (copied from real home) ~/.kigi/memory/ (pre-populated by tests) ~/.kigi/logs/ (memory.log appears here) And a workspace directory used as cwd when spawning the agent. """ def __init__(self, workspace_name="test-project"): self.root = tempfile.mkdtemp(prefix="kigi-memtest-") self.fake_home = os.path.join(self.root, "home") self.workspace = os.path.join(self.root, "workspace", workspace_name) os.makedirs(self.fake_home) os.makedirs(self.workspace) # Create .kigi dirs in fake home self.kigi_home = os.path.join(self.fake_home, ".kigi") self.memory_dir = os.path.join(self.kigi_home, "memory") self.logs_dir = os.path.join(self.kigi_home, "logs") os.makedirs(self.memory_dir) os.makedirs(self.logs_dir) # Copy auth from real home real_auth = os.path.expanduser("~/.kigi/auth.json") if os.path.isfile(real_auth): shutil.copy2(real_auth, os.path.join(self.kigi_home, "auth.json")) def write_config(self, toml_str): """Write global ~/.kigi/config.toml (where the agent reads config).""" with open(os.path.join(self.kigi_home, "config.toml"), "w") as f: f.write(toml_str) def write_global_memory(self, content): """Write global MEMORY.md.""" path = os.path.join(self.memory_dir, "MEMORY.md") with open(path, "w") as f: f.write(content) return path def find_workspace_dir(self): """Find the workspace hash directory (created by the agent).""" for d in os.listdir(self.memory_dir): full = os.path.join(self.memory_dir, d) if os.path.isdir(full) and d != "sessions": return full return None def write_workspace_memory(self, content): """Write workspace MEMORY.md (must call after agent has created the dir).""" ws_dir = self.find_workspace_dir() if not ws_dir: return None path = os.path.join(ws_dir, "MEMORY.md") with open(path, "w") as f: f.write(content) return path def get_db_path(self): ws_dir = self.find_workspace_dir() if ws_dir: db = os.path.join(ws_dir, "index.sqlite") if os.path.isfile(db): return db return None def query_db(self, sql, params=()): db = self.get_db_path() if not db: return [] conn = sqlite3.connect(db) try: return conn.execute(sql, params).fetchall() finally: conn.close() def memory_log(self): path = os.path.join(self.logs_dir, "memory.log") if os.path.isfile(path): with open(path) as f: return f.read() return "" def session_files(self): """List all session log .md files (in sessions/ subdirectories).""" files = [] for root, _dirs, fnames in os.walk(self.memory_dir): for f in fnames: if f.endswith(".md") and "sessions" in root: files.append(os.path.join(root, f)) return sorted(files) def all_md_files(self): """List all .md files in memory tree (including session logs).""" files = [] for root, _dirs, fnames in os.walk(self.memory_dir): for f in fnames: if f.endswith(".md"): files.append(os.path.join(root, f)) return sorted(files) def spawn_agent(self, extra_env=None): binary = os.environ.get("KIGI_BINARY", "kigi") if not shutil.which(binary) and not os.path.isfile(binary): print(f"{R}kigi binary not found: {binary}{N}") sys.exit(1) env = os.environ.copy() env["HOME"] = self.fake_home env["KIGI_MEMORY"] = "1" env["RUST_LOG"] = "warn" # Disable leader mode env["KIGI_NO_LEADER"] = "1" if extra_env: env.update(extra_env) proc = subprocess.Popen( [binary, "agent", "--no-leader", "stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.workspace, env=env, ) return AcpClient(proc, cwd=self.workspace) def cleanup(self): shutil.rmtree(self.root, ignore_errors=True) def _default_config() -> str: embed_model = os.environ.get("KIGI_MEMORY_EMBEDDING_MODEL", "").strip() embed_model_line = f'model = "{embed_model}"\n' if embed_model else "" return f""" [memory] enabled = true [memory.embedding] {embed_model_line}dimensions = 1024 [memory.search] max_results = 6 min_score = 0.0 [compaction.memory_flush] enabled = true soft_threshold_tokens = 4000 [compaction.pruning] enabled = true keep_last_n_turns = 3 soft_trim_threshold = 4000 """ DEFAULT_CONFIG = _default_config() # Config with flush disabled — for tests that only care about indexing INDEXING_ONLY_CONFIG = """ [memory] enabled = true [memory.search] max_results = 10 min_score = 0.0 [compaction.memory_flush] enabled = false [compaction.pruning] enabled = false """ def init_session(client): """Send ACP initialize + session/new to create a session. The ACP protocol requires: 1. initialize -> returns capabilities 2. session/new -> creates session, triggers memory init + reindex """ resp = client.send( "initialize", { "protocolVersion": "2025-03-26", "clientInfo": {"name": "memory-integration-test", "version": "0.1"}, "capabilities": {}, }, ) if "error" in resp: return resp # Create a session (triggers spawn_session_actor -> MEMORY_INIT) params = {"mcpServers": []} if client.cwd: params["cwd"] = client.cwd sess = client.send("session/new", params) if "result" in sess: client.session_id = sess["result"].get("sessionId") return sess # ═══════════════════════════════════════════════════════════════════════════ # GROUP 1: Indexing & Storage # ═══════════════════════════════════════════════════════════════════════════ def test_global_memory_indexing(): """Global MEMORY.md is indexed into chunks + FTS on session start.""" section("1. Global Memory Indexing + Embeddings") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) marker = uuid.uuid4().hex[:8] env.write_global_memory(f"""# Team Conventions * Always use graphite to create PRs (marker: {marker}) * Never commit without TL review * Use blake3 for content hashing * Follow Rust 2024 edition conventions """) ok(f"wrote global MEMORY.md (marker={marker})") client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"init: {resp}") return ok("agent initialized") # Wait for background reindex + embedding time.sleep(5) # Check chunks chunks = env.query_db("SELECT id, source, length(text) FROM chunks") if chunks: ok(f"indexed {len(chunks)} chunk(s)") else: fail("no chunks after reindex") client.close() return # Check FTS for our marker fts = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (marker,), ) if fts and fts[0][0] > 0: ok(f"FTS finds marker '{marker}'") else: fail(f"FTS can't find marker '{marker}'") # Check embeddings try: emb = env.query_db("SELECT count(*) FROM chunks_vec_rowids") if emb and emb[0][0] > 0: ok(f"{emb[0][0]} embedding(s) computed") else: skip("no embeddings (API key or sqlite-vec missing)") except Exception: skip("embeddings table not available") client.close() finally: env.cleanup() def test_workspace_vs_global_memory(): """Both global and workspace MEMORY.md are indexed with correct source labels.""" section("2. Workspace vs Global Memory (source classification)") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Global\n* Global rule: use graphite for PRs\n") # Start agent to create workspace dir client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"init: {resp}") return time.sleep(3) # Now write workspace memory ws_path = env.write_workspace_memory("# Workspace\n* Workspace rule: use cargo fmt\n") if ws_path: ok("wrote workspace MEMORY.md") else: fail("couldn't find workspace dir") client.close() return client.close() # Restart agent to reindex both client2 = env.spawn_agent() init_session(client2) time.sleep(4) chunks = env.query_db("SELECT id, source FROM chunks ORDER BY source") sources = {src for _, src in chunks} if "global" in sources: ok("global source indexed") else: fail("global source missing", f"got sources: {sources}") if "workspace" in sources: ok("workspace source indexed") else: fail("workspace source missing", f"got sources: {sources}") # FTS finds content from each source for word, label in [("graphite", "global"), ("cargo", "workspace")]: r = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (word,), ) if r and r[0][0] > 0: ok(f"FTS finds {label} content ('{word}')") else: fail(f"FTS can't find {label} content ('{word}')") client2.close() finally: env.cleanup() def test_index_integrity(): """Schema tables (chunks, meta, FTS) exist and are consistent.""" section("3. Index Schema + Data Integrity") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Rules\n* Rule 1\n* Rule 2\n* Rule 3\n") client = env.spawn_agent() init_session(client) time.sleep(3) # Tables tables = [t[0] for t in env.query_db("SELECT name FROM sqlite_master WHERE type='table'")] for t in ["chunks", "meta"]: if t in tables: ok(f"table '{t}' exists") else: fail(f"table '{t}' missing") # FTS table if "chunks_fts" in tables: ok("FTS virtual table exists") else: fail("chunks_fts missing") # Chunk/FTS row-count consistency cc = env.query_db("SELECT count(*) FROM chunks")[0][0] fc = env.query_db("SELECT count(*) FROM chunks_fts")[0][0] if cc == fc: ok(f"chunks ({cc}) == FTS ({fc})") else: fail(f"chunks ({cc}) != FTS ({fc})") # Every chunk has required fields nulls = env.query_db( "SELECT count(*) FROM chunks " "WHERE id IS NULL OR text IS NULL OR path IS NULL OR hash IS NULL" ) if nulls and nulls[0][0] == 0: ok("all chunks have required fields (id, text, path, hash)") else: fail(f"{nulls[0][0]} chunks with NULL required fields") # Meta meta = {k: v for k, v in env.query_db("SELECT key, value FROM meta")} if "embedding_dimensions" in meta: ok(f"embedding_dimensions = {meta['embedding_dimensions']}") if "schema_version" in meta: ok(f"schema_version = {meta['schema_version']}") client.close() finally: env.cleanup() def test_chunk_hash_correctness(): """Each chunk's stored hash matches blake3(text).""" section("4. Chunk Hash Correctness (blake3)") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("""# Hashing Test * Line one for hashing * Line two for hashing * Line three for hashing """) client = env.spawn_agent() init_session(client) time.sleep(3) rows = env.query_db("SELECT hash, text FROM chunks") if not rows: fail("no chunks to verify") client.close() return # blake3 isn't available in Python stdlib, so we verify format # (64-char lowercase hex = 256-bit digest) rather than recomputing. # The actual blake3 correctness is covered by Rust unit tests in index.rs. mismatches = 0 for stored_hash, _text in rows: if len(stored_hash) == 64 and all(c in "0123456789abcdef" for c in stored_hash): pass # valid 256-bit hex digest else: mismatches += 1 if mismatches == 0: ok(f"all {len(rows)} chunk hashes are valid blake3 format (64-char hex)") else: fail(f"{mismatches}/{len(rows)} chunks have invalid hash format") # Verify same content produces same hash (deterministic) hashes = [h for h, _ in rows] unique = len(set(hashes)) texts = [t for _, t in rows] unique_texts = len(set(texts)) if unique == unique_texts: ok("hash is deterministic (unique hash per unique text)") else: skip(f"hash uniqueness: {unique} hashes for {unique_texts} unique texts") client.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 2: FTS Search # ═══════════════════════════════════════════════════════════════════════════ def test_fts_search_quality(): """FTS matches exact words, OR queries, and avoids false positives.""" section("5. FTS Search Quality") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("""# Coding Standards * Always use snake_case for Rust functions * Prefer Result over panic * Use tracing instead of println for logging * SQLite FTS5 for full-text search * blake3 for content hashing """) client = env.spawn_agent() init_session(client) time.sleep(3) # Exact word match r = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH 'tracing'" ) if r and r[0][0] > 0: ok("FTS: exact match 'tracing'") else: fail("FTS: no match for 'tracing'") # OR query r = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH 'blake3 OR sqlite'" ) if r and r[0][0] > 0: ok("FTS: OR query 'blake3 OR sqlite'") else: fail("FTS: OR query failed") # No match r = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH 'zzzznonexistent'" ) if r and r[0][0] == 0: ok("FTS: no false positives") else: fail("FTS: false positive for nonexistent term") # Prefix search r = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH 'trac*'" ) if r and r[0][0] > 0: ok("FTS: prefix search 'trac*'") else: skip("FTS: prefix search 'trac*' not matched") client.close() finally: env.cleanup() def test_fts_special_characters(): """FTS handles queries with special characters without crashing.""" section("6. FTS Special Character Handling") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("""# Edge Cases * Use C++ for performance-critical code * Configure with --enable-feature=fast_path * Email: dev@example.com * Path: /usr/local/bin/kigi * Version >= 2.0.0 """) client = env.spawn_agent() init_session(client) time.sleep(3) # These special-character queries should NOT crash the FTS engine test_queries = [ ("C++", "C++"), ("--enable-feature", "flag-style"), ("dev@example.com", "email"), ("/usr/local/bin", "path"), (">= 2.0.0", "comparison"), ("'quoted'", "single quotes"), ('"double"', "double quotes"), ("(parens)", "parentheses"), ("", "empty string"), (" ", "whitespace only"), ] for query, label in test_queries: try: # Match the Rust backend's sanitization: split on non-alphanumeric, # keep tokens >= 2 chars, join with OR import re as _re words = [w for w in _re.split(r"[^a-zA-Z0-9]+", query) if len(w) >= 2] if not words: ok(f"FTS: '{label}' → empty after sanitization (safe)") continue fts_q = " OR ".join(words) r = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (fts_q,), ) ok(f"FTS: '{label}' query OK (matched={r[0][0] if r else 0})") except Exception as e: fail(f"FTS: '{label}' query crashed: {e}") client.close() finally: env.cleanup() def test_fts_multi_file_search(): """FTS returns results from both global and workspace memory files.""" section("7. FTS Multi-File Search") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) global_marker = uuid.uuid4().hex[:8] ws_marker = uuid.uuid4().hex[:8] env.write_global_memory(f"# Global\n* Global unique marker: {global_marker}\n") # Session 1: create workspace dir c1 = env.spawn_agent() init_session(c1) time.sleep(3) env.write_workspace_memory(f"# Workspace\n* Workspace unique marker: {ws_marker}\n") c1.close() # Session 2: reindex both c2 = env.spawn_agent() init_session(c2) time.sleep(4) # Search for global marker r = env.query_db( "SELECT c.source FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (global_marker,), ) if r and r[0][0] == "global": ok("FTS: global marker found with source=global") elif r: fail(f"FTS: global marker found but source={r[0][0]}") else: fail("FTS: global marker not found") # Search for workspace marker r = env.query_db( "SELECT c.source FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (ws_marker,), ) if r and r[0][0] == "workspace": ok("FTS: workspace marker found with source=workspace") elif r: fail(f"FTS: workspace marker found but source={r[0][0]}") else: fail("FTS: workspace marker not found") c2.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 3: Reindex, Idempotency, Content Updates # ═══════════════════════════════════════════════════════════════════════════ def test_reindex_idempotency(): """Reindexing same content across sessions doesn't create duplicates.""" section("8. Reindex Idempotency — Same content, no duplicates") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Stable Content\n* This should only appear once\n") # Session 1 c1 = env.spawn_agent() init_session(c1) time.sleep(3) count1 = env.query_db("SELECT count(*) FROM chunks")[0][0] hashes1 = sorted([h[0] for h in env.query_db("SELECT hash FROM chunks")]) c1.close() # Session 2 — same content c2 = env.spawn_agent() init_session(c2) time.sleep(3) count2 = env.query_db("SELECT count(*) FROM chunks")[0][0] hashes2 = sorted([h[0] for h in env.query_db("SELECT hash FROM chunks")]) c2.close() if count1 == count2: ok(f"chunk count stable across sessions ({count1})") else: fail(f"chunk count changed: {count1} → {count2}") if hashes1 == hashes2: ok("chunk hashes identical across sessions") else: fail(f"hashes differ: session1={len(hashes1)}, session2={len(hashes2)}") finally: env.cleanup() def test_content_update_detection(): """Modified MEMORY.md is re-indexed with new content.""" section("9. Content Update — Modified MEMORY.md re-indexed") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) marker_v1 = uuid.uuid4().hex[:8] env.write_global_memory(f"# Version 1\n* Old content marker: {marker_v1}\n") c1 = env.spawn_agent() init_session(c1) time.sleep(3) v1_texts = env.query_db("SELECT text FROM chunks") c1.close() # Update content with a new marker marker_v2 = uuid.uuid4().hex[:8] env.write_global_memory(f"# Version 2\n* Updated content marker: {marker_v2}\n") c2 = env.spawn_agent() init_session(c2) time.sleep(3) v2_texts = env.query_db("SELECT text FROM chunks") if v1_texts and v2_texts and v1_texts != v2_texts: ok("chunk text updated after content change") else: fail("chunk not updated") # Old marker should be gone, new should be present r = env.query_db("SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH ?", (marker_v2,)) if r and r[0][0] > 0: ok(f"FTS finds new marker '{marker_v2}'") else: fail(f"FTS can't find new marker '{marker_v2}'") r = env.query_db("SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH ?", (marker_v1,)) if r and r[0][0] == 0: ok(f"old marker '{marker_v1}' removed from FTS") else: fail(f"old marker '{marker_v1}' still in FTS") c2.close() finally: env.cleanup() def test_large_file_chunking(): """A large MEMORY.md is split into multiple reasonably-sized chunks.""" section("10. Large File — Proper Chunking") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) # Write a large file with multiple sections sections = [] for i in range(10): sections.append(f"## Section {i}\n\n{'Lorem ipsum dolor sit amet. ' * 50}\n") env.write_global_memory("# Large Document\n\n" + "\n".join(sections)) client = env.spawn_agent() init_session(client) time.sleep(4) chunks = env.query_db("SELECT count(*) FROM chunks")[0][0] if chunks > 1: ok(f"large file split into {chunks} chunks") else: fail(f"large file not chunked (only {chunks} chunk)") # Verify each chunk has reasonable size sizes = env.query_db("SELECT length(text) FROM chunks") max_size = max(s[0] for s in sizes) if max_size <= 6000: ok(f"max chunk size {max_size} chars (under limit)") else: fail(f"chunk too large: {max_size} chars") # Verify chunks cover all sections (no data loss) all_text = "\n".join(t[0] for t in env.query_db("SELECT text FROM chunks")) for i in range(10): if f"Section {i}" in all_text: pass # good else: fail(f"Section {i} missing from chunks") break else: ok("all 10 sections present across chunks") client.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 4: Memory Enable/Disable & Config # ═══════════════════════════════════════════════════════════════════════════ def test_memory_disabled(): """No index is created when memory is disabled.""" section("11. Memory Disabled — No Index Created") env = IsolatedEnv() try: env.write_config(""" [memory] enabled = false """) env.write_global_memory("# Should not be indexed\n") client = env.spawn_agent(extra_env={"KIGI_MEMORY": "0"}) init_session(client) time.sleep(2) db = env.get_db_path() if db is None: ok("no index created when memory disabled") else: fail(f"index created at {db} despite memory disabled") # Memory log should not have MEMORY_INIT log = env.memory_log() if "MEMORY_INIT: storage + backend created" not in log: ok("no MEMORY_INIT logged when disabled") else: fail("MEMORY_INIT logged despite memory being disabled") client.close() finally: env.cleanup() def test_memory_disabled_no_config(): """Memory stays off when no [memory] section in config at all.""" section("12. No Memory Config Section — Memory stays off") env = IsolatedEnv() try: # Config with no memory section at all env.write_config("") env.write_global_memory("# Should not be indexed\n") client = env.spawn_agent(extra_env={"KIGI_MEMORY": "0"}) init_session(client) time.sleep(2) db = env.get_db_path() if db is None: ok("no index created without memory config section") else: fail(f"index created without config section: {db}") client.close() finally: env.cleanup() def test_memory_enabled_env_override(): """KIGI_MEMORY=1 env var enables memory even without config.""" section("13. KIGI_MEMORY Env Var Override") env = IsolatedEnv() try: # Config enables memory env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Env test content\n* Key value here\n") client = env.spawn_agent(extra_env={"KIGI_MEMORY": "1"}) init_session(client) time.sleep(3) db = env.get_db_path() if db: ok("index created with KIGI_MEMORY=1") else: fail("no index despite KIGI_MEMORY=1 and config enabled") client.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 5: Logging # ═══════════════════════════════════════════════════════════════════════════ def test_memory_log_events(): """Memory log file is written with lifecycle events.""" section("14. Memory Log Events") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Test\n* Test content for logging\n") client = env.spawn_agent() init_session(client) time.sleep(4) client.close() log = env.memory_log() events = ["MEMORY_INIT", "MEMORY_REINDEX"] for ev in events: if ev in log: ok(f"log: {ev}") else: fail(f"log missing: {ev}") if not log: fail("memory.log empty (--features dev not enabled?)") else: lines = log.strip().split("\n") ok(f"memory.log has {len(lines)} line(s)") finally: env.cleanup() def test_memory_log_contains_session_id(): """Log entries include the session ID for correlation.""" section("15. Memory Log Contains Session ID") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Log Session Test\n") client = env.spawn_agent() init_session(client) time.sleep(3) client.close() log = env.memory_log() if not log: skip("memory.log empty (--features dev not enabled?)") return # Log should contain structured fields — at least the MEMORY_INIT event if "MEMORY_INIT" in log: ok("log has structured MEMORY_INIT event") else: fail("no MEMORY_INIT in log") # Verify log is valid structured data (each line should be parseable or human-readable) lines = [line for line in log.strip().split("\n") if line.strip()] if len(lines) > 0: ok(f"log has {len(lines)} non-empty line(s)") else: fail("log has no content lines") finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 6: Session End Hook # # NOTE: session_end currently only fires via SessionCommand::Shutdown, # which is sent in TUI/headless modes on SIGTERM. In stdio mode (used by # tests), the agent drops when stdin closes, racing the session actor's # event loop. The channel-close path includes on_session_end but the # LocalSet may exit before it runs. These tests verify the hook when it # fires but gracefully skip when the race is lost. # ═══════════════════════════════════════════════════════════════════════════ def test_session_end_summary(): """Session end writes a summary .md file when >= 2 user messages.""" section("16. Session End — Summary Written") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Base Memory\n") client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"init: {resp}") return time.sleep(2) # Send enough prompts to qualify for session-end (>= 2 user messages) for i in range(3): client.prompt(f"test message {i} about Rust programming", timeout=10) time.sleep(1) client.close() time.sleep(2) # Check session files sfiles = env.session_files() if sfiles: ok(f"{len(sfiles)} session log(s) written") for sf in sfiles: os.path.getsize(sf) with open(sf) as f: content = f.read() if "## Session Summary" in content: ok(f" {os.path.basename(sf)}: has Session Summary header") else: fail(f" {os.path.basename(sf)}: missing Session Summary header") else: skip("no session logs (session may have been too short for model)") finally: env.cleanup() def test_session_end_short_session_skipped(): """Sessions with < 2 user messages don't write a summary.""" section("17. Session End — Short session skipped") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Base\n") client = env.spawn_agent() init_session(client) time.sleep(2) # Only send 1 message — below the MIN_USER_MESSAGES threshold client.prompt("single message", timeout=5) time.sleep(1) client.close() time.sleep(2) sfiles = env.session_files() if not sfiles: ok("no session log for single-message session (correctly skipped)") else: # Could still be OK if the threshold is 1, but we expect 2 skip(f"session log written for short session ({len(sfiles)} files)") finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 7: Cross-Session Context Persistence # ═══════════════════════════════════════════════════════════════════════════ def test_cross_session_index_persistence(): """Index persists across sessions — chunks from session 1 are available in session 2.""" section("18. Cross-Session Index Persistence") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) marker = uuid.uuid4().hex[:8] env.write_global_memory(f"# Cross-session\n* Persistent marker: {marker}\n") # Session 1: index the content c1 = env.spawn_agent() init_session(c1) time.sleep(3) count1 = env.query_db("SELECT count(*) FROM chunks")[0][0] c1.close() time.sleep(1) if count1 == 0: fail("session 1 didn't index anything") return # Session 2: verify the index persists c2 = env.spawn_agent() init_session(c2) time.sleep(2) count2 = env.query_db("SELECT count(*) FROM chunks")[0][0] if count2 >= count1: ok(f"index persisted: session1={count1}, session2={count2}") else: fail(f"index shrunk: session1={count1}, session2={count2}") # FTS still works in session 2 r = env.query_db("SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH ?", (marker,)) if r and r[0][0] > 0: ok("FTS finds marker from session 1 in session 2") else: fail("FTS can't find marker from session 1") c2.close() finally: env.cleanup() def test_session_log_indexed_next_session(): """Session log .md written in session 1 gets indexed in session 2.""" section("19. Session Log Indexed in Next Session") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Base\n") # Session 1: send enough messages to generate a session log c1 = env.spawn_agent() init_session(c1) time.sleep(2) for i in range(3): c1.prompt(f"discuss topic {i} about memory systems", timeout=10) time.sleep(1) c1.close() time.sleep(2) sfiles = env.session_files() if not sfiles: skip("no session logs written in session 1, can't test re-indexing") return ok(f"session 1 wrote {len(sfiles)} log(s)") # Session 2: reindex should pick up the session logs c2 = env.spawn_agent() init_session(c2) time.sleep(4) # Check if session content appears in chunks chunks = env.query_db("SELECT source, text FROM chunks") session_chunks = [(s, t) for s, t in chunks if s == "session"] if session_chunks: ok(f"{len(session_chunks)} session chunk(s) indexed") else: skip("session logs not yet indexed (may need explicit reindex)") c2.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 8: Large & Edge Cases # ═══════════════════════════════════════════════════════════════════════════ def test_empty_memory_file(): """Empty MEMORY.md doesn't crash the indexer.""" section("20. Empty MEMORY.md — No Crash") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("") client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"agent crashed with empty MEMORY.md: {resp}") else: ok("agent started with empty MEMORY.md") time.sleep(2) chunks = env.query_db("SELECT count(*) FROM chunks") if chunks: ok(f"indexer handled empty file ({chunks[0][0]} chunks)") else: ok("indexer handled empty file (no DB created)") client.close() finally: env.cleanup() def test_unicode_memory_content(): """MEMORY.md with unicode (emoji, CJK, etc.) is indexed correctly.""" section("21. Unicode Memory Content") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("""# International Content 🌍 * Japanese: プログラミングの規約 * Chinese: 代码审查流程 * Emoji markers: 🔥 performance 🐛 bugs 🚀 deployment * Arabic: مراجعة الكود * Mixed: Use café-style naming for résumé parser """) client = env.spawn_agent() init_session(client) time.sleep(3) chunks = env.query_db("SELECT count(*) FROM chunks")[0][0] if chunks > 0: ok(f"unicode content indexed ({chunks} chunks)") else: fail("unicode content not indexed") # FTS should find ASCII words embedded in unicode content r = env.query_db("SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH 'performance'") if r and r[0][0] > 0: ok("FTS finds ASCII word in unicode content") else: fail("FTS can't find ASCII word in unicode content") client.close() finally: env.cleanup() def test_binary_content_resilience(): """MEMORY.md with binary-ish content doesn't crash the indexer.""" section("22. Binary-ish Content Resilience") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) # Write content with null-adjacent chars, control chars, etc. env.write_global_memory( "# Edge\n" "* Tab\there\n" "* Line\x0bfeed\n" "* Backspace\x08test\n" "* Long line: " + "x" * 10000 + "\n" ) client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"agent crashed with binary content: {resp}") else: ok("agent started with binary-ish content") time.sleep(3) chunks = env.query_db("SELECT count(*) FROM chunks") count = chunks[0][0] if chunks else 0 ok(f"indexer handled binary-ish content ({count} chunks)") client.close() finally: env.cleanup() def test_multiple_workspace_isolation(): """Two different workspaces have separate indices.""" section("23. Multiple Workspace Isolation") env1 = IsolatedEnv(workspace_name="project-alpha") try: env1.write_config(INDEXING_ONLY_CONFIG) env1.write_global_memory("# Shared\n* Shared content\n") # Start agent in workspace alpha c1 = env1.spawn_agent() init_session(c1) time.sleep(3) c1.close() # Now create workspace beta in the same fake home beta_workspace = os.path.join(env1.root, "workspace", "project-beta") os.makedirs(beta_workspace, exist_ok=True) # Config is already in global ~/.kigi/config.toml from env1.write_config # Start agent in workspace beta (reuse env1's fake home) binary = os.environ.get("KIGI_BINARY", "kigi") env_vars = os.environ.copy() env_vars["HOME"] = env1.fake_home env_vars["KIGI_MEMORY"] = "1" env_vars["RUST_LOG"] = "warn" env_vars["KIGI_NO_LEADER"] = "1" proc2 = subprocess.Popen( [binary, "agent", "--no-leader", "stdio"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=beta_workspace, env=env_vars, ) c2 = AcpClient(proc2, cwd=beta_workspace) init_session(c2) time.sleep(3) c2.close() # Count workspace dirs in memory (should be 2 different hashes) ws_dirs = [ d for d in os.listdir(env1.memory_dir) if os.path.isdir(os.path.join(env1.memory_dir, d)) and d != "sessions" ] if len(ws_dirs) >= 2: ok(f"two workspace dirs created: {ws_dirs}") elif len(ws_dirs) == 1: # Could be same hash — depends on workspace path hashing skip("only 1 workspace dir (workspaces may hash the same)") else: fail("no workspace dirs found") finally: env1.cleanup() def test_concurrent_access_safety(): """Two sequential sessions on the same workspace don't corrupt the index.""" section("24. Sequential Session Safety (no corruption)") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Concurrency\n* Shared data for safety test\n") for session_num in range(3): c = env.spawn_agent() resp = init_session(c) if "error" in resp: fail(f"session {session_num} failed to init: {resp}") return time.sleep(2) c.close() time.sleep(1) # After 3 sessions, DB should be consistent chunks = env.query_db("SELECT count(*) FROM chunks") fts = env.query_db("SELECT count(*) FROM chunks_fts") if chunks and fts and chunks[0][0] == fts[0][0]: ok(f"3 sessions: DB consistent ({chunks[0][0]} chunks = {fts[0][0]} FTS)") elif chunks and fts: fail(f"DB inconsistent: chunks={chunks[0][0]}, FTS={fts[0][0]}") else: fail("no data after 3 sessions") # Verify integrity try: integrity = env.query_db("PRAGMA integrity_check") if integrity and integrity[0][0] == "ok": ok("SQLite integrity check: ok") else: fail(f"SQLite integrity issue: {integrity}") except Exception as e: fail(f"integrity check failed: {e}") finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 9: Flush — artifact reindexing & dedup # ═══════════════════════════════════════════════════════════════════════════ def test_flush_artifact_reindexed(): """A manually-written flush .md file is picked up by the next session's reindex.""" section("25. Flush Artifact Reindexed on Next Session") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Base\n") # Session 1: let the agent create the workspace dir + index c1 = env.spawn_agent() init_session(c1) time.sleep(3) ws_dir = env.find_workspace_dir() if not ws_dir: fail("no workspace dir after session 1") c1.close() return c1.close() time.sleep(1) # Manually write a flush artifact (mimicking what run_memory_flush writes) flush_marker = uuid.uuid4().hex[:8] sessions_dir = os.path.join(ws_dir, "sessions") os.makedirs(sessions_dir, exist_ok=True) flush_path = os.path.join(sessions_dir, "2025-02-25-flush-sim123456.md") with open(flush_path, "w") as f: f.write(f"""## Key Decisions * We chose Rust for the memory backend (marker: {flush_marker}) * blake3 for content-addressed dedup ## Architecture * SQLite + FTS5 for search * sqlite-vec for embeddings """) ok(f"wrote simulated flush artifact (marker={flush_marker})") # Session 2: reindex should pick it up c2 = env.spawn_agent() init_session(c2) time.sleep(4) # Check that the flush content is in the index r = env.query_db( "SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH ?", (flush_marker,) ) if r and r[0][0] > 0: ok(f"flush artifact indexed — FTS finds marker '{flush_marker}'") else: fail(f"flush artifact NOT indexed — FTS can't find '{flush_marker}'") # Check source label r = env.query_db( "SELECT DISTINCT c.source FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (flush_marker,), ) if r: ok(f"flush artifact source = '{r[0][0]}'") else: fail("no source for flush artifact chunks") c2.close() finally: env.cleanup() def test_flush_dedup_by_hash(): """Identical flush content written twice doesn't create duplicate chunks.""" section("26. Flush Dedup by Content Hash") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Base\n") c1 = env.spawn_agent() init_session(c1) time.sleep(3) ws_dir = env.find_workspace_dir() if not ws_dir: fail("no workspace dir") c1.close() return c1.close() time.sleep(1) # Write the same content to two different flush files sessions_dir = os.path.join(ws_dir, "sessions") os.makedirs(sessions_dir, exist_ok=True) identical_content = "## Decisions\n* Always use tracing\n* Always use blake3\n" for _i, name in enumerate(["2025-02-25-flush-aaa.md", "2025-02-25-flush-bbb.md"]): with open(os.path.join(sessions_dir, name), "w") as f: f.write(identical_content) # Reindex c2 = env.spawn_agent() init_session(c2) time.sleep(4) # Check that dedup works — same content hash should not duplicate chunks hashes = env.query_db("SELECT hash, count(*) FROM chunks GROUP BY hash HAVING count(*) > 1") if not hashes: ok("no duplicate chunk hashes (dedup working)") else: # Two files with same content might produce same chunks — # the reindex should detect identical hash and skip skip(f"{len(hashes)} hash collision(s) — dedup may be file-level not chunk-level") c2.close() finally: env.cleanup() def test_flush_log_events(): """Memory log contains MEMORY_FLUSH events when flush is configured.""" section("27. Flush Log Events") env = IsolatedEnv() try: # Enable flush with very low threshold so it might trigger env.write_config(""" [memory] enabled = true [compaction.memory_flush] enabled = true soft_threshold_tokens = 100 [compaction.pruning] enabled = false """) env.write_global_memory("# Flush logging test\n") client = env.spawn_agent() init_session(client) time.sleep(2) # Send several prompts to build up some token usage for i in range(5): client.prompt(f"Tell me about topic {i}: " + "context ", timeout=15) time.sleep(2) client.close() time.sleep(2) log = env.memory_log() if "MEMORY_FLUSH" in log: ok("MEMORY_FLUSH event in log (flush triggered)") if "MEMORY_FLUSH: completed" in log: ok("MEMORY_FLUSH: completed event logged") else: skip("MEMORY_FLUSH not triggered (context usage may not have hit threshold)") if "MEMORY_COMPACT" in log: ok("MEMORY_COMPACT event in log (compaction triggered)") else: skip("MEMORY_COMPACT not triggered (context usage may not have hit threshold)") finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 10: Compaction — post-compaction memory re-injection # ═══════════════════════════════════════════════════════════════════════════ def test_compaction_resets_memory_injection(): """After compaction, memory context is re-injected on the next turn.""" section("28. Compaction Resets Memory Injection") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Important context\n* Always use graphite for PRs\n") client = env.spawn_agent() init_session(client) time.sleep(3) # Send many messages to try to trigger compaction for i in range(8): client.prompt( f"Generate a long response about topic {i}. " + "Please include lots of detail. ", timeout=20, ) time.sleep(2) client.close() time.sleep(2) log = env.memory_log() if "MEMORY_COMPACT: post-compaction reset" in log: ok("post-compaction memory injection reset detected") else: skip("compaction not triggered (would need more messages to fill context)") # If MEMORY_INJECT appeared at the start, that's the first-turn injection if "MEMORY_INJECT" in log: ok("MEMORY_INJECT event found (first-turn injection worked)") # Count inject events — if compaction happened, we should see >= 2 inject_count = log.count("MEMORY_INJECT") if inject_count >= 2: ok(f"MEMORY_INJECT appeared {inject_count} times (re-injected after compaction)") else: skip(f"only {inject_count} MEMORY_INJECT (compaction may not have triggered)") else: skip("no MEMORY_INJECT in log (memory context injection may not be configured)") finally: env.cleanup() def test_compaction_preserves_index(): """Compaction doesn't corrupt or destroy the memory index.""" section("29. Compaction Preserves Index Integrity") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) marker = uuid.uuid4().hex[:8] env.write_global_memory(f"# Persistent\n* Critical marker: {marker}\n") client = env.spawn_agent() init_session(client) time.sleep(3) # Record pre-compaction state pre_chunks = env.query_db("SELECT count(*) FROM chunks")[0][0] # Try to trigger compaction for i in range(6): client.prompt(f"Topic {i}: " + "detailed information ", timeout=15) time.sleep(2) # Index should still be intact post_chunks = env.query_db("SELECT count(*) FROM chunks")[0][0] if post_chunks >= pre_chunks: ok(f"index preserved: pre={pre_chunks}, post={post_chunks}") else: fail(f"index lost chunks: pre={pre_chunks}, post={post_chunks}") # FTS still finds our marker r = env.query_db("SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH ?", (marker,)) if r and r[0][0] > 0: ok("FTS still finds marker after potential compaction") else: fail("FTS lost marker after potential compaction") # Integrity check integrity = env.query_db("PRAGMA integrity_check") if integrity and integrity[0][0] == "ok": ok("SQLite integrity preserved after session with potential compaction") else: fail(f"integrity issue: {integrity}") client.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 11: Pruning — config acceptance & behavior verification # ═══════════════════════════════════════════════════════════════════════════ def test_pruning_config_accepted(): """Custom pruning config values are accepted by the agent.""" section("30. Pruning Config Accepted") env = IsolatedEnv() try: env.write_config(""" [memory] enabled = true [compaction.pruning] enabled = true keep_last_n_turns = 2 soft_trim_threshold = 500 soft_trim_head = 100 soft_trim_tail = 100 hard_clear_age_turns = 5 """) env.write_global_memory("# Pruning Test\n") client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"agent rejected pruning config: {resp}") else: ok("agent accepted aggressive pruning config (keep_last=2, hard_clear=5)") time.sleep(2) client.close() finally: env.cleanup() def test_pruning_disabled_no_side_effects(): """With pruning disabled, tool results are never modified.""" section("31. Pruning Disabled — No Side Effects") env = IsolatedEnv() try: env.write_config(""" [memory] enabled = true [compaction.pruning] enabled = false """) env.write_global_memory("# No-prune test\n") client = env.spawn_agent() init_session(client) time.sleep(2) # Send several messages for i in range(4): client.prompt(f"message {i} with pruning disabled", timeout=10) time.sleep(1) client.close() time.sleep(1) # With pruning disabled, the agent should run fine ok("agent ran with pruning disabled, no errors") # Verify no pruning log messages log = env.memory_log() if "pruned" not in log.lower() and "trimmed" not in log.lower(): ok("no pruning-related log entries when disabled") else: skip("pruning-related logs found (may be from other subsystem)") finally: env.cleanup() def test_pruning_extreme_config(): """Extreme pruning config (keep_last=0, threshold=1) doesn't crash.""" section("32. Pruning Extreme Config — No Crash") env = IsolatedEnv() try: env.write_config(""" [memory] enabled = true [compaction.pruning] enabled = true keep_last_n_turns = 0 soft_trim_threshold = 1 soft_trim_head = 10 soft_trim_tail = 10 hard_clear_age_turns = 1 """) env.write_global_memory("# Extreme pruning\n") client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"agent crashed with extreme pruning config: {resp}") else: ok("agent survived extreme pruning config (keep=0, threshold=1, hard_clear=1)") time.sleep(2) # Send a few messages — pruning should aggressively trim for i in range(3): client.prompt(f"extreme prune test message {i}", timeout=10) time.sleep(1) # Agent should still be alive resp = client.prompt("final message after extreme pruning", timeout=10) if "error" not in resp or resp.get("error") == "broken pipe": ok("agent still responsive after extreme pruning") else: skip(f"agent response: {resp.get('error', 'ok')}") client.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 12: End-to-end round-trip — store → search → find # # These are the most important tests. They verify that memory produced by # each lifecycle event (session_end hook, flush, reindex) is actually # SEARCHABLE in a subsequent session. Without these, the rest of the suite # only proves plumbing exists — not that the system is useful. # ═══════════════════════════════════════════════════════════════════════════ def test_roundtrip_session_end_searchable(): """Session-end hook writes content that is FTS-searchable in the next session.""" section("33. Round-trip: session_end → searchable in next session") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Base\n") # Session 1: send enough messages to trigger session_end hook (>= 2 user msgs). # Use distinctive topic words so we can search for them. c1 = env.spawn_agent() init_session(c1) time.sleep(2) for i in range(3): c1.prompt( f"Tell me about flamingos and their migration patterns (turn {i})", timeout=10, ) time.sleep(1) c1.close() time.sleep(2) # Verify a session log was written sfiles = env.session_files() if not sfiles: skip("no session log written — session_end hook didn't fire, can't test round-trip") return ok(f"session 1 wrote {len(sfiles)} session log(s)") # Read the session log to see what topics it captured with open(sfiles[0]) as f: session_content = f.read() if "flamingo" in session_content.lower(): ok("session log captured topic 'flamingo'") else: # The metadata summary captures user message text, so 'flamingo' should be there skip(f"session log doesn't mention 'flamingo' (content: {session_content[:200]}...)") # Session 2: reindex picks up the session log → search should find it c2 = env.spawn_agent() init_session(c2) time.sleep(4) # Search the index for our distinctive topic r = env.query_db( "SELECT c.text, c.source FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH 'flamingo OR flamingos OR migration'" ) if r: ok(f"FTS finds session content in session 2 ({len(r)} match(es), source='{r[0][1]}')") # Verify the matched text actually contains our topic found_text = " ".join(row[0] for row in r) if "flamingo" in found_text.lower(): ok("matched text contains 'flamingo' — memory is useful") else: fail("FTS matched but text doesn't contain 'flamingo'") else: fail("FTS can't find session 1 topics in session 2 — round-trip broken") c2.close() finally: env.cleanup() def test_roundtrip_flush_artifact_searchable(): """Flush output is searchable by topic keywords in the next session.""" section("34. Round-trip: flush artifact → searchable by topic") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Base\n") # Session 1: create workspace dir c1 = env.spawn_agent() init_session(c1) time.sleep(3) ws_dir = env.find_workspace_dir() if not ws_dir: fail("no workspace dir") c1.close() return c1.close() time.sleep(1) # Simulate flush output with distinctive, searchable content sessions_dir = os.path.join(ws_dir, "sessions") os.makedirs(sessions_dir, exist_ok=True) with open(os.path.join(sessions_dir, "2025-02-25-flush-roundtrip.md"), "w") as f: f.write("""## Architecture Decisions * Chose PostgreSQL over MySQL for JSONB support * Using Redis for session caching with 15-minute TTL * Kubernetes with Istio service mesh for zero-trust networking ## User Preferences * Prefers snake_case for all Python code * Always run black formatter before committing * Test coverage threshold is 85% """) ok("wrote flush artifact with distinct topics") # Session 2: reindex → search c2 = env.spawn_agent() init_session(c2) time.sleep(4) # Search for specific technical decisions searches = [ ("PostgreSQL OR JSONB", "database choice"), ("Redis OR caching OR TTL", "caching layer"), ("Kubernetes OR Istio", "infrastructure"), ("snake_case OR black OR formatter", "code style"), ("coverage OR threshold", "testing policy"), ] found_count = 0 for query, label in searches: r = env.query_db( "SELECT c.text FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (query,), ) if r: found_count += 1 ok(f"FTS finds '{label}' from flush artifact") else: fail(f"FTS can't find '{label}' — flush content not searchable") if found_count == len(searches): ok(f"all {found_count}/{len(searches)} flush topics searchable — memory is useful") c2.close() finally: env.cleanup() def test_roundtrip_global_memory_searchable_across_sessions(): """Global MEMORY.md content remains searchable across multiple sessions.""" section("35. Round-trip: global memory persists and is searchable across 3 sessions") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) unique_topics = { "zephyr": "project codename", "quaternion": "math concept", "obsidian": "tool name", } lines = [f"* {v}: {k}" for k, v in unique_topics.items()] env.write_global_memory("# Durable Knowledge\n" + "\n".join(lines) + "\n") for session_num in range(1, 4): c = env.spawn_agent() init_session(c) time.sleep(3) # Verify every unique topic is still searchable for word, label in unique_topics.items(): r = env.query_db( "SELECT count(*) FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (word,), ) if not r or r[0][0] == 0: fail(f"session {session_num}: FTS lost '{word}' ({label})") c.close() return ok(f"session {session_num}: all {len(unique_topics)} topics searchable") c.close() time.sleep(1) ok("global memory remained searchable across 3 sessions") finally: env.cleanup() def test_roundtrip_workspace_memory_searchable(): """Workspace MEMORY.md content is searchable with workspace-specific queries.""" section("36. Round-trip: workspace memory searchable by project-specific terms") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) env.write_global_memory("# Global\n* Use graphite for PRs\n") # Session 1: create workspace dir c1 = env.spawn_agent() init_session(c1) time.sleep(3) # Write workspace-specific memory ws_content = """# Project Foxtrot Setup * Backend: Axum with tower middleware * Database: CockroachDB with sqlx driver * Auth: JWT tokens with ed25519 signing * CI: Buildkite with parallelism=8 * Deploy: ArgoCD with progressive delivery """ ws_path = env.write_workspace_memory(ws_content) if not ws_path: fail("couldn't find workspace dir") c1.close() return ok("wrote workspace MEMORY.md with project-specific terms") c1.close() time.sleep(1) # Session 2: reindex and search c2 = env.spawn_agent() init_session(c2) time.sleep(4) project_searches = [ ("Axum OR tower", "web framework"), ("CockroachDB OR sqlx", "database"), ("JWT OR ed25519", "authentication"), ("Buildkite OR parallelism", "CI pipeline"), ("ArgoCD OR progressive", "deployment"), ] found = 0 for query, label in project_searches: r = env.query_db( "SELECT c.text, c.source FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (query,), ) if r: found += 1 # Verify it's actually from workspace source sources = {row[1] for row in r} if "workspace" in sources: ok(f"FTS finds '{label}' from workspace source") else: ok(f"FTS finds '{label}' (source={sources})") else: fail(f"FTS can't find '{label}' from workspace memory") if found == len(project_searches): ok(f"all {found}/{len(project_searches)} workspace topics searchable") c2.close() finally: env.cleanup() def test_roundtrip_mixed_sources_searchable(): """Global + workspace + session content all coexist and are independently searchable.""" section("37. Round-trip: mixed sources (global + workspace + session) all searchable") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) # Write global memory with unique terms env.write_global_memory("""# Global Conventions * Always use ribonuclease as the error-handling pattern * Prefer dendrochronology for date calculations """) # Session 1: create workspace dir, write workspace memory c1 = env.spawn_agent() init_session(c1) time.sleep(3) env.write_workspace_memory("""# Workspace: Project Orion * Use spectroscopy for config parsing * Deploy with helioseismology orchestration """) # Send messages to trigger session_end (≥2 user msgs with unique topics) for msg in [ "We need to implement bioluminescence detection", "Also add the thermocline monitoring feature", "And integrate the magnetosphere dashboard", ]: c1.send( "session/prompt", {"messages": [{"role": "user", "content": {"type": "text", "text": msg}}]}, timeout=10, ) time.sleep(1) c1.close() time.sleep(2) # Also simulate a flush artifact with unique terms ws_dir = env.find_workspace_dir() if ws_dir: sessions_dir = os.path.join(ws_dir, "sessions") os.makedirs(sessions_dir, exist_ok=True) with open(os.path.join(sessions_dir, "2025-02-25-flush-mixed.md"), "w") as f: f.write("## Flush Notes\n* Remember the cephalopod configuration pattern\n") # Session 2: reindex all sources → search each c2 = env.spawn_agent() init_session(c2) time.sleep(5) source_checks = [ # (query, expected_source, label) ("ribonuclease OR dendrochronology", "global", "global memory terms"), ("spectroscopy OR helioseismology", "workspace", "workspace memory terms"), ("cephalopod", "session", "flush artifact terms"), ] all_found = True for query, expected_source, label in source_checks: r = env.query_db( "SELECT c.source, c.text FROM chunks_fts f JOIN chunks c ON c.rowid = f.rowid " "WHERE chunks_fts MATCH ?", (query,), ) if r: actual_sources = {row[0] for row in r} ok(f"FTS finds {label} (sources={actual_sources})") if expected_source in actual_sources: ok(f" → correct source '{expected_source}'") else: skip(f" → expected source '{expected_source}', got {actual_sources}") else: fail(f"FTS can't find {label} — source '{expected_source}' missing") all_found = False # Also check session_end content if it was written sfiles = env.session_files() session_terms = ["bioluminescence", "thermocline", "magnetosphere"] if sfiles: # Check if session log content is searchable for term in session_terms: r = env.query_db( "SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH ?", (term,) ) if r and r[0][0] > 0: ok(f"session_end topic '{term}' searchable") else: # Session metadata summary captures user message text skip(f"session_end topic '{term}' not in FTS (may not be in summary)") break else: skip("no session logs to search (session_end hook didn't fire)") if all_found: ok("all sources coexist and are searchable — memory system works end-to-end") c2.close() finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 13: End-to-end search — hybrid search through the live agent # # These tests verify that the agent's search pipeline (hybrid_search with # FTS + optional vector KNN, recency decay, source weights) works end-to-end. # Verification is done via memory.log events, NOT direct DB queries. # # Log events checked: # MEMORY_INJECT_SEARCH — first-turn search with query + result count # MEMORY_INJECT — memory context was injected into the prompt # MEMORY_SEARCH — model called the memory_search tool # ═══════════════════════════════════════════════════════════════════════════ def test_e2e_first_turn_injection_finds_memory(): """First-turn injection searches memory and injects results into prompt.""" section("38. E2E: first-turn injection finds pre-populated memory") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("""# Project Conventions * Always use protobuf for service-to-service communication * Deploy using ArgoCD with progressive rollouts * Use CockroachDB for the primary datastore """) client = env.spawn_agent() resp = init_session(client) if "error" in resp: fail(f"init: {resp}") return time.sleep(5) # wait for reindex to complete # Send a first message related to the memory content — triggers injection resp = client.prompt("What serialization format should I use for our services?", timeout=20) time.sleep(3) client.close() time.sleep(2) log = env.memory_log() # 1. Verify the search was executed if "MEMORY_INJECT_SEARCH" in log: ok("MEMORY_INJECT_SEARCH logged — first-turn search ran") # Check that results > 0 import re m = re.search(r"MEMORY_INJECT_SEARCH: results=(\d+)", log) if m and int(m.group(1)) > 0: ok(f"first-turn search found {m.group(1)} result(s)") elif m: fail("first-turn search found 0 results — memory content not searchable") else: skip("couldn't parse result count from log") else: fail("MEMORY_INJECT_SEARCH not in log — first-turn search didn't run") # 2. Verify injection happened if "MEMORY_INJECT:" in log: ok("MEMORY_INJECT logged — memory context was injected into prompt") else: fail("MEMORY_INJECT not in log — search returned results but injection failed") finally: env.cleanup() def test_e2e_first_turn_injection_query_matches_user_message(): """First-turn search query is derived from the user's first message.""" section("39. E2E: injection search query matches user message") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Memory\n* Use flamingos for load balancing\n") client = env.spawn_agent() init_session(client) time.sleep(5) # Send a distinctive first message client.prompt("Tell me about our flamingo configuration", timeout=20) time.sleep(3) client.close() time.sleep(2) log = env.memory_log() if "MEMORY_INJECT_SEARCH" in log: # The query should contain words from the user's message if "flamingo" in log.lower(): ok("search query contains 'flamingo' from user message") else: skip("search query logged but doesn't contain 'flamingo' (may be truncated)") else: fail("MEMORY_INJECT_SEARCH not logged") finally: env.cleanup() def test_e2e_no_injection_when_memory_empty(): """No MEMORY_INJECT when memory has no relevant content.""" section("40. E2E: no injection with empty memory") env = IsolatedEnv() try: env.write_config(INDEXING_ONLY_CONFIG) # Write empty memory — nothing to find env.write_global_memory("") client = env.spawn_agent() init_session(client) time.sleep(2) client.prompt("What is the project setup?", timeout=10) time.sleep(2) client.close() time.sleep(1) log = env.memory_log() if "MEMORY_INJECT_SEARCH" in log: import re m = re.search(r"MEMORY_INJECT_SEARCH: results=(\d+)", log) if m and int(m.group(1)) == 0: ok("search returned 0 results for empty memory") elif m: skip(f"search returned {m.group(1)} results (index may have stale data)") # MEMORY_INJECT should NOT appear (no results to inject) if "MEMORY_INJECT: first-turn memory context injected" not in log: ok("no MEMORY_INJECT with empty memory — correct") else: fail("MEMORY_INJECT fired despite empty memory") finally: env.cleanup() def test_e2e_injection_with_multiple_sources(): """First-turn injection finds content from both global and workspace sources.""" section("41. E2E: injection finds global + workspace memory") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Global\n* Always use spectroscopy for config parsing\n") # Session 1: create workspace dir and write workspace memory c1 = env.spawn_agent() init_session(c1) time.sleep(3) env.write_workspace_memory( "# Workspace\n* Use helioseismology for deployment orchestration\n" ) c1.close() time.sleep(1) # Session 2: both sources should be indexed and searchable c2 = env.spawn_agent() init_session(c2) time.sleep(6) # extra time for reindex of both sources # Send a prompt that should match workspace memory c2.prompt("deployment orchestration helioseismology", timeout=20) time.sleep(3) c2.close() time.sleep(2) log = env.memory_log() if "MEMORY_INJECT_SEARCH" in log: import re m = re.search(r"MEMORY_INJECT_SEARCH: results=(\d+)", log) if m and int(m.group(1)) > 0: ok(f"injection search found {m.group(1)} result(s) from indexed sources") elif m: fail("injection search found 0 results despite both sources being indexed") else: skip("couldn't parse result count from MEMORY_INJECT_SEARCH log") else: fail("MEMORY_INJECT_SEARCH not logged") if "MEMORY_INJECT:" in log: ok("memory context injected from multi-source index") else: fail("MEMORY_INJECT not logged") finally: env.cleanup() def test_e2e_memory_search_tool_invocation(): """Model can invoke memory_search tool and get results (when prompted).""" section("42. E2E: memory_search tool invocation") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("""# Project Knowledge Base * The authentication system uses JWT tokens with ed25519 signing * Rate limiting is handled by a Redis-backed token bucket at 1000 req/s * Database migrations use sqlx with offline mode for CI """) client = env.spawn_agent() init_session(client) time.sleep(4) # Ask the model to explicitly search memory client.prompt( "Search your memory for information about our authentication system", timeout=20, ) time.sleep(5) client.close() time.sleep(1) log = env.memory_log() # Check if the model called memory_search if "MEMORY_SEARCH: invoked" in log: ok("model invoked memory_search tool") if "MEMORY_SEARCH: complete" in log: import re m = re.search(r"MEMORY_SEARCH: complete.*results=(\d+)", log) if m: count = int(m.group(1)) if count > 0: ok(f"memory_search returned {count} result(s) — tool works e2e") else: fail("memory_search returned 0 results despite indexed content") else: ok("MEMORY_SEARCH: complete logged (couldn't parse count)") else: skip("model didn't call memory_search (model-dependent — may need explicit prompting)") finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # GROUP 14: Embedding (optional, depends on API availability) # ═══════════════════════════════════════════════════════════════════════════ def test_embedding_computation(): """Embeddings are computed for indexed chunks (requires API key).""" section("42. Embedding Computation") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("""# Embedding Test * First piece of knowledge about embeddings * Second piece about vector search * Third item about semantic similarity """) client = env.spawn_agent() init_session(client) time.sleep(6) # embedding is async, give extra time chunks = env.query_db("SELECT count(*) FROM chunks")[0][0] if chunks == 0: fail("no chunks indexed") client.close() return try: emb = env.query_db("SELECT count(*) FROM chunks_vec_rowids") if emb and emb[0][0] > 0: ok(f"{emb[0][0]}/{chunks} chunks have embeddings") if emb[0][0] == chunks: ok("all chunks embedded (100% coverage)") else: skip(f"partial embedding coverage ({emb[0][0]}/{chunks})") else: skip("no embeddings computed (API key or sqlite-vec unavailable)") except Exception: skip("chunks_vec table not available") client.close() finally: env.cleanup() def test_embedding_idempotency(): """Re-running embedding on same content doesn't create duplicates.""" section("28. Embedding Idempotency") env = IsolatedEnv() try: env.write_config(DEFAULT_CONFIG) env.write_global_memory("# Stable\n* Content for embedding idempotency\n") c1 = env.spawn_agent() init_session(c1) time.sleep(5) try: emb1 = env.query_db("SELECT count(*) FROM chunks_vec_rowids")[0][0] except Exception: skip("embeddings not available") return c1.close() c2 = env.spawn_agent() init_session(c2) time.sleep(5) try: emb2 = env.query_db("SELECT count(*) FROM chunks_vec_rowids")[0][0] except Exception: skip("embeddings not available") return c2.close() if emb1 == emb2: ok(f"embedding count stable ({emb1})") else: fail(f"embedding count changed: {emb1} → {emb2}") finally: env.cleanup() # ═══════════════════════════════════════════════════════════════════════════ # Main runner # ═══════════════════════════════════════════════════════════════════════════ ALL_TESTS = [ # Group 1: Indexing & Storage test_global_memory_indexing, test_workspace_vs_global_memory, test_index_integrity, test_chunk_hash_correctness, # Group 2: FTS Search test_fts_search_quality, test_fts_special_characters, test_fts_multi_file_search, # Group 3: Reindex, Idempotency, Content Updates test_reindex_idempotency, test_content_update_detection, test_large_file_chunking, # Group 4: Enable/Disable & Config test_memory_disabled, test_memory_disabled_no_config, test_memory_enabled_env_override, # Group 5: Logging test_memory_log_events, test_memory_log_contains_session_id, # Group 6: Session End Hook test_session_end_summary, test_session_end_short_session_skipped, # Group 7: Cross-Session Persistence test_cross_session_index_persistence, test_session_log_indexed_next_session, # Group 8: Large & Edge Cases test_empty_memory_file, test_unicode_memory_content, test_binary_content_resilience, test_multiple_workspace_isolation, test_concurrent_access_safety, # Group 9: Flush — artifact reindexing & dedup test_flush_artifact_reindexed, test_flush_dedup_by_hash, test_flush_log_events, # Group 10: Compaction test_compaction_resets_memory_injection, test_compaction_preserves_index, # Group 11: Pruning test_pruning_config_accepted, test_pruning_disabled_no_side_effects, test_pruning_extreme_config, # Group 12: End-to-end round-trip (store → search → find) test_roundtrip_session_end_searchable, test_roundtrip_flush_artifact_searchable, test_roundtrip_global_memory_searchable_across_sessions, test_roundtrip_workspace_memory_searchable, test_roundtrip_mixed_sources_searchable, # Group 13: E2E search (hybrid search through the live agent) test_e2e_first_turn_injection_finds_memory, test_e2e_first_turn_injection_query_matches_user_message, test_e2e_no_injection_when_memory_empty, test_e2e_injection_with_multiple_sources, test_e2e_memory_search_tool_invocation, # Group 14: Embeddings test_embedding_computation, test_embedding_idempotency, ] # Tests that don't need a model endpoint (fast, good for CI) FAST_TESTS = [ test_index_integrity, test_chunk_hash_correctness, test_fts_search_quality, test_fts_special_characters, test_reindex_idempotency, test_content_update_detection, test_large_file_chunking, test_memory_disabled, test_memory_disabled_no_config, test_empty_memory_file, test_unicode_memory_content, test_binary_content_resilience, test_concurrent_access_safety, test_flush_artifact_reindexed, test_flush_dedup_by_hash, test_roundtrip_flush_artifact_searchable, test_roundtrip_global_memory_searchable_across_sessions, test_roundtrip_workspace_memory_searchable, test_e2e_first_turn_injection_finds_memory, test_e2e_no_injection_when_memory_empty, test_pruning_config_accepted, test_pruning_extreme_config, ] def find_repo_root(): """Walk up from cwd to find the repo root (contains Cargo.toml + crates/).""" d = os.path.abspath(os.getcwd()) while d != "/": if os.path.isfile(os.path.join(d, "Cargo.toml")) and os.path.isdir( os.path.join(d, "crates") ): return d d = os.path.dirname(d) return None def build_binary(): """Build kigi-tui with --features dev --release. Returns binary path.""" repo = find_repo_root() if not repo: print(f"{R}Could not find repo root (no Cargo.toml + crates/ above cwd){N}") sys.exit(1) binary = os.path.join(repo, "target", "release", "kigi-tui") # Find rg for KIGI_SHELL_BUNDLE_RG_PATH rg = shutil.which("rg") if not rg: print(f"{R}rg (ripgrep) not found — required for build{N}") sys.exit(1) env = os.environ.copy() env["KIGI_SHELL_BUNDLE_RG_PATH"] = rg print(f"{B}Building kigi-tui (release + dev)...{N}") result = subprocess.run( ["cargo", "build", "-p", "kigi-tui", "--features", "dev", "--release"], cwd=repo, env=env, ) if result.returncode != 0: print(f"{R}Build failed (exit {result.returncode}){N}") sys.exit(1) print(f"{G}Build OK{N}: {binary}\n") return binary def main(): print(f"\n{B}{'=' * 60}") print(" Memory System Integration Tests — Full Suite") print(f"{'=' * 60}{N}\n") # Build or locate binary if "KIGI_BINARY" in os.environ: binary = os.environ["KIGI_BINARY"] print(f"Binary: {binary} (from $KIGI_BINARY, skipping build)") else: binary = build_binary() os.environ["KIGI_BINARY"] = binary if not os.path.isfile(binary): print(f"{R}Binary not found: {binary}{N}") sys.exit(1) print(f"Binary: {binary}") print(f"PID: {os.getpid()}") args = sys.argv[1:] if "--fast" in args: tests = FAST_TESTS args.remove("--fast") print(f"Mode: {Y}fast{N} ({len(tests)} tests, no model dependency)") elif args: # Run specific tests by name test_map = {t.__name__: t for t in ALL_TESTS} tests = [] for name in args: if name in test_map: tests.append(test_map[name]) else: print(f"{R}Unknown test: {name}{N}") print(f"Available: {', '.join(test_map.keys())}") sys.exit(1) print(f"Mode: {Y}selective{N} ({len(tests)} tests)") else: tests = ALL_TESTS print(f"Mode: {Y}full{N} ({len(tests)} tests)") print() for t in tests: try: t() except Exception as e: fail(f"EXCEPTION in {t.__name__}: {e}") import traceback traceback.print_exc() print(f"\n{B}{'=' * 60}") print(f" {G}Passed: {PASS}{N} {R}Failed: {FAIL}{N} {Y}Skipped: {SKIP}{N}") total = PASS + FAIL if total > 0: rate = PASS / total * 100 print(f" Pass rate: {rate:.0f}% ({PASS}/{total})") print(f"{B}{'=' * 60}{N}") sys.exit(1 if FAIL > 0 else 0) if __name__ == "__main__": main()