Files
Kigi-CLI/crates/codegen/kigi-shell/tests/memory_integration/run_tests.py
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

2674 lines
93 KiB
Python
Executable File

#!/usr/bin/env python3
# ruff: noqa: E701, E702
"""
Memory System Integration Tests — Full Suite.
Launches grok 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 grok 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 grok environment with custom $HOME.
The agent subprocess gets a fake $HOME with:
~/.grok/auth.json (copied from real home)
~/.grok/memory/ (pre-populated by tests)
~/.grok/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="grok-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 .grok dirs in fake home
self.grok_home = os.path.join(self.fake_home, ".grok")
self.memory_dir = os.path.join(self.grok_home, "memory")
self.logs_dir = os.path.join(self.grok_home, "logs")
os.makedirs(self.memory_dir)
os.makedirs(self.logs_dir)
# Copy auth from real home
real_auth = os.path.expanduser("~/.grok/auth.json")
if os.path.isfile(real_auth):
shutil.copy2(real_auth, os.path.join(self.grok_home, "auth.json"))
def write_config(self, toml_str):
"""Write global ~/.grok/config.toml (where the agent reads config)."""
with open(os.path.join(self.grok_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", "grok")
if not shutil.which(binary) and not os.path.isfile(binary):
print(f"{R}grok 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/grok
* 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 ~/.grok/config.toml from env1.write_config
# Start agent in workspace beta (reuse env1's fake home)
binary = os.environ.get("KIGI_BINARY", "grok")
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()