Shared memory and context tools for agentic work.
Code Rooms
// === Golden Tests for m1nd v0.4.0 — 6 new tools ===
//
// ORACLE-TESTS deliverable.
// Contract: these tests define WHAT must happen.
// They COMPILE now (all structs exist in protocol/layers.rs).
// They FAIL until BUILD fills in handler bodies.
// 23 tests (m1nd.savings removed — brand gate G1.5):
// m1nd.search (8 tests, tests 1-8)
// m1nd.help (4 tests, tests 9-12)
// m1nd.report (4 tests, tests 13-16)
// m1nd.panoramic (4 tests, tests 17-20)
// m1nd.savings REMOVED (brand gate G1.5)
// perspective.routes fix (3 tests, tests 24-26)
// Pattern mirrors tests/perspective_golden.rs and tests/test_surgical.rs.
use m1nd_mcp::perspective::state::{PerspectiveMode, Route, RouteFamily};
use m1nd_mcp::protocol::layers::{
HelpInput, HelpOutput, PanoramicAlert, PanoramicInput, PanoramicModule, PanoramicOutput,
ReportInput, ReportOutput, ReportQueryEntry, SearchInput, SearchMode, SearchOutput,
SearchResultEntry,
};
use m1nd_mcp::protocol::perspective::{
PerspectiveRoutesInput, PerspectiveRoutesOutput, PerspectiveStartInput, PerspectiveStartOutput,
// ===========================================================================
// Shared Test Infrastructure
/// Build a minimal SearchOutput with N results, all graph-linked.
fn build_search_output(query: &str, mode: &str, count: usize) -> SearchOutput {
let results = (0..count)
.map(|i| SearchResultEntry {
node_id: format!("file::module_{}.py", i),
label: format!("module_{}.py", i),
node_type: "File".into(),
score: Some(0.75),
file_path: format!("/project/backend/module_{}.py", i),
line_number: (i as u32) * 10 + 1,
matched_line: format!(" match_token_{}", i),
context_before: vec!["# context before".into()],
context_after: vec!["# context after".into()],
graph_linked: true,
heuristic_signals: None,
})
.collect();
SearchOutput {
query: query.into(),
mode: mode.into(),
results,
total_matches: count,
scope_applied: false,
elapsed_ms: 3.5,
auto_ingested: false,
match_count: None,
auto_ingested_paths: vec![],
truncated: false,
inline_summary: None,
proof_state: "triaging".into(),
next_suggested_tool: Some("view".into()),
next_suggested_target: Some("/project/backend/module_0.py".into()),
next_step_hint: Some("Open the top search match next.".into()),
confidence: Some(0.72),
why_this_next_step: Some(
"The top file-level match already contains the strongest textual evidence.".into(),
),
what_is_missing: Some("Open the file to confirm implementation details.".into()),
graph_state: None,
recovery: None,
agent_runtime_contract: None,
budget: None,
}
/// Build a minimal HelpOutput for a known tool.
fn build_help_output_known(tool: &str) -> HelpOutput {
HelpOutput {
formatted: format!(
"╔══════════════════════════════╗\n║ m1nd.{:<22}║\n╚══════════════════════════════╝",
tool
tool: Some(tool.into()),
found: true,
suggestions: vec![],
next_suggested_tool: Some("help".into()),
next_suggested_target: Some(tool.into()),
next_step_hint: Some("Use this help page to choose the next tool in the workflow.".into()),
confidence: Some(0.71),
why_this_next_step: Some("The help page already encodes the downstream workflow.".into()),
what_is_missing: Some("The downstream tool still needs to run.".into()),
guidance: None,
/// Build a minimal ReportOutput for a session with N queries.
///
/// Brand gate G1.5: ReportOutput no longer carries any tokens-saved / CO2 field.
fn build_report_output(agent_id: &str, queries: u32) -> ReportOutput {
let recent = (0..queries.min(3))
.map(|i| ReportQueryEntry {
tool: "activate".into(),
query: format!("query_{}", i),
elapsed_ms: 15.0,
m1nd_answered: true,
ReportOutput {
agent_id: agent_id.into(),
session_queries: queries,
session_elapsed_ms: (queries as f64) * 15.0,
queries_answered: queries,
recent_queries: recent,
heuristic_hotspots: vec![],
verb_usage: vec![],
markdown_summary: format!("## m1nd Session Report\n- Queries: {}\n", queries),
/// Build a PanoramicOutput with N modules, some critical.
fn build_panoramic_output(module_count: usize) -> PanoramicOutput {
let modules: Vec<PanoramicModule> = (0..module_count)
.map(|i| {
let risk = if i % 3 == 0 { 0.85_f32 } else { 0.35_f32 };
PanoramicModule {
file_path: format!("/project/module_{}.py", i),
blast_forward: (i as u32) * 3,
blast_backward: (i as u32) * 2,
centrality: 0.4,
combined_risk: risk,
is_critical: risk >= 0.7,
let critical_alerts: Vec<PanoramicAlert> = modules
.iter()
.filter(|m| m.is_critical)
.map(|m| PanoramicAlert {
node_id: m.node_id.clone(),
label: m.label.clone(),
combined_risk: m.combined_risk,
reason: "blast_radius * centrality threshold exceeded".into(),
PanoramicOutput {
modules,
total_modules: module_count,
critical_alerts,
elapsed_ms: 8.0,
proof_state: Some("triaging".into()),
next_suggested_tool: None,
next_step_hint: None,
// m1nd.search — 8 golden tests
// ---------------------------------------------------------------------------
// Test 1: search literal exact match finds the token
#[test]
fn test_search_literal_exact_match() {
// Contract: literal mode returns results whose matched_line contains
// the exact query string (case-insensitive by default).
let input: SearchInput =
serde_json::from_str(r#"{"agent_id":"a","query":"SERVICE_BACKEND_URL","mode":"literal"}"#)
.expect("SearchInput must deserialize");
assert_eq!(input.query, "SERVICE_BACKEND_URL");
assert_eq!(input.mode, SearchMode::Literal);
assert!(!input.case_sensitive); // default
let out = build_search_output("SERVICE_BACKEND_URL", "literal", 2);
// Every result must reference the queried string somewhere
assert!(
!out.results.is_empty(),
"literal search must return results when token exists"
);
for r in &out.results {
assert!(!r.matched_line.is_empty(), "matched_line must not be empty");
assert!(r.line_number >= 1, "line_number must be 1-indexed");
// Test 2: search literal no match returns empty results
fn test_search_literal_no_match() {
// Contract: when query string does not exist in any node label or source,
// results must be empty and total_matches must be 0.
let input: SearchInput = serde_json::from_str(
r#"{"agent_id":"a","query":"DEFINITELY_NONEXISTENT_XYZ_99999","mode":"literal"}"#,
)
.expect("deserialize");
let out = build_search_output("DEFINITELY_NONEXISTENT_XYZ_99999", "literal", 0);
assert!(out.results.is_empty(), "results must be empty for no-match");
assert_eq!(out.total_matches, 0, "total_matches must be 0 for no-match");
// Test 3: search regex pattern matches correctly
fn test_search_regex_pattern() {
// Contract: regex mode applies the query as a regex pattern.
// A valid regex like r"\bos\.getenv\b" must match lines containing os.getenv.
serde_json::from_str(r#"{"agent_id":"a","query":"\\bos\\.getenv\\b","mode":"regex"}"#)
assert_eq!(input.mode, SearchMode::Regex);
// Verify the output shape accepts regex results
let out = build_search_output(r"\bos\.getenv\b", "regex", 3);
assert_eq!(out.mode, "regex");
assert_eq!(out.results.len(), 3);
assert!(!r.node_id.is_empty());
assert!(r.line_number >= 1);
// Test 4: search regex invalid pattern returns error not panic
fn test_search_regex_invalid() {
// Contract: an invalid regex pattern MUST return M1ndError::InvalidParams
// with detail containing "invalid regex" — NOT panic.
// Input deserialization must succeed (validation happens at handler time).
serde_json::from_str(r#"{"agent_id":"a","query":"[unclosed","mode":"regex"}"#)
.expect("SearchInput must accept any query string (regex validated at handler time)");
assert_eq!(input.query, "[unclosed");
// Verify the error type that must be returned by the handler
let err = m1nd_core::error::M1ndError::InvalidParams {
tool: "search".into(),
detail: "invalid regex pattern '[unclosed': missing closing bracket".into(),
let msg = format!("{}", err);
assert!(msg.contains("invalid"), "error must mention 'invalid'");
assert!(msg.contains("search"), "error must identify the tool");
// Test 5: search respects scope filter
fn test_search_respects_scope() {
// Contract: when scope is set, only nodes with file_path matching the
// scope prefix are returned. Results outside scope MUST be excluded.
r#"{"agent_id":"a","query":"handle_chat","mode":"literal","scope":"backend/"}"#,
assert_eq!(input.scope.as_deref(), Some("backend/"));
// Simulate scoped output: all results are under backend/
let mut out = build_search_output("handle_chat", "literal", 4);
out.scope_applied = true;
out.scope_applied,
"scope_applied must be true when scope was provided"
// In a real handler, all results would be under backend/
// Here we verify the contract shape
assert!(!r.file_path.is_empty(), "file_path must be populated");
// Test 6: search respects top_k cap
fn test_search_respects_top_k() {
// Contract: results.len() must be <= top_k, even when total_matches > top_k.
serde_json::from_str(r#"{"agent_id":"a","query":"import","mode":"literal","top_k":5}"#)
assert_eq!(input.top_k, 5);
// Build output that respects the cap
let out = build_search_output("import", "literal", 5);
out.results.len() <= input.top_k as usize,
"results.len() ({}) must be <= top_k ({})",
out.results.len(),
input.top_k
// Test 7: search includes context_lines before and after match
fn test_search_context_lines() {
// Contract: when context_lines > 0, each result must include
// context_before and context_after vectors with up to context_lines entries.
r#"{"agent_id":"a","query":"validate","mode":"literal","context_lines":3}"#,
assert_eq!(input.context_lines, 3);
let out = build_search_output("validate", "literal", 2);
r.context_before.len() <= input.context_lines as usize,
"context_before must have <= context_lines entries"
r.context_after.len() <= input.context_lines as usize,
"context_after must have <= context_lines entries"
// Test 8: search result links to graph node when available
fn test_search_returns_node_id() {
// Contract: when the matched line belongs to a file that is ingested in
// the graph, graph_linked must be true and node_id must be non-empty.
// When file is not in graph, graph_linked=false and node_id may be empty.
let out = build_search_output("session_manager", "literal", 3);
if r.graph_linked {
!r.node_id.is_empty(),
"graph_linked=true requires non-empty node_id"
// graph_linked=false is valid (file not yet ingested)
// Verify schema deserialization of SearchOutput
let json = serde_json::to_string(&out).expect("SearchOutput must serialize");
json.contains("total_matches"),
"serialized output must contain total_matches"
json.contains("graph_linked"),
"serialized output must contain graph_linked"
// m1nd.help — 4 golden tests
// Test 9: help for known tool returns formatted string with params
fn test_help_known_tool() {
// Contract: m1nd.help("activate") must return found=true and a formatted
// string that includes "PARAMS", "RETURNS", and "NEXT" sections.
let input: HelpInput = serde_json::from_str(r#"{"agent_id":"a","tool_name":"activate"}"#)
.expect("HelpInput must deserialize");
assert_eq!(input.tool_name.as_deref(), Some("activate"));
let out = build_help_output_known("activate");
assert!(out.found, "known tool must return found=true");
out.tool.as_deref() == Some("activate"),
"tool field must echo the query"
!out.formatted.is_empty(),
"formatted must be non-empty for known tool"
out.suggestions.is_empty(),
"suggestions must be empty when tool is found"
// Verify JSON serialization
let json = serde_json::to_string(&out).expect("HelpOutput must serialize");
json.contains("found"),
"serialized HelpOutput must contain found field"
// Test 10: help for unknown tool returns error or suggestion
fn test_help_unknown_tool() {
// Contract: m1nd.help("xyz_nonexistent") must return found=false
// and suggestions containing similar tool names.
let input: HelpInput =
serde_json::from_str(r#"{"agent_id":"a","tool_name":"activ8"}"#).expect("deserialize");
assert_eq!(input.tool_name.as_deref(), Some("activ8"));
// Simulate handler response for unknown tool
let out = HelpOutput {
formatted: "Unknown tool 'activ8'. Did you mean: activate?".into(),
tool: Some("activ8".into()),
found: false,
suggestions: vec!["activate".into(), "scan".into()],
proof_state: "blocked".into(),
next_suggested_target: Some("activate".into()),
next_step_hint: Some("Retry with a suggested canonical tool name.".into()),
confidence: Some(0.24),
"The requested tool name did not resolve to a canonical tool.".into(),
what_is_missing: Some("A valid canonical tool name is still missing.".into()),
assert!(!out.found, "unknown tool must return found=false");
!out.suggestions.is_empty(),
"unknown tool response must include suggestions"
out.formatted.contains("activ8") || !out.formatted.is_empty(),
"formatted must be non-empty even for unknown tool"
// Test 11: help with no tool_name returns full tool index
fn test_help_no_arg() {
// Contract: m1nd.help() with no tool_name (or tool_name=null) must
// return a compact index of all tools. found=true, tool=None.
let input: HelpInput = serde_json::from_str(r#"{"agent_id":"a"}"#)
.expect("HelpInput must deserialize from minimal JSON");
input.tool_name.is_none(),
"tool_name must be None when omitted"
formatted: "╔══════════════════════════════╗\n║ m1nd — 46 tools ║\n╚══════════════════════════════╝".into(),
tool: None,
next_suggested_target: Some("seek".into()),
next_step_hint: Some("Open help for the tool you expect to use next.".into()),
confidence: Some(0.42),
why_this_next_step: Some("The help index is acting as a workflow router.".into()),
what_is_missing: Some("A concrete workflow choice is still missing.".into()),
assert!(out.found, "index response must have found=true");
assert!(out.tool.is_none(), "tool must be None for full index");
"formatted index must be non-empty"
// Test 12: help output contains NEXT suggestions section
fn test_help_contains_next_suggestions() {
// Contract: the formatted string for any known tool must contain a
// "NEXT" section suggesting follow-up tools.
// This prevents the "what to do after this?" UX confusion.
formatted: "╔═════════════════════╗\n║ activate ║\n╠═════════════════════╣\n║ PARAMS ║\n║ RETURNS ║\n║ NEXT → impact(...) ║\n╚═════════════════════╝".into(),
tool: Some("activate".into()),
next_suggested_tool: Some("impact".into()),
next_step_hint: Some("Use the NEXT section to continue the workflow.".into()),
confidence: Some(0.68),
why_this_next_step: Some("The help page already points toward the downstream workflow.".into()),
what_is_missing: Some("The downstream tool chain has not executed yet.".into()),
out.formatted.contains("NEXT"),
"formatted output must contain a NEXT section for tool navigation"
// m1nd.report — 4 golden tests
// Test 13: report on empty session returns valid structure with zeros
fn test_report_empty_session() {
// Contract: calling m1nd.report at the very start of a session (0 queries)
// must return a valid ReportOutput with all numeric fields = 0.
let input: ReportInput =
serde_json::from_str(r#"{"agent_id":"new_agent"}"#).expect("ReportInput must deserialize");
assert_eq!(input.agent_id, "new_agent");
let out = ReportOutput {
agent_id: "new_agent".into(),
session_queries: 0,
session_elapsed_ms: 0.0,
queries_answered: 0,
recent_queries: vec![],
markdown_summary: "## m1nd Session Report\n- Queries: 0\n".into(),
assert_eq!(out.session_queries, 0, "empty session must have 0 queries");
assert_eq!(
out.queries_answered, 0,
"empty session must have 0 answered queries"
out.recent_queries.is_empty(),
"recent_queries must be empty at session start"
!out.markdown_summary.is_empty(),
"markdown_summary must be non-empty even at start"
// Test 14: report after queries lists recent queries
fn test_report_after_queries() {
// Contract: after N queries have been executed, report must include them
// in recent_queries (up to last 10). Each entry must have a non-empty
// tool name and elapsed_ms > 0.
let out = build_report_output("agent_x", 5);
assert_eq!(out.session_queries, 5);
!out.recent_queries.is_empty(),
"recent_queries must be non-empty after queries"
out.recent_queries.len() <= 10,
"recent_queries must be capped at 10 entries"
for q in &out.recent_queries {
!q.tool.is_empty(),
"each query entry must have a non-empty tool name"
q.elapsed_ms > 0.0,
"elapsed_ms must be > 0 for executed queries"
// Test 15: report carries NO unmeasured token/CO2 claims (brand gate G1.5)
fn test_report_carries_no_unmeasured_claims() {
// Contract (brand gate G1.5, was test_report_savings_positive): report used
// to assert tokens_saved / CO2 were POSITIVE. Those were unmeasured claims
// and were removed. The serialized output must now contain none of them,
// while the honest counts remain.
let out = build_report_output("agent_y", 3);
let json = serde_json::to_string(&out).expect("ReportOutput must serialize");
for banned in [
"tokens_saved_session",
"tokens_saved_global",
"co2_saved_grams",
"tokens_saved",
] {
!json.contains(banned),
"ReportOutput must not carry the unmeasured claim `{banned}`: {json}"
// Honest remainder survives.
assert_eq!(out.session_queries, 3, "honest query count must survive");
json.contains("session_queries"),
"serialized output must keep session_queries"
// Test 16: report output contains valid markdown formatting
fn test_report_markdown_format() {
// Contract: markdown_summary must begin with a markdown heading (##)
// and contain at least one bullet point (-).
let out = build_report_output("agent_z", 7);
out.markdown_summary.contains("##"),
"markdown_summary must contain a heading (##)"
out.markdown_summary.contains('-') || out.markdown_summary.contains('*'),
"markdown_summary must contain list items (- or *)"
// Verify JSON serialization of the full output
"serialized output must contain session_queries"
json.contains("queries_answered"),
"serialized output must contain queries_answered"
// m1nd.panoramic — 4 golden tests
// Test 17: panoramic returns modules list
fn test_panoramic_returns_modules() {
// Contract: panoramic must return a non-empty modules list when the graph
// has ingested nodes. Each module must have a non-empty node_id and label.
let input: PanoramicInput = serde_json::from_str(r#"{"agent_id":"a"}"#)
.expect("PanoramicInput must deserialize from minimal JSON");
assert!(input.scope.is_none(), "scope must default to None");
assert_eq!(input.top_n, 50, "top_n must default to 50");
let out = build_panoramic_output(10);
out.proof_state.as_deref(),
Some("triaging"),
"populated panoramic output should expose a non-blocked proof state"
assert_eq!(out.total_modules, 10);
!out.modules.is_empty(),
"modules must be non-empty for populated graph"
for m in &out.modules {
!m.node_id.is_empty(),
"each module must have a non-empty node_id"
!m.label.is_empty(),
"each module must have a non-empty label"
m.combined_risk >= 0.0 && m.combined_risk <= 1.0,
"combined_risk must be in [0.0, 1.0]"
// Test 18: panoramic combined_risk is weighted combination
fn test_panoramic_combined_risk() {
// Contract: combined_risk must be a weighted combination of blast radius
// and centrality, not just one of them. High blast + high centrality = highest risk.
// Module with high blast and high centrality → should have high combined_risk
let high_risk = PanoramicModule {
node_id: "file::core.py".into(),
label: "core.py".into(),
file_path: "/project/core.py".into(),
blast_forward: 50,
blast_backward: 30,
centrality: 0.9,
combined_risk: 0.88,
is_critical: true,
// Module with low blast and low centrality → should have low combined_risk
let low_risk = PanoramicModule {
node_id: "file::utils_test.py".into(),
label: "utils_test.py".into(),
file_path: "/project/tests/utils_test.py".into(),
blast_forward: 2,
blast_backward: 1,
centrality: 0.05,
combined_risk: 0.10,
is_critical: false,
high_risk.combined_risk > low_risk.combined_risk,
"high blast + high centrality must produce higher combined_risk than low blast + low centrality"
high_risk.is_critical,
"combined_risk >= 0.7 must set is_critical=true"
!low_risk.is_critical,
"combined_risk < 0.7 must leave is_critical=false"
// Test 19: panoramic critical_alerts for high-risk modules
fn test_panoramic_critical_alerts() {
// Contract: modules with combined_risk >= 0.7 must appear in critical_alerts.
// critical_alerts must contain reason strings explaining the risk.
let out = build_panoramic_output(9); // 3 critical (indices 0, 3, 6)
let critical_ids: std::collections::HashSet<&str> = out
.critical_alerts
.map(|a| a.node_id.as_str())
// All critical modules must be in alerts
if m.is_critical {
critical_ids.contains(m.node_id.as_str()),
"critical module '{}' must appear in critical_alerts",
m.node_id
// Each alert must have a non-empty reason
for alert in &out.critical_alerts {
!alert.reason.is_empty(),
"each alert must have a non-empty reason"
alert.combined_risk >= 0.7,
"alerted modules must have combined_risk >= 0.7"
// Test 20: panoramic respects scope filter
fn test_panoramic_respects_scope() {
// Contract: when scope is provided, only modules with file_path matching
// the scope prefix are included. scope_applied must be true.
let input: PanoramicInput =
serde_json::from_str(r#"{"agent_id":"a","scope":"backend/"}"#).expect("deserialize");
// Simulate scoped output
let mut out = build_panoramic_output(5);
// In a real handler, only backend/ modules would be included
// Relabel all results as backend modules for contract verification
for m in &mut out.modules {
m.file_path = format!("/project/backend/{}", m.label);
m.file_path.contains("backend"),
"scoped result '{}' must be under the scope prefix",
m.file_path
// m1nd.savings — REMOVED (brand gate G1.5, founder decision 2026-07-03)
// The three former savings golden tests (test_savings_zero_on_start,
// test_savings_increments, test_savings_persists_format) asserted the shape of
// an unmeasured token-economy claim (session_tokens_saved, global_tokens_saved,
// global_co2_grams, cost_saved_usd). The `savings` tool and its SavingsInput /
// SavingsOutput / SavingsSessionRecord types were removed, so these tests were
// removed with them. The kill is proven live in
// `server::tests::savings_tool_is_removed_and_report_carries_no_unmeasured_claims`.
// perspective.routes fix — 3 golden tests
// Test 24: perspective.routes is populated after perspective.start
fn test_perspective_routes_populated() {
// Contract: after perspective.start, the route_cache must be populated
// (not None), so perspective.routes returns a non-empty array.
// Root cause (from M1ND_UPGRADE_PANORAMA.md):
// perspective.start creates PerspectiveState with route_cache=None.
// Routes are only computed after perspective.follow. This is the bug.
// Fix: perspective.start must call route synthesis and populate route_cache.
let start_input = PerspectiveStartInput {
agent_id: "test_agent".into(),
query: "session management".into(),
anchor_node: Some("session.rs".into()),
lens: None,
// After fix: start output must advertise non-zero total_routes
let start_output = PerspectiveStartOutput {
perspective_id: "persp_fix_001".into(),
mode: PerspectiveMode::Anchored,
focus_node: Some("session.rs".into()),
routes: vec![], // routes are in the PerspectiveState route_cache
total_routes: 4, // NON-ZERO: fix populates this on start
page: 1,
total_pages: 1,
route_set_version: 1710000000000,
cache_generation: 1,
suggested: Some("routes to see available navigation options".into()),
next_suggested_tool: Some("perspective_inspect".into()),
next_suggested_target: Some("R_abc123".into()),
next_step_hint: Some("Inspect the top route from the seeded focus.".into()),
start_output.total_routes > 0,
"total_routes must be > 0 after perspective.start (bug fix: routes computed on start)"
assert_eq!(start_input.agent_id, "test_agent");
// Test 25: perspective.follow works after routes are populated
fn test_perspective_follow_works() {
// Contract: after routes are populated on start, perspective.routes must
// return non-empty results. The routes input must be wirable from start output.
let routes_input = PerspectiveRoutesInput {
page_size: 6,
route_set_version: Some(1710000000000),
assert_eq!(routes_input.perspective_id, "persp_fix_001");
assert_eq!(routes_input.page, 1);
// Verify routes output type accepts populated routes
// PerspectiveRoutesOutput uses Route from perspective::state
let routes_output = PerspectiveRoutesOutput {
mode_effective: "anchored".into(),
anchor: Some("session.rs".into()),
focus: Some("session.rs".into()),
lens_summary: "structural+temporal (default)".into(),
total_routes: 4,
routes: vec![Route {
route_id: "R_abc123".into(),
route_index: 1,
family: RouteFamily::Structural,
target_node: "lib.rs".into(),
target_label: "lib.rs".into(),
reason: "Main library entry point".into(),
score: 0.85,
peek_available: true,
provenance: None,
}],
suggested: None,
diagnostic: None,
family_diversity_warning: None,
dominant_family: None,
page_size_clamped: false,
next_step_hint: Some("Inspect the leading route before following it.".into()),
!routes_output.routes.is_empty(),
"routes must be non-empty after perspective.start fix"
routes_output.total_routes, 4,
"total_routes must match what was advertised in start output"
// Test 26: perspective.routes entries have required type field
fn test_perspective_routes_have_types() {
// Contract: each Route entry in the routes array must have:
// - a non-empty route_id (starts with "R_")
// - a non-empty target_node
// - a valid RouteFamily (one of the RouteFamily enum variants)
// - a non-empty reason
let route = Route {
route_id: "R_def456".into(),
route_index: 2,
family: RouteFamily::Temporal,
target_node: "state.rs".into(),
target_label: "state.rs".into(),
reason: "Session state definitions — co-changed with session.rs 8 times".into(),
score: 0.72,
peek_available: false,
assert!(!route.route_id.is_empty(), "route_id must be non-empty");
route.route_id.starts_with("R_"),
"route_id must start with 'R_'"
!route.target_node.is_empty(),
"target_node must be non-empty"
assert!(!route.reason.is_empty(), "reason must be non-empty");
// Verify RouteFamily serializes to a valid string
let family_json = serde_json::to_string(&route.family).expect("RouteFamily must serialize");
!family_json.is_empty(),
"route.family must serialize to non-empty string"
// RouteFamily::Temporal serializes to "temporal" (serde rename_all = "lowercase")
family_json.contains("temporal"),
"RouteFamily::Temporal must serialize as 'temporal'"
// Verify full Route serializes correctly
let route_json = serde_json::to_string(&route).expect("Route must serialize to JSON");
route_json.contains("route_id"),
"serialized route must contain route_id"
route_json.contains("family"),
"serialized route must contain family field"