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>
163 lines
4.9 KiB
Rust
163 lines
4.9 KiB
Rust
//! Hermetic tests for `kei_pet::reflect::propose_tune`.
|
||
//!
|
||
//! Each test builds an in-memory `PetManifest` (no disk, no TOML parsing)
|
||
//! so the logic is tested in isolation from schema serialization.
|
||
|
||
use kei_pet::reflect::{propose_tune, CorrectionSignal, ProposedChange};
|
||
use kei_pet::schema::{
|
||
Addressing, Directness, Edge, Forbidden, HumorFrequency, HumorStyle,
|
||
Identity, Initiative, Meta, PetManifest, Profanity, Tone, Voice,
|
||
};
|
||
|
||
fn base_manifest() -> PetManifest {
|
||
PetManifest {
|
||
schema: 1,
|
||
identity: Identity {
|
||
pet_name: "Kei".into(),
|
||
user_name: "Alex".into(),
|
||
addressing: Addressing::ByName,
|
||
languages: vec!["en".into()],
|
||
},
|
||
voice: Voice {
|
||
tone_primary: Tone::Neutral,
|
||
tone_secondary: vec![],
|
||
humor_style: HumorStyle::None,
|
||
humor_frequency: HumorFrequency::Rare,
|
||
},
|
||
edge: Edge {
|
||
profanity: Profanity::Never,
|
||
profanity_languages: vec![],
|
||
directness: Directness::Balanced,
|
||
initiative: Initiative::Wait,
|
||
},
|
||
appearance: None,
|
||
room: None,
|
||
privacy: None,
|
||
interests: vec![],
|
||
routines: vec![],
|
||
forbidden: Forbidden {
|
||
topics: vec![],
|
||
tone_patterns: vec![],
|
||
},
|
||
meta: Meta {
|
||
schema_version_written_by: "kei-pet 0.1.0".into(),
|
||
created_at: "2026-04-23T12:00:00Z".into(),
|
||
last_tuned: "2026-04-23T12:00:00Z".into(),
|
||
tune_count: 0,
|
||
},
|
||
}
|
||
}
|
||
|
||
fn sig(topic: &str, ts: i64) -> CorrectionSignal {
|
||
CorrectionSignal {
|
||
timestamp: ts,
|
||
topic: topic.into(),
|
||
severity: 5,
|
||
note: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn propose_tune_empty_signals_returns_empty() {
|
||
let m = base_manifest();
|
||
let out = propose_tune(&m, &[]);
|
||
assert!(out.is_empty(), "empty signals → no proposals, got {out:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn propose_tune_threshold_too_verbose_3() {
|
||
let m = base_manifest();
|
||
let signals = vec![
|
||
sig("too_verbose", 100),
|
||
sig("too_verbose", 101),
|
||
sig("too_verbose", 102),
|
||
];
|
||
let out = propose_tune(&m, &signals);
|
||
assert!(
|
||
out.contains(&ProposedChange::SetDirectness("direct".into())),
|
||
"3× too_verbose on balanced manifest must emit SetDirectness(direct); got {out:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn propose_tune_below_threshold_too_verbose_2() {
|
||
let m = base_manifest();
|
||
let signals = vec![
|
||
sig("too_verbose", 100),
|
||
sig("too_verbose", 101),
|
||
];
|
||
let out = propose_tune(&m, &signals);
|
||
assert!(
|
||
!out.contains(&ProposedChange::SetDirectness("direct".into())),
|
||
"2× too_verbose is below threshold; got {out:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn propose_tune_threshold_forbidden_2() {
|
||
let m = base_manifest();
|
||
let signals = vec![
|
||
sig("forbidden_topic:diagnosis", 100),
|
||
sig("forbidden_topic:diagnosis", 101),
|
||
];
|
||
let out = propose_tune(&m, &signals);
|
||
assert!(
|
||
out.contains(&ProposedChange::AddForbiddenTopic("diagnosis".into())),
|
||
"2× forbidden_topic:diagnosis on clean manifest must emit AddForbiddenTopic(diagnosis); got {out:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn propose_tune_idempotent_directness_hard() {
|
||
let mut m = base_manifest();
|
||
m.edge.directness = Directness::Hard;
|
||
let signals = vec![
|
||
sig("too_verbose", 100),
|
||
sig("too_verbose", 101),
|
||
sig("too_verbose", 102),
|
||
sig("too_verbose", 103),
|
||
];
|
||
let out = propose_tune(&m, &signals);
|
||
assert!(
|
||
!out.iter().any(|c| matches!(c, ProposedChange::SetDirectness(_))),
|
||
"manifest already Hard → no SetDirectness proposal; got {out:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn propose_tune_idempotent_forbidden_already_listed() {
|
||
let mut m = base_manifest();
|
||
m.forbidden.topics = vec!["diagnosis".into()];
|
||
let signals = vec![
|
||
sig("forbidden_topic:diagnosis", 100),
|
||
sig("forbidden_topic:diagnosis", 101),
|
||
sig("forbidden_topic:diagnosis", 102),
|
||
];
|
||
let out = propose_tune(&m, &signals);
|
||
assert!(
|
||
!out.iter().any(|c| matches!(c, ProposedChange::AddForbiddenTopic(_))),
|
||
"diagnosis already in forbidden list → no AddForbiddenTopic proposal; got {out:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn propose_tune_initiative_and_tone_thresholds() {
|
||
let m = base_manifest();
|
||
let signals = vec![
|
||
sig("not_proactive_enough", 100),
|
||
sig("not_proactive_enough", 101),
|
||
sig("not_proactive_enough", 102),
|
||
sig("too_formal", 200),
|
||
sig("too_formal", 201),
|
||
sig("too_formal", 202),
|
||
];
|
||
let out = propose_tune(&m, &signals);
|
||
assert!(
|
||
out.contains(&ProposedChange::SetInitiative("proactive".into())),
|
||
"3× not_proactive_enough must emit SetInitiative(proactive); got {out:?}"
|
||
);
|
||
assert!(
|
||
out.contains(&ProposedChange::SetTonePrimary("warm".into())),
|
||
"3× too_formal must emit SetTonePrimary(warm); got {out:?}"
|
||
);
|
||
}
|