48 crates, 859 tests green (+58 kei-pet tests, was 801 at v0.35.0).
Full substrate pipeline test: all 8 agents launched via kei-agent-runtime
prepare → composed capability-fragment prompts → Agent tool invocations.
Zero file conflicts across disjoint scopes. Every agent self-verified
and landed files direct to main.
## A. memory (4 tests) — persistent conversations
- src/memory.rs — (user_id, pet_name)-scoped conversation log
- SQLite via rusqlite, index (user_id, pet_name, ts DESC)
- record_interaction / recent / search with LIKE-escape
## B. evolution (3 tests) — version diff + fork chain
- src/evolution.rs — PersonaVersion { version, parent_version, manifest }
- diff(old, new) → Vec<Change> (tone / directness / initiative / forbidden / humor)
- fork_version increments + links parent
## C. wizard (5 markdown phases) — /pet-init skill
- skills/pet-init/SKILL.md + 4 phases (identity / voice / edge / emit)
- AskUserQuestion-driven, no TOML editing for end users
- Writes ~/.claude/pet/<user_id>.toml + calls kei-pet keygen if needed
## D. templates (3 tests + 5 presets) — role-based personas
- templates/{friend,tutor,coach,therapist-companion,productivity-partner}.toml
- src/templates.rs — PetTemplate enum + load_template + list_templates
- Schema-enum mapping documented (dry→engineering-meta, etc) — schema.rs
expansion is future work
## E. bridge (3 tests) — /spawn-agent pet overlay
- src/bridge.rs — compose_prompt_with_pet(base + persona overlay + task)
- skills/spawn-agent/phase-3-pet-overlay.md — interactive pet selector
## F. recall (4 tests) — "have we discussed this before?"
- src/recall.rs — wraps kei_dna_index::precedent with body_sha8()
- SHA-256 first 4 bytes → 8 hex lowercase (matches kei_shared width)
- Fetches started_ts per hit for honest sort-by-recency
## G. reflect (7 tests) — self-reflection threshold proposals
- src/reflect.rs — CorrectionSignal + ProposedChange
- Thresholds: 3× too_verbose → SetDirectness, 2× forbidden_topic → AddForbidden, etc
- Idempotent: no-op if manifest already in desired state
## H. fleet (6 tests) — multi-pet per user
- src/fleet.rs — PetFleet { user_id, pets, active_pet }
- add_pet / switch_active / load_fleet with toml persistence
- shared_memory_key vs per_pet_memory_key — one user scopes multiple pets
## Known follow-ups (not blockers)
- Phase-4-emit of /spawn-agent should read PET_MANIFEST_PATH from new
phase-3-pet-overlay and pass to kei-spawn (wiring next wave)
- SKILL.md for spawn-agent should list new pet-overlay phase
- Schema enum expansion: humor_style "dry/witty", directness "direct/
gentle/blunt", initiative "proactive/nudge" as first-class variants
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
133 lines
4.4 KiB
Rust
133 lines
4.4 KiB
Rust
//! Hermetic tests for `kei_pet::memory`. Every test uses an in-memory
|
|
//! SQLite connection so nothing touches disk.
|
|
|
|
use kei_pet::memory::{ensure_schema, record_interaction, recent, search, MemoryTag};
|
|
use rusqlite::Connection;
|
|
|
|
fn fresh_db() -> Connection {
|
|
let conn = Connection::open_in_memory().expect("open in-memory sqlite");
|
|
ensure_schema(&conn).expect("ensure_schema idempotent");
|
|
// Second call must be a no-op.
|
|
ensure_schema(&conn).expect("ensure_schema second call");
|
|
conn
|
|
}
|
|
|
|
fn tag(user: &str, pet: &str) -> MemoryTag {
|
|
MemoryTag { user_id: user.into(), pet_name: pet.into() }
|
|
}
|
|
|
|
#[test]
|
|
fn record_and_recall_round_trip() {
|
|
let conn = fresh_db();
|
|
let t = tag("alice", "scout");
|
|
|
|
let id1 = record_interaction(&conn, &t, "user", "hello scout", 100).unwrap();
|
|
let id2 = record_interaction(&conn, &t, "assistant", "woof back", 101).unwrap();
|
|
let id3 = record_interaction(&conn, &t, "user", "good boy", 102).unwrap();
|
|
|
|
assert!(id1 < id2 && id2 < id3, "rowids strictly increase");
|
|
|
|
let rows = recent(&conn, &t, 10).unwrap();
|
|
assert_eq!(rows.len(), 3);
|
|
// Newest first.
|
|
assert_eq!(rows[0].ts, 102);
|
|
assert_eq!(rows[0].text, "good boy");
|
|
assert_eq!(rows[0].role, "user");
|
|
assert_eq!(rows[1].ts, 101);
|
|
assert_eq!(rows[2].ts, 100);
|
|
|
|
// Limit is respected.
|
|
let top2 = recent(&conn, &t, 2).unwrap();
|
|
assert_eq!(top2.len(), 2);
|
|
assert_eq!(top2[0].ts, 102);
|
|
assert_eq!(top2[1].ts, 101);
|
|
}
|
|
|
|
#[test]
|
|
fn recall_scoped_by_user_id_and_pet_name() {
|
|
let conn = fresh_db();
|
|
// 2 users x 2 pets = 4 independent streams, 3 messages each.
|
|
let streams = [
|
|
tag("alice", "scout"),
|
|
tag("alice", "nova"),
|
|
tag("bob", "scout"),
|
|
tag("bob", "nova"),
|
|
];
|
|
for (i, s) in streams.iter().enumerate() {
|
|
for k in 0..3 {
|
|
let ts = (i as i64) * 1000 + k as i64;
|
|
let text = format!("{}/{}#{}", s.user_id, s.pet_name, k);
|
|
record_interaction(&conn, s, "user", &text, ts).unwrap();
|
|
}
|
|
}
|
|
|
|
// Each stream sees exactly its own 3 messages.
|
|
for s in &streams {
|
|
let rows = recent(&conn, s, 50).unwrap();
|
|
assert_eq!(rows.len(), 3, "stream {:?} should have 3 rows", s);
|
|
for r in &rows {
|
|
let prefix = format!("{}/{}#", s.user_id, s.pet_name);
|
|
assert!(
|
|
r.text.starts_with(&prefix),
|
|
"leak: {:?} leaked into stream {:?}",
|
|
r.text,
|
|
s
|
|
);
|
|
}
|
|
}
|
|
|
|
// Confirm total rows = 12 across all streams (sanity on writes).
|
|
let all: i64 = conn
|
|
.query_row("SELECT COUNT(*) FROM pet_conversations", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(all, 12);
|
|
}
|
|
|
|
#[test]
|
|
fn search_by_substring_matches_content() {
|
|
let conn = fresh_db();
|
|
let t = tag("alice", "scout");
|
|
let other = tag("bob", "scout");
|
|
|
|
record_interaction(&conn, &t, "user", "let's go to the park", 1).unwrap();
|
|
record_interaction(&conn, &t, "assistant", "park sounds great", 2).unwrap();
|
|
record_interaction(&conn, &t, "user", "what about dinner", 3).unwrap();
|
|
// Same keyword under a different tag — MUST NOT leak into alice/scout.
|
|
record_interaction(&conn, &other, "user", "park for bob", 4).unwrap();
|
|
|
|
let hits = search(&conn, &t, "park", 10).unwrap();
|
|
assert_eq!(hits.len(), 2, "two park matches for alice/scout");
|
|
// Newest first.
|
|
assert_eq!(hits[0].ts, 2);
|
|
assert_eq!(hits[1].ts, 1);
|
|
assert!(hits.iter().all(|h| h.text.contains("park")));
|
|
|
|
// No false matches.
|
|
let none = search(&conn, &t, "zebra", 10).unwrap();
|
|
assert!(none.is_empty());
|
|
|
|
// Limit respected.
|
|
let one = search(&conn, &t, "park", 1).unwrap();
|
|
assert_eq!(one.len(), 1);
|
|
assert_eq!(one[0].ts, 2);
|
|
}
|
|
|
|
#[test]
|
|
fn search_escapes_like_metacharacters() {
|
|
// Regression guard: `%` and `_` in the user query must be literal,
|
|
// not SQL LIKE wildcards.
|
|
let conn = fresh_db();
|
|
let t = tag("alice", "scout");
|
|
|
|
record_interaction(&conn, &t, "user", "literal 100% match", 1).unwrap();
|
|
record_interaction(&conn, &t, "user", "no percent here", 2).unwrap();
|
|
record_interaction(&conn, &t, "user", "under_score here", 3).unwrap();
|
|
|
|
let hits = search(&conn, &t, "100%", 10).unwrap();
|
|
assert_eq!(hits.len(), 1);
|
|
assert_eq!(hits[0].ts, 1);
|
|
|
|
let under = search(&conn, &t, "under_score", 10).unwrap();
|
|
assert_eq!(under.len(), 1);
|
|
assert_eq!(under[0].ts, 3);
|
|
}
|