use super::*;
#[cfg(feature = "daemon")]
use serde_json::json;
#[test]
fn parse_user_prompt_prefers_prompt_field() {
let json_with_prompt = serde_json::json!({
"prompt": "what is rust?",
"cwd": "/tmp/example",
})
.to_string();
assert_eq!(parse_user_prompt(&json_with_prompt), "what is rust?");
let json_without_prompt = serde_json::json!({"cwd": "/tmp/example"}).to_string();
assert_eq!(parse_user_prompt(&json_without_prompt), json_without_prompt);
assert_eq!(parse_user_prompt("plain text query"), "plain text query");
assert_eq!(parse_user_prompt(""), "");
}
#[test]
fn filter_drawers_by_deny_tags_handles_edge_cases() {
use filter::{filter_drawers_by_deny_tags, RecalledDrawer};
let make = |tags: &[&str]| RecalledDrawer {
content: "irrelevant".into(),
tags: tags.iter().map(|s| s.to_string()).collect(),
layer: Some(2),
score: Some(0.9),
};
let drawers = vec![make(&["claude-session"]), make(&["rust"])];
let out = filter_drawers_by_deny_tags(drawers.clone(), &[]);
assert_eq!(out.len(), 2, "empty deny list must pass everything");
let drawers = vec![make(&["Claude-Session"]), make(&["rust"])];
let out = filter_drawers_by_deny_tags(drawers, &["claude-session".to_string()]);
assert_eq!(out.len(), 1);
assert!(out[0].tags.iter().any(|t| t == "rust"));
let drawers = vec![make(&[]), make(&["user-prompt"])];
let out = filter_drawers_by_deny_tags(drawers, &["user-prompt".to_string()]);
assert_eq!(out.len(), 1, "tagless drawers must survive the filter");
assert!(out[0].tags.is_empty());
let drawers = vec![
make(&["claude-session"]),
make(&["user-prompt"]),
make(&["signal"]),
];
let out = filter_drawers_by_deny_tags(
drawers,
&["claude-session".to_string(), "user-prompt".to_string()],
);
assert_eq!(out.len(), 1);
assert_eq!(out[0].tags, vec!["signal".to_string()]);
}
#[test]
fn select_relevant_triples_filters_by_prompt_overlap() {
use filter::{select_relevant_triples, RawTriple};
let triples = vec![
RawTriple {
subject: "tga".into(),
predicate: "is_alias_for".into(),
object: "trusty-git-analytics".into(),
},
RawTriple {
subject: "python".into(),
predicate: "is_fact".into(),
object: "language".into(),
},
RawTriple {
subject: "rust".into(),
predicate: "is_fact".into(),
object: "language".into(),
},
];
let chosen = select_relevant_triples(&triples, "tell me about rust integration", 5);
assert_eq!(chosen.len(), 1, "only the rust triple should match");
assert_eq!(chosen[0].subject, "rust");
let none = select_relevant_triples(&triples, "weather forecast next week", 5);
assert!(none.is_empty());
}
#[test]
fn select_relevant_triples_drops_structural_predicates() {
use filter::{select_relevant_triples, RawTriple};
let triples = vec![
RawTriple {
subject: "tag:worktree".into(),
predicate: "tags".into(),
object: "drawer:58141829-0918-4198-a274-307c49b5a671".into(),
},
RawTriple {
subject: "room:General".into(),
predicate: "contains".into(),
object: "drawer:3887130b-2630-429f-a440-979acea9edb5".into(),
},
RawTriple {
subject: "topic:12fbc5c8f19b4c32bb4641d2e42c0b93ca385086".into(),
predicate: "mentioned-in".into(),
object: "drawer:3887130b-2630-429f-a440-979acea9edb5".into(),
},
RawTriple {
subject: "worktree".into(),
predicate: "has_convention".into(),
object: "one worktree per reviewable PR outcome".into(),
},
];
let prompt = "worktree general topic 12fbc5c8f19b4c32bb4641d2e42c0b93ca385086 drawer";
let chosen = select_relevant_triples(&triples, prompt, 20);
assert_eq!(
chosen.len(),
1,
"only the has_convention triple asserts knowledge; got: {chosen:?}"
);
assert_eq!(chosen[0].predicate, "has_convention");
}
#[test]
fn select_relevant_triples_drops_creator_provenance_triples() {
use filter::{select_relevant_triples, RawTriple};
let drawer = "drawer:58141829-0918-4198-a274-307c49b5a671";
let triples: Vec<RawTriple> = [
"tag:creator:client=trusty-memory-mcp",
"tag:creator:source=mcp",
"tag:creator:version=0.23.1",
"tag:creator:cwd=/users/bob/duetto/cto",
]
.iter()
.map(|s| RawTriple {
subject: (*s).into(),
predicate: "tags".into(),
object: drawer.into(),
})
.collect();
let chosen = select_relevant_triples(
&triples,
"who was the creator client mcp version cwd for this drawer",
20,
);
assert!(
chosen.is_empty(),
"creator provenance is never a KG fact; got: {chosen:?}"
);
}
#[test]
fn compose_injection_truncates_at_cap() {
use filter::{RawTriple, RecalledDrawer};
use format::compose_injection;
let lines = INJECTION_BYTE_CAP / "- fact line\n".len() + 64;
let big_global = "## Big block\n".to_string() + &"- fact line\n".repeat(lines);
let drawers: Vec<RecalledDrawer> = (0..5)
.map(|i| RecalledDrawer {
content: format!("drawer {i} content"),
tags: vec!["tag1".into()],
layer: Some(2),
score: Some(0.9),
})
.collect();
let triples: Vec<RawTriple> = (0..5)
.map(|i| RawTriple {
subject: format!("subject{i}"),
predicate: "p".into(),
object: "object".into(),
})
.collect();
let out = compose_injection(Some(&big_global), &drawers, 0, &triples, Some("alpha"));
assert!(
out.len() <= INJECTION_BYTE_CAP,
"expected len <= cap; got {}",
out.len()
);
assert!(
out.ends_with('…'),
"expected `…` truncation marker; got tail: {}",
&out[out.len().saturating_sub(20)..]
);
}
#[test]
fn compose_injection_empty_inputs_yields_empty() {
use format::compose_injection;
let out = compose_injection(None, &[], 0, &[], Some("alpha"));
assert!(out.is_empty(), "got: {out:?}");
}
#[test]
fn injection_byte_cap_is_eight_kib() {
assert_eq!(INJECTION_BYTE_CAP, 8 * 1024);
assert_eq!(DEFAULT_TOP_K, 12, "requirement 2: max size raised from 5");
}
#[test]
fn relevance_floor_drops_all_noise_drawers() {
use filter::{filter_drawers_by_relevance_floor, RecalledDrawer};
let noise: Vec<RecalledDrawer> = (0..5)
.map(|i| RecalledDrawer {
content: format!("off-topic session drawer {i}"),
tags: vec!["signal".into()],
layer: Some(1),
score: Some(0.15),
})
.collect();
let out = filter_drawers_by_relevance_floor(noise, DEFAULT_RELEVANCE_FLOOR);
assert!(
out.kept.is_empty(),
"0.15 L1-penalty drawers must not reach the injection; got {:?}",
out.kept.len()
);
assert_eq!(out.withheld, 5, "all five must be counted for the notice");
}
#[test]
fn relevance_floor_keeps_high_scoring_drawer() {
use filter::{filter_drawers_by_relevance_floor, RecalledDrawer};
let make = |content: &str, score: f32| RecalledDrawer {
content: content.into(),
tags: Vec::new(),
layer: Some(2),
score: Some(score),
};
let mixed = vec![
make("genuine on-topic hit about rust integration", 0.56),
make("noise a", 0.15),
make("noise b", 0.15),
make("noise c", 0.2446),
make("noise d", 0.3439),
];
let out = filter_drawers_by_relevance_floor(mixed, DEFAULT_RELEVANCE_FLOOR);
assert_eq!(out.kept.len(), 1, "the genuine hit must survive");
assert!(out.kept[0].content.contains("genuine on-topic hit"));
assert_eq!(out.withheld, 4);
}
#[test]
fn relevance_floor_keeps_drawer_without_score() {
use filter::{filter_drawers_by_relevance_floor, RecalledDrawer};
let drawers = vec![RecalledDrawer {
content: "daemon predates the score field".into(),
tags: Vec::new(),
layer: Some(2),
score: None,
}];
let out = filter_drawers_by_relevance_floor(drawers, DEFAULT_RELEVANCE_FLOOR);
assert_eq!(out.kept.len(), 1, "unknown score must not mean dropped");
assert_eq!(out.withheld, 0);
}
#[test]
fn compose_injection_announces_withheld_drawers() {
use filter::RecalledDrawer;
use format::compose_injection;
let drawers: Vec<RecalledDrawer> = (0..2)
.map(|i| RecalledDrawer {
content: format!("kept drawer {i}"),
tags: Vec::new(),
layer: Some(2),
score: Some(0.7),
})
.collect();
let out = compose_injection(None, &drawers, 3, &[], Some("alpha"));
assert!(out.contains("kept drawer 0"), "kept content must render");
assert!(
out.contains("3 further memories withheld"),
"the withheld count must be visible; got:\n{out}"
);
assert!(
out.contains("memory_recall"),
"the notice must point at how to see past the floor; got:\n{out}"
);
}
#[test]
fn compose_injection_is_silent_when_everything_was_withheld() {
use format::compose_injection;
let silenced = compose_injection(None, &[], 5, &[], Some("alpha"));
assert!(
silenced.is_empty(),
"an all-withheld recall must emit nothing at all; got {} bytes:\n{silenced}",
silenced.len()
);
let nothing_existed = compose_injection(None, &[], 0, &[], Some("alpha"));
assert!(
nothing_existed.is_empty(),
"zero candidates must emit nothing; got:\n{nothing_existed}"
);
for probe in [
compose_injection(None, &[], 1, &[], Some("alpha")),
compose_injection(None, &[], 99, &[], None),
] {
assert!(
!probe.contains("cleared the relevance floor"),
"the total-silence notice is retired; got:\n{probe}"
);
}
}
#[test]
fn compose_injection_caps_kg_section_bytes() {
use filter::RawTriple;
use format::{compose_injection, KG_SECTION_BYTE_CAP};
let triples: Vec<RawTriple> = (0..10)
.map(|i| RawTriple {
subject: format!("subject-with-a-long-name-{i}"),
predicate: "has_convention".into(),
object: format!("an object string long enough to matter, number {i}"),
})
.collect();
let out = compose_injection(None, &[], 0, &triples, Some("alpha"));
assert!(
out.len() <= KG_SECTION_BYTE_CAP,
"KG section must stay within {KG_SECTION_BYTE_CAP} bytes, got {}:\n{out}",
out.len()
);
assert!(
out.contains("subject-with-a-long-name-0"),
"the cap must trim the tail, not suppress the section; got:\n{out}"
);
}
#[test]
fn project_scope_drops_foreign_cwd_drawer() {
use filter::{filter_drawers_by_project_scope, RecalledDrawer};
let mk = |name: &str, tags: Vec<String>| RecalledDrawer {
content: name.to_string(),
tags,
layer: Some(2),
score: Some(0.7),
};
let drawers = vec![
mk(
"duetto",
vec!["creator:cwd=/Users/bob/Duetto/cto".to_string()],
),
mk("untagged", vec!["worktree".to_string()]),
mk(
"in-tree",
vec!["creator:cwd=/users/bob/proj/trusty-tools/crates/trusty-memory".to_string()],
),
];
let out = filter_drawers_by_project_scope(drawers, Some("/users/bob/proj/trusty-tools"));
let names: Vec<&str> = out.kept.iter().map(|d| d.content.as_str()).collect();
assert_eq!(
names,
vec!["untagged", "in-tree"],
"only the other repository's drawer may be dropped"
);
assert_eq!(out.dropped, 1, "the drop must be counted, not silent");
}
fn git_in(dir: &std::path::Path, args: &[&str]) {
let out = std::process::Command::new("git")
.arg("-C")
.arg(dir)
.args([
"-c",
"user.name=test",
"-c",
"user.email=test@example.invalid",
"-c",
"commit.gpgsign=false",
])
.args(args)
.output()
.expect("git must be on PATH for these fixtures");
assert!(
out.status.success(),
"git {args:?} in {} failed: {}",
dir.display(),
String::from_utf8_lossy(&out.stderr)
);
}
fn init_test_repo(dir: &std::path::Path) {
std::fs::create_dir_all(dir).expect("mkdir repo");
git_in(dir, &["init", "-q"]);
git_in(dir, &["commit", "-q", "--allow-empty", "-m", "init"]);
}
#[test]
fn session_project_root_resolves_a_worktree_to_its_main_checkout() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().join("trusty-tools");
init_test_repo(&root);
let worktree = root.join(".claude/worktrees/agent-abc123");
git_in(
&root,
&[
"worktree",
"add",
"-q",
"-b",
"agent-abc123",
&worktree.to_string_lossy(),
],
);
assert!(
worktree.join(".git").is_file(),
"fixture must reproduce git's linked-worktree `.git` file"
);
let payload = serde_json::json!({ "cwd": worktree.to_string_lossy() }).to_string();
let resolved = resolve_session_project_root(&payload).expect("root resolves");
let expected = filter::normalise_project_path(
&std::fs::canonicalize(&root)
.expect("canonicalize root")
.to_string_lossy(),
);
assert_eq!(
resolved, expected,
"a worktree cwd must resolve to the checkout that owns it"
);
}
#[test]
fn project_scope_keeps_a_repo_root_writer_when_the_session_is_in_a_crate() {
use filter::{filter_drawers_by_project_scope, RecalledDrawer};
let tmp = tempfile::tempdir().expect("tempdir");
let repo = tmp.path().join("trusty-tools");
init_test_repo(&repo);
let crate_dir = repo.join("crates/trusty-memory");
let sibling_crate = repo.join("crates/trusty-search");
let foreign = tmp.path().join("some-other-repo");
for d in [&crate_dir, &sibling_crate, &foreign] {
std::fs::create_dir_all(d).expect("mkdir");
}
std::fs::write(repo.join("Cargo.toml"), "[workspace]\n").expect("workspace manifest");
std::fs::write(crate_dir.join("Cargo.toml"), "[package]\n").expect("crate manifest");
let payload = serde_json::json!({ "cwd": crate_dir.to_string_lossy() }).to_string();
let root = resolve_session_project_root(&payload).expect("session root resolves");
let mk = |dir: &std::path::Path| {
let cwd = dir.to_string_lossy().to_string();
RecalledDrawer {
content: cwd.clone(),
tags: vec![format!("creator:cwd={cwd}")],
layer: Some(2),
score: Some(0.7),
}
};
let out = filter_drawers_by_project_scope(
vec![mk(&repo), mk(&sibling_crate), mk(&foreign)],
Some(&root),
);
let names: Vec<&str> = out.kept.iter().map(|d| d.content.as_str()).collect();
assert_eq!(
names,
vec![
repo.to_string_lossy().as_ref(),
sibling_crate.to_string_lossy().as_ref()
],
"a session inside one crate must keep the repo root and its sibling crates; \
session root resolved to {root}"
);
}
#[test]
fn project_scope_keeps_in_tree_writers_and_drops_prefix_siblings() {
use filter::{filter_drawers_by_project_scope, RecalledDrawer};
let root = "/users/bob/proj/trusty-tools";
let mk = |cwd: &str| RecalledDrawer {
content: cwd.to_string(),
tags: vec![format!("creator:cwd={cwd}")],
layer: Some(2),
score: Some(0.7),
};
let worktree = format!("{root}/.claude/worktrees/agent-abc123");
let nested = format!("{root}/crates/trusty-memory/src");
let shouty = "/Users/Bob/Proj/Trusty-Tools/crates/";
let sibling = "/users/bob/proj/trusty-tools-fork";
let out = filter_drawers_by_project_scope(
vec![mk(&worktree), mk(&nested), mk(shouty), mk(sibling)],
Some(root),
);
let names: Vec<&str> = out.kept.iter().map(|d| d.content.as_str()).collect();
assert_eq!(
names,
vec![worktree.as_str(), nested.as_str(), shouty],
"worktrees, subdirectories, and case/slash variants are this project; \
a name-prefix sibling is not"
);
let untagged = RecalledDrawer {
content: "untagged".into(),
tags: vec!["worktree".into()],
layer: Some(2),
score: Some(0.7),
};
let empty_cwd = mk(" ");
let open = filter_drawers_by_project_scope(vec![untagged, empty_cwd], Some(root));
assert_eq!(
open.kept.len(),
2,
"a drawer with no recorded cwd, or an empty one, is unjudgeable and stays"
);
assert_eq!(open.dropped, 0);
let no_root = filter_drawers_by_project_scope(vec![mk(sibling)], None);
assert_eq!(
no_root.kept.len(),
1,
"an unresolvable session root disables the filter rather than dropping content"
);
assert_eq!(
no_root.dropped, 0,
"the disabled filter must report a zero drop, not an unreported one"
);
}
#[test]
fn project_scope_counts_what_it_drops() {
use filter::{filter_drawers_by_project_scope, RecalledDrawer};
let root = "/users/bob/proj/trusty-tools";
let mk = |cwd: &str| RecalledDrawer {
content: cwd.to_string(),
tags: vec![format!("creator:cwd={cwd}")],
layer: Some(2),
score: Some(0.7),
};
let out = filter_drawers_by_project_scope(
vec![
mk(root),
mk("/users/bob/proj/trusty-tools/crates/trusty-memory"),
mk("/users/bob/duetto/cto"),
mk("/users/bob/proj/some-other-repo"),
],
Some(root),
);
assert_eq!((out.kept.len(), out.dropped), (2, 2));
}
#[test]
fn session_project_root_is_none_inside_a_separate_git_dir_child() {
use filter::{filter_drawers_by_project_scope, RecalledDrawer};
let tmp = tempfile::tempdir().expect("tempdir");
let outer = tmp.path().join("outer");
let child = outer.join("sub");
std::fs::create_dir_all(&child).expect("mkdir child");
init_test_repo(&outer);
let modules = outer.join(".git/modules");
std::fs::create_dir_all(&modules).expect("mkdir modules");
git_in(
&child,
&[
"init",
"-q",
&format!("--separate-git-dir={}", modules.join("sub").display()),
".",
],
);
assert!(
child.join(".git").is_file(),
"fixture must reproduce the `.git` FILE a submodule checkout carries"
);
let payload = serde_json::json!({ "cwd": child.to_string_lossy() }).to_string();
let resolved = resolve_session_project_root(&payload);
assert_eq!(
resolved, None,
"a root that is not a working tree must disable the filter, not become it"
);
let cwd = child.to_string_lossy().to_string();
let drawer = RecalledDrawer {
content: cwd.clone(),
tags: vec![format!("creator:cwd={cwd}")],
layer: Some(2),
score: Some(0.7),
};
let out = filter_drawers_by_project_scope(vec![drawer], resolved.as_deref());
assert_eq!(
(out.kept.len(), out.dropped),
(1, 0),
"a drawer written inside the child checkout is this project's own"
);
}
#[test]
fn configured_relevance_floor_clamps_to_bounds() {
assert_eq!(clamp_floor(None), DEFAULT_RELEVANCE_FLOOR);
assert_eq!(clamp_floor(Some("0.5")), 0.5);
assert_eq!(clamp_floor(Some(" 0.42 ")), 0.42);
assert_eq!(clamp_floor(Some("0")), 0.0, "zero must disable the gate");
assert_eq!(clamp_floor(Some("-3")), 0.0);
assert_eq!(clamp_floor(Some("9")), 1.0);
assert_eq!(clamp_floor(Some("banana")), DEFAULT_RELEVANCE_FLOOR);
assert_eq!(clamp_floor(Some("NaN")), DEFAULT_RELEVANCE_FLOOR);
}
#[test]
fn configured_top_k_clamps_to_bounds() {
assert_eq!(clamp_top_k(None), DEFAULT_TOP_K);
assert_eq!(clamp_top_k(Some("7")), 7);
assert_eq!(clamp_top_k(Some("0")), DEFAULT_TOP_K);
assert_eq!(clamp_top_k(Some("999")), MAX_TOP_K);
assert_eq!(clamp_top_k(Some("nope")), DEFAULT_TOP_K);
}
#[test]
fn recall_query_is_the_whole_prompt() {
let long: String = "explain the retrieval floor and why it matters. "
.repeat(400)
.trim_end()
.to_string();
assert!(long.len() > 12_000, "fixture must exceed any plausible cap");
let payload = serde_json::json!({ "prompt": long, "cwd": "/tmp" }).to_string();
let parsed = parse_user_prompt(&payload);
assert_eq!(parsed, long, "the recall query must not be truncated");
assert!(
crate::hook_prompt_excerpt(&parsed).len() < parsed.len(),
"excerpt helper must stay distinct from the query path"
);
}
#[test]
fn resolve_palace_for_log_prefers_stdin_cwd() {
let _env = crate::commands::env_test_lock().blocking_lock();
let tmp = tempfile::tempdir().expect("tempdir");
let project = tmp.path().join("stdin-driven-project");
std::fs::create_dir_all(&project).expect("create project dir");
let payload = serde_json::json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project.to_string_lossy(),
"prompt": "hello"
})
.to_string();
let expected =
crate::messaging::cwd_palace_slug_at(&project).expect("derive slug from stdin cwd");
let got = resolve_palace_for_log(&payload);
assert_eq!(
got, expected,
"stdin `cwd` must override the process cwd for the log palace slug"
);
assert!(
got.contains("stdin-driven-project"),
"expected slug derived from stdin path, got {got:?}"
);
}
#[test]
fn resolve_palace_for_log_falls_back_to_process_cwd() {
let _env = crate::commands::env_test_lock().blocking_lock();
let from_empty = resolve_palace_for_log("");
let from_garbage = resolve_palace_for_log("not json at all");
assert_eq!(from_empty, from_garbage);
assert_ne!(from_empty, "<unknown>");
}
#[tokio::test]
async fn prompt_context_returns_ok_without_daemon() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
}
let res = handle_prompt_context_with_payload(String::new()).await;
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(
res.is_ok(),
"missing daemon lockfile must degrade to Ok(()), got {res:?}"
);
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_recalls_palace_drawers() {
let _guard = crate::commands::env_test_lock().lock().await;
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-recall-pop").await;
for (text, tags) in [
(
"Rust integration uses tokio for async tasks and serde for JSON",
vec!["rust", "tokio"],
),
(
"Python bindings ship via PyO3 with custom ABI shims",
vec!["python", "pyo3"],
),
(
"Knowledge graph stores triples in redb with valid_from intervals",
vec!["kg", "redb"],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "how does rust integration work?"
})
.to_string();
let start = std::time::Instant::now();
let body = build_injection_body(&payload).await;
let elapsed_ms = start.elapsed().as_millis();
eprintln!("prompt_context_recalls_palace_drawers latency: {elapsed_ms}ms");
assert!(
!body.is_empty() && body != EMPTY_PLACEHOLDER,
"populated palace must return real content; got:\n{body}"
);
assert!(
body.to_lowercase().contains("rust") && body.to_lowercase().contains("integration"),
"expected rust integration drawer in injection; got:\n{body}"
);
assert!(
body.contains("Relevant memories") || body.contains("memories from palace"),
"expected a `Relevant memories` section; got:\n{body}"
);
assert!(
elapsed_ms < 5_000,
"prompt-context too slow ({elapsed_ms}ms) — investigate"
);
addr_handle.shutdown().await;
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_header_names_the_alias_target() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_MIN_SCORE);
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
let (state, _data_dir_tmp, project_dir_tmp, _project_dir, canonical, addr_handle) =
spin_up_test_daemon_with_palace("ptx-alias-canonical").await;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": canonical,
"text": "Rust integration uses tokio for async tasks and serde for JSON",
"room": "General",
"tags": ["rust", "tokio"],
}),
)
.await
.expect("memory_remember");
let derived = "ptx-alias-derived";
let aliased_project = project_dir_tmp.path().join(derived);
std::fs::create_dir_all(&aliased_project).expect("aliased project dir");
crate::project_root::write_project_pin(
&aliased_project,
&crate::project_root::ProjectPin::new(derived),
)
.expect("write pin for the aliased project");
let registry_dir =
trusty_common::palace_alias::default_palace_registry_dir().expect("registry dir");
assert!(
!registry_dir.join(derived).join("palace.json").exists(),
"the derived slug must own no palace, or this test proves nothing"
);
trusty_common::palace_alias::PalaceAliasStore::register_alias(
®istry_dir,
derived,
&canonical,
)
.expect("register palace alias");
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": aliased_project.to_string_lossy(),
"prompt": "how does rust integration work?"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
body.contains(&format!("## Relevant memories from palace `{canonical}`")),
"the header must name the palace the drawers came from; got:\n{body}"
);
assert!(
!body.contains(derived),
"the pre-redirect slug is not a usable memory-tool input and must not \
appear in the injection; got:\n{body}"
);
addr_handle.shutdown().await;
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_off_topic_prompt_injects_nothing() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_MIN_SCORE);
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-floor-e2e").await;
for (text, tags) in [
(
"Rust integration uses tokio for async tasks and serde for JSON encoding",
vec!["rust", "tokio"],
),
(
"Python bindings ship via PyO3 with custom ABI shims for the runtime",
vec!["python", "pyo3"],
),
(
"Knowledge graph stores triples in redb with valid_from intervals per edge",
vec!["kg", "redb"],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "what is the capital of France"
})
.to_string();
let body = build_injection_body(&payload).await;
for leaked in ["tokio", "PyO3", "valid_from"] {
assert!(
!body.contains(leaked),
"off-topic prompt must not inject `{leaked}`; got:\n{body}"
);
}
assert!(
body.is_empty(),
"an off-topic prompt must inject nothing at all; got {} bytes:\n{body}",
body.len()
);
addr_handle.shutdown().await;
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_empty_palace_falls_back_to_global() {
let _guard = crate::commands::env_test_lock().lock().await;
let (_state, _data_dir_tmp, _project_dir_tmp, project_dir, _slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-recall-empty").await;
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "no drawers exist here"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
body.is_empty(),
"empty palace + empty prompt-facts must inject nothing; got {} bytes:\n{body}",
body.len()
);
addr_handle.shutdown().await;
}
#[cfg(feature = "daemon")]
async fn spin_up_test_daemon_with_palace(
palace_slug: &str,
) -> (
crate::AppState,
tempfile::TempDir,
tempfile::TempDir,
std::path::PathBuf,
String,
DaemonHandle,
) {
trusty_common::memory_core::retrieval::seed_shared_embedder_with_mock();
let data_tmp = tempfile::tempdir().expect("data tempdir");
let project_tmp = tempfile::tempdir().expect("project tempdir");
let project_dir = project_tmp.path().join(palace_slug);
std::fs::create_dir_all(&project_dir).expect("project dir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, data_tmp.path());
std::env::remove_var(crate::prompt_log::ENV_ENABLED);
std::env::remove_var(crate::prompt_log::ENV_DIR);
std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
std::env::remove_var(trusty_common::PALACE_OVERRIDE_ENV);
std::env::set_var("TRUSTY_SKIP_PALACE_ENFORCEMENT", "1");
}
crate::project_root::write_project_pin(
&project_dir,
&crate::project_root::ProjectPin::new(palace_slug.to_string()),
)
.expect("write project pin for fixture");
let data_root =
trusty_common::resolve_data_dir("trusty-memory").expect("resolve data dir under override");
let state = crate::AppState::new(data_root.clone());
state.set_ready();
let _ = crate::tools::dispatch_tool(&state, "palace_create", json!({"name": palace_slug}))
.await
.expect("palace_create");
let socket = crate::transport::uds::socket_path().expect("socket path under the override");
let state_for_server = state.clone();
let (stop, shutdown) = tokio::sync::oneshot::channel::<()>();
let serve_socket = socket.clone();
let handle = tokio::spawn(async move {
let _ =
crate::transport::uds::serve_with_shutdown(state_for_server, &serve_socket, async {
let _ = shutdown.await;
})
.await;
});
let mut attempts = 0;
while attempts < 500 {
if trusty_common::uds::socket_is_serving(&socket, std::time::Duration::from_millis(200))
.await
{
break;
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
attempts += 1;
}
assert!(
attempts < 500,
"daemon never began serving {} (attempts={attempts})",
socket.display()
);
(
state,
data_tmp,
project_tmp,
project_dir,
palace_slug.to_string(),
DaemonHandle {
socket,
stop: Some(stop),
join: Some(handle),
},
)
}
#[cfg(feature = "daemon")]
struct DaemonHandle {
#[allow(dead_code)]
socket: std::path::PathBuf,
stop: Option<tokio::sync::oneshot::Sender<()>>,
join: Option<tokio::task::JoinHandle<()>>,
}
#[cfg(feature = "daemon")]
impl DaemonHandle {
async fn shutdown(mut self) {
if let Some(stop) = self.stop.take() {
let _ = stop.send(());
}
if let Some(h) = self.join.take() {
if tokio::time::timeout(std::time::Duration::from_secs(10), &mut { h })
.await
.is_err()
{
}
}
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
}
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_recall_filters_deny_tags() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-deny-tags").await;
for (text, tags) in [
(
"user: how do I use rust async tokio runtime and serde derive macros in this project to glue an http handler to a kafka producer",
vec!["claude-session", "user-prompt", "rust"],
),
(
"user: yes please go ahead and refactor the rust async producer module, this captured prompt fragment should never be surfaced",
vec!["user-prompt", "rust"],
),
(
"Rust integration uses tokio for async tasks and serde for JSON",
vec!["rust", "tokio"],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "how does rust integration work?"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
body.contains("tokio") && body.contains("serde"),
"signal drawer must survive deny filter; got:\n{body}"
);
assert!(
!body.contains("kafka producer"),
"claude-session-tagged drawer must be filtered out; got:\n{body}"
);
assert!(
!body.contains("captured prompt fragment"),
"user-prompt-tagged drawer must be filtered out; got:\n{body}"
);
addr_handle.shutdown().await;
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_recall_env_override_extends_deny_list() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::set_var(ENV_RECALL_DENY_TAGS, "noise-tag");
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-env-deny").await;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": "Rust integration uses tokio and serde for the async layer",
"room": "General",
"tags": ["noise-tag", "rust"],
}),
)
.await
.expect("memory_remember");
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "how does rust integration work?"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
!body.contains("tokio and serde"),
"noise-tag drawer must be filtered when env override targets it; got:\n{body}"
);
unsafe {
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
addr_handle.shutdown().await;
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_recall_all_filtered_falls_back_to_global() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-all-filtered").await;
for (text, tags) in [
(
"user: status update on the rust async rewrite, the kafka consumer should not surface in any prompt-context injection",
vec!["claude-session", "user-prompt", "rust"],
),
(
"user: yes please continue with the rust refactor on the producer side, this prompt fragment must be filtered out of recall",
vec!["claude-session", "rust"],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "tell me about rust"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
!body.contains("kafka consumer") && !body.contains("producer side"),
"filtered drawer content must not leak; got:\n{body}"
);
assert!(
!body.contains("Relevant memories"),
"no `Relevant memories` section should render when every drawer is filtered; got:\n{body}"
);
addr_handle.shutdown().await;
}
#[tokio::test]
async fn prompt_context_logs_attempt_without_daemon() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
std::env::remove_var(crate::prompt_log::ENV_ENABLED);
std::env::remove_var(crate::prompt_log::ENV_DIR);
std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
}
let res = handle_prompt_context_with_payload(String::new()).await;
let logs_dir = trusty_common::resolve_data_dir("trusty-memory")
.expect("resolve data dir")
.join("logs");
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(res.is_ok());
let files: Vec<_> = std::fs::read_dir(&logs_dir)
.expect("logs dir should be created")
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("enriched-prompts."))
})
.collect();
assert_eq!(
files.len(),
1,
"expected one enriched-prompts log file, got {files:?}"
);
let content = std::fs::read_to_string(&files[0]).expect("read log");
let line = content.lines().next().expect("at least one line");
let parsed: crate::prompt_log::PromptLogEntry =
serde_json::from_str(line).expect("parse JSONL");
assert_eq!(parsed.hook_type, "UserPromptSubmit");
assert_eq!(parsed.injection_kind, "prompt-context-facts");
}
#[tokio::test]
async fn bounded_blocking_times_out_on_slow_closure() {
let start = std::time::Instant::now();
let result: Option<String> = bounded_blocking(
|| {
std::thread::sleep(std::time::Duration::from_millis(400));
"too-late".to_string()
},
std::time::Duration::from_millis(100),
)
.await;
let elapsed = start.elapsed();
assert_eq!(result, None, "slow closure must fail open to None");
assert!(
elapsed < std::time::Duration::from_millis(300),
"bounded_blocking did not return promptly: elapsed={elapsed:?}"
);
}
#[tokio::test]
async fn bounded_blocking_returns_value_when_fast_enough() {
let result =
bounded_blocking(|| "fast".to_string(), std::time::Duration::from_millis(300)).await;
assert_eq!(result, Some("fast".to_string()));
}
#[cfg(feature = "daemon")]
#[tokio::test(flavor = "multi_thread")]
async fn handle_prompt_context_fails_open_on_slow_daemon() {
let _guard = crate::commands::env_test_lock().lock().await;
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var(trusty_common::DATA_DIR_OVERRIDE_ENV, tmp.path());
std::env::remove_var(crate::prompt_log::ENV_ENABLED);
std::env::remove_var(crate::prompt_log::ENV_DIR);
std::env::remove_var(crate::prompt_log::ENV_HASH_PROMPTS);
}
let socket = crate::transport::uds::socket_path().expect("socket path under the override");
let listener = trusty_common::uds::bind_hardened(&socket).expect("bind the stalled socket");
let server = tokio::spawn(async move {
while let Ok((conn, _)) = listener.accept().await {
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
drop(conn);
});
}
});
let start = std::time::Instant::now();
let res = handle_prompt_context_with_payload(String::new()).await;
let elapsed = start.elapsed();
server.abort();
unsafe {
std::env::remove_var(trusty_common::DATA_DIR_OVERRIDE_ENV);
}
assert!(
res.is_ok(),
"must fail open even with a stalled daemon, got {res:?}"
);
let budget = BODY_DEADLINE + EMIT_DEADLINE + std::time::Duration::from_millis(750);
assert!(
elapsed < budget,
"handle_prompt_context took {elapsed:?}, expected under budget {budget:?} \
(a stalled daemon must never make the hook wait out its own timeout)"
);
}
#[test]
fn injection_drops_provenance_tags() {
use format::render_tags;
let tags: Vec<String> = [
"rust",
"creator:client=trusty-memory-mcp",
"tokio",
"creator:version=0.21.2",
"creator:source=mcp",
"creator:cwd=/Users/masa/trusty-mpm-projects/bobmatnyc/trusty-tools/.base",
]
.iter()
.map(|s| s.to_string())
.collect();
let rendered = render_tags(&tags).expect("topical tags must still render");
assert!(
!rendered.contains("creator:"),
"provenance must not reach the injection; got: {rendered}"
);
assert!(
rendered.contains("`rust`") && rendered.contains("`tokio`"),
"topical tags must survive; got: {rendered}"
);
let only_provenance: Vec<String> = tags
.iter()
.filter(|t| t.starts_with("creator:"))
.cloned()
.collect();
assert_eq!(
render_tags(&only_provenance),
None,
"an all-provenance tag list must render nothing, not an empty suffix"
);
}
#[test]
fn injection_caps_rendered_tag_count() {
use format::{render_tags, MAX_RENDERED_TAGS};
let tags: Vec<String> = [
"slate-prioritization-in-flight",
"trusty-search-reinstall-in-flight",
"standing-instruction",
"session-2eb72dca",
"resume-target",
"issue-2833",
"bob-decision",
"trusty-mpm",
"status",
"kg",
"redb",
"python",
]
.iter()
.map(|s| s.to_string())
.collect();
let rendered = render_tags(&tags).expect("tags must render");
let shown = rendered.matches('`').count() / 2;
assert!(
shown <= MAX_RENDERED_TAGS,
"at most MAX_RENDERED_TAGS tags may render; got {shown}: {rendered}"
);
assert!(
rendered.contains(&format!("+{} more", tags.len() - shown)),
"held-back tags must be announced, not dropped silently; got: {rendered}"
);
assert!(
!rendered.contains("python"),
"the tail of the tag list must not render; got: {rendered}"
);
}
#[test]
fn injection_caps_rendered_tag_bytes() {
use format::{render_tags, MAX_RENDERED_TAGS, MAX_RENDERED_TAG_CHARS};
const TAG_CHARS: usize = 55;
let long: Vec<String> = (0..4)
.map(|i| {
let stem = format!("workstream-{i}-");
format!("{stem}{}", "x".repeat(TAG_CHARS - stem.chars().count()))
})
.collect();
assert_eq!(
long[0].chars().count(),
TAG_CHARS,
"fixture must be 55 chars"
);
let rendered = render_tags(&long).expect("tags must render");
assert!(
rendered.chars().count() <= MAX_RENDERED_TAG_CHARS,
"the tag label must stay inside its {MAX_RENDERED_TAG_CHARS}-char budget; \
got {} chars: {rendered}",
rendered.chars().count()
);
let shown = rendered.matches('`').count() / 2;
assert!(
shown < MAX_RENDERED_TAGS,
"with 55-char tags the byte budget must bind before the count cap; \
got {shown} tags: {rendered}"
);
assert!(
rendered.contains(&format!("+{} more", long.len() - shown)),
"tags held back for length must be announced too; got: {rendered}"
);
let huge = vec![format!("w-{}", "y".repeat(400))];
let rendered = render_tags(&huge).expect("a single oversized tag still renders");
assert!(
rendered.contains(&huge[0]),
"the lone tag must survive whole"
);
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_injection_has_no_provenance_tags() {
use format::MAX_RENDERED_TAGS;
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_MIN_SCORE);
std::env::remove_var(ENV_RECALL_DENY_TAGS);
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-tag-render").await;
for (text, tags) in [
(
"Rust integration uses tokio for async tasks and serde for JSON encoding",
vec![
"rust", "tokio", "serde", "async", "json", "encoding", "runtime", "crate",
],
),
(
"Rust error handling prefers thiserror in libraries and anyhow in binaries",
vec![
"rust",
"thiserror",
"anyhow",
"errors",
"libraries",
"binaries",
"convention",
],
),
] {
let tags_json: Vec<serde_json::Value> = tags.iter().map(|t| json!(t)).collect();
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": text,
"room": "General",
"tags": tags_json,
}),
)
.await
.expect("memory_remember");
}
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": "how does rust integration work with tokio and serde?"
})
.to_string();
let body = build_injection_body(&payload).await;
assert!(
!body.is_empty() && body != EMPTY_PLACEHOLDER,
"fixture must actually recall something for this test to mean anything"
);
assert!(
body.contains("_(tags:"),
"fixture must render a tag suffix for this test to mean anything; got:\n{body}"
);
assert!(
!body.contains("creator:"),
"no provenance tag may reach the injection; got:\n{body}"
);
for bullet in body.lines().filter(|l| l.contains("_(tags:")) {
let shown = bullet
.split("_(tags:")
.nth(1)
.map(|suffix| suffix.matches('`').count() / 2)
.unwrap_or(0);
assert!(
shown <= MAX_RENDERED_TAGS,
"bullet renders {shown} tags, over the cap of {MAX_RENDERED_TAGS}: {bullet}"
);
}
addr_handle.shutdown().await;
}
#[cfg(feature = "daemon")]
#[tokio::test]
async fn prompt_context_logs_recall_query_shape() {
let _guard = crate::commands::env_test_lock().lock().await;
unsafe {
std::env::remove_var(ENV_MIN_SCORE);
std::env::remove_var(super::query::ENV_QUERY_TOKEN_BUDGET);
}
let (state, _data_dir_tmp, _project_dir_tmp, project_dir, slug, addr_handle) =
spin_up_test_daemon_with_palace("prompt-ctx-query-shape").await;
let _ = crate::tools::dispatch_tool(
&state,
"memory_remember",
json!({
"palace": slug,
"text": "Rust integration uses tokio for async tasks and serde for JSON encoding",
"room": "General",
"tags": [json!("rust")],
}),
)
.await
.expect("memory_remember");
let long_result = "The retrieval relevance floor interacts with the top_k cap.\n".repeat(400);
let prompt = format!(
"<task-notification>\n\
<task-id>a23c46a0439fa7881</task-id>\n\
<tool-use-id>toolu_01PvoC76SpHX65DVbJi7sPes</tool-use-id>\n\
<output-file>/private/tmp/claude-502/-Users-masa-projects/tasks/a23.output</output-file>\n\
<status>completed</status>\n\
<summary>Agent \"rust integration\" finished</summary>\n\
<result>{long_result}</result>\n\
</task-notification>"
);
assert!(prompt.len() > 20_000, "fixture must be far past the window");
let payload = json!({
"hook_event_name": "UserPromptSubmit",
"cwd": project_dir.to_string_lossy(),
"prompt": prompt,
})
.to_string();
let _ = build_injection_body(&payload).await;
let logs_dir = trusty_common::resolve_data_dir("trusty-memory")
.expect("resolve data dir")
.join("logs");
let log_file = std::fs::read_dir(&logs_dir)
.expect("logs dir")
.flatten()
.map(|e| e.path())
.find(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("enriched-prompts."))
})
.expect("an enriched-prompts log file");
let content = std::fs::read_to_string(&log_file).expect("read log");
let entry: crate::prompt_log::PromptLogEntry = content
.lines()
.filter_map(|l| serde_json::from_str::<crate::prompt_log::PromptLogEntry>(l).ok())
.next_back()
.expect("at least one parseable log line");
let shape = entry
.recall_query
.expect("an over-window query must leave a shape record, not truncate silently");
assert!(
shape.original_tokens > shape.budget_tokens,
"fixture must exceed the budget; got {shape:?}"
);
assert!(
shape.sent_tokens <= shape.budget_tokens,
"the query sent to the embedder must fit its window; got {shape:?}"
);
assert!(
shape.envelope_stripped,
"the task-notification envelope must be stripped; got {shape:?}"
);
assert!(
shape.units_dropped > 0 && shape.reshaped(),
"the reduction must be recorded, not silent; got {shape:?}"
);
addr_handle.shutdown().await;
}
#[test]
fn compose_injection_drops_a_kg_fact_tier_s_already_rendered() {
use filter::RawTriple;
let tier_s = crate::prompt_facts::build_prompt_context(&[(
"tga".to_string(),
"is_alias_for".to_string(),
"trusty-git-analytics".to_string(),
)]);
assert!(
tier_s.contains("trusty-git-analytics"),
"the fixture must actually render the fact: {tier_s}"
);
let out = format::compose_injection(
Some(&tier_s),
&[],
0,
&[RawTriple {
subject: "tga".into(),
predicate: "is_alias_for".into(),
object: "trusty-git-analytics".into(),
}],
None,
);
assert_eq!(
out.matches("trusty-git-analytics").count(),
1,
"the fact must be rendered once, not once per section: {out}"
);
assert!(
!out.contains("## Relevant KG facts"),
"a section with nothing left to say must not emit its heading: {out}"
);
}
#[test]
fn compose_injection_keeps_a_kg_fact_tier_s_did_not_render() {
use filter::RawTriple;
let tier_s = crate::prompt_facts::build_prompt_context(&[(
"tga".to_string(),
"is_alias_for".to_string(),
"trusty-git-analytics".to_string(),
)]);
let out = format::compose_injection(
Some(&tier_s),
&[],
0,
&[RawTriple {
subject: "ts".into(),
predicate: "is_alias_for".into(),
object: "trusty-search".into(),
}],
None,
);
assert!(
out.contains("## Relevant KG facts"),
"a fact Tier S never showed still earns the section: {out}"
);
assert!(out.contains("trusty-search"), "{out}");
assert_eq!(
out.matches("trusty-git-analytics").count(),
1,
"the Tier S fact is still rendered exactly once: {out}"
);
}