diff --git a/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs b/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs index c280db8c57..68dda3f784 100644 --- a/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs +++ b/crates/tracedecay-application/src/advisory/host_delivery_consume_tests.rs @@ -17,8 +17,8 @@ use tracedecay_contracts::feedback::{ feedback_surface_operation, }; use tracedecay_contracts::{ - ApplicationOperation, CancellationContext, CapabilityGrantId, CapabilityGrantSnapshot, - Deadline, DiagnosticProviderDescriptor, DiagnosticProviderIdentity, + ApplicationOperation, ApplicationOutcome, CancellationContext, CapabilityGrantId, + CapabilityGrantSnapshot, Deadline, DiagnosticProviderDescriptor, DiagnosticProviderIdentity, DiagnosticProviderIdentityParts, DiagnosticProviderResult, DiagnosticProviderState, DisclosureClass, PolicyDecisionRef, ProviderCoverage, ProviderDocumentIdentity, ProviderFreshness, ProviderOrigin, ProviderProvenance, ProviderSourceIdentity, RequestContext, @@ -39,7 +39,7 @@ use tracedecay_domain::feedback::{ use tracedecay_domain::{ ActorId, CodeGenerationId, CommitId, ComponentVersion, ContentDigest, FileOccurrenceId, LanguageDescriptorRevision, LanguageId, LocatorDigest, ManifestDigest, ProjectId, ProviderId, - RefId, RepositoryId, RetrievalAnchorId, SourceSpan, UtcMicros, WorktreeId, + RefId, RepositoryId, RetrievalAnchorId, SourceSpan, SymbolOccurrenceId, UtcMicros, WorktreeId, }; use tracedecay_hooks::{HookFeedbackDeliveryOutcomeV1, HookFeedbackRollbackSwitchV1}; use tracedecay_lsp::{ @@ -485,7 +485,9 @@ async fn consume_fixture() -> ConsumeFixture { target: request.input.target.clone(), affected_files: vec![request.input.target.file.clone()], affected_callers: Vec::new(), - affected_tests: Vec::new(), + affected_tests: vec![ + SymbolOccurrenceId::new("symbol.feedback-entry-test").expect("affected test symbol"), + ], evidence_anchors: Vec::new(), state: FeedbackImpactStateV1::Complete, affected_tests_state: FeedbackImpactStateV1::Complete, @@ -620,10 +622,21 @@ async fn completed_publication_is_consumed_into_exactly_one_hook_notice() { ) .await .expect("project published affected tests"); - assert!(matches!( - affected_tests, - crate::feedback::owner::FeedbackReadInvocationResultV1::AffectedTests(Ok(_)) - )); + let crate::feedback::owner::FeedbackReadInvocationResultV1::AffectedTests(Ok(envelope)) = + affected_tests + else { + panic!("published affected-tests read must return the projection"); + }; + let ApplicationOutcome::Evidence(evidence) = envelope.outcome else { + panic!("affected-tests projection must be evidence, not a problem or effect"); + }; + let payload = evidence.payload.expect("affected-tests payload"); + assert_eq!( + payload.affected_tests, + vec![SymbolOccurrenceId::new("symbol.feedback-entry-test").expect("affected test symbol")] + ); + assert_eq!(payload.state, Some(FeedbackImpactStateV1::Complete)); + assert!(payload.evidence_anchors.is_empty()); let listed = fixture .registration .feedback_owner diff --git a/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs b/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs index 0b46e65a17..12ed92251c 100644 --- a/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs +++ b/crates/tracedecay-automation-runtime/src/automation/run_ledger.rs @@ -553,7 +553,12 @@ pub fn canonical_record_completion_micros(record: &AutomationRunLedgerRecord) -> .map(|(_, completed_at_micros)| completed_at_micros) } -pub(super) fn canonical_record_started_at_seconds( +/// Schema-aware start instant in Unix seconds. +/// +/// Schema v1 rows store RFC3339. Schema v2 rows store nonnegative Unix +/// seconds. Callers that window the ledger, including analytics, must use +/// this instead of assuming one textual form. +pub fn canonical_record_started_at_seconds( record: &AutomationRunLedgerRecord, label: &str, ) -> Result { diff --git a/crates/tracedecay-mcp/src/handlers/analytics.rs b/crates/tracedecay-mcp/src/handlers/analytics.rs index 1ff48a33b0..8cf9cfdce8 100644 --- a/crates/tracedecay-mcp/src/handlers/analytics.rs +++ b/crates/tracedecay-mcp/src/handlers/analytics.rs @@ -23,13 +23,14 @@ use tracedecay_domain::{FactOwnerV1, ObservationScopeV1, ProjectId}; use tracedecay_session_memory::memory::MemoryApplication; use tracedecay_store::{FactReadControl, StoreShardScopeV1}; -use tracedecay_automation_runtime::automation::run_ledger::load_run_records; +use tracedecay_automation_runtime::automation::run_ledger::{ + canonical_record_started_at_seconds, load_run_records, +}; use tracedecay_daemon_service::retained_owner::open_project_retained_memory_target; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::{AnalyticsToolCounts, RegisteredGlobalDb}; use tracedecay_project::project::TraceDecay; use tracedecay_project::project::current_timestamp; -use tracedecay_runtime_core::timeutil::parse_rfc3339_timestamp; use tracedecay_session_memory::fact_store::DatabaseFactStore; use tracedecay_store_runtime::retained_memory::MemoryTargetAccessV1; @@ -793,7 +794,7 @@ async fn automation_section(project_root: &Path, since: i64) -> Value { let mut in_window = 0usize; let mut by_job: BTreeMap> = BTreeMap::new(); for record in &records { - if let Some(started_at) = parse_rfc3339_timestamp(&record.started_at) + if let Ok(started_at) = canonical_record_started_at_seconds(record, "analytics window") && started_at < since { continue; diff --git a/crates/tracedecay-session-memory/src/fact_store/crud/commands.rs b/crates/tracedecay-session-memory/src/fact_store/crud/commands.rs index 660f932da5..3838f333af 100644 --- a/crates/tracedecay-session-memory/src/fact_store/crud/commands.rs +++ b/crates/tracedecay-session-memory/src/fact_store/crud/commands.rs @@ -714,8 +714,21 @@ async fn update_project_memory_fact_with_cas_tx( .patch() .source_label() .unwrap_or_else(|| previous_payload.source_label()); - let Some(sanitized) = - sanitize_payload(content, category, tags, entities, metadata, source_label)? + // The operation digest above keeps the caller's label order so a retry is + // the same update. Stored payloads hash labels in canonical order; the + // sanitizer receipt must see that order or the write is rejected. + let mut canonical_tags = tags.to_vec(); + let mut canonical_entities = entities.to_vec(); + canonical_tags.sort_unstable(); + canonical_entities.sort_unstable(); + let Some(sanitized) = sanitize_payload( + content, + category, + &canonical_tags, + &canonical_entities, + metadata, + source_label, + )? else { return Err(storage_message( PROJECT_MEMORY_WRITE_OPERATION, diff --git a/crates/tracedecay/src/daemon/tests.rs b/crates/tracedecay/src/daemon/tests.rs index d2a4998eae..be935c1cc0 100644 --- a/crates/tracedecay/src/daemon/tests.rs +++ b/crates/tracedecay/src/daemon/tests.rs @@ -31,6 +31,7 @@ use super::{ mod bootstrap; mod code_index_hydration; +mod feedback_impact; mod handshake; mod invocation_ownership; mod lifecycle; diff --git a/crates/tracedecay/src/daemon/tests/feedback_impact.rs b/crates/tracedecay/src/daemon/tests/feedback_impact.rs new file mode 100644 index 0000000000..cb94e1ad67 --- /dev/null +++ b/crates/tracedecay/src/daemon/tests/feedback_impact.rs @@ -0,0 +1,500 @@ +//! `tracedecay_feedback_impact` as an MCP client sees it. +//! +//! The tool is a handle-addressed read. A diagnostics handle minted by a +//! published advisory cycle projects that cycle's identity and impact, and +//! nothing else. A handle that was never issued, or that was issued for a +//! different feedback read, is the same concealed refusal. +//! +//! A cycle whose every diagnostic provider is unavailable terminates +//! `daemon_unavailable` and mints no handle. The production way to move one +//! provider off that state is a real compiler warning admitted by +//! `tracedecay_diagnose`. + +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tracedecay_mcp::JsonRpcResponse; + +use crate::daemon::ProductionProjectCompositionHarnessV1; + +const IMPACT_TOOL: &str = "tracedecay_feedback_impact"; +const IMPACT_RESULT_SCHEMA: &str = "schema.application.feedback.impact.result"; +const PROBE_SOURCE: &str = + "pub fn feedback_impact_probe() -> i32 {\n let unused_impact = 7;\n 0\n}\n"; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn feedback_impact_projects_the_published_cycle_and_conceals_other_handles() { + let temp = tempfile::TempDir::new().expect("temp dir"); + let project = temp.path().join("project"); + std::fs::create_dir_all(project.join("src")).expect("source dir"); + std::fs::write(project.join("src/lib.rs"), PROBE_SOURCE).expect("source file"); + commit_project(&project); + let head = git_head(&project); + let document_uri = url::Url::from_file_path(project.join("src/lib.rs")) + .expect("document file URI") + .to_string(); + + let harness = ProductionProjectCompositionHarnessV1::open(temp.path(), vec![project.clone()]) + .await + .expect("production composition"); + + assert_invalid_request( + &harness + .call_tool( + &project, + IMPACT_TOOL, + json!({"request_handle": " not-a-handle", "format": "json"}), + ) + .await + .expect("whitespace handle call"), + "application surface request handle is invalid", + ); + assert_invalid_request( + &harness + .call_tool(&project, IMPACT_TOOL, json!({"format": "json"})) + .await + .expect("missing handle call"), + "application surface request does not match its reviewed schema: missing field `request_handle`", + ); + assert_invalid_request( + &harness + .call_tool( + &project, + IMPACT_TOOL, + json!({ + "request_handle": "rh_0123456789abcdef01234567", + "files": ["src/lib.rs"], + "format": "json" + }), + ) + .await + .expect("unknown field call"), + "application surface request does not match its reviewed schema: unknown field `files`, expected `request_handle`", + ); + + let absent = wait_for_feedback_owner(&harness, &project).await; + assert_concealed_impact(&absent); + + let published = publish_advisory_cycle(&harness, &project, &document_uri).await; + let cycle = &published["cycle"]; + let impact_handle = published["read_handles"]["impact_handle"] + .as_str() + .expect("published impact handle") + .to_owned(); + let list_handle = published["read_handles"]["list_handle"] + .as_str() + .expect("published list handle") + .to_owned(); + assert_ne!( + impact_handle, list_handle, + "a list handle must not be reusable as the impact handle" + ); + + let foreign = harness + .call_tool( + &project, + IMPACT_TOOL, + json!({"request_handle": list_handle, "format": "json"}), + ) + .await + .expect("list handle used as impact"); + assert_concealed_impact(&foreign); + + let impact = harness + .call_tool( + &project, + IMPACT_TOOL, + json!({"request_handle": impact_handle, "format": "json"}), + ) + .await + .expect("impact read"); + let envelope = successful_envelope(&impact); + assert_eq!( + envelope["contract"], + json!({ + "schema_id": IMPACT_RESULT_SCHEMA, + "schema_revision": 1 + }) + ); + let payload = &envelope["outcome"]["value"]["payload"]; + let expected = json!({ + "result_id": cycle["result_id"], + "cycle_id": cycle["cycle_id"], + "scope": cycle["scope"], + "content_identity": cycle.get("content_identity").cloned().unwrap_or(Value::Null), + "impact": cycle["impact"].clone(), + "state": cycle["impact_state"].clone(), + }); + assert_eq!(payload, &expected); + assert_eq!(payload["scope"]["branch_ref"], json!("refs/heads/master")); + assert_eq!(payload["scope"]["head_commit_id"], json!(head)); + let mut keys = payload + .as_object() + .expect("impact payload object") + .keys() + .cloned() + .collect::>(); + keys.sort(); + assert_eq!( + keys, + vec![ + "content_identity", + "cycle_id", + "impact", + "result_id", + "scope", + "state" + ] + ); + + harness.shutdown().await; +} + +fn assert_invalid_request(response: &JsonRpcResponse, detail: &str) { + assert!( + response.result.is_none(), + "an invalid impact request must not return a tool result: {response:?}" + ); + let error = response + .error + .as_ref() + .expect("invalid impact request is a JSON-RPC error"); + assert_eq!(response.id, json!(1)); + assert_eq!(error.code, -32602); + assert_eq!( + error.message, + format!( + "tool project route failed: reason_code=application_surface_invalid_request retryable=false: {detail}" + ) + ); + assert_eq!( + error.data, + Some(json!({ + "tool": IMPACT_TOOL, + "reason_code": "application_surface_invalid_request", + "retryable": false, + "detail": detail, + "kind": "invalid_request", + "code": "application_surface_invalid_request" + })) + ); +} + +fn assert_concealed_impact(response: &JsonRpcResponse) { + assert!( + response.error.is_none(), + "concealment is a tool result, not a JSON-RPC error: {response:?}" + ); + let result = response.result.as_ref().expect("tool result"); + assert_eq!(result["isError"], json!(true)); + assert_eq!(result["content"][0]["type"], json!("text")); + let envelope: Value = serde_json::from_str( + result["content"][0]["text"] + .as_str() + .expect("concealed impact text"), + ) + .expect("concealed impact envelope"); + let request_id = envelope["request_id"] + .as_str() + .expect("request id") + .to_owned(); + assert!( + request_id.starts_with("request."), + "daemon-minted request id: {request_id}" + ); + assert_eq!( + envelope["contract"], + json!({ + "schema_id": IMPACT_RESULT_SCHEMA, + "schema_revision": 1 + }) + ); + let problem = json!({ + "revision": 1, + "kind": "not_found_or_not_authorized", + "code": "not_found_or_not_authorized", + "message": "The requested resource was not found or is not authorized", + "diagnostic": null, + "committed_receipt": null, + "owning_layer": "application", + "terminality": "pre_admission", + "retryable": false, + "retry": "never", + "retry_scope": null, + "retry_after_millis": null, + "cancellation_stage": null, + "unavailable_classification": null, + "execution_failure_classification": null, + "request_id": request_id, + "trace_id": request_id, + "details": [], + "legal_actions": [], + "coverage": null + }); + assert_eq!(result["problem"], problem); + assert_eq!(envelope["problem"], problem); +} + +async fn wait_for_feedback_owner( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, +) -> JsonRpcResponse { + let deadline = Instant::now() + Duration::from_mins(1); + loop { + let response = harness + .call_tool( + project, + IMPACT_TOOL, + json!({ + "request_handle": "rh_000000000000000000000000", + "format": "json" + }), + ) + .await + .expect("absent impact handle"); + if response.error.is_none() + && response + .result + .as_ref() + .is_some_and(|result| result["problem"]["kind"] == "not_found_or_not_authorized") + { + return response; + } + let retryable_owner = response.result.as_ref().is_some_and(|result| { + result["problem"]["code"] == "feedback.owner_unavailable" + && result["problem"]["retryable"] == true + }); + assert!( + retryable_owner, + "an unknown impact handle must stay concealed once the owner is mounted, or stay retryably unavailable before that: {response:?}" + ); + assert!( + Instant::now() < deadline, + "feedback owner stayed unavailable: {response:?}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +async fn publish_advisory_cycle( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + document_uri: &str, +) -> Value { + let compiler_output = compiler_warning(project); + let deadline = Instant::now() + Duration::from_secs(90); + loop { + match publish_compiler_warning(harness, project, &compiler_output).await { + CompilerPublication::Published => {} + CompilerPublication::StillSettling(detail) => { + assert!( + Instant::now() < deadline, + "compiler diagnostics stayed unpublished: {detail}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + } + + let response = harness + .call_tool( + project, + "tracedecay_feedback_advisory_cycle", + json!({"document_uri": document_uri, "format": "json"}), + ) + .await + .expect("advisory cycle call"); + if response.error.is_none() + && response + .result + .as_ref() + .is_some_and(|result| result.get("isError") != Some(&json!(true))) + { + let payload = &successful_envelope(&response)["outcome"]["value"]["payload"]; + if payload["cycle"]["published"] == json!(true) + && payload["read_handles"]["impact_handle"].is_string() + { + return payload.clone(); + } + // The owner can answer before the compiler snapshot is the + // generation the cycle reads. An unpublished `daemon_unavailable` + // cycle is that window, not a successful impact proof. + assert_eq!( + payload["cycle"]["termination"], + json!("daemon_unavailable"), + "a settled cycle must publish the diagnostics handle: {payload}" + ); + } else { + let retryable = response.result.as_ref().is_some_and(|result| { + result["problem"]["code"] == "feedback.advisory-cycle.unavailable" + && result["problem"]["retryable"] == true + }); + assert!( + retryable, + "advisory cycle must publish or stay retryably unavailable: {response:?}" + ); + } + assert!( + Instant::now() < deadline, + "advisory cycle stayed unpublished: {response:?}" + ); + // The next attempt republishes against the generation the cycle is + // about to read, so a generation move cannot strand the warning. + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +enum CompilerPublication { + Published, + StillSettling(String), +} + +fn compiler_warning(project: &Path) -> String { + let output_dir = tempfile::TempDir::new().expect("compiler output dir"); + let output = Command::new("rustc") + .current_dir(project) + .args([ + "--crate-type=lib", + "--edition=2024", + "--emit=metadata", + "--color=never", + "src/lib.rs", + "--out-dir", + ]) + .arg(output_dir.path()) + .output() + .expect("run rustc"); + let stderr = String::from_utf8(output.stderr).expect("rustc stderr utf-8"); + assert!( + output.status.success(), + "rustc must compile the probe with a warning, not an error: {stderr}" + ); + assert!( + stderr.contains("unused variable: `unused_impact`"), + "the probe must emit the unused-variable warning the diagnostic store admits: {stderr}" + ); + stderr +} + +async fn publish_compiler_warning( + harness: &ProductionProjectCompositionHarnessV1, + project: &Path, + compiler_output: &str, +) -> CompilerPublication { + let response = harness + .call_tool( + project, + "tracedecay_diagnose", + json!({ + "cargo_output": compiler_output, + "include_callers": false, + "format": "json" + }), + ) + .await + .expect("diagnose call"); + if response.error.is_some() + || response + .result + .as_ref() + .is_some_and(|result| result["isError"] == json!(true)) + { + return CompilerPublication::StillSettling(format!("{response:?}")); + } + let body = tool_json(&response); + let status = body["published"]["status"].as_str().unwrap_or(""); + match status { + "published" => { + assert_eq!( + body["published"]["inserted"], + json!(1), + "one unused-variable warning must enter the diagnostic store: {body}" + ); + let diagnostics = body["diagnostics"] + .as_array() + .expect("diagnose diagnostics"); + assert!( + diagnostics.iter().any(|item| { + item["severity"] == json!("warning") + && item["message"] == json!("unused variable: `unused_impact`") + && item["file"] + .as_str() + .is_some_and(|file| file.ends_with("src/lib.rs")) + }), + "diagnose must report the compiler warning on src/lib.rs: {body}" + ); + CompilerPublication::Published + } + "skipped" | "failed" => CompilerPublication::StillSettling(body.to_string()), + _ => panic!("diagnose publication has no typed status: {body}"), + } +} + +fn tool_json(response: &JsonRpcResponse) -> Value { + assert!( + response.error.is_none(), + "tool call must not be a JSON-RPC error: {response:?}" + ); + let result = response.result.as_ref().expect("tool result"); + assert_ne!(result["isError"], json!(true), "{result}"); + serde_json::from_str( + result["content"][0]["text"] + .as_str() + .expect("tool result text"), + ) + .expect("tool result json") +} + +fn successful_envelope(response: &JsonRpcResponse) -> Value { + assert!( + response.error.is_none(), + "successful impact read must not be a JSON-RPC error: {response:?}" + ); + let result = response.result.as_ref().expect("tool result"); + assert_ne!(result["isError"], json!(true), "{result}"); + assert_eq!(result["content"][0]["type"], json!("text")); + serde_json::from_str( + result["content"][0]["text"] + .as_str() + .expect("impact result text"), + ) + .expect("impact result envelope") +} + +fn commit_project(project: &Path) { + let git = |arguments: &[&str]| { + let status = Command::new("git") + .current_dir(project) + .args(arguments) + .status() + .expect("run git"); + assert!(status.success(), "git {arguments:?}"); + }; + git(&["init", "--quiet", "-b", "master"]); + git(&["add", "."]); + git(&[ + "-c", + "user.name=TraceDecay Test", + "-c", + "user.email=tracedecay@example.invalid", + "commit", + "--quiet", + "-m", + "test: seed feedback impact", + ]); +} + +fn git_head(project: &Path) -> String { + let output = Command::new("git") + .current_dir(project) + .args(["rev-parse", "HEAD"]) + .output() + .expect("read HEAD"); + assert!(output.status.success(), "git rev-parse HEAD"); + String::from_utf8(output.stdout) + .expect("HEAD utf-8") + .trim() + .to_owned() +} diff --git a/crates/tracedecay/tests/mcp_suite/analytics_test.rs b/crates/tracedecay/tests/mcp_suite/analytics_test.rs index 26f2bc7c81..93da6e3b5b 100644 --- a/crates/tracedecay/tests/mcp_suite/analytics_test.rs +++ b/crates/tracedecay/tests/mcp_suite/analytics_test.rs @@ -8,9 +8,11 @@ use serde_json::json; #[cfg(feature = "test-transport")] use crate::support::{ extract_json, extract_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, - production_composition_fixture, real_mcp_server, setup_empty_project, + production_composition_fixture, }; #[cfg(feature = "test-transport")] +use serde_json::Value; +#[cfg(feature = "test-transport")] use tracedecay::project::current_timestamp; #[cfg(feature = "test-transport")] use tracedecay::test_support::host_admission::HostAdmissionTestRuntimeV1; @@ -65,6 +67,27 @@ fn hint_event( } } +#[cfg(feature = "test-transport")] +fn metric<'a>(metrics: &'a Value, name: &str) -> &'a Value { + metrics + .as_array() + .unwrap_or_else(|| panic!("expected a metrics array, got {metrics}")) + .iter() + .find(|metric| metric["metric"].as_str() == Some(name)) + .unwrap_or_else(|| panic!("missing metric {name} in {metrics}")) +} + +#[cfg(feature = "test-transport")] +fn ledger_row(run_id: &str, task: &str, status: &str, started_at: i64) -> String { + format!( + "{{\"schema_version\":2,\"run_id\":\"{run_id}\",\"trigger\":\"scheduler\",\ + \"task\":\"{task}\",\"backend\":\"codex_app_server\",\"status\":\"{status}\",\ + \"accepted_count\":0,\"rejected_count\":0,\"started_at\":\"{started_at}\",\ + \"completed_at\":\"{started_at}\",\"completed_at_micros\":{}}}", + started_at.saturating_mul(1_000_000) + ) +} + #[cfg(feature = "test-transport")] #[tokio::test] async fn analytics_reports_tool_tiers_top_tools_and_zero_call_tools() { @@ -96,22 +119,23 @@ async fn analytics_reports_tool_tiers_top_tools_and_zero_call_tools() { handle_real_server_tool_call(&server, "tracedecay_analytics", json!({"format": "json"})) .await; let payload = extract_json(&json_res); - assert!( - payload["observatory"]["metrics"] - .as_array() - .is_some_and(|metrics| !metrics.is_empty()), - "MCP analytics must expose canonical Observatory values and coverage" - ); - assert!( - payload["costs"]["usage"] - .as_array() - .is_some_and(|metrics| !metrics.is_empty()), - "MCP analytics must expose canonical Costs values and coverage" - ); - assert!( - payload["observatory"]["metrics"][0]["coverage"]["state"].is_string(), - "MCP Observatory metrics must retain typed coverage" + // MCP tool calls are not observability envelopes, so the observatory read + // stays the empty known window even after grep and fact-store calls. + let observed_events = metric(&payload["observatory"]["metrics"], "observability_events"); + assert_eq!(payload["observatory"]["watermark"], "analytics:empty"); + assert_eq!(payload["observatory"]["current"], true); + assert_eq!(observed_events["value"].as_f64(), Some(0.0)); + assert_eq!(observed_events["unit"], "events"); + assert_eq!(observed_events["coverage"]["state"], "known"); + assert_eq!(observed_events["coverage"]["observed"].as_u64(), Some(0)); + assert_eq!(observed_events["coverage"]["eligible"].as_u64(), Some(0)); + let provider_tokens = metric(&payload["costs"]["usage"], "provider_tokens"); + assert!(provider_tokens["value"].is_null()); + assert_eq!( + provider_tokens["unavailable_reason"], + "provider_usage_unavailable" ); + assert_eq!(provider_tokens["coverage"]["state"], "unknown"); let tools = &payload["tools"]; assert_eq!(tools["available"].as_bool(), Some(true)); assert_eq!(tools["raw_distinct_event_name_count"].as_i64(), Some(2)); @@ -189,8 +213,23 @@ async fn analytics_reports_tool_tiers_top_tools_and_zero_call_tools() { #[cfg(feature = "test-transport")] #[tokio::test] async fn analytics_section_filter_returns_only_the_requested_section() { - let (cg, _env, _dir) = setup_empty_project().await; - let server = real_mcp_server(cg).await; + let fixture = production_composition_fixture().await; + let project_id = HostAdmissionTestRuntimeV1::canonical_project_key(&fixture.project_root); + let timestamp = current_timestamp() - 60; + fixture + .harness + .append_profile_analytics_events_for_test(&[tool_call_event( + &project_id, + "tracedecay_grep", + "error", + timestamp, + )]) + .await + .expect("seeding one tool call should succeed"); + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); let res = handle_real_server_tool_call( &server, @@ -199,16 +238,32 @@ async fn analytics_section_filter_returns_only_the_requested_section() { ) .await; let payload = extract_json(&res); - assert!( - payload.get("tools").is_some(), - "tools section missing: {payload}" + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["scope"], "project"); + assert_eq!(payload["window_days"].as_i64(), Some(14)); + assert_eq!(payload["event_count"].as_i64(), Some(1)); + assert_eq!(payload["event_count_truncated"], false); + assert_eq!( + payload["tools"]["top_tools"], + json!([{ + "tool_name": "tracedecay_grep", + "tier": "navigation", + "calls": 1, + "errors": 1, + }]) + ); + assert_eq!( + payload["tools"]["tiers"], + json!([{"tier": "navigation", "calls": 1, "errors": 1}]) ); for unrelated in ["hints", "facts", "automation", "observatory", "costs"] { assert!( payload.get(unrelated).is_none(), - "sectioned analytics unexpectedly included {unrelated}" + "sectioned analytics unexpectedly included {unrelated}: {payload}" ); } + drop(server); + fixture.harness.shutdown().await; } #[cfg(feature = "test-transport")] @@ -226,10 +281,11 @@ async fn analytics_rejects_unknown_scope_and_section() { json!({"scope": "bogus"}), ) .await; - let message = response["error"]["message"] - .as_str() - .expect("unknown scope must return a JSON-RPC error message"); - assert!(message.contains("scope"), "unexpected error: {response}"); + assert_eq!(response["error"]["code"].as_i64(), Some(-32603)); + assert_eq!( + response["error"]["message"], + "tool execution failed: config error: unknown scope for tracedecay_analytics: bogus (use 'project' or 'all')" + ); let response = handle_real_server_tool_call_raw( &server, @@ -237,10 +293,11 @@ async fn analytics_rejects_unknown_scope_and_section() { json!({"section": "bogus"}), ) .await; - let message = response["error"]["message"] - .as_str() - .expect("unknown section must return a JSON-RPC error message"); - assert!(message.contains("section"), "unexpected error: {response}"); + assert_eq!(response["error"]["code"].as_i64(), Some(-32603)); + assert_eq!( + response["error"]["message"], + "tool execution failed: config error: unknown section for tracedecay_analytics: bogus (use 'tools', 'hints', 'facts', or 'automation')" + ); drop(server); fixture.harness.shutdown().await; } @@ -259,28 +316,209 @@ async fn analytics_degrades_gracefully_for_a_zero_data_project() { .await; let payload = extract_json(&res); + assert_eq!(payload["status"], "ok"); + assert_eq!(payload["scope"], "project"); + assert_eq!(payload["window_days"].as_i64(), Some(14)); assert_eq!(payload["event_count"].as_i64(), Some(0)); + assert_eq!(payload["event_count_truncated"], false); assert_eq!(payload["tools"]["available"].as_bool(), Some(false)); + assert_eq!( + payload["tools"]["raw_distinct_event_name_count"].as_i64(), + Some(0) + ); + assert_eq!(payload["tools"]["tiers"], json!([])); + assert_eq!(payload["tools"]["top_tools"], json!([])); + + let observed_events = metric(&payload["observatory"]["metrics"], "observability_events"); + assert_eq!(payload["observatory"]["watermark"], "analytics:empty"); + assert_eq!(payload["observatory"]["current"], true); + assert_eq!(observed_events["value"].as_f64(), Some(0.0)); + assert_eq!(observed_events["coverage"]["state"], "known"); + assert_eq!(observed_events["coverage"]["observed"].as_u64(), Some(0)); + assert_eq!(payload["costs"]["watermark"], "provider-usage:0;savings:0"); + assert_eq!(payload["costs"]["current"], false); + let saved_tokens = metric(&payload["costs"]["usage"], "saved_tokens"); + assert_eq!(saved_tokens["value"].as_f64(), Some(0.0)); + assert_eq!(saved_tokens["unit"], "tokens"); + assert_eq!(saved_tokens["coverage"]["state"], "known"); + assert_eq!(saved_tokens["coverage"]["observed"].as_u64(), Some(0)); + assert_eq!(saved_tokens["coverage"]["eligible"].as_u64(), Some(0)); + let provider_tokens = metric(&payload["costs"]["usage"], "provider_tokens"); + assert!(provider_tokens["value"].is_null()); + assert_eq!( + provider_tokens["unavailable_reason"], + "provider_usage_unavailable" + ); + assert_eq!(provider_tokens["coverage"]["state"], "unknown"); + let provider_cost = metric(&payload["costs"]["estimated_cost"], "provider_cost"); + assert!(provider_cost["value"].is_null()); + assert_eq!( + provider_cost["unavailable_reason"], + "provider_usage_unavailable" + ); // Hints are computed from the same (empty) durable event window: a real // zero, not an error. assert_eq!(payload["hints"]["available"].as_bool(), Some(true)); - let by_category = payload["hints"]["by_category"] + assert_eq!(payload["hints"]["source"], "analytics_events"); + let search = payload["hints"]["by_category"] .as_array() - .expect("by_category array"); - assert!( - by_category - .iter() - .all(|row| row["emitted"].as_i64() == Some(0)), - "expected zero hint counts for an empty window: {by_category:?}" + .expect("by_category array") + .iter() + .find(|row| row["category"] == "search") + .expect("search hint category"); + assert_eq!( + search, + &json!({ + "category": "search", + "emitted": 0, + "followed": 0, + "ignored": 0, + "suppressed": 0, + }) ); // The fact-store funnel and automation ledger resolve to real, empty // data for a freshly initialized project rather than failing. - assert_eq!(payload["facts"]["available"].as_bool(), Some(true)); - assert_eq!(payload["facts"]["facts"].as_i64(), Some(0)); - assert_eq!(payload["automation"]["available"].as_bool(), Some(true)); + let project_root = payload["project_root"] + .as_str() + .expect("project root") + .to_string(); + assert_eq!( + payload["facts"], + json!({ + "available": true, + "project_root": project_root, + "facts": 0, + "retrievals": 0, + "facts_retrieved": 0, + "helpful_feedback": 0, + "unhelpful_feedback": 0, + "facts_rated": 0, + }) + ); + assert_eq!(payload["automation"]["available"], true); + assert_eq!( + payload["automation"]["records_considered"].as_i64(), + Some(0) + ); assert_eq!(payload["automation"]["records_in_window"].as_i64(), Some(0)); + assert_eq!(payload["automation"]["records_truncated"], false); + assert_eq!(payload["automation"]["by_job"], json!([])); + let dashboard_root = payload["automation"]["dashboard_root"] + .as_str() + .expect("enrolled dashboard root") + .to_string(); + + let fact_content = "Analytics fact funnel records one committed fact."; + let added = handle_real_server_tool_call( + &server, + "tracedecay_fact_store_add", + json!({ + "content": fact_content, + "category": "decision", + "entities": ["analytics fact funnel"], + "trust": 0.94, + "source_label": "analytics-fact-funnel" + }), + ) + .await; + let added = extract_json(&added); + assert_eq!(added["outcome"], "committed"); + assert_eq!(added["result"]["disposition"], "added"); + assert_eq!(added["result"]["fact"]["fact"]["content"], fact_content); + + let dashboard_path = std::path::PathBuf::from(&dashboard_root); + std::fs::create_dir_all(&dashboard_path).expect("dashboard root"); + let now = current_timestamp(); + let ledger = format!( + "{}\n{}\n{}\n{}\n", + ledger_row( + "analytics-recent-success", + "memory_curator", + "succeeded", + now - 60 + ), + ledger_row( + "analytics-recent-failure", + "skill_writer", + "failed", + now - 120 + ), + ledger_row("analytics-recent-queued", "user_job", "queued", now - 180), + ledger_row( + "analytics-stale-success", + "session_reflector", + "succeeded", + now - 20 * 86_400 + ), + ); + std::fs::write(dashboard_path.join("automation_runs.jsonl"), ledger) + .expect("automation ledger"); + + let facts = extract_json( + &handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"section": "facts", "format": "json"}), + ) + .await, + ); + assert_eq!( + facts["facts"], + json!({ + "available": true, + "project_root": project_root, + "facts": 1, + "retrievals": 0, + "facts_retrieved": 0, + "helpful_feedback": 0, + "unhelpful_feedback": 0, + "facts_rated": 0, + }) + ); + + let automation = extract_json( + &handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"section": "automation", "format": "json"}), + ) + .await, + ); + assert_eq!( + automation["automation"], + json!({ + "available": true, + "dashboard_root": dashboard_root, + "records_considered": 4, + "records_in_window": 3, + "records_truncated": false, + "by_job": [ + { + "job": "memory_curator", + "succeeded": 1, + "failed": 0, + "skipped": 0, + "other": 0, + }, + { + "job": "skill_writer", + "succeeded": 0, + "failed": 1, + "skipped": 0, + "other": 0, + }, + { + "job": "user_job", + "succeeded": 0, + "failed": 0, + "skipped": 0, + "other": 1, + }, + ], + }) + ); drop(server); fixture.harness.shutdown().await; @@ -298,9 +536,17 @@ async fn analytics_degrades_gracefully_for_a_zero_data_project() { .await; let text = extract_text(&md_res); assert!( - text.contains("No MCP tool calls recorded"), + text.contains("_No MCP tool calls recorded in this window._"), "expected an empty-state note in markdown: {text}" ); + assert!( + text.contains("**window_days:** 14"), + "expected the default window in markdown: {text}" + ); + assert!( + text.contains("**event_count:** 0"), + "expected the empty event count in markdown: {text}" + ); drop(markdown_server); markdown_fixture.harness.shutdown().await; } @@ -448,6 +694,7 @@ async fn analytics_reconciles_public_catalog_with_alias_internal_and_unknown_or_ ]) ); + server.ledger_writes_settled().await; let markdown = handle_real_server_tool_call( &server, "tracedecay_analytics", @@ -455,17 +702,25 @@ async fn analytics_reconciles_public_catalog_with_alias_internal_and_unknown_or_ ) .await; let text = extract_text(&markdown); - for heading in [ - "raw distinct event names", - "called available defined tools", - "available defined tool count", - "maximal defined tool count", - "Aliased Call Names", - "Bound Internal Call Names", - "Unavailable Public Call Names", - "Unknown or Retired Call Names", + for line in [ + "**event_count:** 6", + "**raw distinct event names:** 6", + "**called available defined tools:** 3", + "- **navigation** - 3 calls, 1 errors", + "- **admin** - 1 calls, 0 errors", + "- **other** - 2 calls, 1 errors", + "- **tracedecay_grep** (navigation) - 2 calls, 1 errors", + "- **tracedecay_analytics** (admin) - 1 calls, 0 errors", + "- **tracedecay_admin_cli** (other) - 1 calls, 0 errors", + "- **tracedecay_context** (navigation) - 1 calls, 0 errors", + "- **tracedecay_removed_tool** (other) - 1 calls, 1 errors", + "- **grep** → **tracedecay_grep** - 1 calls, 1 errors", + "- **mcp__tracedecay__tracedecay_context** → **tracedecay_context** - 1 calls, 0 errors", + "- **tracedecay_admin_cli** - 1 calls, 0 errors", + "- **tracedecay_removed_tool** - 1 calls, 1 errors", + "#### Unavailable Public Call Names\n_None._", ] { - assert!(text.contains(heading), "missing {heading}: {text}"); + assert!(text.contains(line), "missing `{line}` in:\n{text}"); } drop(server); @@ -545,6 +800,7 @@ async fn analytics_aggregates_sections_before_any_event_sample_cap() { let events = vec![ hint_event(&project_id, "hint_emitted", None, timestamp), hint_event(&project_id, "hint_outcome", Some("acted"), timestamp), + hint_event(&project_id, "hint_outcome", Some("ignored"), timestamp), hint_event(&project_id, "suppressed_duplicate", None, timestamp), tool_call_event(&project_id, "tracedecay_grep", "ok", timestamp), ]; @@ -582,7 +838,7 @@ async fn analytics_aggregates_sections_before_any_event_sample_cap() { handle_real_server_tool_call(&server, "tracedecay_analytics", json!({"format": "json"})) .await; let payload = extract_json(&response); - assert_eq!(payload["event_count"].as_i64(), Some(10_005)); + assert_eq!(payload["event_count"].as_i64(), Some(10_006)); assert_eq!(payload["event_count_truncated"].as_bool(), Some(false)); let search = payload["hints"]["by_category"] .as_array() @@ -590,9 +846,16 @@ async fn analytics_aggregates_sections_before_any_event_sample_cap() { .iter() .find(|row| row["category"] == "search") .expect("search hint category"); - assert_eq!(search["emitted"].as_i64(), Some(1)); - assert_eq!(search["followed"].as_i64(), Some(1)); - assert_eq!(search["suppressed"].as_i64(), Some(1)); + assert_eq!( + search, + &json!({ + "category": "search", + "emitted": 1, + "followed": 1, + "ignored": 1, + "suppressed": 1, + }) + ); assert_eq!( payload["tools"]["raw_distinct_event_name_count"].as_i64(), @@ -607,6 +870,286 @@ async fn analytics_aggregates_sections_before_any_event_sample_cap() { "tracedecay_grep" ); assert_eq!(payload["tools"]["top_tools"][0]["calls"].as_i64(), Some(1)); + + let markdown = handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"section": "hints", "format": "markdown"}), + ) + .await; + let text = extract_text(&markdown); + assert!( + text.contains("- **search** - emitted 1, followed 1, ignored 1, suppressed 1"), + "missing the search hint line in:\n{text}" + ); + drop(server); + fixture.harness.shutdown().await; +} + +#[cfg(feature = "test-transport")] +fn active_window_events(project_id: &str, now: i64) -> Vec { + vec![ + tool_call_event(project_id, "tracedecay_grep", "ok", now - 60), + tool_call_event( + "project.foreign.analytics", + "tracedecay_fact_store_list", + "error", + now - 60, + ), + tool_call_event(project_id, "tracedecay_dead_code", "ok", now - 20 * 86_400), + ] +} + +#[cfg(feature = "test-transport")] +#[tokio::test] +async fn analytics_keeps_foreign_and_stale_events_out_of_the_active_window() { + let fixture = production_composition_fixture().await; + let project_id = HostAdmissionTestRuntimeV1::canonical_project_key(&fixture.project_root); + let now = current_timestamp(); + fixture + .harness + .append_profile_analytics_events_for_test(&active_window_events(&project_id, now)) + .await + .expect("seeding mixed-scope analytics events should succeed"); + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + + let payload = extract_json( + &handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"section": "tools", "format": "json"}), + ) + .await, + ); + assert_eq!(payload["scope"], "project"); + assert_eq!(payload["project_id"], project_id); + assert_eq!(payload["window_days"].as_i64(), Some(14)); + assert_eq!(payload["event_count"].as_i64(), Some(1)); + assert_eq!( + payload["tools"]["tiers"], + json!([{"tier": "navigation", "calls": 1, "errors": 0}]) + ); + assert_eq!( + payload["tools"]["top_tools"], + json!([{ + "tool_name": "tracedecay_grep", + "tier": "navigation", + "calls": 1, + "errors": 0, + }]) + ); + drop(server); + fixture.harness.shutdown().await; +} + +#[cfg(feature = "test-transport")] +#[tokio::test] +async fn analytics_scope_all_counts_foreign_project_events_and_keeps_project_facts() { + let fixture = production_composition_fixture().await; + let project_id = HostAdmissionTestRuntimeV1::canonical_project_key(&fixture.project_root); + let now = current_timestamp(); + fixture + .harness + .append_profile_analytics_events_for_test(&active_window_events(&project_id, now)) + .await + .expect("seeding mixed-scope analytics events should succeed"); + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + + let payload = extract_json( + &handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"scope": "all", "window_days": 14, "format": "json"}), + ) + .await, + ); + assert_eq!(payload["scope"], "all"); + assert!(payload["project_id"].is_null()); + assert_eq!(payload["window_days"].as_i64(), Some(14)); + assert_eq!(payload["event_count"].as_i64(), Some(2)); + assert_eq!( + payload["tools"]["tiers"], + json!([ + {"tier": "navigation", "calls": 1, "errors": 0}, + {"tier": "memory", "calls": 1, "errors": 1}, + ]) + ); + assert_eq!( + payload["tools"]["top_tools"], + json!([ + { + "tool_name": "tracedecay_fact_store_list", + "tier": "memory", + "calls": 1, + "errors": 1, + }, + { + "tool_name": "tracedecay_grep", + "tier": "navigation", + "calls": 1, + "errors": 0, + }, + ]) + ); + assert_eq!(payload["facts"]["available"], true); + assert_eq!(payload["facts"]["facts"].as_i64(), Some(0)); + assert_eq!(payload["facts"]["project_root"], payload["project_root"]); + assert_eq!(payload["automation"]["records_in_window"].as_i64(), Some(0)); + drop(server); + fixture.harness.shutdown().await; +} + +#[cfg(feature = "test-transport")] +#[tokio::test] +async fn analytics_clamps_window_days_and_applies_the_clamped_window() { + let fixture = production_composition_fixture().await; + let project_id = HostAdmissionTestRuntimeV1::canonical_project_key(&fixture.project_root); + let now = current_timestamp(); + fixture + .harness + .append_profile_analytics_events_for_test(&active_window_events(&project_id, now)) + .await + .expect("seeding mixed-scope analytics events should succeed"); + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + + let wide = extract_json( + &handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"section": "tools", "window_days": 400, "format": "json"}), + ) + .await, + ); + assert_eq!(wide["window_days"].as_i64(), Some(365)); + assert_eq!(wide["event_count"].as_i64(), Some(2)); + assert_eq!( + wide["tools"]["top_tools"], + json!([ + { + "tool_name": "tracedecay_dead_code", + "tier": "analysis", + "calls": 1, + "errors": 0, + }, + { + "tool_name": "tracedecay_grep", + "tier": "navigation", + "calls": 1, + "errors": 0, + }, + ]) + ); + + server.ledger_writes_settled().await; + let narrow = extract_json( + &handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"section": "tools", "window_days": 0, "format": "json"}), + ) + .await, + ); + assert_eq!(narrow["window_days"].as_i64(), Some(1)); + assert_eq!(narrow["event_count"].as_i64(), Some(2)); + assert_eq!( + narrow["tools"]["top_tools"], + json!([ + { + "tool_name": "tracedecay_analytics", + "tier": "admin", + "calls": 1, + "errors": 0, + }, + { + "tool_name": "tracedecay_grep", + "tier": "navigation", + "calls": 1, + "errors": 0, + }, + ]) + ); + drop(server); + fixture.harness.shutdown().await; +} + +#[cfg(feature = "test-transport")] +#[tokio::test] +async fn analytics_limits_top_tools_to_the_ten_highest_call_counts() { + let fixture = production_composition_fixture().await; + let project_id = HostAdmissionTestRuntimeV1::canonical_project_key(&fixture.project_root); + let timestamp = current_timestamp() - 60; + let mut events = Vec::new(); + for index in 0..11 { + let calls = 11 - index; + let name = format!("tracedecay_rank_{index:02}"); + for call in 0..calls { + let outcome = if index == 0 && call == 0 { + "error" + } else { + "ok" + }; + events.push(tool_call_event(&project_id, &name, outcome, timestamp)); + } + } + fixture + .harness + .append_profile_analytics_events_for_test(&events) + .await + .expect("seeding ranked tool calls should succeed"); + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + + let payload = extract_json( + &handle_real_server_tool_call( + &server, + "tracedecay_analytics", + json!({"section": "tools", "format": "json"}), + ) + .await, + ); + let top_tools = payload["tools"]["top_tools"] + .as_array() + .expect("top_tools array"); + assert_eq!(top_tools.len(), 10); + assert_eq!( + top_tools[0], + json!({ + "tool_name": "tracedecay_rank_00", + "tier": "other", + "calls": 11, + "errors": 1, + }) + ); + assert_eq!( + top_tools[9], + json!({ + "tool_name": "tracedecay_rank_09", + "tier": "other", + "calls": 2, + "errors": 0, + }) + ); + assert!( + top_tools + .iter() + .all(|tool| tool["tool_name"] != "tracedecay_rank_10"), + "the eleventh tool by call volume must be omitted: {top_tools:?}" + ); + assert_eq!( + payload["tools"]["raw_distinct_event_name_count"].as_i64(), + Some(11) + ); drop(server); fixture.harness.shutdown().await; } diff --git a/crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs new file mode 100644 index 0000000000..01f8471d02 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/changelog_behavior_test.rs @@ -0,0 +1,393 @@ +use serde_json::{Value, json}; +use tracedecay::daemon::ProductionProjectCompositionHarnessV1; +use tracedecay_mcp::JsonRpcResponse; + +use super::support::{TestTempDir, test_temp_dir}; +use crate::common::fixture::GitFixture; + +struct ChangelogRepo { + harness: ProductionProjectCompositionHarnessV1, + project_root: std::path::PathBuf, + _isolation: TestTempDir, +} + +async fn open_repo(prepare: impl FnOnce(&GitFixture)) -> ChangelogRepo { + let isolation = test_temp_dir(); + let project_root = isolation.path().join("project"); + let fixture = GitFixture::primary(&project_root); + prepare(&fixture); + let harness = Box::pin(ProductionProjectCompositionHarnessV1::open( + isolation.path(), + vec![project_root.clone()], + )) + .await + .expect("production composition for changelog"); + ChangelogRepo { + harness, + project_root, + _isolation: isolation, + } +} + +async fn call_changelog(repo: &ChangelogRepo, arguments: Value) -> JsonRpcResponse { + repo.harness + .call_tool(&repo.project_root, "tracedecay_changelog", arguments) + .await + .expect("changelog tools/call") +} + +fn success_result(response: &JsonRpcResponse) -> &Value { + assert!( + response.error.is_none(), + "changelog must answer inside a tool result, not a JSON-RPC error: {:?}", + response.error + ); + response + .result + .as_ref() + .expect("changelog tools/call result") +} + +fn json_text(result: &Value) -> &str { + result["content"] + .as_array() + .and_then(|items| { + items.iter().find_map(|item| { + let text = item["text"].as_str()?; + text.trim_start().starts_with('{').then_some(text) + }) + }) + .unwrap_or_else(|| panic!("changelog JSON content missing from {result}")) +} + +fn payload(result: &Value) -> Value { + serde_json::from_str(json_text(result)).unwrap_or_else(|error| { + panic!( + "changelog JSON should parse: {error}\n{}", + json_text(result) + ) + }) +} + +fn observable_symbols(body: &Value, key: &str) -> Vec { + let mut rows = body[key] + .as_array() + .unwrap_or_else(|| panic!("{key} must be an array in {body}")) + .iter() + .map(|symbol| { + json!({ + "kind": symbol["kind"], + "qualified_name": symbol["qualified_name"], + "name": symbol["name"], + "file": symbol["file"], + "content_digest": symbol["content_digest"], + }) + }) + .collect::>(); + rows.sort_by(|left, right| { + left["qualified_name"] + .as_str() + .cmp(&right["qualified_name"].as_str()) + }); + rows +} + +#[tokio::test] +async fn changelog_rejects_missing_and_non_object_arguments() { + let repo = open_repo(|fixture| { + let root = fixture.root(); + std::fs::create_dir_all(root.join("src")).expect("src"); + std::fs::write(root.join("src/lib.rs"), "pub fn kept() {}\n").expect("source"); + fixture.commit_all("initial"); + }) + .await; + + let missing_from = call_changelog(&repo, json!({"to_ref": "HEAD", "format": "json"})).await; + let missing_from = missing_from + .error + .expect("missing from_ref is a JSON-RPC error"); + assert_eq!(missing_from.code, -32602); + assert_eq!(missing_from.message, "missing required parameter: from_ref"); + assert_eq!( + missing_from.data, + Some(json!({ + "tool": "tracedecay_changelog", + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: from_ref" + })) + ); + + let missing_to = call_changelog(&repo, json!({"from_ref": "HEAD", "format": "json"})).await; + let missing_to = missing_to + .error + .expect("missing to_ref is a JSON-RPC error"); + assert_eq!(missing_to.code, -32602); + assert_eq!(missing_to.message, "missing required parameter: to_ref"); + assert_eq!( + missing_to.data, + Some(json!({ + "tool": "tracedecay_changelog", + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: to_ref" + })) + ); + + let not_object = call_changelog(&repo, json!(["HEAD", "HEAD"])).await; + let not_object = not_object + .error + .expect("a non-object argument list is a JSON-RPC error"); + assert_eq!(not_object.code, -32603); + assert_eq!( + not_object.message, + "tool execution failed: config error: invalid arguments: tracedecay_changelog expects a JSON object" + ); + assert_eq!( + not_object.data, + Some(json!({ + "tool": "tracedecay_changelog", + "cli_fallback": "This tool is also available from the shell: `tracedecay tool changelog ...` (`tracedecay tool changelog --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." + })) + ); +} + +#[tokio::test] +async fn changelog_unknown_ref_is_a_typed_git_error() { + let repo = open_repo(|fixture| { + let root = fixture.root(); + std::fs::create_dir_all(root.join("src")).expect("src"); + std::fs::write(root.join("src/lib.rs"), "pub fn kept() {}\n").expect("source"); + fixture.commit_all("initial"); + }) + .await; + + let response = call_changelog( + &repo, + json!({ + "from_ref": "no-such-changelog-ref", + "to_ref": "HEAD", + "format": "json" + }), + ) + .await; + let result = success_result(&response); + assert_eq!(result["isError"], true); + let body = payload(result); + assert_eq!(body["error"]["kind"], "git"); + assert_eq!(body["error"]["operation"], "diff"); + let message = body["error"]["message"] + .as_str() + .unwrap_or_else(|| panic!("git error message missing: {body}")); + assert!( + message.starts_with("cannot resolve 'no-such-changelog-ref':"), + "unresolvable ref must name itself in the git error: {message}" + ); +} + +#[tokio::test] +async fn changelog_between_commits_lists_the_file_and_withholds_branch_symbols() { + let repo = open_repo(|fixture| { + let root = fixture.root(); + std::fs::create_dir_all(root.join("src")).expect("src"); + std::fs::write(root.join("src/lib.rs"), "pub fn original() {}\n").expect("source"); + fixture.commit_all("initial"); + std::fs::write( + root.join("src/lib.rs"), + "pub fn original() {}\npub fn added() {}\n", + ) + .expect("source"); + fixture.commit_all("add function"); + }) + .await; + + let response = call_changelog( + &repo, + json!({"from_ref": "HEAD~1", "to_ref": "HEAD", "format": "json"}), + ) + .await; + let result = success_result(&response); + assert!( + result.get("isError").is_none(), + "a revision-expression diff is a partial answer, not a tool error: {result}" + ); + assert_eq!( + json_text(result), + r#"{"changed_file_count":1,"changed_files":["src/lib.rs"],"from_ref":"HEAD~1","status":"partial","symbol_changes_coverage":{"reason":"exact_local_branch_required","retryable":false,"status":"unavailable"},"symbols_added":[],"symbols_modified":[],"symbols_removed":[],"to_ref":"HEAD"}"# + ); + + let markdown = call_changelog(&repo, json!({"from_ref": "HEAD~1", "to_ref": "HEAD"})).await; + let markdown = success_result(&markdown); + let rendered = markdown["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("default changelog text missing: {markdown}")); + assert_eq!( + rendered, + "\ +**changed_file_count:** 1 +**from_ref:** HEAD~1 +**status:** partial +**to_ref:** HEAD + +## changed_files +- src/lib.rs + +## symbol_changes_coverage +**reason:** exact_local_branch_required +**retryable:** false +**status:** unavailable +symbols_added: none +symbols_modified: none +symbols_removed: none +" + ); +} + +#[tokio::test] +async fn changelog_deleted_subtree_lists_only_the_removed_file() { + let repo = open_repo(|fixture| { + let root = fixture.root(); + std::fs::create_dir_all(root.join("crates/sub")).expect("subtree"); + std::fs::write(root.join("crates/sub/keep.rs"), "pub fn k() {}\n").expect("source"); + std::fs::write(root.join("main.rs"), "fn main() {}\n").expect("source"); + fixture.commit_all("initial"); + std::fs::remove_dir_all(root.join("crates")).expect("drop subtree"); + fixture.commit_all("drop crates"); + }) + .await; + + let response = call_changelog( + &repo, + json!({"from_ref": "HEAD~1", "to_ref": "HEAD", "format": "json"}), + ) + .await; + let result = success_result(&response); + assert_eq!( + json_text(result), + r#"{"changed_file_count":1,"changed_files":["crates/sub/keep.rs"],"from_ref":"HEAD~1","status":"partial","symbol_changes_coverage":{"reason":"exact_local_branch_required","retryable":false,"status":"unavailable"},"symbols_added":[],"symbols_modified":[],"symbols_removed":[],"to_ref":"HEAD"}"# + ); +} + +#[tokio::test] +async fn changelog_same_branch_tip_reports_no_changes() { + let repo = open_repo(|fixture| { + let root = fixture.root(); + std::fs::create_dir_all(root.join("src")).expect("src"); + std::fs::write(root.join("src/lib.rs"), "pub fn kept() {}\n").expect("source"); + fixture.commit_all("initial"); + }) + .await; + + let response = call_changelog( + &repo, + json!({"from_ref": "main", "to_ref": "main", "format": "json"}), + ) + .await; + let result = success_result(&response); + assert!( + result.get("isError").is_none(), + "identical tips are an empty changelog, not an error: {result}" + ); + let body = payload(result); + assert_eq!(body["status"], "complete"); + assert_eq!(body["from_ref"], "main"); + assert_eq!(body["to_ref"], "main"); + assert_eq!(body["changed_file_count"], 0); + assert_eq!(body["changed_files"], json!([])); + assert_eq!(body["symbols_added"], json!([])); + assert_eq!(body["symbols_removed"], json!([])); + assert_eq!(body["symbols_modified"], json!([])); + assert_eq!( + body["symbol_changes_coverage"], + json!({"status": "complete"}) + ); + let base = body["base_generation"] + .as_str() + .unwrap_or_else(|| panic!("identical tips must name the base generation: {body}")); + let head = body["head_generation"] + .as_str() + .unwrap_or_else(|| panic!("identical tips must name the head generation: {body}")); + assert_eq!(base, head, "the same tip is one generation: {body}"); +} + +#[tokio::test] +async fn changelog_between_local_branches_names_added_removed_and_modified_symbols() { + let repo = open_repo(|fixture| { + let root = fixture.root(); + std::fs::create_dir_all(root.join("src")).expect("src"); + std::fs::write( + root.join("src/lib.rs"), + "pub fn kept() {}\npub fn removed_fn() {}\npub fn changed_fn() { let _ = 1; }\n", + ) + .expect("base source"); + fixture.commit_all("initial"); + fixture.run(&["switch", "-c", "feature"]); + std::fs::write( + root.join("src/lib.rs"), + "pub fn kept() {}\npub fn changed_fn() { let _ = 2; }\npub fn added_fn() {}\n", + ) + .expect("feature source"); + fixture.commit_all("revise symbols"); + fixture.run(&["switch", "main"]); + }) + .await; + + let response = call_changelog( + &repo, + json!({"from_ref": "main", "to_ref": "feature", "format": "json"}), + ) + .await; + let result = success_result(&response); + assert!( + result.get("isError").is_none(), + "a local-branch changelog is an answer, not a tool error: {result}" + ); + let body = payload(result); + assert_eq!(body["status"], "complete", "{body}"); + assert_eq!(body["from_ref"], "main"); + assert_eq!(body["to_ref"], "feature"); + assert_eq!(body["changed_files"], json!(["src/lib.rs"])); + assert_eq!(body["changed_file_count"], 1); + assert_eq!( + body["symbol_changes_coverage"], + json!({"status": "complete"}) + ); + assert_ne!( + body["base_generation"].as_str().expect("base generation"), + body["head_generation"].as_str().expect("head generation"), + "different tips must not share a generation: {body}" + ); + assert_eq!( + observable_symbols(&body, "symbols_added"), + vec![json!({ + "kind": "function", + "qualified_name": "src/lib.rs::added_fn", + "name": "added_fn", + "file": "src/lib.rs", + "content_digest": "sha256:19b08a1214d48a2af703ce3ef9538939a8e5d08a55bd9a568e30263d2d8ae9a6", + })], + "{body}" + ); + assert_eq!( + observable_symbols(&body, "symbols_removed"), + vec![json!({ + "kind": "function", + "qualified_name": "src/lib.rs::removed_fn", + "name": "removed_fn", + "file": "src/lib.rs", + "content_digest": "sha256:d2609bdc15fd11af59b21c574e6c7a560b6ff1963e73c606b7df32e021a5a135", + })], + "{body}" + ); + assert_eq!( + observable_symbols(&body, "symbols_modified"), + vec![json!({ + "kind": "function", + "qualified_name": "src/lib.rs::changed_fn", + "name": "changed_fn", + "file": "src/lib.rs", + "content_digest": "sha256:faa06ddf52ace2f77e20545f60cbf1af7ca9325f216d43cee9206803af884ff3", + })], + "{body}" + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/main.rs b/crates/tracedecay/tests/mcp_suite/main.rs index d33e534a16..d3496bd5ea 100644 --- a/crates/tracedecay/tests/mcp_suite/main.rs +++ b/crates/tracedecay/tests/mcp_suite/main.rs @@ -17,6 +17,8 @@ mod common; mod analytics_test; +#[cfg(feature = "test-transport")] +mod changelog_behavior_test; mod context_relevance_eval_test; mod fixture; mod git_correlation_test; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs index 629adc480e..ec5f21ffcf 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test.rs @@ -6,18 +6,41 @@ mod admin_test; #[cfg(feature = "test-transport")] mod affected_tests_behavior_test; +#[cfg(feature = "test-transport")] +mod affected_tests_test; +#[cfg(feature = "test-transport")] +mod ast_grep_rewrite_behavior_test; mod automation_runs_test; mod bounded_analysis_test; #[cfg(feature = "test-transport")] mod branch_search_test; #[cfg(feature = "test-transport")] mod branch_sensitivity_test; +#[cfg(feature = "test-transport")] +mod configuration_unset_test; +mod context_behavior_test; mod context_test; +mod dependency_depth_test; mod dependency_hint_test; +mod derives_test; +mod diagnose_test; #[cfg(feature = "test-transport")] mod edit_test; +#[cfg(feature = "test-transport")] +mod fact_store_update_behavior_test; +#[cfg(feature = "test-transport")] +mod feedback_list_test; +#[cfg(feature = "test-transport")] +mod files_behavior_test; +mod find_exact_symbol_test; mod graph_analysis_test; mod graph_query_test; +mod grep_behavior_test; +#[cfg(feature = "test-transport")] +mod hermes_skill_bridge_test; +mod inheritance_depth_test; +#[cfg(feature = "test-transport")] +mod lcm_grep_behavior_test; mod lcm_test; #[cfg(feature = "test-transport")] mod memory_contradiction_contract_test; @@ -27,6 +50,15 @@ mod memory_facts_test; mod memory_feedback_test; #[cfg(feature = "test-transport")] mod move_symbol_test; +mod node_behavior_test; +#[cfg(feature = "test-transport")] +mod port_order_test; +#[cfg(feature = "test-transport")] +mod project_context_test; +#[cfg(feature = "test-transport")] +mod project_list_test; +#[cfg(feature = "test-transport")] +mod project_search_behavior_test; #[cfg(feature = "test-transport")] mod rename_symbol_test; mod retrieve_truncation_test; @@ -35,6 +67,8 @@ mod session_search_test; #[cfg(feature = "test-transport")] mod shell_dead_code_test; mod skills_automation_test; +#[cfg(feature = "test-transport")] +mod source_edit_rollback_test; mod status_runtime_test; #[cfg(feature = "test-transport")] mod test_map_test; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_test.rs new file mode 100644 index 0000000000..8ed7c57896 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/affected_tests_test.rs @@ -0,0 +1,347 @@ +//! `tracedecay_affected_tests` through the production MCP `tools/call` path. +//! +//! The tool does not search the graph itself. A host passes the handle minted +//! from a recorded feedback publication. This fixture's providers are all +//! unavailable, and the domain refuses to record that termination, so no +//! handle exists. The reachable tool answers are the refusals. The cycle +//! impact is the test list a later publication would project; symbol ids come +//! from `tracedecay_find_exact_symbol`, not from the cycle. + +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; +use tracedecay_mcp::jsonrpc::JsonRpcResponse; +use url::Url; + +use crate::support::{ + ProductionCompositionFixture, production_composition_fixture_with_sources, + truncated_response_handle, wait_for_current_graph, +}; + +const LIB: &str = r#"pub fn feedback_entry(input: u32) -> u32 { + feedback_public_replay_missing_symbol(input) +} + +#[cfg(test)] +mod tests { + fn support_helper() { + super::feedback_entry(0); + } + + #[test] + fn feedback_entry_test() { + assert_eq!(super::feedback_entry(1), 1); + } +} + +#[test] +fn root_feedback_entry_test() { + assert_eq!(feedback_entry(2), 2); +} + +#[test] +fn unrelated_test() { + let _ = 1 + 1; +} +"#; + +#[tokio::test] +async fn affected_tests_refuses_bad_handles_while_the_cycle_names_covering_tests() { + let fixture = production_composition_fixture_with_sources(|project| { + std::fs::create_dir_all(project.join("src")).unwrap(); + std::fs::write(project.join("src/lib.rs"), LIB).unwrap(); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production MCP server"); + wait_for_current_graph(&server).await; + + let invalid = call( + &fixture, + "tracedecay_affected_tests", + json!({"request_handle": " invalid", "format": "json"}), + ) + .await; + assert_invalid_handle(&invalid); + + let missing_field = call(&fixture, "tracedecay_affected_tests", json!({})).await; + assert_missing_handle_field(&missing_field); + + let inline = symbol_id(&fixture, "feedback_entry_test", "::feedback_entry_test").await; + let root = symbol_id( + &fixture, + "root_feedback_entry_test", + "::root_feedback_entry_test", + ) + .await; + let helper = symbol_id(&fixture, "support_helper", "::support_helper").await; + let unrelated = symbol_id(&fixture, "unrelated_test", "::unrelated_test").await; + + let document_uri = Url::from_file_path(fixture.project_root.join("src/lib.rs")) + .expect("advisory document URI") + .to_string(); + let published = published_cycle(&fixture, &document_uri).await; + let cycle = &published["cycle"]; + // A daemon-unavailable cycle is not a recordable publication, so the + // domain mints no handle. The tool's reachable answer is the typed + // refusal below; the tests it would project are the cycle impact. + assert_eq!(cycle["termination"], "daemon_unavailable"); + assert_eq!(cycle["published"], false); + assert_eq!(cycle["affected_tests_state"], "partial"); + assert!(published["read_handles"].is_null(), "{published}"); + let reported = cycle["impact"]["affected_tests"] + .as_array() + .expect("cycle affected_tests"); + assert!( + reported.iter().any(|id| id == &json!(inline)), + "feedback_entry_test must be attributed: {reported:?}" + ); + assert!( + reported.iter().any(|id| id == &json!(root)), + "root_feedback_entry_test must be attributed: {reported:?}" + ); + assert!( + reported.iter().any(|id| id == &json!(unrelated)), + "conservative attribution keeps unrelated_test: {reported:?}" + ); + assert!( + !reported.iter().any(|id| id == &json!(helper)), + "a non-test helper must not be an affected test: {reported:?}" + ); + + let unknown = call( + &fixture, + "tracedecay_affected_tests", + json!({"request_handle": "rh_missing-affected-tests"}), + ) + .await; + assert_unknown_handle_markdown(&unknown); + + fixture.harness.shutdown().await; +} + +fn error_str<'a>(error: &'a tracedecay_mcp::jsonrpc::JsonRpcError, field: &str) -> &'a str { + error + .data + .as_ref() + .and_then(|data| data.get(field)) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("{field} missing on tool error: {error:?}")) +} + +fn assert_invalid_handle(response: &JsonRpcResponse) { + let error = response + .error + .as_ref() + .expect("an untrimmed handle is a JSON-RPC error, not an empty success"); + assert_eq!(error.code, -32602); + assert_eq!(error_str(error, "tool"), "tracedecay_affected_tests"); + assert_eq!( + error_str(error, "reason_code"), + "application_surface_invalid_request" + ); + assert_eq!(error_str(error, "kind"), "invalid_request"); + assert_eq!( + error + .data + .as_ref() + .and_then(|data| data.get("retryable")) + .and_then(Value::as_bool), + Some(false) + ); + assert_eq!( + error_str(error, "detail"), + "application surface request handle is invalid" + ); + assert_eq!( + error.message, + "tool project route failed: reason_code=application_surface_invalid_request retryable=false: application surface request handle is invalid" + ); +} + +fn assert_missing_handle_field(response: &JsonRpcResponse) { + let error = response + .error + .as_ref() + .expect("omitting request_handle is a JSON-RPC error"); + assert_eq!(error.code, -32602); + assert_eq!(error_str(error, "tool"), "tracedecay_affected_tests"); + assert_eq!( + error_str(error, "reason_code"), + "application_surface_invalid_request" + ); + assert_eq!(error_str(error, "kind"), "invalid_request"); + assert_eq!( + error + .data + .as_ref() + .and_then(|data| data.get("retryable")) + .and_then(Value::as_bool), + Some(false) + ); + let detail = error + .data + .as_ref() + .and_then(|data| data.get("detail")) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("missing schema detail: {error:?}")); + assert!( + detail.contains("missing field `request_handle`"), + "the refusal must name the missing handle: {detail}" + ); +} + +fn assert_unknown_handle_markdown(response: &JsonRpcResponse) { + assert!( + response.error.is_none(), + "an unknown handle is a typed tool problem, not a transport error: {:?}", + response.error + ); + let result = response + .result + .as_ref() + .expect("unknown-handle tool result"); + assert_eq!(result["isError"], true, "{result}"); + let text = result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("unknown-handle markdown: {result}")); + assert!( + text.contains("## affected\\_tests"), + "default view must name the tool: {text}" + ); + assert!( + text.contains("- Problem: `not_found_or_not_authorized`"), + "{text}" + ); + assert!( + text.contains("- Message: The requested resource was not found or is not authorized"), + "{text}" + ); + assert!(text.contains("- Retryable: `false`"), "{text}"); + assert!(text.contains("- Retry: `never`"), "{text}"); +} + +async fn published_cycle(fixture: &ProductionCompositionFixture, document_uri: &str) -> Value { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + let response = call( + fixture, + "tracedecay_feedback_advisory_cycle", + json!({"document_uri": document_uri, "format": "json"}), + ) + .await; + if response.error.is_none() + && response + .result + .as_ref() + .is_some_and(|result| result["isError"] != true) + { + let result = response.result.expect("advisory result"); + let envelope = tool_json(fixture, &result).await; + let payload = envelope + .pointer("/outcome/value/payload") + .cloned() + .unwrap_or_else(|| panic!("advisory cycle returned no payload: {envelope}")); + assert_eq!(envelope["outcome"]["outcome"], "evidence"); + return payload; + } + let retryable = advisory_retryable(&response); + assert!( + retryable && Instant::now() < deadline, + "advisory cycle did not publish a readable cycle: {response:?}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +fn advisory_retryable(response: &JsonRpcResponse) -> bool { + if let Some(error) = &response.error { + return error + .data + .as_ref() + .is_some_and(|data| data["retryable"] == true); + } + let Some(result) = &response.result else { + return false; + }; + let Ok(body) = + serde_json::from_str::(result["content"][0]["text"].as_str().unwrap_or("")) + else { + return false; + }; + body["problem"]["code"] == "feedback.advisory-cycle.unavailable" + && body["problem"]["retryable"] == true +} + +async fn symbol_id( + fixture: &ProductionCompositionFixture, + name: &str, + qualified_suffix: &str, +) -> String { + let response = call( + fixture, + "tracedecay_find_exact_symbol", + json!({"name": name, "limit": 20, "format": "json"}), + ) + .await; + assert!(response.error.is_none(), "{:?}", response.error); + let result = response.result.expect("exact-symbol result"); + assert_ne!(result["isError"], true, "{result}"); + let payload = tool_json(fixture, &result).await; + payload["matches"] + .as_array() + .and_then(|matches| { + matches.iter().find(|item| { + item["name"] == name + && item["qualified_name"] + .as_str() + .is_some_and(|qualified| qualified.ends_with(qualified_suffix)) + }) + }) + .and_then(|item| item["id"].as_str()) + .unwrap_or_else(|| panic!("indexed symbol {name} ({qualified_suffix}) missing: {payload}")) + .to_owned() +} + +async fn call( + fixture: &ProductionCompositionFixture, + name: &str, + arguments: Value, +) -> JsonRpcResponse { + fixture + .harness + .call_tool(&fixture.project_root, name, arguments) + .await + .unwrap_or_else(|error| panic!("{name} MCP call failed: {error}")) +} + +async fn tool_json(fixture: &ProductionCompositionFixture, result: &Value) -> Value { + let text = result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("tool text missing: {result}")); + let text = if let Some(handle) = truncated_response_handle(text) { + let retrieved = call( + fixture, + "tracedecay_retrieve", + json!({"handle": handle, "format": "json"}), + ) + .await; + assert!(retrieved.error.is_none(), "{:?}", retrieved.error); + let retrieved = retrieved.result.expect("retrieve result"); + let record: Value = serde_json::from_str( + retrieved["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("retrieve text missing: {retrieved}")), + ) + .unwrap_or_else(|error| panic!("retrieve JSON: {error}; {retrieved}")); + record["content"] + .as_str() + .unwrap_or_else(|| panic!("retrieve did not restore the tool text: {record}")) + .to_owned() + } else { + text.to_owned() + }; + serde_json::from_str(&text).unwrap_or_else(|error| panic!("tool JSON: {error}; {text}")) +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs new file mode 100644 index 0000000000..47c5157040 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/ast_grep_rewrite_behavior_test.rs @@ -0,0 +1,615 @@ +use crate::support::{ + ProductionSourceEditFixture, TestTempDir, expect_tool_error, extract_first_json_content, + init_production_source_edit_project, test_temp_dir, +}; +use serde_json::{Value, json}; +use std::fs; +use tracedecay_mcp::ToolResult; + +const STALE_STATE: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000"; +const PATTERN: &str = "reserve_stock($SKU, $QTY)"; +const REWRITE: &str = "reserve_stock($QTY, $SKU)"; +const CHECKOUT: &str = "src/checkout.rs"; +const OTHER: &str = "src/other.rs"; +const OTHER_SOURCE: &str = "fn other() {}\n"; + +const CHECKOUT_BEFORE: &str = "\ +fn caller() { + reserve_stock(sku, 0); + reserve_stock( + sku, + 1, + ); +} +fn reserve_stock(sku: &str, qty: i32) {} +// reserve_stock(sku, 0) stays in the comment +let label = \"reserve_stock(sku, 0)\"; +"; + +const CHECKOUT_AFTER: &str = "\ +fn caller() { + reserve_stock(0, sku); + reserve_stock(1, sku); +} +fn reserve_stock(sku: &str, qty: i32) {} +// reserve_stock(sku, 0) stays in the comment +let label = \"reserve_stock(sku, 0)\"; +"; + +const CHECKOUT_DIFF: &str = "\ +@@ -1,9 +1,6 @@ + fn caller() { +- reserve_stock(sku, 0); +- reserve_stock( +- sku, +- 1, +- ); ++ reserve_stock(0, sku); ++ reserve_stock(1, sku); + } + fn reserve_stock(sku: &str, qty: i32) {} + // reserve_stock(sku, 0) stays in the comment"; + +const NO_MATCH_MESSAGE: &str = "ast-grep failed (exit 1). stdout: []"; +const OPERATION: &str = "use-case.application.source-edit.ast-grep-rewrite"; + +fn require_ast_grep() { + assert!( + tracedecay_mcp::ast_grep_available(), + "structural rewrite proof requires the host ast-grep CLI" + ); +} + +async fn open_project(files: &[(&str, &str)]) -> (ProductionSourceEditFixture, TestTempDir) { + let dir = test_temp_dir(); + let project = dir.path().join("project"); + for (relative, contents) in files { + let path = project.join(relative); + fs::create_dir_all(path.parent().expect("fixture file has a parent")).unwrap(); + fs::write(&path, contents).unwrap(); + } + let (fixture, _) = init_production_source_edit_project(&project).await; + (fixture, dir) +} + +async fn call_rewrite( + fixture: &ProductionSourceEditFixture, + args: Value, +) -> tracedecay_domain::errors::Result { + let mut args = args; + if let Some(object) = args.as_object_mut() { + object + .entry("format".to_owned()) + .or_insert_with(|| json!("json")); + } + fixture + .harness + .server(&fixture.project_root) + .expect("registered source-edit server") + .call_tool_for_test("tracedecay_ast_grep_rewrite", args) + .await +} + +fn edit_json(result: &ToolResult) -> Value { + extract_first_json_content(&result.value) +} + +fn assert_file(fixture: &ProductionSourceEditFixture, relative: &str, contents: &str) { + let actual = fs::read(fixture.project_root.join(relative)).unwrap(); + assert_eq!( + actual, + contents.as_bytes(), + "bytes of {relative} after tracedecay_ast_grep_rewrite" + ); +} + +fn assert_effect(parsed: &Value, outcome: &str, payload_success: bool, payload_message: &str) { + assert_eq!( + parsed["effect"]["effect_class"], + json!("source_edit"), + "{parsed}" + ); + assert_eq!( + parsed["effect"]["receipt"]["outcome"], + json!(outcome), + "{parsed}" + ); + assert_eq!( + parsed["effect"]["payload"]["operation"], + json!(OPERATION), + "{parsed}" + ); + assert_eq!( + parsed["effect"]["payload"]["files"], + json!([CHECKOUT]), + "{parsed}" + ); + assert_eq!( + parsed["effect"]["payload"]["success"], + json!(payload_success), + "{parsed}" + ); + assert_eq!( + parsed["effect"]["payload"]["durable_metadata_only"], + json!(true), + "{parsed}" + ); + assert_eq!( + parsed["effect"]["payload"]["message"], + json!(payload_message), + "{parsed}" + ); +} + +#[tokio::test] +async fn ast_grep_rewrite_dry_run_then_apply_swaps_every_call_and_leaves_the_rest() { + require_ast_grep(); + let (fixture, _dir) = open_project(&[(CHECKOUT, CHECKOUT_BEFORE), (OTHER, OTHER_SOURCE)]).await; + + let preview = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": REWRITE, + "dry_run": true + }), + ) + .await + .expect("dry run"); + assert_eq!(preview.semantic_error(), Some(false), "{preview:?}"); + assert!(preview.touched_files.is_empty(), "{preview:?}"); + let preview_json = edit_json(&preview); + assert_eq!(preview_json["success"], json!(true), "{preview_json}"); + assert_eq!(preview_json["file_path"], json!(CHECKOUT), "{preview_json}"); + assert_eq!(preview_json["pattern"], json!(PATTERN), "{preview_json}"); + assert_eq!(preview_json["rewrite"], json!(REWRITE), "{preview_json}"); + assert_eq!(preview_json["dry_run"], json!(true), "{preview_json}"); + assert_eq!(preview_json["diff"], json!(CHECKOUT_DIFF), "{preview_json}"); + assert_eq!( + preview_json["message"], + json!("dry run. Nothing written; preview only (ast-grep rewrite completed)"), + "{preview_json}" + ); + assert_eq!(preview_json["replayed"], json!(false), "{preview_json}"); + assert_effect( + &preview_json, + "completed", + true, + "source edit completed; detailed edit output was not retained", + ); + let expected_state = preview_json["expected_state"] + .as_str() + .expect("preview expected_state") + .to_owned(); + let predicted_state = preview_json["predicted_state"] + .as_str() + .expect("preview predicted_state") + .to_owned(); + assert_ne!( + expected_state, predicted_state, + "a real rewrite must change the candidate digest" + ); + assert_file(&fixture, CHECKOUT, CHECKOUT_BEFORE); + assert_file(&fixture, OTHER, OTHER_SOURCE); + + let applied = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": REWRITE, + "idempotency_key": "ast-grep-rewrite.behavior.apply", + "expected_state": expected_state + }), + ) + .await + .expect("apply"); + assert_eq!(applied.semantic_error(), Some(false), "{applied:?}"); + assert_eq!( + applied.touched_files, + vec![CHECKOUT.to_owned()], + "{applied:?}" + ); + let applied_json = edit_json(&applied); + assert_eq!(applied_json["success"], json!(true), "{applied_json}"); + assert_eq!(applied_json["file_path"], json!(CHECKOUT), "{applied_json}"); + assert_eq!(applied_json["pattern"], json!(PATTERN), "{applied_json}"); + assert_eq!(applied_json["rewrite"], json!(REWRITE), "{applied_json}"); + assert!(applied_json.get("dry_run").is_none(), "{applied_json}"); + assert!(applied_json.get("diff").is_none(), "{applied_json}"); + assert_eq!( + applied_json["message"], + json!("ast-grep rewrite completed"), + "{applied_json}" + ); + assert_eq!(applied_json["replayed"], json!(false), "{applied_json}"); + assert_eq!( + applied_json["expected_state"], + json!(expected_state), + "{applied_json}" + ); + assert_eq!( + applied_json["predicted_state"], + json!(predicted_state), + "{applied_json}" + ); + assert_eq!( + applied_json["effect"]["receipt"]["expected_state"], + json!(expected_state), + "{applied_json}" + ); + assert_eq!( + applied_json["effect"]["receipt"]["committed_state"], + json!(predicted_state), + "{applied_json}" + ); + assert_effect( + &applied_json, + "completed", + true, + "source edit completed; detailed edit output was not retained", + ); + assert_file(&fixture, CHECKOUT, CHECKOUT_AFTER); + assert_file(&fixture, OTHER, OTHER_SOURCE); +} + +#[tokio::test] +async fn ast_grep_rewrite_exact_retry_replays_and_a_different_input_conflicts() { + require_ast_grep(); + let (fixture, _dir) = open_project(&[(CHECKOUT, CHECKOUT_BEFORE)]).await; + let preview = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": REWRITE, + "dry_run": true + }), + ) + .await + .expect("dry run"); + let preview_json = edit_json(&preview); + let expected_state = preview_json["expected_state"] + .as_str() + .expect("preview expected_state") + .to_owned(); + let first = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": REWRITE, + "idempotency_key": "ast-grep-rewrite.behavior.replay", + "expected_state": expected_state + }), + ) + .await + .expect("apply"); + assert_eq!(first.semantic_error(), Some(false), "{first:?}"); + assert_eq!(first.touched_files, vec![CHECKOUT.to_owned()], "{first:?}"); + let first_json = edit_json(&first); + assert_eq!( + first_json["message"], + json!("ast-grep rewrite completed"), + "{first_json}" + ); + assert_eq!(first_json["replayed"], json!(false), "{first_json}"); + assert_eq!(first_json["pattern"], json!(PATTERN), "{first_json}"); + let effect_id = first_json["effect"]["effect_id"].clone(); + let bound_state = first_json["expected_state"] + .as_str() + .expect("apply expected_state") + .to_owned(); + let predicted_state = first_json["predicted_state"] + .as_str() + .expect("apply predicted_state") + .to_owned(); + assert_file(&fixture, CHECKOUT, CHECKOUT_AFTER); + + let retry = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": REWRITE, + "idempotency_key": "ast-grep-rewrite.behavior.replay", + "expected_state": bound_state + }), + ) + .await + .expect("exact retry"); + assert_eq!(retry.semantic_error(), Some(false), "{retry:?}"); + assert!(retry.touched_files.is_empty(), "{retry:?}"); + let retry_json = edit_json(&retry); + assert_eq!(retry_json["success"], json!(true), "{retry_json}"); + assert_eq!(retry_json["failed"], json!(false), "{retry_json}"); + assert_eq!(retry_json["replayed"], json!(true), "{retry_json}"); + assert!( + retry_json.get("pattern").is_none(), + "a replay does not restate the live rewrite body: {retry_json}" + ); + assert_eq!( + retry_json["message"], + json!("source edit completed; detailed edit output was not retained"), + "{retry_json}" + ); + assert_eq!( + retry_json["expected_state"], + json!(bound_state), + "{retry_json}" + ); + assert_eq!( + retry_json["predicted_state"], + json!(predicted_state), + "{retry_json}" + ); + assert_eq!(retry_json["effect"]["effect_id"], effect_id, "{retry_json}"); + assert_eq!( + retry_json["effect"]["payload"]["operation"], + json!(OPERATION), + "{retry_json}" + ); + assert_eq!( + retry_json["effect"]["payload"]["files"], + json!([CHECKOUT]), + "{retry_json}" + ); + assert_file(&fixture, CHECKOUT, CHECKOUT_AFTER); + + let conflict = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": "reserve_stock($SKU, $QTY)", + "idempotency_key": "ast-grep-rewrite.behavior.replay", + "expected_state": bound_state + }), + ) + .await; + assert_eq!( + expect_tool_error(conflict), + "project route error (source_edit.idempotency_conflict): source edit idempotency key conflicts with a prior input" + ); + assert_file(&fixture, CHECKOUT, CHECKOUT_AFTER); +} + +#[tokio::test] +async fn ast_grep_rewrite_refuses_unmatched_patterns_paths_and_stale_previews() { + require_ast_grep(); + let (fixture, dir) = open_project(&[ + (CHECKOUT, CHECKOUT_BEFORE), + ("src/notes.txt", "reserve_stock(sku, 0);\n"), + ]) + .await; + + let unmatched = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": "missing_call($X)", + "rewrite": "gone($X)", + "dry_run": true + }), + ) + .await + .expect("unmatched pattern"); + assert_eq!(unmatched.semantic_error(), Some(true), "{unmatched:?}"); + assert_eq!( + unmatched.failure_message(), + Some(NO_MATCH_MESSAGE), + "{unmatched:?}" + ); + let unmatched_json = edit_json(&unmatched); + assert_eq!(unmatched_json["success"], json!(false), "{unmatched_json}"); + assert_eq!( + unmatched_json["file_path"], + json!(CHECKOUT), + "{unmatched_json}" + ); + assert_eq!( + unmatched_json["pattern"], + json!("missing_call($X)"), + "{unmatched_json}" + ); + assert_eq!( + unmatched_json["rewrite"], + json!("gone($X)"), + "{unmatched_json}" + ); + assert_eq!(unmatched_json["dry_run"], json!(true), "{unmatched_json}"); + assert!(unmatched_json.get("diff").is_none(), "{unmatched_json}"); + assert_eq!( + unmatched_json["message"], + json!(NO_MATCH_MESSAGE), + "{unmatched_json}" + ); + assert_eq!(unmatched_json["replayed"], json!(false), "{unmatched_json}"); + assert_effect( + &unmatched_json, + "failed", + false, + "source edit completed; detailed edit output was not retained", + ); + assert_eq!( + unmatched_json["effect"]["payload"]["failed"], + json!(false), + "{unmatched_json}" + ); + assert_file(&fixture, CHECKOUT, CHECKOUT_BEFORE); + + let prose = call_rewrite( + &fixture, + json!({ + "path": "src/notes.txt", + "pattern": "reserve_stock(sku, 0)", + "rewrite": "ship_stock(sku, 0)", + "dry_run": true + }), + ) + .await + .expect("extension without a parser"); + assert_eq!(prose.semantic_error(), Some(true)); + let prose_json = edit_json(&prose); + assert_eq!(prose_json["success"], json!(false), "{prose_json}"); + assert_eq!( + prose_json["file_path"], + json!("src/notes.txt"), + "{prose_json}" + ); + assert_eq!( + prose_json["pattern"], + json!("reserve_stock(sku, 0)"), + "{prose_json}" + ); + assert_eq!( + prose_json["rewrite"], + json!("ship_stock(sku, 0)"), + "{prose_json}" + ); + assert_eq!( + prose_json["message"], + json!(NO_MATCH_MESSAGE), + "{prose_json}" + ); + assert_file(&fixture, "src/notes.txt", "reserve_stock(sku, 0);\n"); + assert_file(&fixture, CHECKOUT, CHECKOUT_BEFORE); + + let outside = dir.path().join("secret.rs"); + fs::write(&outside, "fn secret() {}\n").unwrap(); + let escaped = call_rewrite( + &fixture, + json!({ + "path": "../secret.rs", + "pattern": "secret", + "rewrite": "leaked", + "dry_run": true + }), + ) + .await + .expect("path outside the worktree is a tool result"); + assert_eq!(escaped.semantic_error(), Some(true), "{escaped:?}"); + let escaped_json = edit_json(&escaped); + assert_eq!(escaped_json["success"], json!(false), "{escaped_json}"); + assert_eq!(escaped_json["failed"], json!(true), "{escaped_json}"); + assert_eq!( + escaped_json["message"], + json!("source edit failed before the effect: config error: path is not within the project"), + "{escaped_json}" + ); + assert_eq!(fs::read(&outside).unwrap(), b"fn secret() {}\n"); + + let missing = call_rewrite( + &fixture, + json!({ + "path": "src/missing.rs", + "pattern": "anything", + "rewrite": "else", + "dry_run": true + }), + ) + .await + .expect("missing file is a tool result"); + assert_eq!(missing.semantic_error(), Some(true)); + let missing_json = edit_json(&missing); + assert_eq!(missing_json["success"], json!(false), "{missing_json}"); + assert_eq!(missing_json["failed"], json!(true), "{missing_json}"); + assert_eq!( + missing_json["message"], + json!( + "source edit failed before the effect: config error: failed to read src/missing.rs: file was not found" + ), + "{missing_json}" + ); + + let directory = call_rewrite( + &fixture, + json!({ + "path": "src", + "pattern": "fn", + "rewrite": "pub fn", + "dry_run": true + }), + ) + .await + .expect("directory is a tool result"); + assert_eq!(directory.semantic_error(), Some(true)); + let directory_json = edit_json(&directory); + assert_eq!(directory_json["success"], json!(false), "{directory_json}"); + assert_eq!(directory_json["failed"], json!(true), "{directory_json}"); + assert_eq!( + directory_json["message"], + json!( + "source edit failed before the effect: config error: source edit path is not a regular file beneath the authorized worktree" + ), + "{directory_json}" + ); + + let missing_arg = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "dry_run": true + }), + ) + .await; + assert_eq!( + expect_tool_error(missing_arg), + "config error: missing required parameter: rewrite" + ); + + let unpreviewed = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": REWRITE + }), + ) + .await; + assert_eq!( + expect_tool_error(unpreviewed), + "config error: source edit apply requires a fresh idempotency_key and the expected_state returned by a preview" + ); + assert_file(&fixture, CHECKOUT, CHECKOUT_BEFORE); + + let stale = call_rewrite( + &fixture, + json!({ + "path": CHECKOUT, + "pattern": PATTERN, + "rewrite": REWRITE, + "idempotency_key": "ast-grep-rewrite.behavior.stale", + "expected_state": STALE_STATE + }), + ) + .await + .expect("stale apply returns a failed edit"); + assert_eq!(stale.semantic_error(), Some(true), "{stale:?}"); + let stale_json = edit_json(&stale); + assert_eq!(stale_json["success"], json!(false), "{stale_json}"); + assert_eq!(stale_json["failed"], json!(true), "{stale_json}"); + assert_eq!( + stale_json["message"], + json!("source edit failed before the effect"), + "{stale_json}" + ); + assert_eq!(stale_json["replayed"], json!(false), "{stale_json}"); + assert_eq!( + stale_json["effect"]["receipt"]["outcome"], + json!("failed"), + "{stale_json}" + ); + assert_eq!( + stale_json["effect"]["receipt"]["expected_state"], + json!(STALE_STATE), + "{stale_json}" + ); + assert!( + stale_json["effect"]["receipt"]["committed_state"].is_null(), + "{stale_json}" + ); + assert_file(&fixture, CHECKOUT, CHECKOUT_BEFORE); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/configuration_unset_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/configuration_unset_test.rs new file mode 100644 index 0000000000..5e54ca2739 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/configuration_unset_test.rs @@ -0,0 +1,363 @@ +//! `tracedecay_configuration_unset` through a production MCP `tools/call`. +//! +//! The effect is the next configuration read, not a dispatch trace. Unset +//! drops the project override, so the read's sole candidate is the registry +//! default `configuration.registry.default.v1`. A repeat of the same request +//! returns that receipt again and does not write another revision. + +use crate::support::{ + extract_real_server_text, handle_real_server_tool_call, production_composition_fixture, +}; +use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; + +const PREWARM_KEY: &str = "diagnostics.prewarm.v1"; +const UNSET_KEY: &str = "configuration.idempotency.mcp-unset-prewarm"; + +fn registry_default_candidate() -> Value { + json!({ + "layer": {"kind": "default"}, + "revision_id": "configuration.registry.default.v1", + "disposition": "defaulted", + "safe_reason": "registry_default" + }) +} + +fn boolean_value(value: bool) -> Value { + json!({"kind": "boolean", "value": value}) +} + +async fn call_mcp(server: &McpServer, tool: &str, arguments: Value) -> (Value, Value) { + let result = handle_real_server_tool_call(server, tool, arguments).await; + let parsed = serde_json::from_str(extract_real_server_text(&result)) + .unwrap_or_else(|error| panic!("{tool} returned invalid JSON ({error}): {result}")); + (result, parsed) +} + +fn payload<'a>(envelope: &'a Value, outcome: &str) -> &'a Value { + assert_eq!(envelope["outcome"]["outcome"], json!(outcome), "{envelope}"); + envelope + .pointer("/outcome/value/payload") + .unwrap_or_else(|| panic!("{outcome} envelope omitted its payload: {envelope}")) +} + +fn setting(server_result: &(Value, Value)) -> Value { + payload(&server_result.1, "evidence").clone() +} + +async fn read_prewarm(server: &McpServer) -> (Value, Value) { + call_mcp( + server, + "tracedecay_configuration_get", + json!({"key": PREWARM_KEY}), + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn configuration_unset_removes_the_project_override_and_replays() { + let production = production_composition_fixture().await; + let server = production + .harness + .server(&production.project_root) + .expect("production MCP server"); + let project_id = production + .harness + .project_id(&production.project_root) + .await + .expect("registered fixture project"); + + let before = setting(&read_prewarm(&server).await); + assert_eq!(before["key"], json!(PREWARM_KEY)); + assert_eq!(before["effective_value"], boolean_value(false)); + assert_eq!(before["candidates"], json!([registry_default_candidate()])); + let initial_revision = before["revision_id"] + .as_str() + .expect("initial configuration revision") + .to_owned(); + + let (set_result, set_envelope) = call_mcp( + &server, + "tracedecay_configuration_set", + json!({ + "layer": {"kind": "project", "project_id": project_id}, + "key": PREWARM_KEY, + "value": boolean_value(true), + "expected_revision": initial_revision, + "idempotency_key": "configuration.idempotency.mcp-unset-proof-set" + }), + ) + .await; + assert_eq!(set_result["isError"], Value::Null, "{set_envelope}"); + let set_payload = payload(&set_envelope, "effect"); + assert_eq!( + set_envelope["outcome"]["value"]["receipt"]["operation"], + json!("use-case.application.configuration.set") + ); + let override_revision = set_payload["result_revision_id"] + .as_str() + .expect("set result revision") + .to_owned(); + assert_ne!(override_revision, initial_revision); + + let overridden = setting(&read_prewarm(&server).await); + assert_eq!(overridden["effective_value"], boolean_value(true)); + assert_eq!(overridden["revision_id"], json!(override_revision)); + assert_eq!( + overridden["candidates"], + json!([{ + "layer": {"kind": "project", "project_id": project_id}, + "revision_id": override_revision, + "disposition": "winning", + "safe_reason": null + }]) + ); + + let (unset_result, unset_envelope) = call_mcp( + &server, + "tracedecay_configuration_unset", + json!({ + "layer": {"kind": "project", "project_id": project_id}, + "key": PREWARM_KEY, + "expected_revision": override_revision, + "idempotency_key": UNSET_KEY + }), + ) + .await; + assert_eq!(unset_result["isError"], Value::Null, "{unset_envelope}"); + assert_eq!(unset_envelope["outcome"]["outcome"], json!("effect")); + let effect = &unset_envelope["outcome"]["value"]; + assert_eq!(effect["effect_class"], json!("configuration_write")); + assert_eq!(effect["idempotency_key"], json!(UNSET_KEY)); + assert_eq!( + effect["receipt"]["operation"], + json!("use-case.application.configuration.unset") + ); + assert_eq!( + effect["receipt"]["effect_class"], + json!("configuration_write") + ); + assert_eq!(effect["receipt"]["outcome"], json!("completed")); + assert_eq!(effect["receipt"]["idempotency_key"], json!(UNSET_KEY)); + let unset_payload = &effect["payload"]; + assert_eq!(unset_payload["base_revision_id"], json!(override_revision)); + let restored_revision = unset_payload["result_revision_id"] + .as_str() + .expect("unset result revision") + .to_owned(); + assert_ne!(restored_revision, override_revision); + + let restored = setting(&read_prewarm(&server).await); + assert_eq!(restored["key"], json!(PREWARM_KEY)); + assert_eq!(restored["effective_value"], boolean_value(false)); + assert_eq!(restored["revision_id"], json!(restored_revision)); + assert_eq!( + restored["candidates"], + json!([registry_default_candidate()]) + ); + + let (replay_result, replay_envelope) = call_mcp( + &server, + "tracedecay_configuration_unset", + json!({ + "layer": {"kind": "project", "project_id": project_id}, + "key": PREWARM_KEY, + "expected_revision": override_revision, + "idempotency_key": UNSET_KEY + }), + ) + .await; + assert_eq!(replay_result["isError"], Value::Null, "{replay_envelope}"); + assert_eq!(replay_envelope["outcome"]["outcome"], json!("effect")); + assert_eq!( + replay_envelope["outcome"]["value"]["payload"], *unset_payload, + "the same unset request must replay the committed receipt" + ); + assert_eq!( + replay_envelope["outcome"]["value"]["idempotency_key"], + json!(UNSET_KEY) + ); + let after_replay = setting(&read_prewarm(&server).await); + assert_eq!(after_replay["effective_value"], boolean_value(false)); + assert_eq!(after_replay["revision_id"], json!(restored_revision)); + assert_eq!( + after_replay["candidates"], + json!([registry_default_candidate()]) + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn configuration_unset_refuses_stale_protected_unknown_and_default_layer() { + let production = production_composition_fixture().await; + let server = production + .harness + .server(&production.project_root) + .expect("production MCP server"); + let project_id = production + .harness + .project_id(&production.project_root) + .await + .expect("registered fixture project"); + let initial = setting(&read_prewarm(&server).await); + let initial_revision = initial["revision_id"] + .as_str() + .expect("initial configuration revision") + .to_owned(); + + let (_, set_envelope) = call_mcp( + &server, + "tracedecay_configuration_set", + json!({ + "layer": {"kind": "project", "project_id": project_id}, + "key": PREWARM_KEY, + "value": boolean_value(true), + "expected_revision": initial_revision, + "idempotency_key": "configuration.idempotency.mcp-unset-refusal-set" + }), + ) + .await; + let override_revision = payload(&set_envelope, "effect")["result_revision_id"] + .as_str() + .expect("set result revision") + .to_owned(); + + let (stale_result, stale) = call_mcp( + &server, + "tracedecay_configuration_unset", + json!({ + "layer": {"kind": "project", "project_id": project_id}, + "key": PREWARM_KEY, + "expected_revision": "revision.stale-configuration-unset", + "idempotency_key": "configuration.idempotency.mcp-unset-stale" + }), + ) + .await; + assert_eq!(stale_result["isError"], json!(true), "{stale}"); + assert_eq!(stale["problem"]["kind"], json!("conflict")); + assert_eq!(stale["problem"]["code"], json!("configuration.conflict")); + assert_eq!( + stale["problem"]["message"], + json!("The configuration request conflicts with current state") + ); + assert_eq!( + stale["problem"]["diagnostic"]["code"], + json!("configuration.conflict") + ); + assert_eq!( + stale["problem"]["diagnostic"]["message"], + json!("The configuration request conflicts with current state") + ); + assert_eq!(stale["problem"]["retry"], json!("after_revalidate")); + assert_eq!(stale["problem"]["retryable"], json!(true)); + assert_eq!(stale["problem"]["retry_scope"], json!("fresh_request")); + assert_eq!(stale["problem"]["legal_actions"], json!(["refresh"])); + + let (protected_result, protected) = call_mcp( + &server, + "tracedecay_configuration_unset", + json!({ + "layer": {"kind": "project", "project_id": project_id}, + "key": "scope.source_bindings.v1", + "expected_revision": override_revision, + "idempotency_key": "configuration.idempotency.mcp-unset-protected" + }), + ) + .await; + assert_eq!(protected_result["isError"], json!(true), "{protected}"); + assert_eq!(protected["problem"]["kind"], json!("invalid_request")); + assert_eq!( + protected["problem"]["code"], + json!("configuration.policy_widening_forbidden") + ); + assert_eq!( + protected["problem"]["message"], + json!("Configuration policy widening is forbidden") + ); + assert_eq!( + protected["problem"]["diagnostic"]["code"], + json!("configuration.policy_widening_forbidden") + ); + assert_eq!( + protected["problem"]["diagnostic"]["message"], + json!("Configuration policy widening is forbidden") + ); + assert_eq!(protected["problem"]["retry"], json!("never")); + assert_eq!(protected["problem"]["legal_actions"], json!([])); + + let (unknown_result, unknown) = call_mcp( + &server, + "tracedecay_configuration_unset", + json!({ + "layer": {"kind": "project", "project_id": project_id}, + "key": "missing.setting.v1", + "expected_revision": override_revision, + "idempotency_key": "configuration.idempotency.mcp-unset-unknown" + }), + ) + .await; + assert_eq!(unknown_result["isError"], json!(true), "{unknown}"); + assert_eq!(unknown["problem"]["kind"], json!("invalid_request")); + assert_eq!( + unknown["problem"]["code"], + json!("configuration.invalid_request") + ); + assert_eq!( + unknown["problem"]["message"], + json!( + "The configuration request is invalid: setting key is not registered: missing.setting.v1" + ) + ); + assert_eq!( + unknown["problem"]["diagnostic"]["message"], + json!( + "The configuration request is invalid: setting key is not registered: missing.setting.v1" + ) + ); + assert_eq!(unknown["problem"]["retry"], json!("never")); + assert_eq!(unknown["problem"]["legal_actions"], json!([])); + + let (default_layer_result, default_layer) = call_mcp( + &server, + "tracedecay_configuration_unset", + json!({ + "layer": {"kind": "default"}, + "key": PREWARM_KEY, + "expected_revision": override_revision, + "idempotency_key": "configuration.idempotency.mcp-unset-default-layer" + }), + ) + .await; + assert_eq!( + default_layer_result["isError"], + json!(true), + "{default_layer}" + ); + assert_eq!( + default_layer["problem"]["kind"], + json!("not_found_or_not_authorized") + ); + assert_eq!( + default_layer["problem"]["code"], + json!("not_found_or_not_authorized") + ); + assert_eq!( + default_layer["problem"]["message"], + json!("The requested resource was not found or is not authorized") + ); + assert_eq!(default_layer["problem"]["diagnostic"], Value::Null); + assert_eq!(default_layer["problem"]["retry"], json!("never")); + assert_eq!(default_layer["problem"]["legal_actions"], json!([])); + + let still = setting(&read_prewarm(&server).await); + assert_eq!(still["effective_value"], boolean_value(true)); + assert_eq!(still["revision_id"], json!(override_revision)); + assert_eq!( + still["candidates"], + json!([{ + "layer": {"kind": "project", "project_id": project_id}, + "revision_id": override_revision, + "disposition": "winning", + "safe_reason": null + }]) + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_behavior_test.rs new file mode 100644 index 0000000000..37c5c2d5c8 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/context_behavior_test.rs @@ -0,0 +1,374 @@ +//! `tracedecay_context` as an MCP client calls it. +//! +//! Each case sends JSON-RPC `tools/call` through the production server and +//! checks the text the client receives against a literal. `include_code` is +//! set when the body must be stable: without it the handler races search +//! against the verified graph and may omit symbols. + +#![cfg(feature = "test-transport")] + +use std::fs; +use std::path::Path; + +use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; + +use crate::support::{ + extract_real_server_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, + production_composition_fixture_with_sources, warm_code_index_search, +}; + +fn write_billing_sources(project: &Path) { + fs::create_dir_all(project.join("src")).expect("billing src dir"); + fs::write( + project.join("src/lib.rs"), + "pub fn invoice_total(cents: u32) -> u32 {\n cents\n}\n\npub trait TaxPolicy {\n fn tax(&self, cents: u32) -> u32;\n}\n", + ) + .expect("billing lib.rs"); +} + +async fn context_text(server: &McpServer, arguments: Value) -> String { + let result = handle_real_server_tool_call(server, "tracedecay_context", arguments).await; + assert_ne!( + result["isError"], + Value::Bool(true), + "tracedecay_context failed: {result}" + ); + extract_real_server_text(&result).to_owned() +} + +async fn context_json(server: &McpServer, mut arguments: Value) -> Value { + arguments + .as_object_mut() + .expect("context arguments") + .insert("format".to_owned(), json!("json")); + let text = context_text(server, arguments).await; + serde_json::from_str(&text).unwrap_or_else(|error| { + panic!("tracedecay_context JSON was not an object: {error}; text={text}") + }) +} + +fn require_line(markdown: &str, exact: &str) { + assert!( + markdown.lines().any(|line| line == exact), + "missing line {exact:?} in:\n{markdown}" + ); +} + +fn search_identity(payload: &Value, name: &str) -> Value { + let rendered = payload.to_string(); + let matches = payload + .get("search_matches") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("search_matches missing in {rendered}")); + let found = matches + .iter() + .find(|search_match| search_match["name"] == name) + .unwrap_or_else(|| panic!("no search match named {name} in {rendered}")); + json!({ + "name": found["name"], + "qualified_name": found["qualified_name"], + "kind": found["kind"], + "file": found["file"], + "exact_class": found["exact_class"], + "rank": found["rank"], + }) +} + +fn code_identity(payload: &Value) -> Value { + let rendered = payload.to_string(); + let blocks = payload + .get("code") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("code missing in {rendered}")); + assert_eq!(blocks.len(), 1, "one code block: {rendered}"); + let block = &blocks[0]; + json!({ + "file": block["file"], + "start_line": block["start_line"], + "end_line": block["end_line"], + "code": block["code"], + }) +} + +fn symbol_identity(payload: &Value, name: &str) -> Value { + let rendered = payload.to_string(); + let symbols = payload + .get("symbols") + .and_then(Value::as_array) + .unwrap_or_else(|| panic!("symbols missing in {rendered}")); + let symbol = symbols + .iter() + .find(|symbol| symbol["name"] == name) + .unwrap_or_else(|| panic!("no symbol named {name} in {rendered}")); + json!({ + "name": symbol["name"], + "qualified_name": symbol["qualified_name"], + "kind": symbol["kind"], + "file": symbol["file"], + "start_line": symbol["start_line"], + "end_line": symbol["end_line"], + }) +} + +#[tokio::test] +async fn tracedecay_context_returns_invoice_total_and_tax_policy() { + let fixture = production_composition_fixture_with_sources(write_billing_sources).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("billing context server"); + warm_code_index_search(&server, "invoice_total").await; + + let invoice = context_json( + &server, + json!({ + "task": "invoice_total", + "max_nodes": 1, + "include_code": true, + "max_code_blocks": 1 + }), + ) + .await; + assert_eq!(invoice["task"], "invoice_total"); + assert_eq!(invoice["mode"], "explore"); + assert_eq!(invoice["freshness"], json!({"state": "fresh"})); + assert_eq!( + invoice["coverage"], + json!({ + "exact": "complete", + "lexical": "complete", + "graph": "complete", + "recall": "full" + }) + ); + assert_eq!(invoice["memory_matches"], json!([])); + assert_eq!( + search_identity(&invoice, "invoice_total"), + json!({ + "name": "invoice_total", + "qualified_name": "src/lib.rs::invoice_total", + "kind": "function", + "file": "src/lib.rs", + "exact_class": "exact_message", + "rank": 1 + }) + ); + assert_eq!( + symbol_identity(&invoice, "invoice_total"), + json!({ + "name": "invoice_total", + "qualified_name": "src/lib.rs::invoice_total", + "kind": "function", + "file": "src/lib.rs", + "start_line": 1, + "end_line": 3 + }) + ); + assert_eq!( + code_identity(&invoice), + json!({ + "file": "src/lib.rs", + "start_line": 1, + "end_line": 3, + "code": "pub fn invoice_total(cents: u32) -> u32 {\n cents\n}" + }) + ); + + let invoice_markdown = context_text( + &server, + json!({ + "task": "invoice_total", + "max_nodes": 1, + "include_code": true, + "max_code_blocks": 1, + "format": "markdown" + }), + ) + .await; + let invoice_head = "freshness: fresh\n# Context for invoice_total\n\n### Code\n#### src/lib.rs:1\n```\npub fn invoice_total(cents: u32) -> u32 {\n cents\n}\n```\n\n### Related Symbols\n"; + assert!( + invoice_markdown.starts_with(invoice_head), + "invoice markdown:\n{invoice_markdown}" + ); + require_line(&invoice_markdown, "- `src/lib.rs::invoice_total`"); + + let without_code = context_json( + &server, + json!({ + "task": "invoice_total", + "max_nodes": 1, + "include_code": false + }), + ) + .await; + assert_eq!(without_code["code"], json!([])); + assert_eq!(without_code["task"], "invoice_total"); + assert_eq!( + search_identity(&without_code, "invoice_total"), + json!({ + "name": "invoice_total", + "qualified_name": "src/lib.rs::invoice_total", + "kind": "function", + "file": "src/lib.rs", + "exact_class": "exact_message", + "rank": 1 + }) + ); + + let absent = context_json( + &server, + json!({ + "task": "zzz_not_in_this_crate", + "max_nodes": 5, + "include_code": true, + "max_code_blocks": 1 + }), + ) + .await; + let absent_names = absent + .get("search_matches") + .and_then(Value::as_array) + .map(|matches| { + matches + .iter() + .filter_map(|search_match| search_match["name"].as_str()) + .collect::>() + }) + .unwrap_or_default(); + assert!( + absent_names.iter().all(|name| *name != "invoice_total"), + "unrelated task must not return invoice_total: {absent}" + ); + + let plan = context_json( + &server, + json!({ + "task": "TaxPolicy", + "mode": "plan", + "max_nodes": 1, + "include_code": true, + "max_code_blocks": 1 + }), + ) + .await; + assert_eq!(plan["task"], "TaxPolicy"); + assert_eq!(plan["mode"], "plan"); + assert_eq!( + symbol_identity(&plan, "TaxPolicy"), + json!({ + "name": "TaxPolicy", + "qualified_name": "src/lib.rs::TaxPolicy", + "kind": "trait", + "file": "src/lib.rs", + "start_line": 5, + "end_line": 7 + }) + ); + assert_eq!( + code_identity(&plan), + json!({ + "file": "src/lib.rs", + "start_line": 5, + "end_line": 7, + "code": "pub trait TaxPolicy {\n fn tax(&self, cents: u32) -> u32;\n}" + }) + ); + + let plan_markdown = context_text( + &server, + json!({ + "task": "TaxPolicy", + "mode": "plan", + "max_nodes": 1, + "include_code": true, + "max_code_blocks": 1, + "format": "markdown" + }), + ) + .await; + require_line( + &plan_markdown, + "- **TaxPolicy** (trait) - src/lib.rs:5 (0 implementors)", + ); + require_line( + &plan_markdown, + "_No test files found covering these modules._", + ); + require_line(&plan_markdown, "- `src/lib.rs::TaxPolicy`"); + + fixture.harness.shutdown().await; +} + +fn assert_rejected(response: &Value, message: &str) { + assert!( + response.get("result").is_none(), + "rejected call must not return a result: {response}" + ); + assert_eq!(response["jsonrpc"], "2.0"); + assert_eq!(response["id"], 1); + assert_eq!(response["error"]["code"], json!(-32603)); + assert_eq!(response["error"]["message"], message); + assert_eq!(response["error"]["data"]["tool"], "tracedecay_context"); +} + +#[tokio::test] +async fn tracedecay_context_rejects_malformed_requests() { + let fixture = production_composition_fixture_with_sources(write_billing_sources).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("billing context server"); + + let missing = handle_real_server_tool_call_raw(&server, "tracedecay_context", json!({})).await; + assert_rejected( + &missing, + "tool execution failed: config error: invalid arguments for tracedecay_context: missing field `task`", + ); + + let unknown = handle_real_server_tool_call_raw( + &server, + "tracedecay_context", + json!({"task": "invoice_total", "not_a_context_field": true}), + ) + .await; + assert_rejected( + &unknown, + "tool execution failed: config error: invalid arguments for tracedecay_context: unknown field `not_a_context_field`, expected one of `task`, `max_nodes`, `include_code`, `max_code_blocks`, `mode`, `include_memory`, `memory_limit`, `memory_min_trust`, `lexical_anchors`, `prefer_symbol`", + ); + + let mode = handle_real_server_tool_call_raw( + &server, + "tracedecay_context", + json!({"task": "invoice_total", "mode": "nope"}), + ) + .await; + assert_rejected( + &mode, + "tool execution failed: config error: invalid arguments for tracedecay_context: unknown variant `nope`, expected `explore` or `plan`", + ); + + let empty_anchor = handle_real_server_tool_call_raw( + &server, + "tracedecay_context", + json!({"task": "invoice_total", "lexical_anchors": [""]}), + ) + .await; + assert_rejected( + &empty_anchor, + "tool execution failed: config error: lexical anchor 0 is empty", + ); + + let spaced_anchor = handle_real_server_tool_call_raw( + &server, + "tracedecay_context", + json!({"task": "invoice_total", "lexical_anchors": ["invoice total"]}), + ) + .await; + assert_rejected( + &spaced_anchor, + "tool execution failed: config error: lexical anchor 0 must be one identifier or technical term: no surrounding whitespace, inner whitespace, or control characters", + ); + + fixture.harness.shutdown().await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_depth_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_depth_test.rs new file mode 100644 index 0000000000..b5eb79331e --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/dependency_depth_test.rs @@ -0,0 +1,220 @@ +//! User-visible `tracedecay_dependency_depth` answers over the production MCP +//! server. Depth counts dependency edges after cycles collapse to one SCC, and +//! `implements` / `extends` edges must not become file dependencies. + +#![cfg(feature = "test-transport")] + +use std::fs; +use std::path::Path; + +use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; + +use crate::support::{ + extract_real_server_text, handle_real_server_tool_call, + production_composition_fixture_with_sources, warm_code_index_search, +}; + +fn write_dependency_depth_sources(project: &Path) { + let source = project.join("src"); + fs::create_dir_all(source.join("layers")).unwrap(); + fs::create_dir_all(source.join("looped")).unwrap(); + fs::write(source.join("lib.rs"), "pub mod layers;\npub mod looped;\n").unwrap(); + fs::write( + source.join("layers/mod.rs"), + "pub mod top;\npub mod mid;\npub mod leaf;\npub mod aside;\npub mod marker;\n", + ) + .unwrap(); + // `top` calls `mid` calls `leaf`. Module declarations are containment, not + // file dependencies, so this is the only chain under `src/layers`. + fs::write( + source.join("layers/leaf.rs"), + "pub fn leaf() -> u32 { 1 }\n", + ) + .unwrap(); + fs::write( + source.join("layers/mid.rs"), + "use crate::layers::leaf::leaf;\npub fn mid() -> u32 { leaf() }\n", + ) + .unwrap(); + fs::write( + source.join("layers/top.rs"), + "use crate::layers::mid::mid;\npub fn top() -> u32 { mid() }\n", + ) + .unwrap(); + // A derive and a trait in different files used to glue those files + // together through resolver-fuzzy `implements` / `extends` edges. + fs::write( + source.join("layers/aside.rs"), + "#[derive(Debug, Clone)]\npub struct Aside;\n", + ) + .unwrap(); + fs::write(source.join("layers/marker.rs"), "pub trait Marker {}\n").unwrap(); + fs::write( + source.join("looped/mod.rs"), + "pub mod left;\npub mod right;\npub mod down;\n", + ) + .unwrap(); + fs::write( + source.join("looped/left.rs"), + "use crate::looped::right::right;\npub fn left() -> u32 { right() }\n", + ) + .unwrap(); + fs::write( + source.join("looped/right.rs"), + "use crate::looped::left::left;\npub fn right() -> u32 { left() }\n", + ) + .unwrap(); + fs::write( + source.join("looped/down.rs"), + "use crate::looped::left::left;\npub fn down() -> u32 { left() }\n", + ) + .unwrap(); +} + +async fn dependency_depth(server: &McpServer, arguments: Value) -> Value { + let result = + handle_real_server_tool_call(server, "tracedecay_dependency_depth", arguments).await; + let text = extract_real_server_text(&result); + serde_json::from_str(text).unwrap_or_else(|error| { + panic!("tracedecay_dependency_depth must answer with JSON: {error}\n{text}") + }) +} + +/// `top -> mid -> leaf` is two edges. `aside` and `marker` stay length-1 +/// chains: derive and trait metadata is not a file dependency. +#[tokio::test] +async fn dependency_depth_reports_the_call_chain_and_ignores_derives() { + let fixture = production_composition_fixture_with_sources(write_dependency_depth_sources).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + warm_code_index_search(&server, "leaf").await; + + let payload = dependency_depth( + &server, + json!({"path": "src/layers", "limit": 10, "format": "json"}), + ) + .await; + assert_eq!(payload["max_depth"], 2, "payload: {payload}"); + assert_eq!(payload["ideal_depth"], 3, "payload: {payload}"); + assert_eq!( + payload["depth_score"].as_f64(), + Some(1.0), + "payload: {payload}" + ); + assert_eq!( + payload["chains"], + json!([ + { + "file": "src/layers/leaf.rs", + "depth": 2, + "chain": ["src/layers/top.rs", "src/layers/mid.rs", "src/layers/leaf.rs"] + }, + { + "file": "src/layers/mid.rs", + "depth": 1, + "chain": ["src/layers/top.rs", "src/layers/mid.rs"] + }, + { + "file": "src/layers/aside.rs", + "depth": 0, + "chain": ["src/layers/aside.rs"] + }, + { + "file": "src/layers/marker.rs", + "depth": 0, + "chain": ["src/layers/marker.rs"] + }, + { + "file": "src/layers/mod.rs", + "depth": 0, + "chain": ["src/layers/mod.rs"] + }, + { + "file": "src/layers/top.rs", + "depth": 0, + "chain": ["src/layers/top.rs"] + } + ]), + "payload: {payload}" + ); + + // `limit` truncates the ranked chain list. It does not change `max_depth`. + let limited = dependency_depth( + &server, + json!({"path": "src/layers", "limit": 1, "format": "json"}), + ) + .await; + assert_eq!(limited["max_depth"], 2, "limited: {limited}"); + assert_eq!(limited["ideal_depth"], 3, "limited: {limited}"); + assert_eq!( + limited["depth_score"].as_f64(), + Some(1.0), + "limited: {limited}" + ); + assert_eq!( + limited["chains"], + json!([ + { + "file": "src/layers/leaf.rs", + "depth": 2, + "chain": ["src/layers/top.rs", "src/layers/mid.rs", "src/layers/leaf.rs"] + } + ]), + "limited: {limited}" + ); + + fixture.harness.shutdown().await; +} + +/// A pair of mutual calls is one component. The answer is the single edge +/// into that component, not a walk that never ends. +#[tokio::test] +async fn dependency_depth_collapses_a_mutual_call_cycle() { + let fixture = production_composition_fixture_with_sources(write_dependency_depth_sources).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + warm_code_index_search(&server, "left").await; + + let payload = dependency_depth(&server, json!({"path": "src/looped", "format": "json"})).await; + assert_eq!(payload["max_depth"], 1, "payload: {payload}"); + assert_eq!(payload["ideal_depth"], 2, "payload: {payload}"); + assert_eq!( + payload["depth_score"].as_f64(), + Some(1.0), + "payload: {payload}" + ); + + let chains = payload["chains"] + .as_array() + .unwrap_or_else(|| panic!("chains array missing: {payload}")); + assert_eq!( + chains + .iter() + .map(|entry| entry["file"].as_str().unwrap_or("")) + .collect::>(), + vec![ + "src/looped/left.rs", + "src/looped/down.rs", + "src/looped/mod.rs", + ], + "payload: {payload}" + ); + assert_eq!(chains[0]["depth"], 1, "payload: {payload}"); + let chain = chains[0]["chain"] + .as_array() + .unwrap_or_else(|| panic!("cycle chain missing: {payload}")); + assert_eq!(chain.len(), 2, "payload: {payload}"); + assert_eq!(chain[0], "src/looped/down.rs", "payload: {payload}"); + let cycle_member = chain[1].as_str().unwrap_or(""); + assert!( + cycle_member == "src/looped/left.rs" || cycle_member == "src/looped/right.rs", + "the collapsed component's representative must be one of its files: {payload}" + ); + + fixture.harness.shutdown().await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/derives_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/derives_test.rs new file mode 100644 index 0000000000..f01c291c00 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/derives_test.rs @@ -0,0 +1,309 @@ +#![cfg(feature = "test-transport")] + +//! `tracedecay_derives` through the production MCP `tools/call` path. +//! +//! The assertions are the text and JSON an agent receives. Occurrence ids are +//! the only runtime identity; every other field is a literal of the fixture. + +use crate::support::{ + ProductionCompositionFixture, dispatch_mcp_tool_call, + production_composition_fixture_with_sources, warm_code_index_search, +}; +use serde_json::{Value, json}; +use std::fs; + +/// Lines are the fixture's source lines. `tracedecay_derives` reports the +/// item line (1-based), not the attribute line above it. +fn fixture_source() -> String { + [ + "mod inner {", + " #[derive(Debug, Clone)]", + " pub enum Status {", + " Ready,", + " }", + "}", + "", + "#[derive(serde::Serialize)]", + "#[derive(Eq, CustomDerive)]", + "pub struct NamedValue {", + " pub id: u32,", + "}", + "", + "pub struct PlainValue;", + "", + ] + .join("\n") +} + +#[tokio::test] +async fn derives_reports_exact_attached_macro_names() { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), fixture_source()).unwrap(); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("derives fixture server"); + warm_code_index_search(&server, "NamedValue").await; + drop(server); + + let named = derive_symbol( + &fixture, + json!({"qualified_name": "src/lib.rs::NamedValue", "format": "json"}), + ) + .await; + assert_eq!( + without_node_id(&named), + json!({ + "name": "NamedValue", + "qualified_name": "src/lib.rs::NamedValue", + "kind": "struct", + "file": "src/lib.rs", + "line": 10, + "derives": [ + { + "name": "CustomDerive", + "evidence_class": "syntax_exact", + "unavailable_fields": ["generated_trait_impl", "generated_methods"] + }, + { + "name": "Eq", + "evidence_class": "syntax_exact", + "unavailable_fields": ["generated_trait_impl", "generated_methods"] + }, + { + "name": "serde::Serialize", + "evidence_class": "syntax_exact", + "unavailable_fields": ["generated_trait_impl", "generated_methods"] + } + ] + }) + ); + let named_id = node_id(&named); + assert_eq!( + derive_symbol(&fixture, json!({"node_id": named_id, "format": "json"})).await, + named, + "node_id lookup must return the same record as the qualified name" + ); + assert_eq!( + derive_symbol( + &fixture, + json!({"node_id": format!("code-symbol:{named_id}"), "format": "json"}) + ) + .await, + named, + "search evidence anchors unwrap to the same symbol" + ); + assert_eq!( + derive_symbol( + &fixture, + json!({ + "node_id": named_id, + "qualified_name": "src/lib.rs::PlainValue", + "format": "json" + }) + ) + .await, + named, + "node_id wins when both selectors are present" + ); + assert_eq!( + call_text(&fixture, json!({"node_id": named_id, "format": "markdown"})).await, + format!( + "- **NamedValue**\n **kind:** struct\n **file:** src/lib.rs\n **line:** 10\n **derives:** CustomDerive; Eq; serde::Serialize\n **node_id:** `{named_id}`\n **qualified_name:** `src/lib.rs::NamedValue`\n" + ) + ); + assert_eq!( + call_text(&fixture, json!({"node_id": named_id})).await, + format!( + "- **NamedValue**\n **kind:** struct\n **file:** src/lib.rs\n **line:** 10\n **derives:** CustomDerive; Eq; serde::Serialize\n **node_id:** `{named_id}`\n **qualified_name:** `src/lib.rs::NamedValue`\n" + ), + "omitted format is markdown, not JSON" + ); + + let status = derive_symbol( + &fixture, + json!({"qualified_name": "src/lib.rs::inner::Status", "format": "json"}), + ) + .await; + assert_eq!( + without_node_id(&status), + json!({ + "name": "Status", + "qualified_name": "src/lib.rs::inner::Status", + "kind": "enum", + "file": "src/lib.rs", + "line": 3, + "derives": [ + { + "name": "Clone", + "evidence_class": "syntax_exact", + "unavailable_fields": ["generated_trait_impl", "generated_methods"] + }, + { + "name": "Debug", + "evidence_class": "syntax_exact", + "unavailable_fields": ["generated_trait_impl", "generated_methods"] + } + ] + }) + ); + + let plain = derive_symbol( + &fixture, + json!({"qualified_name": "src/lib.rs::PlainValue", "format": "json"}), + ) + .await; + assert_eq!( + without_node_id(&plain), + json!({ + "name": "PlainValue", + "qualified_name": "src/lib.rs::PlainValue", + "kind": "struct", + "file": "src/lib.rs", + "line": 14, + "derives": [] + }) + ); + + let module = derive_symbol( + &fixture, + json!({"qualified_name": "src/lib.rs::inner", "format": "json"}), + ) + .await; + assert_eq!( + without_node_id(&module), + json!({ + "name": "inner", + "qualified_name": "src/lib.rs::inner", + "kind": "module", + "file": "src/lib.rs", + "line": 1, + "derives": [] + }) + ); + + assert_eq!( + call_text( + &fixture, + json!({"qualified_name": "NamedValue", "format": "json"}) + ) + .await, + "No matching symbol found.", + "short names are not qualified-name matches" + ); + assert_eq!( + call_text( + &fixture, + json!({"qualified_name": "crate::does::not::exist", "format": "json"}) + ) + .await, + "No matching symbol found." + ); + assert_eq!( + call_text( + &fixture, + json!({"node_id": "missing-symbol", "format": "json"}) + ) + .await, + "No matching symbol found." + ); + + assert_eq!( + call_error(&fixture, json!({})).await, + json!({ + "code": -32602, + "message": "missing required parameter: qualified_name or node_id", + "data": { + "tool": "tracedecay_derives", + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: qualified_name or node_id" + } + }) + ); + let empty_id = call_error(&fixture, json!({"node_id": ""})).await; + assert_eq!(empty_id["code"], -32603); + assert_eq!( + empty_id["message"], + "tool execution failed: config error: invalid parameter: node_id must not be empty" + ); + assert_eq!(empty_id["data"]["tool"], "tracedecay_derives"); + let evidence_anchor = call_error(&fixture, json!({"node_id": "code-file:not-a-symbol"})).await; + assert_eq!(evidence_anchor["code"], -32603); + assert_eq!( + evidence_anchor["message"], + "tool execution failed: config error: invalid parameter: node_id `code-file:not-a-symbol` is an evidence anchor, not a graph symbol occurrence" + ); + assert_eq!(evidence_anchor["data"]["tool"], "tracedecay_derives"); + + fixture.harness.shutdown().await; +} + +async fn derive_symbol(fixture: &ProductionCompositionFixture, arguments: Value) -> Value { + let text = call_text(fixture, arguments).await; + let payload: Value = serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("tracedecay_derives JSON: {error}\n{text}")); + let items = payload + .as_array() + .unwrap_or_else(|| panic!("tracedecay_derives must return a symbol array: {payload}")); + assert_eq!(items.len(), 1, "{payload}"); + items[0].clone() +} + +fn without_node_id(symbol: &Value) -> Value { + let mut symbol = symbol.clone(); + let node_id = symbol + .as_object_mut() + .expect("symbol record is a JSON object") + .remove("node_id"); + assert!( + node_id + .as_ref() + .and_then(Value::as_str) + .is_some_and(|id| !id.is_empty()), + "node_id must be a non-empty occurrence id, got {node_id:?}" + ); + symbol +} + +fn node_id(symbol: &Value) -> String { + symbol["node_id"] + .as_str() + .unwrap_or_else(|| panic!("node_id missing: {symbol}")) + .to_owned() +} + +async fn call_text(fixture: &ProductionCompositionFixture, arguments: Value) -> String { + match call_derives(fixture, arguments).await { + Ok(result) => result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("MCP text content missing: {result}")) + .to_owned(), + Err(error) => panic!("expected a tool result, got JSON-RPC error: {error}"), + } +} + +async fn call_error(fixture: &ProductionCompositionFixture, arguments: Value) -> Value { + match call_derives(fixture, arguments).await { + Ok(result) => panic!("expected a JSON-RPC error, got: {result}"), + Err(error) => error, + } +} + +async fn call_derives( + fixture: &ProductionCompositionFixture, + arguments: Value, +) -> Result { + let server = fixture + .harness + .server(&fixture.project_root) + .expect("derives fixture server"); + let response = dispatch_mcp_tool_call(&server, "tracedecay_derives", arguments).await; + if !response["error"].is_null() { + return Err(response["error"].clone()); + } + Ok(response["result"].clone()) +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs new file mode 100644 index 0000000000..e889989312 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/diagnose_test.rs @@ -0,0 +1,447 @@ +//! `tracedecay_diagnose` as a caller sees it: production MCP `tools/call`, +//! not the parser. +//! +//! Compiler stderr is the input. The observable result is the mapped +//! diagnostic, the publication report, and the default markdown rendering. +//! Occurrence ids are generation-minted, so they are checked against the live +//! exact-symbol read rather than pinned. + +#![cfg(feature = "test-transport")] + +use std::fs; + +use serde_json::{Value, json}; +use tracedecay_mcp::JsonRpcResponse; + +use crate::support::{ + ProductionCompositionFixture, production_composition_fixture_with_sources, + warm_code_index_search, +}; + +const SOURCE: &str = "pub fn target() {}\npub fn caller() { target(); }\n"; + +const RUSTC_ERROR: &str = "\ +error[E0308]: mismatched types + --> src/lib.rs:1:10 + | +1 | pub fn target() {} + | ^^^^^^ expected `u32`, found `()` + +error: aborting due to 1 previous error +"; + +const RUSTC_ERROR_AND_WARNING: &str = "\ +error[E0308]: mismatched types + --> src/lib.rs:1:10 + | +warning: unused function + --> src/lib.rs:2:1 + | +error: aborting due to 1 previous error +"; + +const COLORED_SHORT_ERROR: &str = "\ +\u{1b}[1m\u{1b}[92m Checking\u{1b}[0m diag-fixture v0.1.0\n\ +src/lib.rs:1:10: \u{1b}[1m\u{1b}[91merror[E0308]\u{1b}[0m: mismatched types\n\ +\u{1b}[1m\u{1b}[91merror\u{1b}[0m: could not compile `diag-fixture` (lib) due to 1 previous error\n"; + +const UNMAPPED_ERROR: &str = "\ +error[E0425]: cannot find value `missing` in this scope + --> src/missing.rs:4:5 + | +"; + +#[tokio::test] +async fn diagnose_reports_literal_mapping_filters_and_refusals() { + let fixture = open_indexed_project().await; + let target_id = exact_symbol_id(&fixture, "target").await; + let caller_id = exact_symbol_id(&fixture, "caller").await; + + let mapped = diagnose_json( + &fixture, + json!({"cargo_output": RUSTC_ERROR, "format": "json"}), + ) + .await; + assert_eq!(mapped["diagnostics"][0]["node"]["node_id"], target_id); + assert_eq!(mapped["diagnostics"][0]["callers"][0]["node_id"], caller_id); + assert_eq!( + without_minted_ids(&mapped), + json!({ + "diagnostics_parsed": 1, + "diagnostics_returned": 1, + "mapped_to_node": 1, + "unmapped": 0, + "truncated": false, + "published": { + "status": "published", + "publication_revision": 1, + "inserted": 1, + "cleared": 0, + "unresolved": [], + "rejected": [] + }, + "diagnostics": [{ + "severity": "error", + "code": "E0308", + "message": "mismatched types", + "file": "src/lib.rs", + "line": 1, + "column": 10, + "node": { + "name": "target", + "kind": "function", + "qualified_name": "src/lib.rs::target", + "file": "src/lib.rs", + "line": 1, + "start_line": 0, + "end_line": 0 + }, + "callers": [{ + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }] + }] + }), + "mapped diagnose payload: {mapped}" + ); + + assert_eq!( + diagnose_text(&fixture, json!({"cargo_output": RUSTC_ERROR})).await, + "\ +## Diagnostics +**Diagnostics parsed:** 1 +**Diagnostics returned:** 1 +**Mapped to node:** 1 +**Unmapped:** 0 +**Truncated:** false + +### Findings +- **ERROR E0308 at src/lib.rs:1:10** + **Message:** mismatched types + **Node:** src/lib.rs::target + **Callers:** caller (src/lib.rs:2) +" + ); + + let colored = diagnose_json( + &fixture, + json!({"cargo_output": COLORED_SHORT_ERROR, "format": "json"}), + ) + .await; + assert_eq!( + without_minted_ids(&colored)["diagnostics"], + json!([{ + "severity": "error", + "code": "E0308", + "message": "mismatched types", + "file": "src/lib.rs", + "line": 1, + "column": 10, + "node": { + "name": "target", + "kind": "function", + "qualified_name": "src/lib.rs::target", + "file": "src/lib.rs", + "line": 1, + "start_line": 0, + "end_line": 0 + }, + "callers": [{ + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }] + }]), + "colored short cargo output must map the same diagnostic: {colored}" + ); + + let errors_only = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR_AND_WARNING, + "severity": "error", + "format": "json" + }), + ) + .await; + assert_eq!(errors_only["diagnostics_parsed"], 1); + assert_eq!(errors_only["diagnostics_returned"], 1); + assert_eq!(errors_only["truncated"], false); + assert_eq!( + without_minted_ids(&errors_only)["diagnostics"], + json!([{ + "severity": "error", + "code": "E0308", + "message": "mismatched types", + "file": "src/lib.rs", + "line": 1, + "column": 10, + "node": { + "name": "target", + "kind": "function", + "qualified_name": "src/lib.rs::target", + "file": "src/lib.rs", + "line": 1, + "start_line": 0, + "end_line": 0 + }, + "callers": [{ + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }] + }]), + "severity=error must keep only the error: {errors_only}" + ); + + let warnings_only = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR_AND_WARNING, + "severity": "warning", + "format": "json" + }), + ) + .await; + assert_eq!( + warnings_only["diagnostics"][0]["node"]["node_id"], + caller_id + ); + assert_eq!( + without_minted_ids(&warnings_only)["diagnostics"], + json!([{ + "severity": "warning", + "code": null, + "message": "unused function", + "file": "src/lib.rs", + "line": 2, + "column": 1, + "node": { + "name": "caller", + "kind": "function", + "qualified_name": "src/lib.rs::caller", + "file": "src/lib.rs", + "line": 2, + "start_line": 1, + "end_line": 1 + }, + "callers": [] + }]), + "severity=warning must keep only the warning and no callers: {warnings_only}" + ); + + let truncated = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR_AND_WARNING, + "max_diagnostics": 1, + "format": "json" + }), + ) + .await; + assert_eq!( + ( + truncated["diagnostics_parsed"].as_u64(), + truncated["diagnostics_returned"].as_u64(), + truncated["truncated"].as_bool(), + truncated["diagnostics"][0]["code"].as_str(), + truncated["diagnostics"][0]["message"].as_str(), + ), + ( + Some(2), + Some(1), + Some(true), + Some("E0308"), + Some("mismatched types") + ), + "spanless summary is not a diagnostic, and the cap keeps the first spanned one: {truncated}" + ); + + let hidden_callers = diagnose_json( + &fixture, + json!({ + "cargo_output": RUSTC_ERROR, + "include_callers": false, + "format": "json" + }), + ) + .await; + assert_eq!( + hidden_callers["diagnostics"][0]["node"]["node_id"], + target_id + ); + assert_eq!(hidden_callers["diagnostics"][0]["callers"], Value::Null); + assert_eq!(hidden_callers["mapped_to_node"], 1); + + let unmapped = diagnose_json( + &fixture, + json!({"cargo_output": UNMAPPED_ERROR, "format": "json"}), + ) + .await; + assert_eq!( + without_minted_ids(&unmapped)["diagnostics"], + json!([{ + "severity": "error", + "code": "E0425", + "message": "cannot find value `missing` in this scope", + "file": "src/missing.rs", + "line": 4, + "column": 5, + "node": null, + "callers": [] + }]), + "a span outside the graph stays in the result with a null node: {unmapped}" + ); + assert_eq!(unmapped["diagnostics_parsed"], 1); + assert_eq!(unmapped["mapped_to_node"], 0); + assert_eq!(unmapped["unmapped"], 1); + + assert_eq!( + diagnose_text(&fixture, json!({"cargo_output": ""})).await, + "\ +## Diagnostics +**Diagnostics parsed:** 0 +**Diagnostics returned:** 0 +**Mapped to node:** 0 +**Unmapped:** 0 +**Truncated:** false + +_No diagnostics._ +" + ); + + let refused = diagnose_rpc(&fixture, json!({})).await; + let error = refused + .error + .as_ref() + .expect("missing cargo_output is a JSON-RPC error"); + assert_eq!(error.code, -32602); + assert_eq!(error.message, "missing required parameter: cargo_output"); + assert_eq!( + error.data, + Some(json!({ + "tool": "tracedecay_diagnose", + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: cargo_output" + })) + ); + + fixture.harness.shutdown().await; +} + +async fn open_indexed_project() -> ProductionCompositionFixture { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), SOURCE).unwrap(); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("diagnose fixture server"); + warm_code_index_search(&server, "target").await; + drop(server); + fixture +} + +async fn exact_symbol_id(fixture: &ProductionCompositionFixture, name: &str) -> String { + let response = fixture + .harness + .call_tool( + &fixture.project_root, + "tracedecay_find_exact_symbol", + json!({"name": name, "limit": 20, "format": "json"}), + ) + .await + .expect("exact symbol read"); + assert!(response.error.is_none(), "{:?}", response.error); + let payload = json_text(&response); + payload["matches"] + .as_array() + .and_then(|matches| matches.iter().find(|item| item["name"] == name)) + .and_then(|item| item["id"].as_str()) + .unwrap_or_else(|| panic!("symbol {name} missing from {payload}")) + .to_owned() +} + +async fn diagnose_json(fixture: &ProductionCompositionFixture, arguments: Value) -> Value { + let response = diagnose_rpc(fixture, arguments).await; + assert!( + response.error.is_none(), + "diagnose failed: {:?}", + response.error + ); + json_text(&response) +} + +async fn diagnose_text(fixture: &ProductionCompositionFixture, arguments: Value) -> String { + let response = diagnose_rpc(fixture, arguments).await; + assert!( + response.error.is_none(), + "diagnose failed: {:?}", + response.error + ); + tool_text(&response) +} + +async fn diagnose_rpc(fixture: &ProductionCompositionFixture, arguments: Value) -> JsonRpcResponse { + fixture + .harness + .call_tool(&fixture.project_root, "tracedecay_diagnose", arguments) + .await + .expect("production MCP tools/call for tracedecay_diagnose") +} + +fn json_text(response: &JsonRpcResponse) -> Value { + let text = tool_text(response); + serde_json::from_str(&text).unwrap_or_else(|error| panic!("{error}\n{text}")) +} + +fn tool_text(response: &JsonRpcResponse) -> String { + response + .result + .as_ref() + .and_then(|result| result["content"][0]["text"].as_str()) + .unwrap_or_else(|| panic!("diagnose returned no text: {response:?}")) + .to_owned() +} + +/// Drop generation-minted occurrence ids so the remaining object is the +/// literal caller-visible diagnostic. +fn without_minted_ids(value: &Value) -> Value { + let mut value = value.clone(); + strip_minted_ids(&mut value); + value +} + +fn strip_minted_ids(value: &mut Value) { + match value { + Value::Array(items) => { + for item in items { + strip_minted_ids(item); + } + } + Value::Object(map) => { + map.remove("node_id"); + map.remove("generation"); + for child in map.values_mut() { + strip_minted_ids(child); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_update_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_update_behavior_test.rs new file mode 100644 index 0000000000..f22fb91ca8 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/fact_store_update_behavior_test.rs @@ -0,0 +1,496 @@ +#![cfg(feature = "test-transport")] + +//! Behavioral proof of `tracedecay_fact_store_update` on the production MCP +//! server. Add seeds the row and get reads it back; every assertion is the +//! update a caller observes, including refusals that must not replace it. + +use serde_json::{Value, json}; + +use crate::support::{ + extract_real_server_text, handle_real_server_tool_call_raw, production_composition_fixture, +}; + +const SEEDED_CONTENT: &str = "Project Phoenix ships on the first of the month"; +const REWRITTEN_CONTENT: &str = "Project Phoenix uses deterministic Amari Memory"; +const REVIEWED_CONTENT: &str = "Project Phoenix uses deterministic Amari Memory after review"; +const STALE_CONTENT: &str = "This stale write must not land"; + +const SORTED_TAGS: &[&str] = &["holographic", "memory"]; +const SORTED_ENTITIES: &[&str] = &["Amari Memory", "Project Phoenix"]; + +fn success_envelope(response: &Value, tool: &str) -> Value { + assert!( + response["error"].is_null(), + "{tool} returned a JSON-RPC error: {response}" + ); + assert_ne!( + response["result"]["isError"], + json!(true), + "{tool} returned a semantic error: {response}" + ); + let text = extract_real_server_text(&response["result"]); + serde_json::from_str(text) + .unwrap_or_else(|error| panic!("{tool} returned invalid JSON: {error}: {text}")) +} + +fn retained_payload(response: &Value, tool: &str) -> Value { + let envelope = success_envelope(response, tool); + envelope + .pointer("/outcome/value/payload") + .cloned() + .unwrap_or_else(|| panic!("{tool} omitted its payload: {envelope}")) +} + +fn assert_problem(response: &Value, expected: Value) { + assert!( + response["error"].is_null(), + "typed refusals stay on the tool result, not a JSON-RPC error: {response}" + ); + assert_eq!( + response["result"]["isError"], + json!(true), + "refused update must set MCP isError: {response}" + ); + let envelope = success_text(response); + let problem = &envelope["problem"]; + assert_eq!(problem["kind"], expected["kind"], "{problem}"); + assert_eq!(problem["code"], expected["code"], "{problem}"); + assert_eq!(problem["message"], expected["message"], "{problem}"); + assert_eq!(problem["retry"], expected["retry"], "{problem}"); + assert_eq!( + problem["legal_actions"], expected["legal_actions"], + "{problem}" + ); + assert_eq!(problem["diagnostic"], expected["diagnostic"], "{problem}"); + assert_eq!( + response["result"]["problem"]["kind"], problem["kind"], + "the structured MCP problem must match the text envelope: {response}" + ); + assert_eq!( + response["result"]["problem"]["message"], problem["message"], + "the structured MCP problem must match the text envelope: {response}" + ); +} + +fn success_text(response: &Value) -> Value { + let text = extract_real_server_text(&response["result"]); + serde_json::from_str(text).unwrap_or_else(|error| panic!("invalid tool JSON: {error}: {text}")) +} + +/// A well-formed id for this owner that was never stored. Flipping the last +/// identity nibble keeps the owner binding and rejects a non-canonical token +/// such as `fact.missing` before the store is consulted. +fn unknown_fact_id(known: &Value) -> String { + let known = known + .as_str() + .unwrap_or_else(|| panic!("seeded fact id must be a string: {known}")); + let mut id = known.to_owned(); + let last = id + .pop() + .unwrap_or_else(|| panic!("seeded fact id must be non-empty: {known}")); + id.push(if last == 'a' { 'b' } else { 'a' }); + id +} + +fn available_fact(projection: &Value) -> &Value { + assert_eq!(projection["kind"], "available", "{projection}"); + projection + .get("fact") + .unwrap_or_else(|| panic!("available projection omitted its fact: {projection}")) +} + +fn assert_fact_snapshot(projection: &Value, snapshot: &Value) { + let fact = available_fact(projection); + assert_eq!(fact["fact_id"], snapshot["fact_id"], "{fact}"); + assert_eq!(fact["content"], snapshot["content"], "{fact}"); + assert_eq!(fact["category"], snapshot["category"], "{fact}"); + assert_eq!(fact["tags"], snapshot["tags"], "{fact}"); + assert_eq!(fact["entities"], snapshot["entities"], "{fact}"); + assert_eq!( + fact["trust_score_millionths"], snapshot["trust_score_millionths"], + "{fact}" + ); + assert_eq!(fact["metadata"], snapshot["metadata"], "{fact}"); + assert_eq!(fact["source_label"], snapshot["source_label"], "{fact}"); + assert_eq!(fact["source"]["kind"], "application", "{fact}"); + assert_eq!( + fact["source"]["operation_id"], snapshot["operation_id"], + "{fact}" + ); + assert_eq!(fact["owner"]["kind"], "project", "{fact}"); + assert_eq!( + fact["owner"]["project_id"], snapshot["project_id"], + "{fact}" + ); +} + +fn assert_committed_update(payload: &Value, fact_id: &Value, project_id: &Value, event_count: u64) { + assert_eq!(payload["commit"]["disposition"], "committed", "{payload}"); + assert_eq!(payload["commit"]["fact_id"], *fact_id, "{payload}"); + assert_eq!( + payload["commit"]["owner"], + json!({"kind": "project", "project_id": project_id}), + "{payload}" + ); + let last_event_id = payload["commit"]["last_event_id"] + .as_str() + .unwrap_or_else(|| panic!("commit omitted last_event_id: {payload}")); + assert!( + !last_event_id.is_empty(), + "commit last_event_id must be a durable id: {payload}" + ); + assert_eq!( + payload["fact"]["fact"]["last_event_id"], + json!(last_event_id), + "{payload}" + ); + let active_assertion_id = payload["commit"]["active_assertion_id"] + .as_str() + .unwrap_or_else(|| panic!("commit omitted active_assertion_id: {payload}")); + assert!( + !active_assertion_id.is_empty(), + "commit active_assertion_id must be a durable id: {payload}" + ); + assert_eq!( + payload["fact"]["fact"]["active_assertion_id"], + json!(active_assertion_id), + "{payload}" + ); + let events = payload["commit"]["committed_event_ids"] + .as_array() + .unwrap_or_else(|| panic!("commit omitted event ids: {payload}")); + assert_eq!(events.len() as u64, event_count, "{payload}"); + assert_eq!( + events.last().map(Value::clone), + Some(payload["commit"]["last_event_id"].clone()), + "{payload}" + ); +} + +/// Update rewrites the supplied fields, keeps the fact id, and reports the +/// exact trust delta. A stale compare-and-swap token, an empty patch, blank +/// content, and an unknown id are refused and leave the stored fact alone. +#[tokio::test] +async fn fact_store_update_preserves_identity_and_rejects_stale_or_empty_writes() { + let fixture = production_composition_fixture().await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production fact-store MCP server"); + + let added = retained_payload( + &handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_add", + json!({ + "content": SEEDED_CONTENT, + "category": "project", + "tags": ["setup"], + "entities": ["Setup Entity"], + "source_label": "setup-label", + "metadata": {"origin": "setup"} + }), + ) + .await, + "tracedecay_fact_store_add", + ); + assert_eq!(added["outcome"], "committed", "{added}"); + assert_eq!(added["result"]["disposition"], "added", "{added}"); + let seeded = available_fact(&added["result"]["fact"]); + assert_eq!(seeded["content"], SEEDED_CONTENT, "{seeded}"); + assert_eq!(seeded["trust_score_millionths"], json!(500_000), "{seeded}"); + let fact_id = seeded["fact_id"].clone(); + let project_id = seeded["owner"]["project_id"].clone(); + let operation_id = seeded["source"]["operation_id"].clone(); + let seeded_event_id = seeded["last_event_id"].clone(); + let seeded_assertion_id = seeded["active_assertion_id"].clone(); + let seeded_created_at = seeded["telemetry"]["created_at"].clone(); + + let rewritten = json!({ + "fact_id": fact_id, + "project_id": project_id, + "operation_id": operation_id, + "content": REWRITTEN_CONTENT, + "category": "decision", + "tags": SORTED_TAGS, + "entities": SORTED_ENTITIES, + "trust_score_millionths": 800_000, + "source_label": "operator-note", + "metadata": {"lane": "proof", "updated": true} + }); + let update_response = handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_update", + json!({ + "fact_id": fact_id, + "expected_last_event_id": seeded_event_id, + "content": REWRITTEN_CONTENT, + "category": "decision", + "tags": ["memory", "holographic"], + "entities": ["Project Phoenix", "Amari Memory"], + "trust": 0.8, + "source_label": {"kind": "set", "value": "operator-note"}, + "metadata": {"updated": true, "lane": "proof"} + }), + ) + .await; + let update_envelope = success_envelope(&update_response, "tracedecay_fact_store_update"); + assert_eq!( + update_envelope["outcome"]["outcome"], "effect", + "{update_envelope}" + ); + assert_eq!( + update_envelope["outcome"]["value"]["effect_class"], "administrative", + "{update_envelope}" + ); + assert_eq!( + update_envelope["outcome"]["value"]["reconciliation"], "reconciled", + "{update_envelope}" + ); + assert_eq!( + update_envelope["outcome"]["value"]["receipt"]["outcome"], "completed", + "{update_envelope}" + ); + let updated = update_envelope + .pointer("/outcome/value/payload") + .cloned() + .expect("update payload"); + assert_eq!( + updated["trust_delta_millionths"], + json!(300_000), + "{updated}" + ); + assert_fact_snapshot(&updated["fact"], &rewritten); + assert_committed_update(&updated, &fact_id, &project_id, 2); + let rewritten_fact = available_fact(&updated["fact"]); + assert_eq!( + rewritten_fact["telemetry"]["created_at"], seeded_created_at, + "update must not rewrite the fact's creation time: {rewritten_fact}" + ); + assert_ne!( + rewritten_fact["telemetry"]["updated_at"], seeded_created_at, + "update must advance updated_at: {rewritten_fact}" + ); + assert_eq!( + rewritten_fact["telemetry"]["retrieval_count"], + json!(0), + "{rewritten_fact}" + ); + assert_eq!( + rewritten_fact["telemetry"]["access_count"], + json!(0), + "{rewritten_fact}" + ); + assert_eq!( + rewritten_fact["telemetry"]["helpful_count"], + json!(0), + "{rewritten_fact}" + ); + assert_eq!( + rewritten_fact["telemetry"]["unhelpful_count"], + json!(0), + "{rewritten_fact}" + ); + assert!( + rewritten_fact["telemetry"]["last_retrieved_at"].is_null(), + "{rewritten_fact}" + ); + assert!( + rewritten_fact["telemetry"]["last_recalled_at"].is_null(), + "{rewritten_fact}" + ); + assert!( + rewritten_fact["telemetry"]["last_feedback_at"].is_null(), + "{rewritten_fact}" + ); + assert_ne!( + rewritten_fact["last_event_id"], seeded_event_id, + "{rewritten_fact}" + ); + assert_ne!( + rewritten_fact["active_assertion_id"], seeded_assertion_id, + "{rewritten_fact}" + ); + + let stored = retained_payload( + &handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_get", + json!({"fact_id": fact_id}), + ) + .await, + "tracedecay_fact_store_get", + ); + assert_fact_snapshot(&stored["fact"], &rewritten); + + let reviewed = json!({ + "fact_id": fact_id, + "project_id": project_id, + "operation_id": operation_id, + "content": REVIEWED_CONTENT, + "category": "decision", + "tags": SORTED_TAGS, + "entities": SORTED_ENTITIES, + "trust_score_millionths": 800_000, + "source_label": "operator-note", + "metadata": {"lane": "proof", "updated": true} + }); + let narrowed = retained_payload( + &handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_update", + json!({ + "fact_id": fact_id, + "content": REVIEWED_CONTENT + }), + ) + .await, + "tracedecay_fact_store_update", + ); + assert_eq!(narrowed["trust_delta_millionths"], json!(0), "{narrowed}"); + assert_fact_snapshot(&narrowed["fact"], &reviewed); + assert_committed_update(&narrowed, &fact_id, &project_id, 1); + let reviewed_event_id = available_fact(&narrowed["fact"])["last_event_id"].clone(); + + let cleared = json!({ + "fact_id": fact_id, + "project_id": project_id, + "operation_id": operation_id, + "content": REVIEWED_CONTENT, + "category": "decision", + "tags": SORTED_TAGS, + "entities": SORTED_ENTITIES, + "trust_score_millionths": 800_000, + "source_label": null, + "metadata": {"lane": "proof", "updated": true} + }); + let cleared_payload = retained_payload( + &handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_update", + json!({ + "fact_id": fact_id, + "expected_last_event_id": reviewed_event_id, + "source_label": {"kind": "clear"} + }), + ) + .await, + "tracedecay_fact_store_update", + ); + assert_eq!( + cleared_payload["trust_delta_millionths"], + json!(0), + "{cleared_payload}" + ); + assert_fact_snapshot(&cleared_payload["fact"], &cleared); + assert_committed_update(&cleared_payload, &fact_id, &project_id, 1); + + let stale = handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_update", + json!({ + "fact_id": fact_id, + "expected_last_event_id": seeded_event_id, + "content": STALE_CONTENT + }), + ) + .await; + assert_problem( + &stale, + json!({ + "kind": "conflict", + "code": "application.retained.conflict", + "message": "The retained operation conflicts with current state.", + "retry": "after_revalidate", + "legal_actions": ["refresh"], + "diagnostic": { + "code": "application.retained.conflict", + "message": "The retained operation conflicts with current state." + } + }), + ); + + let empty = handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_update", + json!({"fact_id": fact_id}), + ) + .await; + assert_problem( + &empty, + json!({ + "kind": "invalid_request", + "code": "application.retained.invalid-request", + "message": "The retained operation request is invalid.", + "retry": "never", + "legal_actions": ["correct_request"], + "diagnostic": { + "code": "application.retained.invalid-request", + "message": "The retained operation request is invalid." + } + }), + ); + + let blank = handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_update", + json!({ + "fact_id": fact_id, + "content": " " + }), + ) + .await; + assert_problem( + &blank, + json!({ + "kind": "invalid_request", + "code": "application.retained.invalid-request", + "message": "The retained operation request is invalid.", + "retry": "never", + "legal_actions": ["correct_request"], + "diagnostic": { + "code": "application.retained.invalid-request", + "message": "The retained operation request is invalid." + } + }), + ); + + let missing = handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_update", + json!({ + "fact_id": unknown_fact_id(&fact_id), + "content": "no such fact" + }), + ) + .await; + assert_problem( + &missing, + json!({ + "kind": "not_found_or_not_authorized", + "code": "not_found_or_not_authorized", + "message": "The requested resource was not found or is not authorized", + "retry": "never", + "legal_actions": [], + "diagnostic": null + }), + ); + + let untouched = retained_payload( + &handle_real_server_tool_call_raw( + &server, + "tracedecay_fact_store_get", + json!({"fact_id": fact_id}), + ) + .await, + "tracedecay_fact_store_get", + ); + assert_fact_snapshot(&untouched["fact"], &cleared); + assert_eq!( + available_fact(&untouched["fact"])["content"], + REVIEWED_CONTENT, + "refused updates must leave the reviewed content stored: {untouched}" + ); + + fixture.harness.shutdown().await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_list_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_list_test.rs new file mode 100644 index 0000000000..a5044e6ef2 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/feedback_list_test.rs @@ -0,0 +1,392 @@ +#![cfg(feature = "test-transport")] + +//! `tracedecay_feedback_list` through the production MCP `tools/call` path. +//! +//! A host cannot list findings until a completed cycle mints a list handle. +//! This test publishes one real compiler diagnostic, then reads it back and +//! checks that other handles are refused rather than returned as an empty page. + +use std::fs; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +use serde_json::{Value, json}; + +use crate::support::{production_composition_fixture_with_sources, wait_for_current_graph}; + +const SYMBOL: &str = "missing_feedback_list_symbol"; +const SOURCE: &str = "pub fn entry() { missing_feedback_list_symbol(); }\n"; + +#[tokio::test] +async fn feedback_list_returns_the_published_compiler_finding_and_denies_other_handles() { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).expect("source directory"); + fs::write( + project.join("Cargo.toml"), + "[package]\nname = \"feedback-list-behavior\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .expect("cargo manifest"); + fs::write(project.join("src/lib.rs"), SOURCE).expect("source"); + }) + .await; + + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production MCP server"); + wait_for_current_graph(&server).await; + drop(server); + + let compiler = compiler_failure(&fixture.project_root); + let message = compiler_diagnostic_message(&compiler); + let diagnosed = tool_json( + &mcp_call( + &fixture, + "tracedecay_diagnose", + json!({ + "cargo_output": compiler, + "include_callers": false, + "format": "json", + }), + ) + .await, + ); + assert_eq!( + diagnosed["published"]["status"], "published", + "compiler output must reach the diagnostic store before list can see it: {diagnosed}" + ); + assert_eq!(diagnosed["published"]["inserted"], 1, "{diagnosed}"); + + let cycle = published_cycle(&fixture).await; + let list_handle = cycle["read_handles"]["list_handle"] + .as_str() + .expect("minted list handle") + .to_owned(); + let cycle_finding = &cycle["finding_handles"][0]; + + let listed_response = mcp_call( + &fixture, + "tracedecay_feedback_list", + json!({ + "request_handle": list_handle, + "format": "json", + }), + ) + .await; + assert_ne!( + listed_response["result"]["isError"], true, + "a minted list handle must not be a tool error: {listed_response}" + ); + let listed = tool_json(&listed_response); + let (packet, payload) = evidence(&listed); + let findings = payload["findings"].as_array().expect("findings array"); + assert_eq!(findings.len(), 1, "{payload}"); + assert_eq!(packet["page"]["total"], 1, "{packet}"); + assert_eq!(packet["page"]["returned"], 1, "{packet}"); + assert!( + packet["page"]["cursor"].is_null(), + "one finding is the whole page: {packet}" + ); + assert_eq!(packet["page"]["returned"], findings.len() as u64); + assert_eq!(packet["coverage"]["returned"], 1, "{packet}"); + assert_eq!(packet["coverage"]["completeness"], "complete", "{packet}"); + assert_eq!(packet["execution"]["termination"], "completed", "{packet}"); + + let finding = &findings[0]; + let projection = &finding["finding"]["diagnostic_projection"]; + let line = SOURCE.trim_end_matches(['\n', '\r']); + let symbol_start = u64::try_from(line.find(SYMBOL).expect("symbol in source")).unwrap(); + assert_eq!( + finding["finding"]["finding_id"], cycle_finding["finding_id"], + "{finding}" + ); + assert_eq!(finding["cycle_id"], cycle["cycle"]["cycle_id"], "{finding}"); + // List mints a fresh get and expand handle for this page. Those are not + // the handles the advisory cycle minted, and neither may be reused as the + // list request. + let listed_get = finding["get_handle"] + .as_str() + .expect("list mints a get handle") + .to_owned(); + let listed_expand = finding["expand_handle"] + .as_str() + .expect("anchored finding mints an expand handle") + .to_owned(); + assert_ne!(listed_get, list_handle); + assert_ne!(listed_expand, list_handle); + assert_ne!(listed_get, listed_expand); + assert_eq!(finding["finding"]["lifecycle"], "active", "{finding}"); + assert_eq!(finding["finding"]["classification"], "new", "{finding}"); + assert_eq!( + finding["finding"]["provider_state"], "supported_completed_complete", + "{finding}" + ); + assert_eq!(projection["code"], "E0425", "{projection}"); + assert_eq!(projection["severity"], "error", "{projection}"); + assert_eq!(projection["producer"], "code_diagnostic", "{projection}"); + assert_eq!( + projection["span"]["start_byte"], symbol_start, + "{projection}" + ); + assert_eq!( + projection["span"]["end_byte"], + u64::try_from(line.len()).unwrap(), + "{projection}" + ); + assert_eq!(projection["safe_bounded_message"], message, "{projection}"); + assert_eq!( + finding["finding"]["safe_bounded_preview"], message, + "{finding}" + ); + + let unknown_response = mcp_call( + &fixture, + "tracedecay_feedback_list", + json!({ + "request_handle": "rh_unknown_feedback_list", + "format": "json", + }), + ) + .await; + let unknown = problem_record(&unknown_response); + assert_eq!(unknown["kind"], "not_found_or_not_authorized", "{unknown}"); + assert_eq!(unknown["code"], "not_found_or_not_authorized", "{unknown}"); + assert_eq!(unknown["retryable"], false, "{unknown}"); + + let get_handle = cycle_finding["get_handle"].as_str().expect("get handle"); + deny_list( + &fixture, + get_handle, + "a cycle get handle must not list findings", + ) + .await; + deny_list( + &fixture, + &listed_get, + "the get handle on a list page must not list findings", + ) + .await; + deny_list( + &fixture, + &listed_expand, + "the expand handle on a list page must not list findings", + ) + .await; + + let malformed = mcp_call( + &fixture, + "tracedecay_feedback_list", + json!({ + "request_handle": " rh_leading_space", + "format": "json", + }), + ) + .await; + assert!( + malformed["result"].is_null(), + "a malformed handle is a protocol error, not an empty page: {malformed}" + ); + assert_eq!(malformed["error"]["code"], -32602, "{malformed}"); + assert_eq!( + malformed["error"]["data"]["kind"], "invalid_request", + "{malformed}" + ); + assert_eq!( + malformed["error"]["data"]["reason_code"], "application_surface_invalid_request", + "{malformed}" + ); + assert_eq!( + malformed["error"]["data"]["retryable"], false, + "{malformed}" + ); + + fixture.harness.shutdown().await; +} + +fn compiler_failure(project: &Path) -> String { + let output_dir = project + .parent() + .expect("project parent") + .join("compiler-out"); + fs::create_dir_all(&output_dir).expect("compiler output directory"); + // `rustup` selects the toolchain from the working directory. The fixture + // project lives outside the repository, so a bare `rustc` there is the + // default toolchain (edition 2021) and never emits E0425. Resolve the + // compiler from the repository root first, then compile in the project. + let workspace = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("workspace root"); + let resolved = Command::new("rustup") + .args(["which", "rustc"]) + .current_dir(workspace) + .output() + .expect("resolve rustc"); + let rustc_path = String::from_utf8(resolved.stdout).expect("rustc path"); + assert!( + resolved.status.success(), + "rustup which rustc failed: {}", + String::from_utf8_lossy(&resolved.stderr) + ); + let output = Command::new(rustc_path.trim()) + .current_dir(project) + .args([ + "--crate-type=lib", + "--edition=2024", + "--error-format=short", + "--color=never", + "src/lib.rs", + "--out-dir", + ]) + .arg(&output_dir) + .output() + .expect("run rustc"); + let stderr = String::from_utf8(output.stderr).expect("rustc stderr"); + assert!( + !output.status.success(), + "the unresolved call must fail compilation: {stderr}" + ); + assert!( + stderr.contains("error[E0425]") && stderr.contains(SYMBOL), + "{stderr}" + ); + stderr +} + +/// The host-visible finding text is the compiler's own E0425 message, not a +/// restatement of a fixture string. A later rustc that rewords the diagnostic +/// still has to carry that exact line through diagnose and list. +fn compiler_diagnostic_message(stderr: &str) -> String { + stderr + .lines() + .find_map(|line| { + line.split_once("error[E0425]: ") + .map(|(_, message)| message.trim().to_owned()) + }) + .expect("rustc E0425 message") +} + +async fn published_cycle(fixture: &crate::support::ProductionCompositionFixture) -> Value { + let document_uri = url::Url::from_file_path(fixture.project_root.join("src/lib.rs")) + .expect("document file URI") + .to_string(); + let deadline = Instant::now() + Duration::from_secs(90); + loop { + let response = mcp_call( + fixture, + "tracedecay_feedback_advisory_cycle", + json!({ + "document_uri": document_uri, + "format": "json", + }), + ) + .await; + if retryable_mount(&response) { + assert!( + Instant::now() < deadline, + "advisory cycle stayed unavailable: {response}" + ); + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + let body = tool_json(&response); + let (_packet, payload) = evidence(&body); + assert_eq!(payload["cycle"]["published"], true, "{payload}"); + assert_eq!(payload["cycle"]["durability"], "durable", "{payload}"); + assert_eq!( + payload["finding_handles"].as_array().map(Vec::len), + Some(1), + "{payload}" + ); + return payload.clone(); + } +} + +fn retryable_mount(response: &Value) -> bool { + let reason = response["error"]["data"]["reason_code"].as_str(); + let code = response["result"]["problem"]["code"].as_str(); + let retryable = response["error"]["data"]["retryable"] == true + || response["result"]["problem"]["retryable"] == true; + retryable + && matches!( + reason.or(code), + Some( + "feedback.advisory-cycle.unavailable" + | "feedback.owner_unavailable" + | "code-graph-unavailable" + | "project_warming" + ) + ) +} + +async fn mcp_call( + fixture: &crate::support::ProductionCompositionFixture, + tool_name: &str, + arguments: Value, +) -> Value { + let response = fixture + .harness + .call_tool(&fixture.project_root, tool_name, arguments) + .await + .unwrap_or_else(|error| panic!("{tool_name} MCP call failed: {error}")); + serde_json::to_value(&response) + .unwrap_or_else(|error| panic!("{tool_name} MCP response was not JSON: {error}")) +} + +fn tool_json(response: &Value) -> Value { + assert!( + response["error"].is_null(), + "MCP tools/call failed: {response}" + ); + let text = response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("MCP tool returned no text: {response}")); + serde_json::from_str(text) + .unwrap_or_else(|error| panic!("MCP tool text was not JSON ({error}): {text}")) +} + +fn evidence(envelope: &Value) -> (&Value, &Value) { + assert_eq!(envelope["outcome"]["outcome"], "evidence", "{envelope}"); + let packet = &envelope["outcome"]["value"]; + let payload = packet + .get("payload") + .unwrap_or_else(|| panic!("evidence omitted its payload: {envelope}")); + (packet, payload) +} + +async fn deny_list( + fixture: &crate::support::ProductionCompositionFixture, + request_handle: &str, + reason: &str, +) { + let response = mcp_call( + fixture, + "tracedecay_feedback_list", + json!({ + "request_handle": request_handle, + "format": "json", + }), + ) + .await; + let problem = problem_record(&response); + assert_eq!( + problem["kind"], "not_found_or_not_authorized", + "{reason}: {problem}" + ); + assert_eq!(problem["code"], "not_found_or_not_authorized", "{problem}"); + assert_eq!(problem["retryable"], false, "{problem}"); +} + +fn problem_record(response: &Value) -> &Value { + assert_eq!( + response["result"]["isError"], true, + "a refused list must be a tool error, not an empty success: {response}" + ); + let record = &response["result"]["problem"]; + assert!( + record.is_object(), + "refused list omitted its problem: {response}" + ); + record +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/files_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/files_behavior_test.rs new file mode 100644 index 0000000000..df2d1e41af --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/files_behavior_test.rs @@ -0,0 +1,342 @@ +//! `tracedecay_files` as a host calls it: one `tools/call` on the production +//! MCP server, against a project whose files are written before the call. +//! +//! The census is the indexed symbol grain, not file nodes. `Cargo.toml` is +//! one `[package]` table plus its three keys. `src/lib.rs` declares one module +//! and one function. `src/greeting.rs` declares one function. `README.md` has +//! no heading, so it contributes no symbol and stays off the list. +//! +//! `glob::Pattern::matches` does not require a literal path separator, so `*` +//! crosses `/`. `*.rs` therefore matches `src/lib.rs`, not only a basename. + +#![cfg(feature = "test-transport")] + +use std::fs; +use std::path::Path; + +use serde_json::{Value, json}; + +use crate::support::{ + ProductionCompositionFixture, extract_first_json_content, + production_composition_fixture_with_sources, +}; + +const TOOL: &str = "tracedecay_files"; + +const CARGO_TOML: &str = + "[package]\nname = \"files_probe\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"; +const LIB_RS: &str = + "pub mod greeting;\n\npub fn root_value() -> i32 {\n greeting::hello()\n}\n"; +const GREETING_RS: &str = "pub fn hello() -> i32 {\n 7\n}\n"; +const README_MD: &str = "not indexed\n"; + +const GROUPED_ALL: &str = "\ +## Files +**indexed files:** 3 +**layout:** grouped + +```text +Cargo.toml (4 symbols) +src/ + greeting.rs (1 symbols) + lib.rs (2 symbols) +``` +"; + +const FLAT_ALL: &str = "\ +## Files +**indexed files:** 3 +**layout:** flat + +```text +- Cargo.toml (4 symbols, 66 bytes) +- src/greeting.rs (1 symbols, 32 bytes) +- src/lib.rs (2 symbols, 72 bytes) +``` +"; + +const GROUPED_SRC: &str = "\ +## Files +**indexed files:** 2 +**layout:** grouped + +```text +src/ + greeting.rs (1 symbols) + lib.rs (2 symbols) +``` +"; + +const GROUPED_GREETING: &str = "\ +## Files +**indexed files:** 1 +**layout:** grouped + +```text +- src/greeting.rs (1 symbols) +``` +"; + +const GROUPED_LIB: &str = "\ +## Files +**indexed files:** 1 +**layout:** grouped + +```text +- src/lib.rs (2 symbols) +``` +"; + +const GROUPED_CARGO: &str = "\ +## Files +**indexed files:** 1 +**layout:** grouped + +```text +- Cargo.toml (4 symbols) +``` +"; + +const EMPTY_GROUPED: &str = "\ +## Files +**indexed files:** 0 +**layout:** grouped + +_No indexed files matched._ +"; + +#[tokio::test] +async fn files_lists_the_indexed_census_and_filters() { + let fixture = files_project().await; + + let grouped = call_markdown(&fixture, json!({})).await; + assert_eq!(grouped, GROUPED_ALL, "default markdown\n{grouped}"); + assert_eq!( + call_markdown(&fixture, json!({"layout": "grouped"})).await, + GROUPED_ALL, + "explicit grouped layout must match the default" + ); + + let grouped_census = listing(3, "grouped", all_files()); + assert_eq!( + call_json(&fixture, json!({"format": "json"})).await, + grouped_census, + "json census" + ); + assert_eq!( + call_json(&fixture, json!({"format": "JSON"})).await, + grouped_census, + "format is case-insensitive" + ); + assert_eq!( + call_json(&fixture, json!({"format": "json", "layout": "flat"})).await, + listing(3, "flat", all_files()), + "flat changes the layout field, not the file records" + ); + assert_eq!( + call_markdown(&fixture, json!({"layout": "flat"})).await, + FLAT_ALL, + "flat markdown" + ); + + let src_files = json!([file("src/greeting.rs", 1, 32), file("src/lib.rs", 2, 72),]); + assert_eq!( + call_json(&fixture, json!({"format": "json", "path": "src"})).await, + listing(2, "grouped", src_files.clone()), + "path src" + ); + assert_eq!( + call_json(&fixture, json!({"format": "json", "path": "src/"})).await, + listing(2, "grouped", src_files), + "a trailing slash is the same directory" + ); + assert_eq!( + call_markdown(&fixture, json!({"path": "src"})).await, + GROUPED_SRC, + "path src markdown" + ); + + assert_eq!( + call_json( + &fixture, + json!({"format": "json", "path": "src/greeting.rs"}) + ) + .await, + listing(1, "grouped", json!([file("src/greeting.rs", 1, 32)])), + "an exact file path matches that file" + ); + assert_eq!( + call_markdown(&fixture, json!({"path": "src/greeting.rs"})).await, + GROUPED_GREETING, + "a single file stays a bullet, not a tree" + ); + assert_eq!( + call_markdown(&fixture, json!({"path": "src/lib.rs"})).await, + GROUPED_LIB, + "path src/lib.rs" + ); + assert_eq!( + call_json(&fixture, json!({"format": "json", "path": "src/lib.rs/"})).await, + listing(0, "grouped", json!([])), + "a trailing slash after a file is a directory prefix, not that file" + ); + assert_eq!( + call_markdown(&fixture, json!({"path": "src/lib"})).await, + EMPTY_GROUPED, + "a path prefix that is not a directory boundary matches nothing" + ); + assert_eq!( + call_json(&fixture, json!({"format": "json", "path": ""})).await, + listing(0, "grouped", json!([])), + "an empty path is a prefix of nothing" + ); + + assert_eq!( + call_json(&fixture, json!({"format": "json", "pattern": "*.rs"})).await, + listing( + 2, + "grouped", + json!([file("src/greeting.rs", 1, 32), file("src/lib.rs", 2, 72),]) + ), + "*.rs crosses directories" + ); + assert_eq!( + call_json(&fixture, json!({"format": "json", "pattern": "*.toml"})).await, + listing(1, "grouped", json!([file("Cargo.toml", 4, 66)])), + "*.toml" + ); + assert_eq!( + call_markdown(&fixture, json!({"pattern": "*.toml"})).await, + GROUPED_CARGO, + "*.toml markdown" + ); + assert_eq!( + call_json(&fixture, json!({"format": "json", "pattern": "*.md"})).await, + listing(0, "grouped", json!([])), + "a heading-less markdown file is not indexed" + ); + assert_eq!( + call_json( + &fixture, + json!({"format": "json", "path": "src", "pattern": "*.toml"}) + ) + .await, + listing(0, "grouped", json!([])), + "path and pattern both have to match" + ); + assert_eq!( + call_json( + &fixture, + json!({"format": "json", "path": "src", "pattern": "**/lib.rs"}) + ) + .await, + listing(1, "grouped", json!([file("src/lib.rs", 2, 72)])), + "path and glob intersect on src/lib.rs" + ); + assert_eq!( + call_markdown(&fixture, json!({"pattern": "nope*"})).await, + EMPTY_GROUPED, + "a glob that matches no indexed file" + ); + + assert_tool_error( + &call(&fixture, json!({"pattern": "["})).await, + "tool execution failed: config error: invalid file glob '[': Pattern syntax error near position 0: invalid range pattern", + ); + assert_tool_error( + &call(&fixture, json!([])).await, + "tool execution failed: config error: invalid arguments: tracedecay_files expects a JSON object", + ); + + fixture.harness.shutdown().await; +} + +async fn files_project() -> ProductionCompositionFixture { + production_composition_fixture_with_sources(|root| { + write(root, "Cargo.toml", CARGO_TOML); + write(root, "src/lib.rs", LIB_RS); + write(root, "src/greeting.rs", GREETING_RS); + write(root, "README.md", README_MD); + }) + .await +} + +fn all_files() -> Value { + json!([ + file("Cargo.toml", 4, 66), + file("src/greeting.rs", 1, 32), + file("src/lib.rs", 2, 72), + ]) +} + +fn file(path: &str, symbols: u64, bytes: u64) -> Value { + json!({"path": path, "symbols": symbols, "bytes": bytes}) +} + +fn listing(count: u64, layout: &str, files: Value) -> Value { + json!({"count": count, "layout": layout, "files": files}) +} + +async fn call_json(fixture: &ProductionCompositionFixture, arguments: Value) -> Value { + let response = call(fixture, arguments).await; + assert!( + response.error.is_none(), + "{TOOL} failed: {:?}", + response.error + ); + let result = response.result.as_ref().expect("tools/call result"); + extract_first_json_content(result) +} + +async fn call_markdown(fixture: &ProductionCompositionFixture, arguments: Value) -> String { + let response = call(fixture, arguments).await; + assert!( + response.error.is_none(), + "{TOOL} failed: {:?}", + response.error + ); + let result = response.result.as_ref().expect("tools/call result"); + result["content"] + .as_array() + .and_then(|items| { + items.iter().find_map(|item| { + let text = item.get("text").and_then(Value::as_str)?; + text.starts_with("## Files\n").then_some(text) + }) + }) + .unwrap_or_else(|| panic!("missing files markdown in {result}")) + .to_owned() +} + +async fn call( + fixture: &ProductionCompositionFixture, + arguments: Value, +) -> tracedecay_mcp::JsonRpcResponse { + fixture + .harness + .call_tool(&fixture.project_root, TOOL, arguments) + .await + .expect("production MCP answers a tools/call") +} + +fn assert_tool_error(response: &tracedecay_mcp::JsonRpcResponse, message: &str) { + assert!(response.result.is_none(), "{response:?}"); + let error = response.error.as_ref().expect("tool error"); + assert_eq!(error.code, -32603, "{error:?}"); + assert_eq!(error.message, message, "{error:?}"); + assert_eq!( + error + .data + .as_ref() + .and_then(|data| data.get("tool")) + .and_then(Value::as_str), + Some(TOOL), + "{error:?}" + ); +} + +fn write(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + fs::create_dir_all(path.parent().expect("parent")).expect("create dirs"); + fs::write(path, contents).expect("write fixture file"); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/find_exact_symbol_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/find_exact_symbol_test.rs new file mode 100644 index 0000000000..6541e5182b --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/find_exact_symbol_test.rs @@ -0,0 +1,269 @@ +//! Production MCP behavior for `tracedecay_find_exact_symbol`. +//! +//! Calls go through `tools/call` on the production server. Expectations are +//! the symbol identity a caller reads, not the index that produced it. + +#![cfg(feature = "test-transport")] + +use crate::support::{ + ProductionCompositionFixture, extract_real_server_text, handle_real_server_tool_call_raw, + production_composition_fixture_with_sources, warm_code_index_search, +}; +use serde_json::{Value, json}; +use std::fs; +use std::sync::Arc; +use tracedecay::mcp::McpServer; + +const SOLVERS_SOURCE: &str = "\ +pub struct Solvers { + pub gmres: u32, +} + +pub fn gmres(x: u32) -> u32 { + x + 1 +} +"; + +const SHARED_TOKEN_SOURCE: &str = "\ +pub fn shared_token() -> u32 { + 1 +} +"; + +fn symbol( + name: &str, + qualified_name: &str, + kind: &str, + file: &str, + line: u64, + signature: &str, +) -> Value { + json!({ + "name": name, + "qualified_name": qualified_name, + "kind": kind, + "file": file, + "line": line, + "signature": signature, + }) +} + +fn gmres_field() -> Value { + symbol( + "gmres", + "src/lib.rs::Solvers::gmres", + "field", + "src/lib.rs", + 2, + "pub gmres: u32", + ) +} + +fn gmres_function() -> Value { + symbol( + "gmres", + "src/lib.rs::gmres", + "function", + "src/lib.rs", + 5, + "pub fn gmres(x: u32) -> u32", + ) +} + +fn shared_token(file: &str) -> Value { + symbol( + "shared_token", + &format!("{file}::shared_token"), + "function", + file, + 1, + "pub fn shared_token() -> u32", + ) +} + +async fn indexed_project() -> (ProductionCompositionFixture, Arc) { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).expect("fixture src dir"); + fs::write(project.join("src/lib.rs"), SOLVERS_SOURCE).expect("solvers source"); + fs::write(project.join("src/billing.rs"), SHARED_TOKEN_SOURCE).expect("billing source"); + fs::write(project.join("src/ledger.rs"), SHARED_TOKEN_SOURCE).expect("ledger source"); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + warm_code_index_search(&server, "gmres").await; + (fixture, server) +} + +async fn exact_payload(server: &McpServer, arguments: Value) -> Value { + let response = + handle_real_server_tool_call_raw(server, "tracedecay_find_exact_symbol", arguments).await; + assert!( + response["error"].is_null(), + "exact-symbol call must succeed: {response}" + ); + serde_json::from_str(extract_real_server_text(&response["result"])) + .expect("exact-symbol response JSON") +} + +fn sorted_without_ids(mut payload: Value) -> Value { + let matches = payload["matches"] + .as_array_mut() + .expect("exact-symbol matches array"); + for item in matches.iter_mut() { + item.as_object_mut() + .expect("exact-symbol match object") + .remove("id"); + } + matches.sort_by(|left, right| { + ( + left["file"].as_str(), + left["line"].as_u64(), + left["kind"].as_str(), + ) + .cmp(&( + right["file"].as_str(), + right["line"].as_u64(), + right["kind"].as_str(), + )) + }); + payload +} + +fn occurrence_ids(payload: &Value) -> Vec { + payload["matches"] + .as_array() + .expect("exact-symbol matches array") + .iter() + .map(|item| { + item["id"] + .as_str() + .expect("exact-symbol match occurrence id") + .to_owned() + }) + .collect() +} + +#[tokio::test] +async fn find_exact_symbol_returns_every_bare_name_hit() { + let (fixture, server) = indexed_project().await; + + let gmres = exact_payload( + &server, + json!({"name": "gmres", "limit": 20, "format": "json"}), + ) + .await; + let gmres_ids = occurrence_ids(&gmres); + assert_eq!( + sorted_without_ids(gmres), + json!({ + "name": "gmres", + "count": 2, + "matches": [gmres_field(), gmres_function()], + }) + ); + assert_eq!(gmres_ids.len(), 2); + assert_ne!(gmres_ids[0], gmres_ids[1]); + + let folded = exact_payload(&server, json!({"name": "Gmres", "format": "json"})).await; + assert_eq!(folded["name"], "Gmres"); + assert_eq!(folded["count"], 2); + assert_eq!( + sorted_without_ids(folded)["matches"], + json!([gmres_field(), gmres_function()]) + ); + + for name in [ + "gmre", + "src/lib.rs::gmres", + "Solvers::gmres", + "shared_tok", + "not_a_symbol", + ] { + assert_eq!( + exact_payload(&server, json!({"name": name, "format": "json"})).await, + json!({"name": name, "count": 0, "matches": []}), + "a non-equal bare name must not match" + ); + } + + fixture.harness.shutdown().await; +} + +#[tokio::test] +async fn find_exact_symbol_applies_limit_and_rejects_bad_arguments() { + let (fixture, server) = indexed_project().await; + let billing = shared_token("src/billing.rs"); + let ledger = shared_token("src/ledger.rs"); + + let all = exact_payload(&server, json!({"name": "shared_token", "format": "json"})).await; + assert_eq!( + sorted_without_ids(all), + json!({ + "name": "shared_token", + "count": 2, + "matches": [billing.clone(), ledger.clone()], + }) + ); + + let capped = exact_payload( + &server, + json!({"name": "shared_token", "limit": 1, "format": "json"}), + ) + .await; + assert_eq!(capped["name"], "shared_token"); + assert_eq!(capped["count"], 1); + let only = sorted_without_ids(capped)["matches"][0].clone(); + assert!( + only == billing || only == ledger, + "limit 1 must return one indexed declaration, got {only}" + ); + + let missing = handle_real_server_tool_call_raw( + &server, + "tracedecay_find_exact_symbol", + json!({"format": "json"}), + ) + .await; + assert_eq!(missing["result"], Value::Null); + assert_eq!(missing["error"]["code"], -32602); + assert_eq!( + missing["error"]["message"], + "missing required parameter: name" + ); + assert_eq!( + missing["error"]["data"], + json!({ + "tool": "tracedecay_find_exact_symbol", + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: name", + }) + ); + + let zero = handle_real_server_tool_call_raw( + &server, + "tracedecay_find_exact_symbol", + json!({"name": "shared_token", "limit": 0, "format": "json"}), + ) + .await; + assert_eq!(zero["result"], Value::Null); + assert_eq!(zero["error"]["code"], -32602); + assert_eq!( + zero["error"]["message"], + "tool project route failed: reason_code=code-graph-invalid-request retryable=false: the code-graph read request is invalid: code graph name resolution limit must be positive" + ); + assert_eq!( + zero["error"]["data"], + json!({ + "tool": "tracedecay_find_exact_symbol", + "reason_code": "code-graph-invalid-request", + "retryable": false, + "detail": "the code-graph read request is invalid: code graph name resolution limit must be positive", + }) + ); + + fixture.harness.shutdown().await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs index 8acf0b25e8..4e8884679c 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_analysis_test.rs @@ -1959,72 +1959,6 @@ pub fn caller() { called(); } ); } -/// `build_file_adjacency` must count only `uses` and `calls` for file-level -/// dependency depth. `implements` and `extends` edges are heavily -/// resolver-fuzzy-bound to nonsense targets in unrelated files. -#[tokio::test] -async fn dependency_depth_excludes_implements_and_extends() { - let dir = test_temp_dir(); - let project_root = dir.path().join("project"); - fs::create_dir_all(&project_root).unwrap(); - let project = project_root.as_path(); - fs::create_dir_all(project.join("src")).unwrap(); - // file_a derives Debug, extractor emits derives_macro and the - // resolver historically pollutes implements edges across files. - fs::write( - project.join("src/lib.rs"), - r#" -mod a; -mod b; -"#, - ) - .unwrap(); - fs::write( - project.join("src/a.rs"), - r#" -#[derive(Debug, Clone)] -pub struct A; -"#, - ) - .unwrap(); - fs::write( - project.join("src/b.rs"), - r#" -pub trait T {} -"#, - ) - .unwrap(); - let (cg, _env) = init_test_project(project).await; - - let result = handle_tool_call( - &cg, - "tracedecay_dependency_depth", - json!({"limit": 100}), - None, - None, - ) - .await - .unwrap(); - let output: Value = serde_json::from_str(extract_text(&result.value)).unwrap(); - let chains = output["chains"] - .as_array() - .expect("dependency-depth response should contain chains"); - assert!( - chains.iter().all(|entry| { - let chain = entry["chain"] - .as_array() - .expect("dependency-depth chain should be an array"); - !chain.windows(2).any(|pair| { - matches!( - (pair[0].as_str(), pair[1].as_str()), - (Some("src/a.rs"), Some("src/b.rs")) | (Some("src/b.rs"), Some("src/a.rs")) - ) - }) - }), - "derive/trait metadata must not create a dependency between leaf files: {output}" - ); -} - /// `tracedecay_diagnose` must normalize span paths before looking them up /// in the graph. cargo emits absolute and (on Windows) backslash-separated /// paths; the graph stores project-relative, forward-slash paths. Without @@ -3646,3 +3580,292 @@ async fn field_sites_ignores_field_text_in_real_rust_literals() { ); assert_eq!(output["write_sites"][0]["line"], 256, "payload: {output}"); } + +fn field_site(line: u64, enclosing: &str, snippet: &str) -> Value { + json!({ + "file": "src/lib.rs", + "line": line, + "enclosing": enclosing, + "snippet": snippet, + }) +} + +const FIELD_BEHAVIOR_SOURCE: &str = r#"pub struct Counter { + pub n: u32, +} + +pub struct Gauge { + pub n: u32, +} + +impl Counter { + pub fn read(&self, gauge: &Gauge) -> u32 { + let kept = self.n; + let other = gauge.n; + kept + other + } +} + +pub fn bump(counter: &mut Counter, gauge: &mut Gauge) -> u32 { + let same = counter.n == 0; + counter.n = 1; + gauge.n += 2; + let borrowed = &mut counter.n; + let shifted = counter.n << 1; + counter.n <<= 1; + let shown = "counter.n = 9"; + // counter.n = 8; + let _ = (same, borrowed, shifted, shown); + counter.n +} + +pub fn arrow(counter: &Counter) -> u32 { + take!(counter.n => 1); + counter.n +} +"#; + +const FIELD_QUALIFIED_SOURCE: &str = r#"pub struct Counter { + pub n: u32, +} + +pub struct Gauge { + pub n: u32, +} + +impl Counter { + pub fn read(&self, gauge: &Gauge) -> u32 { + let kept = self.n; + let other = gauge.n; + kept + other + } +} + +pub fn bump(counter: &mut Counter, gauge: &mut Gauge) -> u32 { + let same = counter.n == 0; + counter.n = 1; + gauge.n += 2; + let borrowed = &mut counter.n; + let shifted = counter.n << 1; + counter.n <<= 1; + counter.n +} +"#; + +async fn call_field_sites(host: &impl AnalysisToolHost, arguments: Value) -> Value { + let result = handle_tool_call(host, "tracedecay_field_sites", arguments, None, None) + .await + .expect("production MCP field-sites call"); + extract_json(&result.value) +} + +#[tokio::test] +async fn field_sites_behavior_reports_literal_read_and_write_sites() { + let dir = test_temp_dir(); + let project_root = dir.path().join("project"); + fs::create_dir_all(project_root.join("src")).unwrap(); + fs::write(project_root.join("src/lib.rs"), FIELD_BEHAVIOR_SOURCE).unwrap(); + let (host, _env) = init_test_project(&project_root).await; + + let read_method = "src/lib.rs::Counter::read"; + let bump = "src/lib.rs::bump"; + let arrow = "src/lib.rs::arrow"; + let reads = json!([ + field_site(11, read_method, "let kept = self.n;"), + field_site(12, read_method, "let other = gauge.n;"), + field_site(18, bump, "let same = counter.n == 0;"), + field_site(22, bump, "let shifted = counter.n << 1;"), + field_site(27, bump, "counter.n"), + field_site(31, arrow, "take!(counter.n => 1);"), + field_site(32, arrow, "counter.n"), + ]); + let writes = json!([ + field_site(19, bump, "counter.n = 1;"), + field_site(20, bump, "gauge.n += 2;"), + field_site(21, bump, "let borrowed = &mut counter.n;"), + field_site(23, bump, "counter.n <<= 1;"), + ]); + + let bare = call_field_sites(&host, json!({"field": "n", "limit": 20, "format": "json"})).await; + assert_eq!( + bare, + json!({ + "field": "n", + "qualifier": null, + "qualifier_applied": false, + "write_count": 4, + "read_count": 7, + "write_sites": writes, + "read_sites": reads, + }), + "bare field must partition assignment, compound assignment, mut borrow, and shift-assign as writes, and comparison, shift, and fat-arrow uses as reads" + ); + + let writes_only = call_field_sites( + &host, + json!({"field": "n", "writes_only": true, "format": "json"}), + ) + .await; + assert_eq!( + writes_only, + json!({ + "field": "n", + "qualifier": null, + "qualifier_applied": false, + "write_count": 4, + "write_sites": writes, + }), + "writes_only must omit the read list rather than return it empty" + ); + + // The scan stops only after both kinds have reached `limit`, so reads that + // precede the first write stay in the result. + let limited = + call_field_sites(&host, json!({"field": "n", "limit": 1, "format": "json"})).await; + assert_eq!( + limited, + json!({ + "field": "n", + "qualifier": null, + "qualifier_applied": false, + "write_count": 1, + "read_count": 3, + "write_sites": [ + field_site(19, bump, "counter.n = 1;"), + ], + "read_sites": [ + field_site(11, read_method, "let kept = self.n;"), + field_site(12, read_method, "let other = gauge.n;"), + field_site(18, bump, "let same = counter.n == 0;"), + ], + }), + ); + + let missing_field = expect_tool_error( + handle_tool_call( + &host, + "tracedecay_field_sites", + json!({"format": "json"}), + None, + None, + ) + .await, + ); + assert_eq!( + missing_field, + "config error: tracedecay_field_sites failed over production MCP: tool execution failed: config error: tracedecay_field_sites requires a 'field' argument" + ); + + // `take!` is parseable Rust, but its body is a token tree, so the qualifier + // path cannot bind `counter.n` to `Counter`. The first unbound site stops + // the qualified census. + let unbound_macro = expect_tool_error( + handle_tool_call( + &host, + "tracedecay_field_sites", + json!({"field": "Counter::n", "format": "json"}), + None, + None, + ) + .await, + ); + assert_eq!( + unbound_macro, + "config error: tracedecay_field_sites failed over production MCP: tool project route failed: reason_code=verified-field-qualifier-unavailable retryable=false: the indexed graph cannot bind field receiver '' at src/lib.rs:31 to exactly one qualified owner" + ); + close_test_graph(host).await; + + let qualified_dir = test_temp_dir(); + let qualified_root = qualified_dir.path().join("project"); + fs::create_dir_all(qualified_root.join("src")).unwrap(); + fs::write(qualified_root.join("src/lib.rs"), FIELD_QUALIFIED_SOURCE).unwrap(); + let (qualified_host, _qualified_env) = init_test_project(&qualified_root).await; + let qualified = call_field_sites( + &qualified_host, + json!({"field": "Counter::n", "format": "json"}), + ) + .await; + assert_eq!( + qualified, + json!({ + "field": "Counter::n", + "qualifier": "Counter", + "qualifier_applied": true, + "write_count": 3, + "read_count": 4, + "write_sites": [ + field_site(19, bump, "counter.n = 1;"), + field_site(21, bump, "let borrowed = &mut counter.n;"), + field_site(23, bump, "counter.n <<= 1;"), + ], + "read_sites": [ + field_site(11, read_method, "let kept = self.n;"), + field_site(18, bump, "let same = counter.n == 0;"), + field_site(22, bump, "let shifted = counter.n << 1;"), + field_site(24, bump, "counter.n"), + ], + }), + "Counter::n must drop Gauge sites" + ); + + let missing = call_field_sites( + &qualified_host, + json!({"field": "Missing::n", "format": "json"}), + ) + .await; + assert_eq!( + missing, + json!({ + "field": "Missing::n", + "qualifier": "Missing", + "qualifier_applied": true, + "write_count": 0, + "read_count": 0, + "write_sites": [], + "read_sites": [], + }), + "an unknown qualifier is an empty census, not every same-named field" + ); + close_test_graph(qualified_host).await; +} + +#[tokio::test] +async fn field_sites_behavior_refuses_unbound_qualified_receiver() { + let dir = test_temp_dir(); + let project_root = dir.path().join("project"); + fs::create_dir_all(project_root.join("src")).unwrap(); + fs::write( + project_root.join("src/lib.rs"), + r#"pub struct Counter { + pub n: u32, +} +pub struct Gauge { + pub n: u32, +} + +pub fn closure_then_sibling(counter: &Counter) -> u32 { + let read_gauge = |counter: Gauge| counter.n; + read_gauge(Gauge { n: 3 }) + counter.n +} +"#, + ) + .unwrap(); + let (host, _env) = init_test_project(&project_root).await; + + let error = expect_tool_error( + handle_tool_call( + &host, + "tracedecay_field_sites", + json!({"field": "Counter::n", "format": "json"}), + None, + None, + ) + .await, + ); + assert_eq!( + error, + "config error: tracedecay_field_sites failed over production MCP: tool project route failed: reason_code=verified-field-qualifier-unavailable retryable=false: the indexed graph cannot bind field receiver '' at src/lib.rs:9 to exactly one qualified owner" + ); + + close_test_graph(host).await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs index 95bd2488a5..715a7b91a8 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/graph_query_test.rs @@ -2026,6 +2026,193 @@ async fn doc_coverage_distinguishes_documented_and_undocumented_enum_variants() ); } +fn census_without_ids(payload: &Value) -> Value { + let mut payload = payload.clone(); + let Some(files) = payload.get_mut("files").and_then(Value::as_array_mut) else { + return payload; + }; + for file in files { + let Some(symbols) = file.get_mut("symbols").and_then(Value::as_array_mut) else { + continue; + }; + for symbol in symbols { + if let Some(object) = symbol.as_object_mut() { + let id = object.get("id").and_then(Value::as_str).unwrap_or(""); + assert!( + !id.is_empty(), + "doc coverage symbol is missing its id: {symbol}" + ); + object.remove("id"); + } + } + } + payload +} + +async fn doc_coverage_census(fixture: &GraphQueryFixture, args: Value) -> Value { + let result = call_production_tool(fixture, "tracedecay_doc_coverage", args, None, None) + .await + .unwrap_or_else(|error| panic!("tracedecay_doc_coverage failed: {error}")); + let text = extract_text(&result.value); + serde_json::from_str(text).unwrap_or_else(|error| { + panic!("tracedecay_doc_coverage payload was not JSON: {error}\n{text}") + }) +} + +#[tokio::test] +async fn doc_coverage_lists_undocumented_public_symbols_and_honors_path_and_limit() { + let (fixture, _root) = graph_query_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + // Item lines are the declaration line, not the doc comment above it. + fs::write( + project.join("src/lib.rs"), + "/// Ready for callers.\n\ + pub fn ready() -> u32 {\n\ + 1\n\ + }\n\ + \n\ + pub fn missing() -> u32 {\n\ + 2\n\ + }\n\ + \n\ + fn hidden() -> u32 {\n\ + 3\n\ + }\n\ + \n\ + pub(crate) fn crate_local() -> u32 {\n\ + 4\n\ + }\n\ + \n\ + /// \n\ + pub fn blank_doc() -> u32 {\n\ + 5\n\ + }\n\ + \n\ + /// Counted.\n\ + pub const COUNTED: u32 = 1;\n\ + \n\ + pub const UNCOUNTED: u32 = 2;\n", + ) + .unwrap(); + fs::write( + project.join("src/other.rs"), + "pub fn elsewhere() -> u32 {\n 9\n}\n", + ) + .unwrap(); + fs::write( + project.join("src/done.rs"), + "/// Finished.\npub fn finished() -> u32 {\n 7\n}\n", + ) + .unwrap(); + }) + .await; + + let lib = doc_coverage_census(&fixture, json!({"path": "src/lib.rs", "limit": 50})).await; + assert_eq!( + census_without_ids(&lib), + json!({ + "path_filter": "src/lib.rs", + "total_undocumented": 3, + "returned_count": 3, + "omitted_count": 0, + "complete": true, + "limit": 50, + "file_count": 1, + "files": [{ + "file": "src/lib.rs", + "count": 3, + "symbols": [ + { + "name": "missing", + "kind": "function", + "line": 6, + "signature": "pub fn missing() -> u32" + }, + { + "name": "blank_doc", + "kind": "function", + "line": 19, + "signature": "pub fn blank_doc() -> u32" + }, + { + "name": "UNCOUNTED", + "kind": "const", + "line": 26, + "signature": "pub const UNCOUNTED: u32 = 2;" + } + ] + }] + }), + "documented, private, and pub(crate) symbols must stay out: {lib}" + ); + + let other = doc_coverage_census(&fixture, json!({"path": "src/other.rs", "limit": 50})).await; + assert_eq!( + census_without_ids(&other), + json!({ + "path_filter": "src/other.rs", + "total_undocumented": 1, + "returned_count": 1, + "omitted_count": 0, + "complete": true, + "limit": 50, + "file_count": 1, + "files": [{ + "file": "src/other.rs", + "count": 1, + "symbols": [{ + "name": "elsewhere", + "kind": "function", + "line": 1, + "signature": "pub fn elsewhere() -> u32" + }] + }] + }), + "a file path must not leak symbols from other files: {other}" + ); + + let done = doc_coverage_census(&fixture, json!({"path": "src/done.rs", "limit": 50})).await; + assert_eq!( + done, + json!({ + "path_filter": "src/done.rs", + "total_undocumented": 0, + "returned_count": 0, + "omitted_count": 0, + "complete": true, + "limit": 50, + "file_count": 0, + "files": [] + }), + "a file whose public symbols are documented reports an empty census: {done}" + ); + + let limited = doc_coverage_census(&fixture, json!({"path": "src", "limit": 1})).await; + assert_eq!( + census_without_ids(&limited), + json!({ + "path_filter": "src", + "total_undocumented": 4, + "returned_count": 1, + "omitted_count": 3, + "complete": false, + "limit": 1, + "file_count": 1, + "files": [{ + "file": "src/lib.rs", + "count": 1, + "symbols": [{ + "name": "missing", + "kind": "function", + "line": 6, + "signature": "pub fn missing() -> u32" + }] + }] + }), + "limit keeps path-then-line order and reports the omitted tail: {limited}" + ); +} + #[tokio::test] async fn test_files_public_path_filters() { let (cg, _dir) = production_graph_query_fixture().await; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/grep_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/grep_behavior_test.rs new file mode 100644 index 0000000000..4010bc1e8c --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/grep_behavior_test.rs @@ -0,0 +1,518 @@ +#![cfg(feature = "test-transport")] + +//! `tracedecay_grep` as an MCP client sees it. +//! +//! Each case sends `tools/call` through the production server and compares the +//! JSON-RPC text with a literal. Occurrence ids embed the fixture generation, +//! so the graph-enrichment id is the exact-symbol id for that source rather +//! than a hash pasted from one run. + +use crate::support::{ + extract_real_server_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, + production_composition_fixture_with_sources, warm_code_index_search, +}; +use serde_json::{Value, json}; +use std::fs; +use std::path::Path; + +const FILES_SCANNED: u64 = 6; +const LINES_EXAMINED: u64 = 24; +const GREETING_LINE: &str = " format!(\"Hello, {}!\", name)"; +const NOTE_LINE: &str = " let _ = \"MixedCaseToken\";"; +const GREET_SIGNATURE: &str = "pub fn greet(name: &str) -> String {"; +const LIB_RS: &str = concat!( + "/// Greets by name.\n", + "pub fn greet(name: &str) -> String {\n", + " format!(\"Hello, {}!\", name)\n", + "}\n", + "fn note() {\n", + " let _ = \"MixedCaseToken\";\n", + "}\n", +); +const NOTE_TXT: &str = "# Notes\nALPHA_NOTE_TOKEN\n"; +const CONTEXT_TXT: &str = "w\nx\ny\nz\nCONTEXT_TARGET\nd\ne\nf\ng\n"; +const CLI_FALLBACK: &str = "This tool is also available from the shell: `tracedecay tool grep ...` \ +(`tracedecay tool grep --help` for parameters). If MCP calls keep failing or timing out, fall \ +back to that CLI instead of querying .tracedecay databases directly."; + +fn write_grep_project(project: &Path) { + fs::create_dir_all(project.join("src")).unwrap(); + fs::create_dir_all(project.join("docs")).unwrap(); + fs::create_dir_all(project.join("secret_dir")).unwrap(); + fs::create_dir_all(project.join("dist")).unwrap(); + fs::write(project.join(".gitignore"), "secret_dir/\n").unwrap(); + fs::write(project.join("src/lib.rs"), LIB_RS).unwrap(); + fs::write(project.join("docs/note.txt"), NOTE_TXT).unwrap(); + fs::write(project.join("ctx.txt"), CONTEXT_TXT).unwrap(); + fs::write(project.join("many.txt"), "CAP_TOKEN\n".repeat(4)).unwrap(); + fs::write(project.join("tracked.txt"), "VISIBLE_TOKEN\n").unwrap(); + fs::write( + project.join("secret_dir/hidden.txt"), + "VISIBLE_TOKEN\nALPHA_NOTE_TOKEN\n", + ) + .unwrap(); + fs::write(project.join("dist/skip.js"), "ALPHA_NOTE_TOKEN\n").unwrap(); + fs::write( + project.join("blob.bin"), + b"ALPHA_NOTE_TOKEN\0ALPHA_NOTE_TOKEN", + ) + .unwrap(); +} + +async fn grep(server: &tracedecay::mcp::McpServer, arguments: Value) -> Value { + handle_real_server_tool_call_raw(server, "tracedecay_grep", arguments).await +} + +async fn symbol_node_id(server: &tracedecay::mcp::McpServer, name: &str) -> String { + let response = handle_real_server_tool_call( + server, + "tracedecay_find_exact_symbol", + json!({"name": name, "limit": 20, "format": "json"}), + ) + .await; + let payload: Value = serde_json::from_str(extract_real_server_text(&response)) + .unwrap_or_else(|error| panic!("exact-symbol JSON for {name}: {error}; {response}")); + payload["matches"] + .as_array() + .and_then(|matches| matches.iter().find(|item| item["name"] == name)) + .and_then(|item| item["id"].as_str()) + .unwrap_or_else(|| panic!("exact-symbol response did not contain {name}: {payload}")) + .to_owned() +} + +fn source_coverage(eligible: Option, returned: u64, partial: bool) -> Value { + let completeness = if partial { "partial" } else { "complete" }; + json!({ + "requested_domains": ["source"], + "visited": LINES_EXAMINED, + "eligible": eligible, + "returned": returned, + "completeness": completeness, + "domains": [{"domain": "source", "completeness": completeness}], + }) +} + +fn complete_payload(results: Value, enriched: u64) -> Value { + let returned = results.as_array().expect("results").len() as u64; + json!({ + "results": results, + "match_count": returned, + "files_scanned": FILES_SCANNED, + "truncated": false, + "coverage": source_coverage(Some(returned), returned, false), + "omissions": [], + "graph_enrichment": { + "status": "complete", + "enriched": enriched, + "returned": returned, + }, + }) +} + +fn assert_markdown(response: &Value, text: &str, touched_bytes: Option) { + assert!( + response["error"].is_null(), + "grep markdown call failed: {response}" + ); + let content = response["result"]["content"] + .as_array() + .unwrap_or_else(|| panic!("grep content: {response}")); + assert_eq!(content[0]["type"], "text", "{response}"); + assert_eq!(content[0]["text"], text, "{response}"); + match touched_bytes { + None => assert_eq!(content.len(), 1, "{response}"), + Some(bytes) => { + let footer = format!( + "\ntracedecay_metrics: before={} after={}", + bytes / 4, + text.len() / 4 + ); + assert_eq!( + content.get(1).and_then(|item| item["text"].as_str()), + Some(footer.as_str()), + "{response}" + ); + assert_eq!(content.len(), 2, "{response}"); + } + } +} + +fn assert_json_payload(response: &Value, expected: Value, touched_bytes: Option) { + assert!( + response["error"].is_null(), + "grep JSON call failed: {response}" + ); + let content = response["result"]["content"] + .as_array() + .unwrap_or_else(|| panic!("grep content: {response}")); + let text = content[0]["text"] + .as_str() + .unwrap_or_else(|| panic!("grep text: {response}")); + let payload: Value = serde_json::from_str(text) + .unwrap_or_else(|error| panic!("grep payload is not JSON: {error}\n{text}")); + assert_eq!(payload, expected); + match touched_bytes { + None => assert_eq!(content.len(), 1, "{response}"), + Some(bytes) => { + let footer = format!( + "\ntracedecay_metrics: before={} after={}", + bytes / 4, + text.len() / 4 + ); + assert_eq!( + content.get(1).and_then(|item| item["text"].as_str()), + Some(footer.as_str()), + "{response}" + ); + assert_eq!(content.len(), 2, "{response}"); + } + } +} + +fn execution_failed(message: &str) -> Value { + json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32603, + "message": message, + "data": { + "tool": "tracedecay_grep", + "cli_fallback": CLI_FALLBACK, + } + } + }) +} + +fn greeting_markdown(node_id: &str) -> String { + format!( + "\ +## Grep Results +- src/lib.rs:3 + > {GREETING_LINE} + _Enclosing symbol: `greet` (`{node_id}`)_ + +_Use `tracedecay_source_body` with a result's `node_id` to read the verified enclosing symbol._ + +_1 matches across {FILES_SCANNED} files._ +" + ) +} + +#[tokio::test] +async fn tracedecay_grep_reports_literal_matches_and_typed_failures() { + let fixture = production_composition_fixture_with_sources(write_grep_project).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production grep server"); + warm_code_index_search(&server, "greet").await; + let greet_id = symbol_node_id(&server, "greet").await; + let note_id = symbol_node_id(&server, "note").await; + + let missing = grep(&server, json!({"format": "json"})).await; + assert_eq!( + missing, + json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32602, + "message": "missing required parameter: pattern", + "data": { + "tool": "tracedecay_grep", + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: pattern", + } + } + }) + ); + + let empty = grep(&server, json!({"pattern": "", "format": "json"})).await; + assert_eq!( + empty, + execution_failed("tool execution failed: config error: pattern must not be empty") + ); + + let absent = grep( + &server, + json!({"pattern": "zzz_no_such_token_anywhere_zzz", "format": "json"}), + ) + .await; + assert_json_payload(&absent, complete_payload(json!([]), 0), None); + let absent_markdown = grep( + &server, + json!({"pattern": "zzz_no_such_token_anywhere_zzz", "format": "markdown"}), + ) + .await; + assert_markdown( + &absent_markdown, + &format!( + "\ +## Grep Results +_No matching lines._ +_Scanned {FILES_SCANNED} files._ +" + ), + None, + ); + + let lib_bytes = u64::try_from(LIB_RS.len()).expect("lib.rs length"); + let greeting_json = grep( + &server, + json!({ + "pattern": "Hello, {}!", + "fixed_strings": true, + "format": "json" + }), + ) + .await; + assert_json_payload( + &greeting_json, + complete_payload( + json!([{ + "file": "src/lib.rs", + "line": 3, + "text": GREETING_LINE, + "symbol": "greet", + "node_id": greet_id.clone(), + }]), + 1, + ), + Some(lib_bytes), + ); + let greeting_markdown_response = grep( + &server, + json!({ + "pattern": "Hello, {}!", + "fixed_strings": true, + "format": "markdown" + }), + ) + .await; + assert_markdown( + &greeting_markdown_response, + &greeting_markdown(&greet_id), + Some(lib_bytes), + ); + + let signature = grep( + &server, + json!({"pattern": "pub fn greet", "format": "json"}), + ) + .await; + assert_json_payload( + &signature, + complete_payload( + json!([{ + "file": "src/lib.rs", + "line": 2, + "text": GREET_SIGNATURE, + "symbol": "greet", + "node_id": greet_id.clone(), + }]), + 1, + ), + Some(u64::try_from(LIB_RS.len()).expect("lib.rs length")), + ); + + let insensitive = grep( + &server, + json!({"pattern": "mixedcasetoken", "format": "json"}), + ) + .await; + assert_json_payload( + &insensitive, + complete_payload( + json!([{ + "file": "src/lib.rs", + "line": 6, + "text": NOTE_LINE, + "symbol": "note", + "node_id": note_id.clone(), + }]), + 1, + ), + Some(u64::try_from(LIB_RS.len()).expect("lib.rs length")), + ); + let sensitive = grep( + &server, + json!({"pattern": "mixedcasetoken", "case_sensitive": true, "format": "json"}), + ) + .await; + assert_json_payload(&sensitive, complete_payload(json!([]), 0), None); + + let narrow_context = grep( + &server, + json!({"pattern": "CONTEXT_TARGET", "context_lines": 1, "format": "json"}), + ) + .await; + assert_json_payload( + &narrow_context, + complete_payload( + json!([{ + "file": "ctx.txt", + "line": 5, + "text": "CONTEXT_TARGET", + "before": ["z"], + "after": ["d"], + }]), + 0, + ), + Some(u64::try_from(CONTEXT_TXT.len()).expect("ctx.txt length")), + ); + let capped_context = grep( + &server, + json!({"pattern": "CONTEXT_TARGET", "context_lines": 99, "format": "json"}), + ) + .await; + assert_json_payload( + &capped_context, + complete_payload( + json!([{ + "file": "ctx.txt", + "line": 5, + "text": "CONTEXT_TARGET", + "before": ["x", "y", "z"], + "after": ["d", "e", "f"], + }]), + 0, + ), + Some(u64::try_from(CONTEXT_TXT.len()).expect("ctx.txt length")), + ); + + let capped = grep( + &server, + json!({"pattern": "CAP_TOKEN", "max_results": 3, "format": "json"}), + ) + .await; + let capped_payload = json_text(&capped); + assert_eq!( + capped_payload["results"], + json!([ + {"file": "many.txt", "line": 1, "text": "CAP_TOKEN"}, + {"file": "many.txt", "line": 2, "text": "CAP_TOKEN"}, + {"file": "many.txt", "line": 3, "text": "CAP_TOKEN"}, + ]) + ); + assert_eq!(capped_payload["match_count"], 3); + assert_eq!(capped_payload["truncated"], true); + assert_eq!(capped_payload["coverage"]["completeness"], "partial"); + assert_eq!(capped_payload["coverage"]["returned"], 3); + assert_eq!(capped_payload["coverage"]["eligible"], Value::Null); + assert_eq!(capped_payload["omissions"], json!([])); + + let clamped = grep( + &server, + json!({"pattern": "CAP_TOKEN", "max_results": 0, "format": "json"}), + ) + .await; + let clamped_payload = json_text(&clamped); + assert_eq!( + clamped_payload["results"], + json!([{"file": "many.txt", "line": 1, "text": "CAP_TOKEN"}]) + ); + assert_eq!(clamped_payload["match_count"], 1); + assert_eq!(clamped_payload["truncated"], true); + + let visible = grep( + &server, + json!({"pattern": "VISIBLE_TOKEN", "format": "json"}), + ) + .await; + assert_json_payload( + &visible, + complete_payload( + json!([{ + "file": "tracked.txt", + "line": 1, + "text": "VISIBLE_TOKEN", + }]), + 0, + ), + Some("VISIBLE_TOKEN\n".len() as u64), + ); + + let note = grep( + &server, + json!({"pattern": "ALPHA_NOTE_TOKEN", "format": "json"}), + ) + .await; + assert_json_payload( + ¬e, + complete_payload( + json!([{ + "file": "docs/note.txt", + "line": 2, + "text": "ALPHA_NOTE_TOKEN", + }]), + 0, + ), + Some(u64::try_from(NOTE_TXT.len()).expect("note.txt length")), + ); + let generated = grep( + &server, + json!({ + "pattern": "ALPHA_NOTE_TOKEN", + "path_glob": "dist/**", + "format": "json" + }), + ) + .await; + let generated_payload = json_text(&generated); + assert_eq!( + generated_payload["results"], + json!([{ + "file": "dist/skip.js", + "line": 1, + "text": "ALPHA_NOTE_TOKEN", + }]) + ); + assert_eq!(generated_payload["match_count"], 1); + assert_eq!(generated_payload["files_scanned"], 1); + assert_eq!(generated_payload["truncated"], false); + assert_eq!(generated_payload["coverage"]["visited"], 1); + assert_eq!(generated_payload["coverage"]["eligible"], 1); + assert_eq!(generated_payload["coverage"]["returned"], 1); + assert_eq!(generated_payload["coverage"]["completeness"], "complete"); + assert_eq!(generated_payload["graph_enrichment"]["enriched"], 0); + assert_eq!(generated_payload["omissions"], json!([])); + + let invalid_group = grep(&server, json!({"pattern": "(", "format": "json"})).await; + assert_eq!( + invalid_group, + execution_failed( + "tool execution failed: config error: invalid regex pattern '(': regex parse error:\n (\n ^\nerror: unclosed group" + ) + ); + let invalid_braces = grep(&server, json!({"pattern": "Hello, {}!", "format": "json"})).await; + assert_eq!( + invalid_braces, + execution_failed( + "tool execution failed: config error: invalid regex pattern 'Hello, {}!': regex parse error:\n Hello, {}!\n ^\nerror: repetition quantifier expects a valid decimal" + ) + ); + let invalid_glob = grep( + &server, + json!({"pattern": "VISIBLE_TOKEN", "path_glob": "[", "format": "json"}), + ) + .await; + assert_eq!( + invalid_glob, + execution_failed( + "tool execution failed: config error: invalid path_glob '[': error parsing glob '[': unclosed character class; missing ']'" + ) + ); + + drop(server); + fixture.harness.shutdown().await; +} + +fn json_text(response: &Value) -> Value { + assert!(response["error"].is_null(), "{response}"); + let text = response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("grep text: {response}")); + serde_json::from_str(text).unwrap_or_else(|error| panic!("grep JSON: {error}\n{text}")) +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hermes_skill_bridge_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hermes_skill_bridge_test.rs new file mode 100644 index 0000000000..9388692ef1 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/hermes_skill_bridge_test.rs @@ -0,0 +1,485 @@ +//! Host-visible behavior of `tracedecay_hermes_skill_bridge`. +//! +//! Calls go through the MCP `tools/call` path. The install lives under an +//! isolated `HOME`; `HERMES_HOME` is pointed at a different tree so a bridge +//! that honored an alternate root would return the wrong skills. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Value, json}; +#[cfg(unix)] +use std::os::unix::fs::symlink; +use tempfile::TempDir; +use tracedecay::mcp::McpServer; + +use crate::mcp_server_test::support::{ + jsonrpc_request, response_with_id, run_client_connection_with_messages, successful_tool_text, +}; +use crate::support::{TestEnv, TestTraceDecay, canonicalize_test_dir, init_test_project}; + +const WORKFLOW_BODY: &str = + "---\nname: workflow\ndescription: Reusable workflow\n---\n\nDo the work.\n"; +const BARE_NOTE_BODY: &str = "# just a note\n"; +const SECRET_BODY: &str = "---\nname: secret\ndescription: Not in the standard install\n---\n"; + +struct HermesHomeGuard { + previous: Option, +} + +impl HermesHomeGuard { + fn set(path: &Path) -> Self { + let previous = std::env::var_os("HERMES_HOME"); + unsafe { + std::env::set_var("HERMES_HOME", path); + } + Self { previous } + } +} + +impl Drop for HermesHomeGuard { + fn drop(&mut self) { + unsafe { + match self.previous.take() { + Some(value) => std::env::set_var("HERMES_HOME", value), + None => std::env::remove_var("HERMES_HOME"), + } + } + } +} + +struct IsolatedHome { + home: PathBuf, + _env: TestEnv, + _dir: TempDir, +} + +async fn open_isolated_home() -> (IsolatedHome, TestTraceDecay) { + let dir = TempDir::new().unwrap(); + let project = dir.path().join("repo"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), "pub fn fixture() {}\n").unwrap(); + let (cg, env) = init_test_project(&project).await; + let home = canonicalize_test_dir(&project.join("home")); + ( + IsolatedHome { + home, + _env: env, + _dir: dir, + }, + cg, + ) +} + +fn path_str(path: &Path) -> String { + path.to_string_lossy().into_owned() +} + +fn write_skill(dir: &Path, body: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("SKILL.md"), body).unwrap(); +} + +fn snapshot_roots(roots: &[PathBuf]) -> BTreeMap> { + let mut files = BTreeMap::new(); + for root in roots { + files.extend(file_snapshot(root)); + } + files +} + +fn file_snapshot(root: &Path) -> BTreeMap> { + let mut files = BTreeMap::new(); + if !root.exists() { + return files; + } + let mut pending = vec![root.to_path_buf()]; + while let Some(dir) = pending.pop() { + for entry in fs::read_dir(&dir).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).unwrap(); + if metadata.file_type().is_symlink() { + let target = fs::read_link(&path).unwrap(); + files.insert(path, target.to_string_lossy().into_owned().into_bytes()); + } else if metadata.is_dir() { + pending.push(path); + } else if metadata.is_file() { + let bytes = fs::read(&path).unwrap(); + files.insert(path, bytes); + } + } + } + files +} + +async fn open_server(cg: TestTraceDecay) -> Arc { + Box::pin(McpServer::new(cg.into_inner(), None)).await +} + +async fn call_bridge(server: &Arc, id: i64, arguments: Value) -> Value { + let responses = run_client_connection_with_messages( + Arc::clone(server), + vec![jsonrpc_request( + json!(id), + "tools/call", + json!({ + "name": "tracedecay_hermes_skill_bridge", + "arguments": arguments, + }), + )], + ) + .await; + response_with_id(&responses, json!(id)) +} + +fn json_payload(response: &Value) -> Value { + let text = successful_tool_text(response, "tracedecay_hermes_skill_bridge"); + serde_json::from_str(text).unwrap_or_else(|error| { + panic!("tracedecay_hermes_skill_bridge JSON was not an object: {error}\n{text}") + }) +} + +fn assert_markdown_line(markdown: &str, line: &str) { + assert!( + markdown.lines().any(|candidate| candidate == line), + "missing markdown line {line:?}\n{markdown}" + ); +} + +fn contracts() -> Value { + json!({ + "lifecycle_owner": "hermes", + "mutation_policy": "read_only; use Hermes to mutate Hermes-owned skills", + "discovery_policy": "standard_user_install_only" + }) +} + +fn skill_summary( + name: &str, + path: &Path, + category: Option<&str>, + description: Option<&str>, + body: Option<&str>, + usage: Option, + pending_write_ids: &[&str], +) -> Value { + let mut skill = json!({ + "name": name, + "path": path_str(path), + "pending_write_ids": pending_write_ids, + }); + if let Some(category) = category { + skill["category"] = json!(category); + } + if let Some(description) = description { + skill["description"] = json!(description); + } + if let Some(body) = body { + skill["body_markdown"] = json!(body); + } + if let Some(usage) = usage { + skill["usage"] = usage; + } + skill +} + +fn pending_write( + id: &str, + source: &Path, + action: Option<&str>, + name: Option<&str>, + summary: Option<&str>, + origin: Option<&str>, + created_at: Option<&str>, + payload: Option, +) -> Value { + let mut pending = json!({ + "id": id, + "source_path": path_str(source), + }); + if let Some(action) = action { + pending["action"] = json!(action); + } + if let Some(name) = name { + pending["name"] = json!(name); + } + if let Some(summary) = summary { + pending["summary"] = json!(summary); + } + if let Some(origin) = origin { + pending["origin"] = json!(origin); + } + if let Some(created_at) = created_at { + pending["created_at"] = json!(created_at); + } + if let Some(payload) = payload { + pending["payload"] = payload; + } + pending +} + +fn populated_inventory(home: &Path, include_bodies: bool, include_payloads: bool) -> Value { + let agent_home = home.join(".hermes"); + let skills_dir = agent_home.join("skills"); + let workflow_dir = skills_dir.join("ops").join("workflow"); + let bare_dir = skills_dir.join("bare-note"); + let staged = agent_home.join("pending/skills/staged.json"); + let other = agent_home.join("pending/skills/other.json"); + let workflow_payload = json!({"name": "workflow", "body": "draft"}); + let other_payload = json!({"name": "missing-skill", "body": "other"}); + json!({ + "status": "ok", + "bridge": { + "agent_home": path_str(&agent_home), + "skills_dir": path_str(&skills_dir), + "skill_count": 2, + "pending_skill_count": 2, + "pending_skill_corrupt_count": 1, + "usage_record_count": 2, + "archive_count": 1, + "skills": [ + skill_summary( + "bare-note", + &bare_dir, + None, + None, + include_bodies.then_some(BARE_NOTE_BODY), + None, + &[], + ), + skill_summary( + "workflow", + &workflow_dir, + Some("ops"), + Some("Reusable workflow"), + include_bodies.then_some(WORKFLOW_BODY), + Some(json!({"uses": 3})), + &["approval-9"], + ), + ], + "pending_skills": [ + pending_write( + "approval-9", + &staged, + Some("stage"), + Some("workflow"), + Some("revise the workflow steps"), + Some("operator"), + Some("2026-04-01T00:00:00Z"), + include_payloads.then(|| workflow_payload), + ), + pending_write( + "approval-other", + &other, + None, + Some("missing-skill"), + None, + None, + None, + include_payloads.then(|| other_payload), + ), + ], + "usage_records": { + "orphan": {"uses": 1}, + "workflow": {"uses": 3} + }, + "contracts": contracts(), + } + }) +} + +fn seed_populated_install(home: &Path) -> PathBuf { + let agent_home = home.join(".hermes"); + let skills_dir = agent_home.join("skills"); + write_skill(&skills_dir.join("ops").join("workflow"), WORKFLOW_BODY); + write_skill(&skills_dir.join("bare-note"), BARE_NOTE_BODY); + fs::write( + skills_dir.join(".usage.json"), + r#"{"orphan":{"uses":1},"workflow":{"uses":3}}"#, + ) + .unwrap(); + fs::create_dir_all(skills_dir.join(".archive").join("retired")).unwrap(); + fs::write(skills_dir.join(".archive").join(".keep"), "hidden").unwrap(); + let pending = agent_home.join("pending/skills"); + fs::create_dir_all(&pending).unwrap(); + fs::write( + pending.join("staged.json"), + r#"{"id":"approval-9","action":"stage","summary":"revise the workflow steps","origin":"operator","created_at":"2026-04-01T00:00:00Z","payload":{"name":"workflow","body":"draft"}}"#, + ) + .unwrap(); + fs::write( + pending.join("other.json"), + r#"{"id":"approval-other","payload":{"name":"missing-skill","body":"other"}}"#, + ) + .unwrap(); + fs::write(pending.join("broken.json"), "not json").unwrap(); + fs::write(pending.join("notes.txt"), "not a pending write").unwrap(); + #[cfg(unix)] + { + let outside = home.join("outside-skill"); + write_skill(&outside, "---\nname: escaped\n---\n"); + symlink(&outside, skills_dir.join("escaped")).unwrap(); + } + + let alternate = home.join("custom-hermes"); + write_skill(&alternate.join("skills").join("secret"), SECRET_BODY); + alternate +} + +#[tokio::test] +async fn hermes_skill_bridge_mcp_returns_standard_install_inventory() { + let (isolated, cg) = open_isolated_home().await; + let alternate = seed_populated_install(&isolated.home); + let _hermes_home = HermesHomeGuard::set(&alternate); + let before = snapshot_roots(&[ + isolated.home.join(".hermes"), + alternate.clone(), + isolated.home.join("outside-skill"), + ]); + let server = open_server(cg).await; + + let markdown = call_bridge(&server, 1, json!({})).await; + let markdown = successful_tool_text(&markdown, "tracedecay_hermes_skill_bridge"); + assert_markdown_line(markdown, "**status:** ok"); + assert_markdown_line(markdown, "**skill_count:** 2"); + assert_markdown_line(markdown, "**pending_skill_count:** 2"); + assert_markdown_line(markdown, "**pending_skill_corrupt_count:** 1"); + assert_markdown_line(markdown, "**usage_record_count:** 2"); + assert_markdown_line(markdown, "**archive_count:** 1"); + assert_markdown_line(markdown, "- **bare-note**"); + assert_markdown_line(markdown, "- **workflow**"); + assert_markdown_line(markdown, " **category:** ops"); + assert_markdown_line(markdown, " **description:** Reusable workflow"); + assert!( + !markdown.contains("secret") + && !markdown.contains("escaped") + && !markdown.contains("Do the work."), + "default inventory must omit alternate roots, symlink escapes, and skill bodies:\n{markdown}" + ); + + let omitted = json_payload( + &call_bridge( + &server, + 2, + json!({"format": "json", "include_skill_bodies": false, "include_pending_payloads": false}), + ) + .await, + ); + let non_bool = json_payload( + &call_bridge( + &server, + 3, + json!({"format": "json", "include_skill_bodies": "true", "include_pending_payloads": 1}), + ) + .await, + ); + let included = json_payload( + &call_bridge( + &server, + 4, + json!({"format": "json", "include_skill_bodies": true, "include_pending_payloads": true}), + ) + .await, + ); + + assert_eq!(omitted, populated_inventory(&isolated.home, false, false)); + assert_eq!(non_bool, populated_inventory(&isolated.home, false, false)); + assert_eq!(included, populated_inventory(&isolated.home, true, true)); + assert_eq!( + included["bridge"]["skills"][1]["body_markdown"], + WORKFLOW_BODY + ); + assert_eq!( + included["bridge"]["pending_skills"][0]["payload"], + json!({"body": "draft", "name": "workflow"}) + ); + assert!( + omitted["bridge"]["skills"][1] + .get("body_markdown") + .is_none() + ); + assert!( + omitted["bridge"]["pending_skills"][0] + .get("payload") + .is_none() + ); + assert_eq!( + snapshot_roots(&[ + isolated.home.join(".hermes"), + alternate, + isolated.home.join("outside-skill"), + ]), + before + ); +} + +#[tokio::test] +async fn hermes_skill_bridge_mcp_reports_missing_install_as_empty_inventory() { + let (isolated, cg) = open_isolated_home().await; + let alternate = isolated.home.join("custom-hermes"); + write_skill(&alternate.join("skills").join("secret"), SECRET_BODY); + let _hermes_home = HermesHomeGuard::set(&alternate); + let before = snapshot_roots(&[alternate.clone()]); + let server = open_server(cg).await; + + let response = call_bridge(&server, 7, json!({"format": "json"})).await; + let payload = json_payload(&response); + let agent_home = isolated.home.join(".hermes"); + assert_eq!( + payload, + json!({ + "status": "ok", + "bridge": { + "agent_home": path_str(&agent_home), + "skills_dir": path_str(&agent_home.join("skills")), + "skill_count": 0, + "pending_skill_count": 0, + "pending_skill_corrupt_count": 0, + "usage_record_count": 0, + "archive_count": 0, + "skills": [], + "pending_skills": [], + "usage_records": {}, + "contracts": contracts(), + } + }) + ); + assert_eq!(snapshot_roots(&[alternate]), before); + assert!(!agent_home.exists()); +} + +#[tokio::test] +async fn hermes_skill_bridge_mcp_rejects_invalid_usage_json() { + let (isolated, cg) = open_isolated_home().await; + let skills_dir = isolated.home.join(".hermes").join("skills"); + write_skill(&skills_dir.join("workflow"), WORKFLOW_BODY); + let usage_path = skills_dir.join(".usage.json"); + fs::write(&usage_path, "not json").unwrap(); + let before = fs::read(&usage_path).unwrap(); + let skill_before = fs::read(skills_dir.join("workflow").join("SKILL.md")).unwrap(); + let server = open_server(cg).await; + + let response = call_bridge(&server, 9, json!({"format": "json"})).await; + assert!(response.get("result").is_none() || response["result"].is_null()); + assert_eq!(response["error"]["code"], -32603); + assert_eq!( + response["error"]["data"]["tool"], + "tracedecay_hermes_skill_bridge" + ); + assert_eq!( + response["error"]["message"], + format!( + "tool execution failed: config error: Hermes skill usage '{}' is invalid JSON: expected ident at line 1 column 2", + usage_path.display() + ) + ); + assert_eq!(fs::read(&usage_path).unwrap(), before); + assert_eq!( + fs::read(skills_dir.join("workflow").join("SKILL.md")).unwrap(), + skill_before + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/inheritance_depth_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/inheritance_depth_test.rs new file mode 100644 index 0000000000..ec564e0053 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/inheritance_depth_test.rs @@ -0,0 +1,456 @@ +//! Production MCP proof for `tracedecay_inheritance_depth`. +//! +//! Every call is a JSON-RPC `tools/call` on the daemon composition, the same +//! path an agent uses. Occurrence ids are generation-local, so expectations +//! pin the fields an operator can read from source: name, kind, file, line, +//! and depth. Ranking order is pinned only when depth makes it unique. + +#![cfg(feature = "test-transport")] + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::{Value, json}; +use tracedecay::daemon::ProductionProjectCompositionHarnessV1; +use tracedecay_mcp::JsonRpcResponse; + +use crate::common::fixture::git_run; +use crate::support::test_temp_dir; + +const HIERARCHY: &str = "\ +pub trait Left {} +pub trait Right: Left {} +pub trait Join: Right {} +pub trait Apex: Join {} +pub fn ignored() -> u8 { 7 } +"; + +const SIDE: &str = "\ +pub trait Outer {} +pub trait Inner: Outer {} +"; + +/// `Peak` extends both a root and that root's child. Depth is the longest +/// chain (2), not the sum of the two parents and not the first bound alone. +const WIDE: &str = "\ +pub trait Shallow {} +pub trait Deep: Shallow {} +pub trait Peak: Shallow + Deep {} +"; + +const LONG_CHAIN: &str = "\ +pub trait T000 {} +pub trait T001: T000 {} +pub trait T002: T001 {} +pub trait T003: T002 {} +pub trait T004: T003 {} +pub trait T005: T004 {} +pub trait T006: T005 {} +pub trait T007: T006 {} +pub trait T008: T007 {} +pub trait T009: T008 {} +pub trait T010: T009 {} +pub trait T011: T010 {} +pub fn outside_the_chain() -> u8 { 0 } +"; + +const CYCLE: &str = "\ +pub trait Alpha: Beta {} +pub trait Beta: Alpha {} +"; + +struct OpenedProject { + harness: ProductionProjectCompositionHarnessV1, + root: PathBuf, + _dir: crate::support::TestTempDir, +} + +async fn open_project(files: &[(&str, &str)]) -> OpenedProject { + let dir = test_temp_dir(); + let root = dir.path().join("project"); + for (relative, contents) in files { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("fixture directory"); + } + fs::write(&path, contents).expect("fixture source"); + } + git_run(&root, &["init", "--quiet"]); + git_run(&root, &["add", "."]); + git_run( + &root, + &[ + "-c", + "user.name=TraceDecay Tests", + "-c", + "user.email=tests@tracedecay.invalid", + "commit", + "--quiet", + "-m", + "fixture", + ], + ); + let harness = ProductionProjectCompositionHarnessV1::open(dir.path(), [root.clone()]) + .await + .expect("production MCP composition"); + let project = OpenedProject { + harness, + root, + _dir: dir, + }; + wait_for_graph(&project).await; + project +} + +async fn wait_for_graph(project: &OpenedProject) { + tokio::time::timeout(Duration::from_secs(20), async { + loop { + let response = call_tool( + project, + "tracedecay_status", + json!({ + "format": "json", + "include_branch_diagnostics": false, + "include_storage_health": false, + "include_session_ingest": false, + "include_staleness": false, + }), + ) + .await; + let status = json_body(&response); + let freshness = &status["code_index_freshness"]; + let serving = &freshness["worktree"]["code_graph_serving"]; + match ( + freshness["status"].as_str(), + serving["state"].as_str(), + serving["reason"].as_str(), + freshness["worktree"]["staleness_state"].as_str(), + ) { + (Some("current"), Some("ready"), _, _) => break, + (Some("warming"), _, _, _) + | (Some("stale"), Some("ready"), _, Some("verifying")) + | (_, Some("pending"), _, _) + | (_, Some("unavailable"), Some("generation_unavailable"), _) => { + tokio::task::yield_now().await; + } + other => panic!("graph readiness became {other:?}: {status}"), + } + } + }) + .await + .expect("graph did not become current"); +} + +async fn call_tool(project: &OpenedProject, tool: &str, arguments: Value) -> JsonRpcResponse { + project + .harness + .call_tool(&project.root, tool, arguments) + .await + .unwrap_or_else(|error| panic!("{tool} MCP call failed: {error}")) +} + +async fn call_inheritance_depth(project: &OpenedProject, arguments: Value) -> JsonRpcResponse { + call_tool(project, "tracedecay_inheritance_depth", arguments).await +} + +fn tool_text<'a>(response: &'a JsonRpcResponse) -> &'a str { + assert!(response.error.is_none(), "MCP error: {:?}", response.error); + let result = response + .result + .as_ref() + .unwrap_or_else(|| panic!("missing MCP result: {response:?}")); + result["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("tool text missing: {result}")) +} + +fn json_body(response: &JsonRpcResponse) -> Value { + let text = tool_text(response); + serde_json::from_str(text).unwrap_or_else(|error| panic!("tool JSON ({error}): {text}")) +} + +fn without_ids(payload: &Value) -> Value { + let mut payload = payload.clone(); + if let Some(items) = payload.get_mut("ranking").and_then(Value::as_array_mut) { + for item in items { + if let Some(object) = item.as_object_mut() { + object.remove("id"); + } + } + } + payload +} + +/// Ids are not stable across fixture homes, but the ranking the caller sees +/// is still ordered by depth descending, then id ascending. +fn assert_occurrence_order(payload: &Value) { + let ranking = payload["ranking"] + .as_array() + .unwrap_or_else(|| panic!("ranking missing: {payload}")); + let mut seen = HashSet::new(); + let mut previous: Option<(u64, &str)> = None; + for item in ranking { + let id = item["id"] + .as_str() + .unwrap_or_else(|| panic!("ranking row has no id: {item}")); + assert!( + id.starts_with("symbol.v1."), + "occurrence id is not the symbol identity: {item}" + ); + assert!(seen.insert(id), "duplicate id {id}"); + let depth = item["depth"] + .as_u64() + .unwrap_or_else(|| panic!("depth missing: {item}")); + if let Some((previous_depth, previous_id)) = previous { + assert!( + depth < previous_depth || (depth == previous_depth && id >= previous_id), + "ranking is not depth-desc, id-asc: {previous_depth}/{previous_id} then {depth}/{id}" + ); + } + previous = Some((depth, id)); + } +} + +fn expect_ranking(payload: &Value, expected: Value) { + assert_occurrence_order(payload); + assert_eq!(without_ids(payload), expected, "full payload: {payload}"); +} + +fn indexed(payload: &Value) -> BTreeMap<&str, (&str, &str, u64, u64)> { + payload["ranking"] + .as_array() + .unwrap_or_else(|| panic!("ranking missing: {payload}")) + .iter() + .map(|item| { + ( + item["name"].as_str().unwrap_or(""), + ( + item["kind"].as_str().unwrap_or(""), + item["file"].as_str().unwrap_or(""), + item["line"].as_u64().unwrap_or(u64::MAX), + item["depth"].as_u64().unwrap_or(u64::MAX), + ), + ) + }) + .collect() +} + +fn strip_id_lines(text: &str) -> String { + let mut rendered = String::new(); + for line in text.lines() { + if line.trim_start().starts_with("**id:**") { + continue; + } + rendered.push_str(line); + rendered.push('\n'); + } + rendered +} + +#[tokio::test] +async fn inheritance_depth_ranks_literal_extends_depths() { + let project = open_project(&[ + ( + "src/lib.rs", + "pub mod hierarchy;\npub mod side;\npub mod wide;\n", + ), + ("src/hierarchy.rs", HIERARCHY), + ("src/side.rs", SIDE), + ("src/wide.rs", WIDE), + ]) + .await; + + let all = json_body(&call_inheritance_depth(&project, json!({"format": "json"})).await); + assert_eq!(all["result_count"], 9); + assert_eq!( + indexed(&all), + [ + ("Apex", ("trait", "src/hierarchy.rs", 4, 3)), + ("Join", ("trait", "src/hierarchy.rs", 3, 2)), + ("Right", ("trait", "src/hierarchy.rs", 2, 1)), + ("Left", ("trait", "src/hierarchy.rs", 1, 0)), + ("Inner", ("trait", "src/side.rs", 2, 1)), + ("Outer", ("trait", "src/side.rs", 1, 0)), + ("Peak", ("trait", "src/wide.rs", 3, 2)), + ("Deep", ("trait", "src/wide.rs", 2, 1)), + ("Shallow", ("trait", "src/wide.rs", 1, 0)), + ] + .into_iter() + .collect() + ); + assert_occurrence_order(&all); + + expect_ranking( + &json_body( + &call_inheritance_depth( + &project, + json!({"format": "json", "path": "src/hierarchy.rs"}), + ) + .await, + ), + json!({ + "result_count": 4, + "ranking": [ + {"name": "Apex", "kind": "trait", "file": "src/hierarchy.rs", "line": 4, "depth": 3}, + {"name": "Join", "kind": "trait", "file": "src/hierarchy.rs", "line": 3, "depth": 2}, + {"name": "Right", "kind": "trait", "file": "src/hierarchy.rs", "line": 2, "depth": 1}, + {"name": "Left", "kind": "trait", "file": "src/hierarchy.rs", "line": 1, "depth": 0} + ] + }), + ); + expect_ranking( + &json_body( + &call_inheritance_depth(&project, json!({"format": "json", "path": "src/wide.rs"})) + .await, + ), + json!({ + "result_count": 3, + "ranking": [ + {"name": "Peak", "kind": "trait", "file": "src/wide.rs", "line": 3, "depth": 2}, + {"name": "Deep", "kind": "trait", "file": "src/wide.rs", "line": 2, "depth": 1}, + {"name": "Shallow", "kind": "trait", "file": "src/wide.rs", "line": 1, "depth": 0} + ] + }), + ); + + let missing = json_body( + &call_inheritance_depth(&project, json!({"format": "json", "path": "src/hierarchy"})).await, + ); + assert_eq!(missing, json!({"result_count": 0, "ranking": []})); + + expect_ranking( + &json_body( + &call_inheritance_depth( + &project, + json!({"format": "json", "path": "src/hierarchy.rs", "limit": 1}), + ) + .await, + ), + json!({ + "result_count": 1, + "ranking": [ + {"name": "Apex", "kind": "trait", "file": "src/hierarchy.rs", "line": 4, "depth": 3} + ] + }), + ); + expect_ranking( + &json_body( + &call_inheritance_depth( + &project, + json!({"format": "json", "path": "src/hierarchy.rs", "limit": 0}), + ) + .await, + ), + json!({"result_count": 0, "ranking": []}), + ); + + let markdown_response = + call_inheritance_depth(&project, json!({"path": "src/hierarchy.rs"})).await; + let markdown = tool_text(&markdown_response); + assert_eq!( + strip_id_lines(markdown), + "\ +**result_count:** 4 + +## ranking +**kind:** trait +**file:** src/hierarchy.rs + +- **Apex** + **line:** 4 + **depth:** 3 +- **Join** + **line:** 3 + **depth:** 2 +- **Right** + **line:** 2 + **depth:** 1 +- **Left** + **line:** 1 + **depth:** 0 +" + ); + + project.harness.shutdown().await; +} + +#[tokio::test] +async fn inheritance_depth_default_limit_keeps_ten_deepest() { + let project = open_project(&[ + ("src/lib.rs", "pub mod long;\n"), + ("src/long.rs", LONG_CHAIN), + ]) + .await; + + expect_ranking( + &json_body(&call_inheritance_depth(&project, json!({"format": "json"})).await), + json!({ + "result_count": 10, + "ranking": [ + {"name": "T011", "kind": "trait", "file": "src/long.rs", "line": 12, "depth": 11}, + {"name": "T010", "kind": "trait", "file": "src/long.rs", "line": 11, "depth": 10}, + {"name": "T009", "kind": "trait", "file": "src/long.rs", "line": 10, "depth": 9}, + {"name": "T008", "kind": "trait", "file": "src/long.rs", "line": 9, "depth": 8}, + {"name": "T007", "kind": "trait", "file": "src/long.rs", "line": 8, "depth": 7}, + {"name": "T006", "kind": "trait", "file": "src/long.rs", "line": 7, "depth": 6}, + {"name": "T005", "kind": "trait", "file": "src/long.rs", "line": 6, "depth": 5}, + {"name": "T004", "kind": "trait", "file": "src/long.rs", "line": 5, "depth": 4}, + {"name": "T003", "kind": "trait", "file": "src/long.rs", "line": 4, "depth": 3}, + {"name": "T002", "kind": "trait", "file": "src/long.rs", "line": 3, "depth": 2} + ] + }), + ); + expect_ranking( + &json_body(&call_inheritance_depth(&project, json!({"format": "json", "limit": 12})).await), + json!({ + "result_count": 12, + "ranking": [ + {"name": "T011", "kind": "trait", "file": "src/long.rs", "line": 12, "depth": 11}, + {"name": "T010", "kind": "trait", "file": "src/long.rs", "line": 11, "depth": 10}, + {"name": "T009", "kind": "trait", "file": "src/long.rs", "line": 10, "depth": 9}, + {"name": "T008", "kind": "trait", "file": "src/long.rs", "line": 9, "depth": 8}, + {"name": "T007", "kind": "trait", "file": "src/long.rs", "line": 8, "depth": 7}, + {"name": "T006", "kind": "trait", "file": "src/long.rs", "line": 7, "depth": 6}, + {"name": "T005", "kind": "trait", "file": "src/long.rs", "line": 6, "depth": 5}, + {"name": "T004", "kind": "trait", "file": "src/long.rs", "line": 5, "depth": 4}, + {"name": "T003", "kind": "trait", "file": "src/long.rs", "line": 4, "depth": 3}, + {"name": "T002", "kind": "trait", "file": "src/long.rs", "line": 3, "depth": 2}, + {"name": "T001", "kind": "trait", "file": "src/long.rs", "line": 2, "depth": 1}, + {"name": "T000", "kind": "trait", "file": "src/long.rs", "line": 1, "depth": 0} + ] + }), + ); + + project.harness.shutdown().await; +} + +#[tokio::test] +async fn inheritance_depth_cycle_is_unavailable() { + let project = + open_project(&[("src/lib.rs", "pub mod cycle;\n"), ("src/cycle.rs", CYCLE)]).await; + + let response = call_inheritance_depth(&project, json!({"format": "json"})).await; + let error = response + .error + .as_ref() + .unwrap_or_else(|| panic!("cycle ranked as success: {:?}", response.result)); + assert_eq!(error.code, -32602); + assert_eq!( + error.message, + "tool project route failed: reason_code=verified-inheritance-depth-unavailable retryable=false: the admitted extends relation contains a cycle" + ); + assert_eq!( + error.data, + Some(json!({ + "tool": "tracedecay_inheritance_depth", + "reason_code": "verified-inheritance-depth-unavailable", + "retryable": false, + "detail": "the admitted extends relation contains a cycle" + })) + ); + assert!(response.result.is_none()); + + project.harness.shutdown().await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_grep_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_grep_behavior_test.rs new file mode 100644 index 0000000000..11cd209ed7 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_grep_behavior_test.rs @@ -0,0 +1,411 @@ +//! `tracedecay_lcm_grep` as an agent calls it: one concrete query in, the +//! transcript snippet the agent would read out. The production MCP server is +//! the subject. + +#![cfg(feature = "test-transport")] + +use crate::support::{ + TemporalLcmProjectionInput, activate_test_temporal_generation, extract_real_server_text, + handle_real_server_tool_call_raw, open_active_project_session_db, + persist_temporal_lcm_observation, real_mcp_server, retained_envelope_payload, + setup_empty_project, +}; +use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; +use tracedecay_domain::{CanonicalMessageRoleV1, UtcMicros}; + +const SESSION: &str = "ledger-recall-session"; +const OTHER_SESSION: &str = "other-recall-session"; +const USER_ID: &str = "msg-user-quicksilver"; +const DECOY_ID: &str = "msg-assistant-decoy"; +const HASH_ID: &str = "msg-issue-hash"; +const CODEX_ID: &str = "msg-codex-quicksilver"; +const OTHER_ID: &str = "msg-other-ledger"; +const USER_TEXT: &str = "the ledger posts entry 17 against quicksilver"; +const DECOY_TEXT: &str = "unrelated orchard balance stays untouched"; +const HASH_TEXT: &str = "the log references issue#123 inside a Cursor transcript"; +const CODEX_TEXT: &str = "codex also saw quicksilver but not the ledger"; +const OTHER_TEXT: &str = "the ledger posts entry 17 in a different session"; + +/// Score the tool returns for the only exact-message hit from one source. +fn sole_source_score() -> Value { + serde_json::from_str("3.999999").expect("sole-source score literal") +} + +fn hit( + provider: &str, + session_id: &str, + message_id: &str, + role: &str, + snippet: &str, + store_id: i64, +) -> Value { + json!({ + "kind": "raw_message", + "provider": provider, + "session_id": session_id, + "message_id": message_id, + "node_id": null, + "store_id": store_id, + "role": role, + "snippet": snippet, + "score": sole_source_score(), + }) +} + +fn page(query: &str, provider: &str, omitted: u64, unknown: u64, hits: Vec) -> Value { + let count = hits.len(); + let status = if omitted == 0 { "ok" } else { "partial" }; + json!({ + "status": status, + "provider": provider, + "query": query, + "count": count, + "sort": "relevance", + "relationship_scope": "all", + "message_type": "all", + "capped_sessions": {}, + "omitted": omitted, + "coverage": { + "visible": 0, + "hidden": 0, + "unknown": unknown, + "redacted": 0, + }, + "hits": hits, + }) +} + +/// The caller's page, minus temporal paths and anchor ids that name the temp +/// project. Coverage stays: it is how the caller learns a hit was withheld. +fn caller_page(payload: &Value) -> Value { + json!({ + "status": payload["status"], + "provider": payload["provider"], + "query": payload["query"], + "count": payload["count"], + "sort": payload["sort"], + "relationship_scope": payload["relationship_scope"], + "message_type": payload["message_type"], + "capped_sessions": payload["capped_sessions"], + "omitted": payload["omitted"], + "coverage": payload["temporal"]["coverage"], + "hits": payload["hits"], + }) +} + +fn jsonrpc_error(response: &Value) -> Value { + json!({ + "code": response["error"]["code"], + "message": response["error"]["message"], + "data": response["error"]["data"], + }) +} + +fn argument_error(detail: &str) -> Value { + let message = format!( + "tool execution failed: config error: invalid retained application request for tracedecay_lcm_grep: {detail}" + ); + json!({ + "code": -32603, + "message": message, + "data": { + "tool": "tracedecay_lcm_grep", + "cli_fallback": "This tool is also available from the shell: `tracedecay tool lcm_grep ...` (`tracedecay tool lcm_grep --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly.", + }, + }) +} + +fn stable_problem(envelope: &Value) -> Value { + let request_id = envelope["request_id"].clone(); + assert_eq!( + envelope["problem"]["request_id"], request_id, + "refusal request identity drifted: {envelope}" + ); + assert_eq!( + envelope["problem"]["trace_id"], request_id, + "refusal trace identity drifted: {envelope}" + ); + let mut problem = envelope["problem"].clone(); + let Some(object) = problem.as_object_mut() else { + panic!("refusal problem is not an object: {problem}"); + }; + object.insert("request_id".to_owned(), json!("")); + object.insert("trace_id".to_owned(), json!("")); + json!({ + "contract": envelope["contract"], + "problem": problem, + }) +} + +fn invalid_request_refusal() -> Value { + json!({ + "contract": { + "schema_id": "schema.application.retained.lcm-grep.result", + "schema_revision": 1, + }, + "problem": { + "revision": 1, + "kind": "invalid_request", + "code": "application.retained.invalid-request", + "message": "The retained operation request is invalid.", + "diagnostic": { + "code": "application.retained.invalid-request", + "message": "The retained operation request is invalid.", + }, + "committed_receipt": null, + "owning_layer": "application", + "terminality": "pre_admission", + "retryable": false, + "retry": "never", + "retry_scope": null, + "retry_after_millis": null, + "cancellation_stage": null, + "unavailable_classification": null, + "execution_failure_classification": null, + "request_id": "", + "trace_id": "", + "details": [], + "legal_actions": ["correct_request"], + "coverage": null, + }, + }) +} + +fn unsupported_sort_refusal() -> Value { + json!({ + "contract": { + "schema_id": "schema.application.retained.lcm-grep.result", + "schema_revision": 1, + }, + "problem": { + "revision": 1, + "kind": "unsupported", + "code": "application.retained.unsupported", + "message": "The retained authority does not support this request.", + "diagnostic": { + "code": "application.retained.unsupported", + "message": "The retained authority does not support this request.", + }, + "committed_receipt": null, + "owning_layer": "application", + "terminality": "pre_admission", + "retryable": false, + "retry": "never", + "retry_scope": null, + "retry_after_millis": null, + "cancellation_stage": null, + "unavailable_classification": null, + "execution_failure_classification": null, + "request_id": "", + "trace_id": "", + "details": [], + "legal_actions": ["correct_request"], + "coverage": null, + }, + }) +} + +async fn seed( + cg: &tracedecay::project::TraceDecay, + provider: &str, + session_id: &str, + message_id: &str, + text: &str, + role: CanonicalMessageRoleV1, + ordinal: i64, + timestamp: i64, +) -> TemporalLcmProjectionInput { + persist_temporal_lcm_observation( + cg, + provider, + session_id, + message_id, + text.to_owned(), + role, + ordinal, + timestamp, + UtcMicros(timestamp), + ) + .await +} + +async fn call(server: &McpServer, args: Value) -> Value { + let response = handle_real_server_tool_call_raw(server, "tracedecay_lcm_grep", args).await; + if !response["error"].is_null() { + return json!({ "jsonrpc_error": jsonrpc_error(&response) }); + } + let text = extract_real_server_text(&response["result"]); + if let Some(payload) = retained_envelope_payload(text) { + return json!({ "page": caller_page(&payload) }); + } + let envelope: Value = serde_json::from_str(text).unwrap_or_else(|error| { + panic!("tracedecay_lcm_grep returned neither a page nor a refusal: {error}\n{text}") + }); + json!({ "refusal": stable_problem(&envelope) }) +} + +#[tokio::test] +async fn lcm_grep_returns_the_matching_snippet_and_refuses_a_bad_query() { + let (cg, _env, _dir) = setup_empty_project().await; + let db = open_active_project_session_db(&cg).await; + // Activate each session before later observations advance the global + // observation sequence past that session's frozen source frontier. + let other = vec![ + seed( + &cg, + "cursor", + OTHER_SESSION, + OTHER_ID, + OTHER_TEXT, + CanonicalMessageRoleV1::User, + 1, + 50, + ) + .await, + ]; + activate_test_temporal_generation(&db, OTHER_SESSION, other).await; + let ledger = vec![ + seed( + &cg, + "cursor", + SESSION, + USER_ID, + USER_TEXT, + CanonicalMessageRoleV1::User, + 1, + 10, + ) + .await, + seed( + &cg, + "cursor", + SESSION, + DECOY_ID, + DECOY_TEXT, + CanonicalMessageRoleV1::Assistant, + 2, + 20, + ) + .await, + seed( + &cg, + "cursor", + SESSION, + HASH_ID, + HASH_TEXT, + CanonicalMessageRoleV1::Assistant, + 3, + 30, + ) + .await, + seed( + &cg, + "codex", + SESSION, + CODEX_ID, + CODEX_TEXT, + CanonicalMessageRoleV1::User, + 4, + 40, + ) + .await, + ]; + activate_test_temporal_generation(&db, SESSION, ledger).await; + let server = real_mcp_server(cg).await; + + let observed = json!({ + "hash_query": call(&server, json!({ "query": "issue#123" })).await, + "session_phrase": call(&server, json!({ + "query": "ledger posts entry 17", + "scope": "session", + "session_id": SESSION, + })).await, + "other_session": call(&server, json!({ + "query": "ledger posts entry 17", + "scope": "session", + "session_id": OTHER_SESSION, + })).await, + "role_assistant": call(&server, json!({ + "query": "orchard balance", + "scope": "session", + "session_id": SESSION, + "role": "assistant", + })).await, + "role_user_misses_assistant": call(&server, json!({ + "query": "orchard balance", + "scope": "session", + "session_id": SESSION, + "role": "user", + })).await, + "provider_cursor": call(&server, json!({ + "provider": "cursor", + "query": "quicksilver", + })).await, + "provider_codex": call(&server, json!({ + "provider": "codex", + "query": "quicksilver", + })).await, + "provider_default_all": call(&server, json!({ "query": "quicksilver" })).await, + "time_window": call(&server, json!({ + "query": "quicksilver", + "start_time": 35, + "end_time": 45, + })).await, + "miss": call(&server, json!({ "query": "qxqvnomatch" })).await, + "missing_query": call(&server, json!({})).await, + "bad_scope": call(&server, json!({ + "query": "quicksilver", + "scope": "everything", + })).await, + "blank_query": call(&server, json!({ "query": " " })).await, + "session_without_id": call(&server, json!({ + "query": "quicksilver", + "scope": "session", + })).await, + "unsupported_sort": call(&server, json!({ + "query": "quicksilver", + "sort": "recency", + })).await, + }); + + let expected = json!({ + "hash_query": { "page": page("issue#123", "all", 1, 1, vec![ + hit("cursor", SESSION, HASH_ID, "assistant", HASH_TEXT, 4), + ])}, + "session_phrase": { "page": page("ledger posts entry 17", "all", 1, 1, vec![ + hit("cursor", SESSION, USER_ID, "user", USER_TEXT, 2), + ])}, + "other_session": { "page": page("ledger posts entry 17", "all", 1, 1, vec![ + hit("cursor", OTHER_SESSION, OTHER_ID, "user", OTHER_TEXT, 1), + ])}, + "role_assistant": { "page": page("orchard balance", "all", 1, 1, vec![ + hit("cursor", SESSION, DECOY_ID, "assistant", DECOY_TEXT, 3), + ])}, + "role_user_misses_assistant": { "page": page("orchard balance", "all", 0, 0, vec![]) }, + "provider_cursor": { "page": page("quicksilver", "cursor", 1, 1, vec![ + hit("cursor", SESSION, USER_ID, "user", USER_TEXT, 2), + ])}, + "provider_codex": { "page": page("quicksilver", "codex", 1, 1, vec![ + hit("codex", SESSION, CODEX_ID, "user", CODEX_TEXT, 5), + ])}, + "provider_default_all": { "page": page("quicksilver", "all", 2, 2, vec![ + hit("codex", SESSION, CODEX_ID, "user", CODEX_TEXT, 5), + hit("cursor", SESSION, USER_ID, "user", USER_TEXT, 2), + ])}, + "time_window": { "page": page("quicksilver", "all", 1, 1, vec![ + hit("codex", SESSION, CODEX_ID, "user", CODEX_TEXT, 5), + ])}, + "miss": { "page": page("qxqvnomatch", "all", 0, 0, vec![]) }, + "missing_query": { "jsonrpc_error": argument_error("missing field `query`") }, + "bad_scope": { "jsonrpc_error": argument_error( + "scope: unknown variant `everything`, expected one of `current`, `session`, `all`" + )}, + "blank_query": { "refusal": invalid_request_refusal() }, + "session_without_id": { "refusal": invalid_request_refusal() }, + "unsupported_sort": { "refusal": unsupported_sort_refusal() }, + }); + assert_eq!(observed, expected); + + server.shutdown().await; +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_test.rs index 5391107993..a39c62135b 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/lcm_test.rs @@ -3,8 +3,12 @@ use crate::common; use crate::support::*; use serde_json::{Value, json}; #[cfg(feature = "test-transport")] +use std::sync::Arc; +#[cfg(feature = "test-transport")] use std::time::SystemTime; #[cfg(feature = "test-transport")] +use tracedecay::mcp::McpServer; +#[cfg(feature = "test-transport")] use tracedecay::test_support::host_admission::LcmLineageFaultForTest; #[cfg(feature = "test-transport")] use tracedecay_domain::CanonicalMessageRoleV1; @@ -2395,6 +2399,371 @@ async fn lcm_status_all_provider_counts_payload_health_once() { assert_eq!(payload["lcm"]["payload"]["missing_count"], 0); } +/// `tracedecay_lcm_status` is the caller-facing census. Each request must +/// count only the session it was given, leave message bodies out of the +/// answer, and refuse a parameter the schema does not accept. +#[cfg(feature = "test-transport")] +#[tokio::test] +async fn lcm_status_over_mcp_counts_the_requested_session() { + let (cg, _env, _dir) = setup_empty_project().await; + seed_lcm_session_message( + &cg, + "status-alpha", + "status-alpha-message", + "alpha orchard body", + 1, + ) + .await; + seed_lcm_session_message(&cg, "status-beta", "status-beta-message", "beta only", 2).await; + let db = open_active_project_session_db(&cg).await; + let alpha = db + .lcm_load_raw_message_for_test("cursor", "status-alpha-message") + .await + .expect("alpha raw message"); + db.lcm_insert_summary_node_for_test( + HostAdmissionScope::Project, + LcmSummaryNodeDraft { + provider: "cursor".to_string(), + conversation_id: "status-alpha".to_string(), + session_id: "status-alpha".to_string(), + depth: 0, + summary_text: "alpha summary must stay out of status".to_string(), + source_refs: vec![LcmSourceRef::RawMessage { + store_id: alpha.store_id, + }], + source_token_count: 20, + summary_token_count: 4, + source_time_start: Some(1), + source_time_end: Some(2), + expand_hint: None, + metadata_json: None, + }, + ) + .await + .expect("summary should insert"); + let server = real_mcp_server(cg).await; + + let (alpha_text, alpha_status) = lcm_status_payload( + &server, + json!({"provider": "cursor", "session_id": "status-alpha"}), + ) + .await; + assert_eq!( + lcm_status_scope(&alpha_status), + json!({ + "status": "ok", + "authority_outcome": {"state": "ready"}, + "deep": false, + "provider": "cursor", + "session_id": "status-alpha", + "raw_message_count": 1, + "summary_node_count": 1, + "external_payload_count": 0, + "missing_payload_count": 0, + "unreferenced_payload_count": 0, + "maintenance_debt_count": 0, + "store_messages": 1, + "estimated_tokens": 0, + "token_estimate": { + "complete": false, + "scanned_messages": 0, + "next_after_store_id": 0 + }, + "dag_nodes": 1, + "dag_tokens": 4, + "dag_source_tokens": 20, + "compression_ratio": "5.0:1", + "depth0": {"count": 1, "tokens": 4, "source_tokens": 20}, + "payload_coverage_state": "partial", + "payload_coverage_reason": "payload_file_census_requires_deep_status", + "integrity_mismatch_count": null, + "externalized_count": 0, + "missing_count": 0, + "redaction_enabled": false, + "lossy_records": 0, + "lifecycle_state_count": 0, + "frontier_count": 0, + "lifecycle_debt_count": 0, + "current_session_id": null, + "current_frontier_store_id": null, + "last_finalized_session_id": null, + "last_finalized_frontier_store_id": null + }), + "{alpha_status}" + ); + + let (beta_text, beta_status) = lcm_status_payload( + &server, + json!({"provider": "cursor", "session_id": "status-beta"}), + ) + .await; + assert_eq!( + lcm_status_scope(&beta_status), + json!({ + "status": "ok", + "authority_outcome": {"state": "ready"}, + "deep": false, + "provider": "cursor", + "session_id": "status-beta", + "raw_message_count": 1, + "summary_node_count": 0, + "external_payload_count": 0, + "missing_payload_count": 0, + "unreferenced_payload_count": 0, + "maintenance_debt_count": 0, + "store_messages": 1, + "estimated_tokens": 0, + "token_estimate": { + "complete": false, + "scanned_messages": 0, + "next_after_store_id": 0 + }, + "dag_nodes": 0, + "dag_tokens": 0, + "dag_source_tokens": 0, + "compression_ratio": "0:1", + "depth0": null, + "payload_coverage_state": "partial", + "payload_coverage_reason": "payload_file_census_requires_deep_status", + "integrity_mismatch_count": null, + "externalized_count": 0, + "missing_count": 0, + "redaction_enabled": false, + "lossy_records": 0, + "lifecycle_state_count": 0, + "frontier_count": 0, + "lifecycle_debt_count": 0, + "current_session_id": null, + "current_frontier_store_id": null, + "last_finalized_session_id": null, + "last_finalized_frontier_store_id": null + }), + "{beta_status}" + ); + + let (_missing_text, missing_status) = lcm_status_payload( + &server, + json!({"provider": "cursor", "session_id": "status-missing"}), + ) + .await; + assert_eq!( + lcm_status_scope(&missing_status), + json!({ + "status": "ok", + "authority_outcome": {"state": "ready"}, + "deep": false, + "provider": "cursor", + "session_id": "status-missing", + "raw_message_count": 0, + "summary_node_count": 0, + "external_payload_count": 0, + "missing_payload_count": 0, + "unreferenced_payload_count": 0, + "maintenance_debt_count": 0, + "store_messages": 0, + "estimated_tokens": 0, + "token_estimate": {"complete": true, "scanned_messages": 0}, + "dag_nodes": 0, + "dag_tokens": 0, + "dag_source_tokens": 0, + "compression_ratio": "0:1", + "depth0": null, + "payload_coverage_state": "partial", + "payload_coverage_reason": "payload_file_census_requires_deep_status", + "integrity_mismatch_count": null, + "externalized_count": 0, + "missing_count": 0, + "redaction_enabled": false, + "lossy_records": 0, + "lifecycle_state_count": 0, + "frontier_count": 0, + "lifecycle_debt_count": 0, + "current_session_id": null, + "current_frontier_store_id": null, + "last_finalized_session_id": null, + "last_finalized_frontier_store_id": null + }), + "{missing_status}" + ); + + let (all_text, all_status) = lcm_status_payload(&server, json!({"provider": "all"})).await; + assert_eq!( + lcm_status_scope(&all_status), + json!({ + "status": "ok", + "authority_outcome": {"state": "ready"}, + "deep": false, + "provider": "all", + "raw_message_count": 2, + "summary_node_count": 1, + "external_payload_count": 0, + "missing_payload_count": 0, + "unreferenced_payload_count": 0, + "maintenance_debt_count": 0, + "store_messages": 2, + "estimated_tokens": 0, + "token_estimate": { + "complete": false, + "scanned_messages": 0, + "next_after_store_id": 0 + }, + "dag_nodes": 1, + "dag_tokens": 4, + "dag_source_tokens": 20, + "compression_ratio": "5.0:1", + "depth0": {"count": 1, "tokens": 4, "source_tokens": 20}, + "payload_coverage_state": "partial", + "payload_coverage_reason": "payload_file_census_requires_deep_status", + "integrity_mismatch_count": null, + "externalized_count": 0, + "missing_count": 0, + "redaction_enabled": false, + "lossy_records": 0, + "lifecycle_state_count": 0, + "frontier_count": 0, + "lifecycle_debt_count": 0, + "current_session_id": null, + "current_frontier_store_id": null, + "last_finalized_session_id": null, + "last_finalized_frontier_store_id": null + }), + "{all_status}" + ); + assert!( + all_status.get("session_id").is_none(), + "an unfiltered census must not invent a session id: {all_status}" + ); + + let (_omitted_text, omitted_provider) = lcm_status_payload(&server, json!({})).await; + assert_eq!(omitted_provider["provider"], "all"); + assert_eq!(omitted_provider["lcm"]["raw_message_count"], 2); + assert_eq!(omitted_provider["lcm"]["summary_node_count"], 1); + + let (deep_text, deep_status) = lcm_status_payload( + &server, + json!({"provider": "cursor", "session_id": "status-alpha", "deep": true}), + ) + .await; + assert_eq!( + lcm_status_scope(&deep_status), + json!({ + "status": "ok", + "authority_outcome": {"state": "ready"}, + "deep": true, + "provider": "cursor", + "session_id": "status-alpha", + "raw_message_count": 1, + "summary_node_count": 1, + "external_payload_count": 0, + "missing_payload_count": 0, + "unreferenced_payload_count": 0, + "maintenance_debt_count": 0, + "store_messages": 1, + "estimated_tokens": 3, + "token_estimate": {"complete": true, "scanned_messages": 1}, + "dag_nodes": 1, + "dag_tokens": 4, + "dag_source_tokens": 20, + "compression_ratio": "5.0:1", + "depth0": {"count": 1, "tokens": 4, "source_tokens": 20}, + "payload_coverage_state": "complete", + "payload_coverage_reason": null, + "integrity_mismatch_count": 0, + "externalized_count": 0, + "missing_count": 0, + "redaction_enabled": false, + "lossy_records": 0, + "lifecycle_state_count": 0, + "frontier_count": 0, + "lifecycle_debt_count": 0, + "current_session_id": null, + "current_frontier_store_id": null, + "last_finalized_session_id": null, + "last_finalized_frontier_store_id": null + }), + "{deep_status}" + ); + + let rendered = format!("{alpha_text}\n{beta_text}\n{all_text}\n{deep_text}"); + assert!( + !rendered.contains("alpha orchard body"), + "status must count the alpha message without returning its body" + ); + assert!(!rendered.contains("beta only")); + assert!(!rendered.contains("alpha summary must stay out of status")); + + let rejected = handle_real_server_tool_call_raw( + &server, + "tracedecay_lcm_status", + json!({"hermes_home": "/tmp/not-a-profile"}), + ) + .await; + assert_eq!(rejected["error"]["code"], -32603); + assert_eq!( + rejected["error"]["message"], + "tool execution failed: config error: unknown parameter `hermes_home` for `tracedecay_lcm_status`" + ); + assert_eq!(rejected["error"]["data"]["tool"], "tracedecay_lcm_status"); + server.shutdown().await; +} + +#[cfg(feature = "test-transport")] +async fn lcm_status_payload(server: &Arc, args: Value) -> (String, Value) { + let result = handle_real_server_tool_call(server, "tracedecay_lcm_status", args).await; + let text = extract_real_server_text(&result).to_owned(); + let payload = serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("tracedecay_lcm_status must return JSON: {error}\n{text}")); + (text, payload) +} + +#[cfg(feature = "test-transport")] +fn lcm_status_scope(payload: &Value) -> Value { + let mut scope = json!({ + "status": payload["status"], + "authority_outcome": payload["authority_outcome"], + "deep": payload["deep"], + "provider": payload["provider"], + "raw_message_count": payload["lcm"]["raw_message_count"], + "summary_node_count": payload["lcm"]["summary_node_count"], + "external_payload_count": payload["lcm"]["external_payload_count"], + "missing_payload_count": payload["lcm"]["missing_payload_count"], + "unreferenced_payload_count": payload["lcm"]["unreferenced_payload_count"], + "maintenance_debt_count": payload["lcm"]["maintenance_debt_count"], + "store_messages": payload["lcm"]["store"]["messages"], + "estimated_tokens": payload["lcm"]["store"]["estimated_tokens"], + "token_estimate": payload["lcm"]["store"]["token_estimate"], + "dag_nodes": payload["lcm"]["dag"]["total_nodes"], + "dag_tokens": payload["lcm"]["dag"]["total_tokens"], + "dag_source_tokens": payload["lcm"]["dag"]["total_source_tokens"], + "compression_ratio": payload["lcm"]["dag"]["compression_ratio"], + "depth0": payload["lcm"]["dag"]["depths"].get("d0").cloned().unwrap_or(Value::Null), + "payload_coverage_state": payload["lcm"]["payload"]["coverage"]["state"], + "payload_coverage_reason": payload["lcm"]["payload"]["coverage"] + .get("reason") + .cloned() + .unwrap_or(Value::Null), + "integrity_mismatch_count": payload["lcm"]["payload"]["integrity_mismatch_count"], + "externalized_count": payload["lcm"]["payload"]["externalized_count"], + "missing_count": payload["lcm"]["payload"]["missing_count"], + "redaction_enabled": payload["lcm"]["redaction"]["enabled"], + "lossy_records": payload["lcm"]["redaction"]["lossy_records"], + "lifecycle_state_count": payload["lcm"]["lifecycle"]["lifecycle_state_count"], + "frontier_count": payload["lcm"]["lifecycle"]["frontier_count"], + "lifecycle_debt_count": payload["lcm"]["lifecycle"]["maintenance_debt_count"], + "current_session_id": payload["lcm"]["lifecycle"]["current_session_id"], + "current_frontier_store_id": payload["lcm"]["lifecycle"]["current_frontier_store_id"], + "last_finalized_session_id": payload["lcm"]["lifecycle"]["last_finalized_session_id"], + "last_finalized_frontier_store_id": payload["lcm"]["lifecycle"]["last_finalized_frontier_store_id"] + }); + if let Some(session_id) = payload.get("session_id") { + scope + .as_object_mut() + .expect("scope object") + .insert("session_id".to_owned(), session_id.clone()); + } + scope +} + // Repeated LCM tool calls in one process must reuse the per-process // The retained project runtime must not re-run the full DDL ensure for each // request. Observable via the version gate: after admission, a manually diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs new file mode 100644 index 0000000000..199680a9f7 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/node_behavior_test.rs @@ -0,0 +1,528 @@ +#![cfg(feature = "test-transport")] + +//! `tracedecay_node` as an MCP client observes it. +//! +//! Calls go through JSON-RPC `tools/call` on the production server. Expected +//! rows are the declarations in `SOURCE`, not values read back out of the +//! handler. `full_file` is that file's 488 bytes divided by 4. `body` is the +//! declaration's line count times 20. Node ids come from +//! `tracedecay_find_exact_symbol` only so the call has an address; every +//! assertion is on `tracedecay_node`. + +use std::fs; + +use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; + +use crate::support::{ + dispatch_mcp_tool_call, production_composition_fixture_with_sources, wait_for_current_graph, +}; + +const SOURCE: &str = r#"/// Loads the current value. +pub async fn fetch_value(key: &str) -> u32 { + if key.is_empty() { + return 0; + } + let mut total = 0; + for byte in key.bytes() { + total += u32::from(byte); + } + total +} + +fn cached_value() -> u32 { + CACHED_BODY_NOT_IN_NODE +} + +#[derive(Debug, Clone)] +pub struct Widget { + pub label: &'static str, +} + +impl Widget { + /// Paints the widget. + pub fn render(&self) -> &'static str { + "WIDGET_BODY_NOT_IN_NODE" + } +} +"#; + +const SOURCE_BYTES: usize = 488; +const FULL_FILE_COST: u64 = 122; +const MISSING_NODE: &str = + "symbol.v1.sha256:0000000000000000000000000000000000000000000000000000000000000000"; +const EVIDENCE_ANCHOR: &str = + "code-graph:symbol.v1.sha256:0000000000000000000000000000000000000000000000000000000000000000"; +const NODE_CLI_FALLBACK: &str = "This tool is also available from the shell: `tracedecay tool node ...` (`tracedecay tool node --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly."; + +#[tokio::test] +async fn tracedecay_node_reports_declared_symbols_and_typed_refusals() { + assert_eq!( + SOURCE.len(), + SOURCE_BYTES, + "full_file literal must match SOURCE" + ); + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), SOURCE).unwrap(); + }) + .await; + let on_disk = fs::read(fixture.project_root.join("src/lib.rs")).expect("fixture source"); + assert_eq!(on_disk.len(), SOURCE_BYTES); + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production node server"); + wait_for_current_graph(&server).await; + + let fetch_id = occurrence_id(&server, "fetch_value", "function").await; + let cached_id = occurrence_id(&server, "cached_value", "function").await; + let widget_id = occurrence_id(&server, "Widget", "struct").await; + let label_id = occurrence_id(&server, "label", "field").await; + let impl_id = occurrence_id(&server, "Widget", "impl").await; + let render_id = occurrence_id(&server, "render", "method").await; + + assert_node(&server, &fetch_id, &fetch_details(&fetch_id)).await; + assert_node(&server, &cached_id, &cached_details(&cached_id)).await; + assert_node(&server, &widget_id, &widget_details(&widget_id)).await; + assert_node(&server, &label_id, &label_details(&label_id)).await; + assert_node(&server, &impl_id, &impl_details(&impl_id)).await; + assert_node(&server, &render_id, &render_details(&render_id)).await; + + let anchored = node_json( + &server, + json!({"node_id": format!("code-symbol:{fetch_id}")}), + ) + .await; + assert_eq!(anchored, fetch_details(&fetch_id)); + let upper_json = node_json(&server, json!({"node_id": fetch_id, "format": "JSON"})).await; + assert_eq!(upper_json, fetch_details(&fetch_id)); + + let fetch_text = tool_text(&node_call(&server, json!({"node_id": fetch_id})).await); + let fetch_markdown_text = + tool_text(&node_call(&server, json!({"node_id": fetch_id, "format": "markdown"})).await); + let fetch_text_format = + tool_text(&node_call(&server, json!({"node_id": fetch_id, "format": "text"})).await); + let expected_fetch_markdown = fetch_markdown(&fetch_id); + assert_eq!(fetch_text, expected_fetch_markdown); + assert_eq!(fetch_markdown_text, expected_fetch_markdown); + assert_eq!(fetch_text_format, expected_fetch_markdown); + assert_eq!( + tool_text(&node_call(&server, json!({"node_id": widget_id})).await), + widget_markdown(&widget_id) + ); + assert_eq!( + tool_text(&node_call(&server, json!({"node_id": cached_id})).await), + cached_markdown(&cached_id) + ); + + for text in [ + tool_text(&node_call(&server, json!({"node_id": fetch_id, "format": "json"})).await), + tool_text(&node_call(&server, json!({"node_id": cached_id, "format": "json"})).await), + tool_text(&node_call(&server, json!({"node_id": render_id, "format": "json"})).await), + expected_fetch_markdown, + ] { + assert!( + !text.contains("key.is_empty()") + && !text.contains("CACHED_BODY_NOT_IN_NODE") + && !text.contains("WIDGET_BODY_NOT_IN_NODE"), + "node details must not return the body: {text}" + ); + } + + let missing = node_call(&server, json!({"node_id": MISSING_NODE})).await; + assert!( + missing.get("error").is_none() || missing["error"].is_null(), + "a missing node is a tool result, not a transport error: {missing}" + ); + assert_eq!(missing["result"]["isError"], true); + assert_eq!( + parse_json(&tool_text(&missing)), + json!({ + "status": "not_found", + "reason_code": "node_not_found", + "node_id": MISSING_NODE, + "message": format!("Node not found: {MISSING_NODE}") + }) + ); + + assert_execution_failed( + &node_call(&server, json!({})).await, + "tool execution failed: config error: invalid arguments for tracedecay_node: missing field `node_id`", + ); + assert_execution_failed( + &node_call(&server, json!({"node_id": ""})).await, + "tool execution failed: config error: invalid parameter: node_id must not be empty", + ); + assert_execution_failed( + &node_call(&server, json!({"node_id": " "})).await, + "tool execution failed: config error: invalid parameter: node_id must not be empty", + ); + assert_execution_failed( + &node_call(&server, json!({"id": fetch_id})).await, + "tool execution failed: config error: invalid arguments for tracedecay_node: unknown field `id`, expected `node_id`", + ); + assert_execution_failed( + &node_call(&server, json!({"node_id": fetch_id, "limit": 1})).await, + "tool execution failed: config error: invalid arguments for tracedecay_node: unknown field `limit`, expected `node_id`", + ); + assert_execution_failed( + &node_call(&server, json!([fetch_id])).await, + "tool execution failed: config error: invalid arguments: tracedecay_node expects a JSON object", + ); + assert_execution_failed( + &node_call(&server, json!({"node_id": EVIDENCE_ANCHOR})).await, + &format!( + "tool execution failed: config error: invalid parameter: node_id `{EVIDENCE_ANCHOR}` is an evidence anchor, not a graph symbol occurrence" + ), + ); + + fixture.harness.shutdown().await; +} + +fn fetch_details(id: &str) -> Value { + details( + id, + "fetch_value", + "function", + "src/lib.rs::fetch_value", + "pub async fn fetch_value(key: &str) -> u32", + Some("Loads the current value."), + true, + &[], + "public", + 2, + 11, + 1, + 1, + 2, + 2, + 200, + ) +} + +fn cached_details(id: &str) -> Value { + details( + id, + "cached_value", + "function", + "src/lib.rs::cached_value", + "fn cached_value() -> u32", + None, + false, + &[], + "private", + 13, + 15, + 0, + 0, + 1, + 1, + 60, + ) +} + +fn widget_details(id: &str) -> Value { + details( + id, + "Widget", + "struct", + "src/lib.rs::Widget", + "pub struct Widget", + None, + false, + &["Clone", "Debug"], + "public", + 18, + 20, + 0, + 0, + 0, + 1, + 60, + ) +} + +fn label_details(id: &str) -> Value { + details( + id, + "label", + "field", + "src/lib.rs::Widget::label", + "pub label: &'static str", + None, + false, + &[], + "public", + 19, + 19, + 0, + 0, + 0, + 1, + 20, + ) +} + +fn impl_details(id: &str) -> Value { + details( + id, + "Widget", + "impl", + "src/lib.rs::Widget", + "impl Widget", + None, + false, + &[], + "private", + 22, + 27, + 0, + 0, + 0, + 1, + 120, + ) +} + +fn render_details(id: &str) -> Value { + details( + id, + "render", + "method", + "src/lib.rs::Widget::render", + "pub fn render(&self) -> &'static str", + Some("Paints the widget."), + false, + &[], + "public", + 24, + 26, + 0, + 0, + 1, + 1, + 60, + ) +} + +#[allow(clippy::too_many_arguments)] +fn details( + id: &str, + name: &str, + kind: &str, + qualified_name: &str, + signature: &str, + docstring: Option<&str>, + is_async: bool, + derives: &[&str], + visibility: &str, + start_line: u64, + end_line: u64, + branches: u64, + loops: u64, + max_nesting: u64, + cyclomatic: u64, + body_cost: u64, +) -> Value { + json!({ + "id": id, + "name": name, + "kind": kind, + "qualified_name": qualified_name, + "file": "src/lib.rs", + "start_line": start_line, + "end_line": end_line, + "signature": signature, + "docstring": docstring, + "is_async": is_async, + "derives": derives, + "visibility": visibility, + "branches": branches, + "loops": loops, + "max_nesting": max_nesting, + "cyclomatic_complexity": cyclomatic, + "complexity_analysis": "complete", + "cost_to_expand": {"body": body_cost, "full_file": FULL_FILE_COST}, + "unavailable_fields": [ + "assertions", + "attrs_start_line", + "returns", + "unchecked_calls", + "unsafe_blocks" + ] + }) +} + +fn fetch_markdown(id: &str) -> String { + format!( + "\ +**branches:** 1 +**complexity_analysis:** complete +**cyclomatic_complexity:** 2 +**docstring:** Loads the current value. +**end_line:** 11 +**file:** src/lib.rs +**id:** `{id}` +**is_async:** true +**kind:** function +**loops:** 1 +**max_nesting:** 2 +**name:** fetch_value +**qualified_name:** `src/lib.rs::fetch_value` +**signature:** `pub async fn fetch_value(key: &str) -> u32` +**start_line:** 2 +**visibility:** public + +## cost_to_expand +**body:** 200 +**full_file:** 122 +derives: none + +## unavailable_fields +- assertions +- attrs_start_line +- returns +- unchecked_calls +- unsafe_blocks +" + ) +} + +fn cached_markdown(id: &str) -> String { + format!( + "\ +**branches:** 0 +**complexity_analysis:** complete +**cyclomatic_complexity:** 1 +**end_line:** 15 +**file:** src/lib.rs +**id:** `{id}` +**is_async:** false +**kind:** function +**loops:** 0 +**max_nesting:** 1 +**name:** cached_value +**qualified_name:** `src/lib.rs::cached_value` +**signature:** `fn cached_value() -> u32` +**start_line:** 13 +**visibility:** private + +## cost_to_expand +**body:** 60 +**full_file:** 122 +derives: none + +## unavailable_fields +- assertions +- attrs_start_line +- returns +- unchecked_calls +- unsafe_blocks +" + ) +} + +fn widget_markdown(id: &str) -> String { + format!( + "\ +**branches:** 0 +**complexity_analysis:** complete +**cyclomatic_complexity:** 1 +**end_line:** 20 +**file:** src/lib.rs +**id:** `{id}` +**is_async:** false +**kind:** struct +**loops:** 0 +**max_nesting:** 0 +**name:** Widget +**qualified_name:** `src/lib.rs::Widget` +**signature:** `pub struct Widget` +**start_line:** 18 +**visibility:** public + +## cost_to_expand +**body:** 60 +**full_file:** 122 + +## derives +- Clone +- Debug + +## unavailable_fields +- assertions +- attrs_start_line +- returns +- unchecked_calls +- unsafe_blocks +" + ) +} + +async fn assert_node(server: &McpServer, id: &str, expected: &Value) { + let actual = node_json(server, json!({"node_id": id})).await; + assert_eq!(actual, *expected); +} + +async fn node_json(server: &McpServer, mut arguments: Value) -> Value { + arguments + .as_object_mut() + .expect("node arguments") + .entry("format") + .or_insert_with(|| json!("json")); + parse_json(&tool_text(&node_call(server, arguments).await)) +} + +async fn node_call(server: &McpServer, arguments: Value) -> Value { + dispatch_mcp_tool_call(server, "tracedecay_node", arguments).await +} + +async fn occurrence_id(server: &McpServer, name: &str, kind: &str) -> String { + let response = dispatch_mcp_tool_call( + server, + "tracedecay_find_exact_symbol", + json!({"name": name, "format": "json"}), + ) + .await; + let payload = parse_json(&tool_text(&response)); + payload["matches"] + .as_array() + .and_then(|matches| { + matches + .iter() + .find(|row| row["name"] == name && row["kind"] == kind) + }) + .and_then(|row| row["id"].as_str()) + .unwrap_or_else(|| panic!("no {kind} named {name} in {payload}")) + .to_owned() +} + +fn tool_text(response: &Value) -> String { + assert!( + response.get("error").is_none() || response["error"].is_null(), + "MCP tools/call failed: {response}" + ); + response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("MCP result has no text: {response}")) + .to_owned() +} + +fn parse_json(text: &str) -> Value { + serde_json::from_str(text) + .unwrap_or_else(|error| panic!("MCP text was not JSON: {error}\n{text}")) +} + +fn assert_execution_failed(response: &Value, message: &str) { + assert_eq!(response["error"]["code"], -32603, "{response}"); + assert_eq!(response["error"]["message"], message, "{response}"); + assert_eq!( + response["error"]["data"], + json!({ + "tool": "tracedecay_node", + "cli_fallback": NODE_CLI_FALLBACK, + }), + "{response}" + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_order_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_order_test.rs new file mode 100644 index 0000000000..e237dfb02c --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/port_order_test.rs @@ -0,0 +1,359 @@ +//! Behavior of `tracedecay_port_order` through the production MCP `tools/call` path. +//! +//! Line numbers below are the 1-based lines of the fixture sources. Level +//! descriptions use the handler's en dash (U+2013), not a hyphen. + +use std::fs; + +use serde_json::{Value, json}; + +use crate::support::{ + ProductionCompositionFixture, extract_json, production_composition_fixture_with_sources, + wait_for_current_graph, +}; + +/// `order/chain.rs`. Functions only: `leaf` line 1, `zeta` line 5, `alpha` +/// line 7, `mid` line 9 (`leaf()`), `top` line 13 (`mid()`). +const ORDER_CHAIN: &str = "\ +pub fn leaf() -> i32 { + 1 +} + +pub fn zeta() {} + +pub fn alpha() {} + +pub fn mid() -> i32 { + leaf() +} + +pub fn top() -> i32 { + mid() +} +"; + +/// `cycle/scc.rs`. One SCC: `alpha` line 1, `beta` line 6, `gamma` line 10, +/// `hub` line 15. +/// +/// Edges: alpha→beta, alpha→hub, beta→gamma, gamma→alpha, gamma→hub, hub→alpha. +const CYCLE_SCC: &str = "\ +pub fn alpha() { + beta(); + hub(); +} + +pub fn beta() { + gamma(); +} + +pub fn gamma() { + alpha(); + hub(); +} + +pub fn hub() { + alpha(); +} +"; + +/// `tied/leaves.rs`. Three independent functions, lines 1–3. +const TIED_LEAVES: &str = "\ +pub fn zeta() {} +pub fn alpha() {} +pub fn middle() {} +"; + +const LEVEL_0: &str = "No internal dependencies. Port these first"; +const LEVEL_1: &str = "Depends only on levels 0\u{2013}0"; +const LEVEL_2: &str = "Depends only on levels 0\u{2013}1"; +const CYCLE_NOTE: &str = "Mutual dependency. Port together, starting at `entry_point` and refactoring `break_point_candidate` to split the cycle."; +const BREAK_RATIONALE: &str = "Highest in-cycle in-degree. Refactoring its callers is the most effective way to fragment this SCC."; + +async fn open_port_order_project() -> ProductionCompositionFixture { + let fixture = production_composition_fixture_with_sources(|project| { + fs::create_dir_all(project.join("order")).unwrap(); + fs::create_dir_all(project.join("cycle")).unwrap(); + fs::create_dir_all(project.join("tied")).unwrap(); + fs::write(project.join("order/chain.rs"), ORDER_CHAIN).unwrap(); + fs::write(project.join("cycle/scc.rs"), CYCLE_SCC).unwrap(); + fs::write(project.join("tied/leaves.rs"), TIED_LEAVES).unwrap(); + }) + .await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production port-order server"); + wait_for_current_graph(&server).await; + fixture +} + +async fn call_port_order(fixture: &ProductionCompositionFixture, mut arguments: Value) -> Value { + arguments + .as_object_mut() + .expect("port_order arguments are an object") + .insert("format".to_owned(), json!("json")); + let response = fixture + .harness + .call_tool(&fixture.project_root, "tracedecay_port_order", arguments) + .await + .expect("production MCP tools/call"); + let result = response.result.unwrap_or_else(|| { + panic!( + "tracedecay_port_order failed: {:?}", + response.error.as_ref().map(|error| &error.message) + ) + }); + extract_json(&result) +} + +fn assert_payload(actual: &Value, expected: Value) { + assert_eq!( + actual, + &expected, + "tracedecay_port_order payload:\n{}", + serde_json::to_string_pretty(actual).unwrap_or_else(|_| actual.to_string()) + ); +} + +fn ordered_chain() -> Value { + json!({ + "source_dir": "order", + "total_symbols": 5, + "returned": 5, + "levels": [ + { + "level": 0, + "description": LEVEL_0, + "symbols": [ + {"name": "leaf", "kind": "function", "file": "order/chain.rs", "line": 1}, + {"name": "zeta", "kind": "function", "file": "order/chain.rs", "line": 5}, + {"name": "alpha", "kind": "function", "file": "order/chain.rs", "line": 7} + ] + }, + { + "level": 1, + "description": LEVEL_1, + "symbols": [ + { + "name": "mid", + "kind": "function", + "file": "order/chain.rs", + "line": 9, + "depends_on": ["leaf"] + } + ] + }, + { + "level": 2, + "description": LEVEL_2, + "symbols": [ + { + "name": "top", + "kind": "function", + "file": "order/chain.rs", + "line": 13, + "depends_on": ["mid"] + } + ] + } + ], + "cycles": [] + }) +} + +#[tokio::test] +async fn port_order_ports_leaves_first_and_reports_one_scc() { + let (_isolated_env, _) = crate::common::IsolatedEnv::acquire().await; + let fixture = open_port_order_project().await; + + // `mid` calls `leaf`; `top` calls `mid`. Leaves share level 0 in source order. + let chain = call_port_order( + &fixture, + json!({"source_dir": "order", "kinds": ["function"]}), + ) + .await; + assert_payload(&chain, ordered_chain()); + + // Unknown kinds are dropped when one supported kind remains. Default + // kinds on a function-only file are the same payload. + let mixed = call_port_order( + &fixture, + json!({"source_dir": "order", "kinds": ["function", "not_a_kind"]}), + ) + .await; + assert_payload(&mixed, ordered_chain()); + let defaults = call_port_order(&fixture, json!({"source_dir": "order"})).await; + assert_payload(&defaults, ordered_chain()); + + // Limit is applied after the tied level is sorted by file and line. + // The omitted function is acyclic, so it is not reported as a cycle. + let limited = call_port_order( + &fixture, + json!({"source_dir": "tied", "kinds": ["function"], "limit": 2}), + ) + .await; + assert_payload( + &limited, + json!({ + "source_dir": "tied", + "total_symbols": 3, + "returned": 2, + "levels": [ + { + "level": 0, + "description": LEVEL_0, + "symbols": [ + {"name": "zeta", "kind": "function", "file": "tied/leaves.rs", "line": 1}, + {"name": "alpha", "kind": "function", "file": "tied/leaves.rs", "line": 2} + ] + } + ], + "cycles": [] + }), + ); + + // In-cycle out-degree ascending, then in-degree descending: + // hub (1, 2), beta (1, 1), alpha (2, 2), gamma (2, 1). + // `hub` is the entry. `alpha` is the last node tied for highest in-degree. + let cycle = call_port_order( + &fixture, + json!({"source_dir": "cycle", "kinds": ["function"]}), + ) + .await; + assert_payload( + &cycle, + json!({ + "source_dir": "cycle", + "total_symbols": 4, + "returned": 0, + "levels": [], + "cycles": [ + { + "size": 4, + "files": [ + {"file": "cycle/scc.rs", "members_in_cycle": 4} + ], + "symbols": [ + { + "name": "hub", + "kind": "function", + "file": "cycle/scc.rs", + "line": 15, + "in_cycle_out_degree": 1, + "in_cycle_in_degree": 2 + }, + { + "name": "beta", + "kind": "function", + "file": "cycle/scc.rs", + "line": 6, + "in_cycle_out_degree": 1, + "in_cycle_in_degree": 1 + }, + { + "name": "alpha", + "kind": "function", + "file": "cycle/scc.rs", + "line": 1, + "in_cycle_out_degree": 2, + "in_cycle_in_degree": 2 + }, + { + "name": "gamma", + "kind": "function", + "file": "cycle/scc.rs", + "line": 10, + "in_cycle_out_degree": 2, + "in_cycle_in_degree": 1 + } + ], + "entry_point": { + "name": "hub", + "file": "cycle/scc.rs", + "line": 15 + }, + "break_point_candidate": { + "name": "alpha", + "file": "cycle/scc.rs", + "line": 1, + "rationale": BREAK_RATIONALE + }, + "note": CYCLE_NOTE + } + ] + }), + ); + + let empty = call_port_order(&fixture, json!({"source_dir": "missing"})).await; + assert_payload( + &empty, + json!({ + "source_dir": "missing", + "total_symbols": 0, + "returned": 0, + "levels": [], + "cycles": [] + }), + ); + + fixture.harness.shutdown().await; +} + +#[tokio::test] +async fn port_order_rejects_unknown_kinds_and_missing_source_dir() { + let (_isolated_env, _) = crate::common::IsolatedEnv::acquire().await; + let fixture = open_port_order_project().await; + + let unknown_kind = tool_error( + &fixture, + json!({"source_dir": "order", "kinds": ["not_a_kind"]}), + ) + .await; + assert_eq!(unknown_kind.0, -32603); + assert_eq!( + unknown_kind.1, + "tool execution failed: config error: invalid parameter: kinds must contain at least one supported node kind" + ); + assert_eq!(unknown_kind.2, "tracedecay_port_order"); + + let missing_source_dir = tool_error(&fixture, json!({})).await; + assert_eq!(missing_source_dir.0, -32603); + assert_eq!( + missing_source_dir.1, + "tool execution failed: config error: invalid arguments for tracedecay_port_order: missing field `source_dir`" + ); + assert_eq!(missing_source_dir.2, "tracedecay_port_order"); + + fixture.harness.shutdown().await; +} + +async fn tool_error( + fixture: &ProductionCompositionFixture, + mut arguments: Value, +) -> (i32, String, String) { + arguments + .as_object_mut() + .expect("port_order arguments are an object") + .insert("format".to_owned(), json!("json")); + let response = fixture + .harness + .call_tool(&fixture.project_root, "tracedecay_port_order", arguments) + .await + .expect("production MCP tools/call"); + assert!( + response.result.is_none(), + "invalid port_order input must not return a result: {:?}", + response.result + ); + let error = response + .error + .expect("invalid port_order input must return a JSON-RPC error"); + let tool = error + .data + .as_ref() + .and_then(|data| data.get("tool")) + .and_then(Value::as_str) + .unwrap_or("") + .to_owned(); + (error.code, error.message, tool) +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_context_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_context_test.rs new file mode 100644 index 0000000000..1981cda600 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_context_test.rs @@ -0,0 +1,380 @@ +//! What a caller of `tracedecay_project_context` observes over MCP `tools/call`. +//! +//! Selectors, the served project, a missing registry, and a failed read are +//! the answers the tool returns. The assertions are those payloads, not the +//! selector parser or the registry port. + +use crate::support::{ + extract_real_server_text, handle_real_server_tool_call, handle_real_server_tool_call_raw, + setup_empty_project, test_temp_dir, +}; +use serde_json::{Value, json}; +use std::fs; +use std::path::Path; + +#[tokio::test] +async fn project_context_returns_the_project_the_caller_named() { + let (cg, _env, _dir) = setup_empty_project().await; + let registry_dir = test_temp_dir(); + let alpha_root = registry_dir.path().join("alpha-checkout"); + fs::create_dir_all(&alpha_root).expect("alpha checkout"); + let active_project_id = cg + .store_layout() + .identity + .project_id + .clone() + .expect("served project identity"); + let runtime = + tracedecay::test_support::host_admission::HostAdmissionTestRuntimeV1::project_scoped( + registry_dir.path(), + cg.project_root(), + tracedecay_domain::ProjectId::new(active_project_id.clone()).expect("project id"), + ) + .await + .expect("registered runtime"); + let registry_path = runtime + .profile_root_for_test() + .join("global.db") + .canonicalize() + .expect("registry path") + .display() + .to_string(); + + // The remote carries a credential. The tool's answer must describe the + // public registry row and must not repeat that credential. + let alpha = runtime + .upsert_code_project( + "proj_alpha", + &alpha_root, + None, + Some("https://token:secret@example.test/alpha.git"), + Some("main"), + ) + .await + .expect("seed proj_alpha"); + let named_alias = runtime + .upsert_project_alias(Path::new("registered-alias"), "proj_alpha") + .await + .expect("seed alias"); + let store = runtime + .upsert_store_instance(tracedecay_global_db::StoreInstanceUpsert { + store_id: "store_alpha".to_string(), + project_id: "proj_alpha".to_string(), + store_kind: "code_project".to_string(), + storage_mode: "profile_sharded".to_string(), + store_relpath: "projects/proj_alpha".to_string(), + manifest_relpath: Some("projects/proj_alpha/store_manifest.json".to_string()), + last_verified_at: Some(1_800_000_001), + last_write_at: None, + }) + .await + .expect("seed store"); + runtime + .upsert_graph_scope(tracedecay_global_db::GraphScopeUpsert { + graph_scope_id: "scope_alpha_main".to_string(), + project_id: "proj_alpha".to_string(), + store_id: "store_alpha".to_string(), + branch_name: "main".to_string(), + db_relpath: "projects/proj_alpha/tracedecay.db".to_string(), + parent_scope_id: None, + last_synced_at: Some(1_800_000_002), + writable: true, + }) + .await + .expect("seed graph scope"); + runtime + .upsert_store_artifact(tracedecay_global_db::StoreArtifactUpsert { + store_id: "store_alpha".to_string(), + artifact_kind: "graph_db".to_string(), + relpath: "projects/proj_alpha/tracedecay.db".to_string(), + size_bytes: Some(128), + schema_version: Some("1".to_string()), + updated_at: Some(1_800_000_003), + }) + .await + .expect("seed artifact"); + let active = runtime + .upsert_code_project( + &active_project_id, + cg.project_root(), + None, + None, + Some("main"), + ) + .await + .expect("seed served project"); + + let server = tracedecay::mcp::McpServer::new_with_host_admission_test_runtime_for_test( + tracedecay::project::TraceDecay::open(cg.project_root()) + .await + .expect("open served project"), + None, + runtime, + ) + .await + .expect("mcp server"); + + let alpha_context = json!({ + "status": "ok", + "is_active": false, + "registry_path": registry_path, + "project": { + "project_id": "proj_alpha", + "label": "alpha-checkout", + "project_root": alpha.display_root, + "display_root": alpha.display_root, + "canonical_root": alpha.canonical_root, + "git_common_dir": null, + "default_branch": "main", + "created_at": alpha.created_at, + "last_seen_at": alpha.last_seen_at, + "is_active": false, + }, + "aliases": sorted_aliases(vec![ + json!({ + "alias_path": alpha.canonical_root, + "project_id": "proj_alpha", + "last_seen_at": alpha.last_seen_at, + }), + json!({ + "alias_path": "git-remote-name:alpha.git", + "project_id": "proj_alpha", + "last_seen_at": alpha.last_seen_at, + }), + json!({ + "alias_path": named_alias.alias_path, + "project_id": "proj_alpha", + "last_seen_at": named_alias.last_seen_at, + }), + ]), + "stores": [{ + "store": { + "store_id": "store_alpha", + "project_id": "proj_alpha", + "store_kind": "code_project", + "storage_mode": "profile_sharded", + "store_relpath": "projects/proj_alpha", + "manifest_relpath": "projects/proj_alpha/store_manifest.json", + "created_at": store.created_at, + "last_verified_at": 1_800_000_001, + "last_write_at": null, + }, + "graph_scopes": [{ + "graph_scope_id": "scope_alpha_main", + "project_id": "proj_alpha", + "store_id": "store_alpha", + "branch_name": "main", + "db_relpath": "projects/proj_alpha/tracedecay.db", + "parent_scope_id": null, + "last_synced_at": 1_800_000_002, + "writable": true, + }], + "artifacts": [{ + "store_id": "store_alpha", + "artifact_kind": "graph_db", + "relpath": "projects/proj_alpha/tracedecay.db", + "size_bytes": 128, + "schema_version": "1", + "updated_at": 1_800_000_003, + }], + }], + }); + let by_id = call_project_context( + &server, + json!({"project_selector": {"project_id": "proj_alpha"}}), + ) + .await; + assert_eq!(by_id, alpha_context, "project id selector"); + assert!( + !by_id.to_string().contains("secret") && !by_id.to_string().contains("token:"), + "project context leaked the registered remote credential: {by_id}" + ); + + let by_alias = call_project_context(&server, json!({"path": "registered-alias"})).await; + assert_eq!( + by_alias, alpha_context, + "a registered alias names the same project as its id" + ); + + let by_path = call_project_context(&server, json!({"path": alpha.display_root})).await; + assert_eq!( + by_path, alpha_context, + "the registered checkout path names the same project" + ); + + let active_context = json!({ + "status": "ok", + "is_active": true, + "registry_path": registry_path, + "project": { + "project_id": active_project_id, + "label": Path::new(&active.display_root) + .file_name() + .and_then(|name| name.to_str()) + .expect("served project label"), + "project_root": active.display_root, + "display_root": active.display_root, + "canonical_root": active.canonical_root, + "git_common_dir": null, + "default_branch": "main", + "created_at": active.created_at, + "last_seen_at": active.last_seen_at, + "is_active": true, + }, + "aliases": [{ + "alias_path": active.canonical_root, + "project_id": active_project_id, + "last_seen_at": active.last_seen_at, + }], + "stores": [], + }); + let omitted = call_project_context(&server, json!({})).await; + assert_eq!( + omitted, active_context, + "omitting the selector reads the served project" + ); + let by_active_id = call_project_context( + &server, + json!({"project_selector": {"project_id": active_project_id}}), + ) + .await; + assert_eq!( + by_active_id, active_context, + "the served project id is the active project" + ); + + let not_found = json!({ + "status": "not_found", + "registry_path": registry_path, + "project": null, + "aliases": [], + "stores": [], + }); + assert_eq!( + call_project_context( + &server, + json!({"project_selector": {"project_id": "proj_missing"}}), + ) + .await, + not_found, + "an unknown project id is not_found, not an empty project" + ); + assert_eq!( + call_project_context(&server, json!({"path": "unknown-alias"})).await, + not_found, + "an unregistered relative path does not adopt the served project" + ); +} + +#[tokio::test] +async fn project_context_reports_an_unmounted_registry_as_unavailable() { + let (cg, _env, _dir) = setup_empty_project().await; + let server = tracedecay::mcp::McpServer::new( + tracedecay::project::TraceDecay::open(cg.project_root()) + .await + .expect("open served project"), + None, + ) + .await; + + let payload = call_project_context( + &server, + json!({"project_selector": {"project_id": "proj_alpha"}}), + ) + .await; + + assert_eq!( + payload, + json!({ + "status": "unavailable", + "message": "project registry is not present for this profile", + "projects": [], + }), + "a server with no registry port must not answer not_found" + ); +} + +#[tokio::test] +async fn project_context_reports_a_broken_registry_read_as_a_tool_error() { + let (cg, _env, _dir) = setup_empty_project().await; + let registry_dir = test_temp_dir(); + let registry_path = registry_dir.path().join("global.db"); + let runtime = + tracedecay::test_support::host_admission::HostAdmissionTestRuntimeV1::project_scoped( + registry_dir.path(), + cg.project_root(), + tracedecay_domain::ProjectId::new( + cg.store_layout() + .identity + .project_id + .clone() + .expect("served project identity"), + ) + .expect("project id"), + ) + .await + .expect("registered runtime"); + runtime + .upsert_code_project("proj_broken", cg.project_root(), None, None, Some("main")) + .await + .expect("seed project"); + runtime + .upsert_project_alias(Path::new("registered-alias"), "proj_broken") + .await + .expect("seed alias"); + rusqlite::Connection::open(®istry_path) + .expect("open registry") + .execute_batch("DROP TABLE project_aliases") + .expect("drop aliases"); + let server = tracedecay::mcp::McpServer::new_with_host_admission_test_runtime_for_test( + tracedecay::project::TraceDecay::open(cg.project_root()) + .await + .expect("open served project"), + None, + runtime, + ) + .await + .expect("mcp server"); + + let response = handle_real_server_tool_call_raw( + &server, + "tracedecay_project_context", + json!({"path": "registered-alias"}), + ) + .await; + + assert_eq!(response["error"]["code"], -32603, "{response}"); + assert_eq!( + response["error"]["data"]["tool"], "tracedecay_project_context", + "{response}" + ); + assert_eq!( + response["error"]["message"], + "tool execution failed: database error: SQLite prepare query failed: no such table: project_aliases (operation: resolve project identity alias)", + "{response}" + ); + assert!( + response.get("result").is_none() || response["result"].is_null(), + "a broken alias table must not become a successful context: {response}" + ); +} + +async fn call_project_context(server: &tracedecay::mcp::McpServer, arguments: Value) -> Value { + let result = + handle_real_server_tool_call(server, "tracedecay_project_context", arguments).await; + assert_eq!( + result["content"][0]["type"], "text", + "project context is a text tool result: {result}" + ); + serde_json::from_str(extract_real_server_text(&result)).expect("project context JSON") +} + +fn sorted_aliases(mut aliases: Vec) -> Vec { + aliases.sort_by(|left, right| { + left["alias_path"] + .as_str() + .unwrap_or("") + .cmp(right["alias_path"].as_str().unwrap_or("")) + }); + aliases +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs new file mode 100644 index 0000000000..ef25ce74b6 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_list_test.rs @@ -0,0 +1,591 @@ +//! `tracedecay_project_list` as an MCP client calls it: one `tools/call` on a +//! real server connection, then the text the caller observes. +//! +//! Timestamps are pinned after registration so the page order and the +//! expected clock fields are inputs, not a wall-clock reading. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; +use tracedecay::project::TraceDecay; +use tracedecay::test_support::host_admission::HostAdmissionTestRuntimeV1; +use tracedecay_domain::ProjectId; +use tracedecay_global_db::{GraphScopeUpsert, StoreArtifactUpsert, StoreInstanceUpsert}; +use tracedecay_mcp::McpTransport; + +use crate::support; + +const SECRET_REMOTE: &str = "https://user:s3cret-token@git.example/alpha.git"; +const ALPHA_CREATED_AT: i64 = 1_700_000_001; +const ALPHA_SEEN_AT: i64 = 1_700_000_020; +const BETA_CREATED_AT: i64 = 1_700_000_002; +const BETA_SEEN_AT: i64 = 1_700_000_030; +const ACTIVE_CREATED_AT: i64 = 1_700_000_003; +const ACTIVE_SEEN_AT: i64 = 1_700_000_010; + +struct RegisteredProject { + id: String, + label: String, + root: String, + git_common_dir: Option, + branch: &'static str, + branches: &'static [&'static str], + kind: &'static str, + created_at: i64, + seen_at: i64, + stores: usize, + artifacts: usize, + aliases: usize, + active: bool, +} + +#[tokio::test] +async fn project_list_returns_the_registry_page_the_caller_asked_for() { + let (cg, _env, _project_dir) = support::setup_empty_project().await; + let profile_dir = support::test_temp_dir(); + let profile_root = fs::canonicalize(profile_dir.path()).expect("profile root"); + let alpha_root = git_repository(&profile_root.join("listed-alpha")); + let beta_root = directory(&profile_root.join("listed-beta")); + + { + let runtime = HostAdmissionTestRuntimeV1::profile(&profile_root) + .await + .expect("profile registry"); + let alpha = runtime + .upsert_code_project( + "proj_alpha", + &alpha_root, + Some(&alpha_root.join(".git")), + Some(SECRET_REMOTE), + Some("main"), + ) + .await + .expect("register alpha"); + assert_eq!( + alpha.git_remote_url.as_deref(), + Some(SECRET_REMOTE), + "the credential remote must be stored so its absence from the listing is real" + ); + let store = runtime + .upsert_store_instance(StoreInstanceUpsert { + store_id: "store_alpha".to_string(), + project_id: alpha.project_id.clone(), + store_kind: "code_project".to_string(), + storage_mode: "profile_sharded".to_string(), + store_relpath: "projects/proj_alpha".to_string(), + manifest_relpath: Some("projects/proj_alpha/store_manifest.json".to_string()), + last_verified_at: Some(1_700_000_004), + last_write_at: None, + }) + .await + .expect("register alpha store"); + runtime + .upsert_graph_scope(GraphScopeUpsert { + graph_scope_id: "scope_alpha_release".to_string(), + project_id: alpha.project_id, + store_id: store.store_id.clone(), + branch_name: "release".to_string(), + db_relpath: "projects/proj_alpha/tracedecay.db".to_string(), + parent_scope_id: None, + last_synced_at: Some(1_700_000_005), + writable: true, + }) + .await + .expect("register alpha branch"); + runtime + .upsert_store_artifact(StoreArtifactUpsert { + store_id: store.store_id, + artifact_kind: "graph_db".to_string(), + relpath: "projects/proj_alpha/tracedecay.db".to_string(), + size_bytes: Some(128), + schema_version: Some("1".to_string()), + updated_at: Some(1_700_000_006), + }) + .await + .expect("register alpha artifact"); + runtime + .upsert_code_project("proj_beta", &beta_root, None, None, Some("dev")) + .await + .expect("register beta"); + } + + let active_id = cg + .store_layout() + .identity + .project_id + .clone() + .expect("active project id"); + let active_project_id = ProjectId::new(active_id.clone()).expect("active project id"); + let runtime = HostAdmissionTestRuntimeV1::project_scoped( + &profile_root, + cg.project_root(), + active_project_id, + ) + .await + .expect("project-scoped registry"); + let active_git = fs::canonicalize(cg.project_root().join(".git")).expect("active .git"); + assert_eq!( + cg.project_root().join(".git"), + active_git, + "the calling checkout must be a primary repository" + ); + runtime + .upsert_code_project( + &active_id, + cg.project_root(), + Some(active_git.as_path()), + None, + Some("main"), + ) + .await + .expect("register the calling project"); + + let registry_path = runtime.profile_database_for_test().db_path().to_path_buf(); + pin_registration_times(®istry_path, &active_id); + + let alpha = registered( + "proj_alpha", + &alpha_root, + Some(alpha_root.join(".git")), + "main", + &["main", "release"], + "primary", + ALPHA_CREATED_AT, + ALPHA_SEEN_AT, + 1, + 1, + 3, + false, + ); + let beta = registered( + "proj_beta", + &beta_root, + None, + "dev", + &["dev"], + "project", + BETA_CREATED_AT, + BETA_SEEN_AT, + 0, + 0, + 1, + false, + ); + let active = registered( + &active_id, + cg.project_root(), + Some(active_git), + "main", + &["main"], + "primary", + ACTIVE_CREATED_AT, + ACTIVE_SEEN_AT, + 0, + 0, + 2, + true, + ); + assert!( + active.label.starts_with(".tmp"), + "the fixture temp dir must sort before listed-alpha, got {}", + active.label + ); + + let server = McpServer::new_with_host_admission_test_runtime_for_test( + TraceDecay::open(cg.project_root()) + .await + .expect("reopen calling project"), + None, + runtime, + ) + .await + .expect("mcp server"); + let registry = registry_path.display().to_string(); + + let default_text = tool_text(tools_call(&server, json!({"limit": 25})).await); + assert_eq!( + default_text, + full_markdown(&active, &alpha, &beta), + "omitted format is markdown" + ); + let explicit_markdown = + tool_text(tools_call(&server, json!({"format": "markdown", "limit": 25})).await); + assert_eq!(explicit_markdown, full_markdown(&active, &alpha, &beta)); + + let json_page = tool_json(tools_call(&server, json!({"format": "json", "limit": 25})).await); + assert_eq!( + json_page, + listing_json( + ®istry, + 25, + false, + &[&beta, &alpha, &active], + &[&active, &alpha, &beta] + ) + ); + + let widest = tool_json(tools_call(&server, json!({"format": "json", "limit": 250})).await); + assert_eq!( + widest, + listing_json( + ®istry, + 100, + false, + &[&beta, &alpha, &active], + &[&active, &alpha, &beta] + ), + "limit is clamped to 100" + ); + + let one = listing_json(®istry, 1, true, &[&beta], &[&beta]); + let clamped = tool_json(tools_call(&server, json!({"format": "json", "limit": 0})).await); + assert_eq!(clamped, one, "limit 0 is clamped to 1"); + let limited = tool_json(tools_call(&server, json!({"format": "json", "limit": 1})).await); + assert_eq!(limited, one); + let truncated_markdown = + tool_text(tools_call(&server, json!({"format": "markdown", "limit": 1})).await); + assert_eq!(truncated_markdown, truncated_beta_markdown(&beta)); +} + +#[tokio::test] +async fn project_list_reports_an_empty_registry_as_an_empty_listing() { + let (cg, _env, _project_dir) = support::setup_empty_project().await; + let profile_dir = support::test_temp_dir(); + let active_id = cg + .store_layout() + .identity + .project_id + .clone() + .expect("active project id"); + let runtime = HostAdmissionTestRuntimeV1::project_scoped( + profile_dir.path(), + cg.project_root(), + ProjectId::new(active_id).expect("active project id"), + ) + .await + .expect("empty registry"); + let registry_path = runtime + .profile_database_for_test() + .db_path() + .display() + .to_string(); + let server = McpServer::new_with_host_admission_test_runtime_for_test( + TraceDecay::open(cg.project_root()) + .await + .expect("reopen calling project"), + None, + runtime, + ) + .await + .expect("mcp server"); + + let markdown = tool_text(tools_call(&server, json!({})).await); + assert_eq!(markdown, "No registered projects found."); + + let payload = tool_json(tools_call(&server, json!({"format": "json"})).await); + assert_eq!( + payload, + json!({ + "status": "ok", + "title": "registered projects", + "registry_path": registry_path, + "limit": 25, + "truncated": false, + "summary": { + "project_count": 0, + "repo_count": 0, + "truncated": false, + }, + "project_tree": [], + "projects": [], + }) + ); +} + +#[tokio::test] +async fn project_list_reports_a_broken_registry_as_a_tool_error() { + let (cg, _env, _project_dir) = support::setup_empty_project().await; + let profile_dir = support::test_temp_dir(); + let profile_root = fs::canonicalize(profile_dir.path()).expect("profile root"); + let beta_root = directory(&profile_root.join("listed-beta")); + { + let runtime = HostAdmissionTestRuntimeV1::profile(&profile_root) + .await + .expect("profile registry"); + runtime + .upsert_code_project("proj_beta", &beta_root, None, None, Some("dev")) + .await + .expect("register beta"); + } + let active_id = cg + .store_layout() + .identity + .project_id + .clone() + .expect("active project id"); + let runtime = HostAdmissionTestRuntimeV1::project_scoped( + &profile_root, + cg.project_root(), + ProjectId::new(active_id).expect("active project id"), + ) + .await + .expect("project-scoped registry"); + let registry_path = runtime.profile_database_for_test().db_path().to_path_buf(); + rusqlite::Connection::open(®istry_path) + .expect("open registry") + .execute_batch("DROP TABLE project_aliases") + .expect("drop project aliases"); + let server = McpServer::new_with_host_admission_test_runtime_for_test( + TraceDecay::open(cg.project_root()) + .await + .expect("reopen calling project"), + None, + runtime, + ) + .await + .expect("mcp server"); + + let response = tools_call(&server, json!({"format": "json"})).await; + assert_eq!( + response, + json!({ + "jsonrpc": "2.0", + "id": 1, + "error": { + "code": -32603, + "message": "tool execution failed: database error: SQLite prepare query failed: no such table: project_aliases (operation: resolve project identity alias)", + "data": { + "tool": "tracedecay_project_list", + "cli_fallback": "This tool is also available from the shell: `tracedecay tool project_list ...` (`tracedecay tool project_list --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." + } + } + }) + ); +} + +fn registered( + id: &str, + root: &Path, + git_common_dir: Option, + branch: &'static str, + branches: &'static [&'static str], + kind: &'static str, + created_at: i64, + seen_at: i64, + stores: usize, + artifacts: usize, + aliases: usize, + active: bool, +) -> RegisteredProject { + let root = root.display().to_string(); + RegisteredProject { + id: id.to_string(), + label: Path::new(&root) + .file_name() + .and_then(|name| name.to_str()) + .expect("project directory name") + .to_string(), + root, + git_common_dir: git_common_dir.map(|path| path.display().to_string()), + branch, + branches, + kind, + created_at, + seen_at, + stores, + artifacts, + aliases, + active, + } +} + +fn listing_json( + registry: &str, + limit: u64, + truncated: bool, + projects: &[&RegisteredProject], + tree: &[&RegisteredProject], +) -> Value { + json!({ + "status": "ok", + "title": "registered projects", + "registry_path": registry, + "limit": limit, + "truncated": truncated, + "summary": { + "project_count": projects.len(), + "repo_count": tree.len(), + "truncated": truncated, + }, + "project_tree": tree.iter().copied().map(tree_group).collect::>(), + "projects": projects.iter().copied().map(project_row).collect::>(), + }) +} + +fn tree_group(project: &RegisteredProject) -> Value { + json!({ + "label": project.label, + "git_common_dir": project.git_common_dir, + "project_count": 1, + "branches": project.branches, + "projects": [{ + "project_id": project.id, + "label": project.label, + "project_root": project.root, + "canonical_root": project.root, + "kind": project.kind, + "default_branch": project.branch, + "branches": project.branches, + "store_count": project.stores, + "artifact_count": project.artifacts, + "alias_count": project.aliases, + "last_seen_at": project.seen_at, + "is_active": project.active, + }], + }) +} + +fn project_row(project: &RegisteredProject) -> Value { + json!({ + "project_id": project.id, + "label": project.label, + "project_root": project.root, + "display_root": project.root, + "canonical_root": project.root, + "git_common_dir": project.git_common_dir, + "default_branch": project.branch, + "created_at": project.created_at, + "last_seen_at": project.seen_at, + "is_active": project.active, + }) +} + +fn full_markdown( + active: &RegisteredProject, + alpha: &RegisteredProject, + beta: &RegisteredProject, +) -> String { + format!( + "Found 3 registered projects across 3 repositories.\n\nRepositories:\n\ + - {active_label} (branches: main)\n \ + - `{active_id}` * [primary] branches: main; stores: 0; path: {active_root}\n\ + - listed-alpha (branches: main, release)\n \ + - `proj_alpha` [primary] branches: main, release; stores: 1; path: {alpha_root}\n\ + - listed-beta (branches: dev)\n \ + - `proj_beta` [project] branches: dev; stores: 0; path: {beta_root}\n", + active_label = active.label, + active_id = active.id, + active_root = active.root, + alpha_root = alpha.root, + beta_root = beta.root, + ) +} + +fn truncated_beta_markdown(beta: &RegisteredProject) -> String { + format!( + "Found 1 registered projects across 1 repositories.\n\nRepositories:\n\ + - listed-beta (branches: dev)\n \ + - `proj_beta` [project] branches: dev; stores: 0; path: {path}\n\n\ + Result truncated; increase limit for more projects.\n", + path = beta.root, + ) +} + +fn pin_registration_times(registry_path: &Path, active_id: &str) { + let connection = rusqlite::Connection::open(registry_path).expect("open registry"); + for (project_id, created_at, seen_at) in [ + ("proj_alpha", ALPHA_CREATED_AT, ALPHA_SEEN_AT), + ("proj_beta", BETA_CREATED_AT, BETA_SEEN_AT), + (active_id, ACTIVE_CREATED_AT, ACTIVE_SEEN_AT), + ] { + let updated = connection + .execute( + "UPDATE code_projects SET created_at = ?1, last_seen_at = ?2 WHERE project_id = ?3", + rusqlite::params![created_at, seen_at, project_id], + ) + .expect("pin registration times"); + assert_eq!(updated, 1, "missing registry row {project_id}"); + } +} + +/// One request in, one response out, the same line framing `McpServer::run_connection` +/// serves to a client. Arguments are not rewritten: an omitted `format` stays omitted +/// and the default page is markdown, not JSON. +struct ClientCall { + request: Option, + response: String, +} + +impl McpTransport for ClientCall { + async fn read_line(&mut self) -> std::io::Result> { + Ok(self.request.take()) + } + + async fn write_line(&mut self, line: &str) -> std::io::Result<()> { + self.response.push_str(line); + Ok(()) + } + + async fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +async fn tools_call(server: &McpServer, arguments: Value) -> Value { + let request = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "tracedecay_project_list", + "arguments": arguments, + } + }); + let mut transport = ClientCall { + request: Some(request.to_string()), + response: String::new(), + }; + Box::pin(server.run_connection(&mut transport)) + .await + .expect("mcp tools/call"); + serde_json::from_str(transport.response.trim()).expect("json-rpc response") +} + +fn tool_text(response: Value) -> String { + assert!( + response.get("error").is_none(), + "project_list failed: {response}" + ); + let result = &response["result"]; + assert!( + result.get("isError").is_none(), + "a registry answer is not a tool error: {result}" + ); + let content = result["content"].as_array().expect("content"); + assert_eq!(content.len(), 1, "one text block: {result}"); + assert_eq!(content[0]["type"], "text"); + content[0]["text"].as_str().expect("text").to_string() +} + +fn tool_json(response: Value) -> Value { + serde_json::from_str(&tool_text(response)).expect("project_list json") +} + +fn directory(path: &Path) -> PathBuf { + fs::create_dir_all(path).expect("create project directory"); + fs::canonicalize(path).expect("canonicalize project directory") +} + +fn git_repository(path: &Path) -> PathBuf { + let root = directory(path); + let status = Command::new(crate::common::git_program()) + .args(["init", "--quiet"]) + .current_dir(&root) + .status() + .expect("git init"); + assert!(status.success(), "git init failed in {}", root.display()); + root +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_search_behavior_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_search_behavior_test.rs new file mode 100644 index 0000000000..6abd724350 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/project_search_behavior_test.rs @@ -0,0 +1,566 @@ +//! `tracedecay_project_search` as a caller sees it over MCP `tools/call`. +//! +//! Each test sends the production JSON-RPC request through `McpServer` and +//! asserts the payload a client reads. Expectations are the registered +//! project values and the typed miss, bound, and refusal states — not a +//! second call into the search handler. + +use std::fs; +use std::path::Path; + +use serde_json::{Value, json}; +use tracedecay::mcp::McpServer; +use tracedecay::project::TraceDecay; +use tracedecay::test_support::host_admission::HostAdmissionTestRuntimeV1; +use tracedecay_domain::ProjectId; + +use crate::support::{ + TestEnv, TestTempDir, TestTraceDecay, extract_real_server_text, handle_real_server_tool_call, + handle_real_server_tool_call_raw, setup_empty_project, test_temp_dir, +}; + +const TOOL: &str = "tracedecay_project_search"; +const SECRET: &str = "secret-token-xyz"; + +struct RegisteredProject { + id: String, + label: String, + branch: String, + display_root: String, + canonical_root: String, + created_at: i64, + last_seen_at: i64, + alias_count: i64, +} + +struct SearchFixture { + _cg: TestTraceDecay, + _env: TestEnv, + _registry_dir: TestTempDir, + _roots: TestTempDir, + server: std::sync::Arc, + registry_path: String, + active: RegisteredProject, + alpha: RegisteredProject, + beta: RegisteredProject, +} + +async fn open_search_fixture() -> SearchFixture { + let (cg, env, _project_dir) = setup_empty_project().await; + let registry_dir = test_temp_dir(); + let roots = test_temp_dir(); + let alpha_root = roots.path().join("search-alpha-root"); + let beta_root = roots.path().join("search-beta-root"); + let alpha_alias = roots.path().join("shared-needle-alpha"); + let beta_alias = roots.path().join("shared-needle-beta"); + fs::create_dir_all(&alpha_root).unwrap(); + fs::create_dir_all(&beta_root).unwrap(); + fs::create_dir_all(&alpha_alias).unwrap(); + fs::create_dir_all(&beta_alias).unwrap(); + + let active_id = cg + .store_layout() + .identity + .project_id + .clone() + .expect("active project identity"); + let runtime = HostAdmissionTestRuntimeV1::project_scoped( + registry_dir.path(), + cg.project_root(), + ProjectId::new(active_id.clone()).expect("active project id"), + ) + .await + .expect("project-scoped registry runtime"); + + let alpha = register_project( + &runtime, + "search-alpha", + "search-alpha-root", + &alpha_root, + "branch-alpha", + Some(&format!( + "https://user:{SECRET}@example.test/search-alpha-repo.git" + )), + Some(&alpha_alias), + 3, + ) + .await; + let beta = register_project( + &runtime, + "search-beta", + "search-beta-root", + &beta_root, + "branch-beta", + None, + Some(&beta_alias), + 2, + ) + .await; + let active_label = file_name(cg.project_root()); + let active = register_project( + &runtime, + &active_id, + &active_label, + cg.project_root(), + "branch-active", + None, + None, + 1, + ) + .await; + + let registry_path = registry_dir + .path() + .join("global.db") + .canonicalize() + .expect("registry database") + .display() + .to_string(); + let server = McpServer::new_with_host_admission_test_runtime_for_test( + TraceDecay::open(cg.project_root()) + .await + .expect("open calling project"), + None, + runtime, + ) + .await + .expect("MCP server"); + + SearchFixture { + _cg: cg, + _env: env, + _registry_dir: registry_dir, + _roots: roots, + server, + registry_path, + active, + alpha, + beta, + } +} + +fn file_name(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or_else(|| panic!("path has no file name: {}", path.display())) + .to_owned() +} + +async fn register_project( + runtime: &HostAdmissionTestRuntimeV1, + project_id: &str, + label: &str, + root: &Path, + branch: &str, + remote: Option<&str>, + alias: Option<&Path>, + alias_count: i64, +) -> RegisteredProject { + let record = runtime + .upsert_code_project(project_id, root, None, remote, Some(branch)) + .await + .unwrap_or_else(|error| panic!("register {project_id}: {error}")); + assert_eq!( + record.project_id, project_id, + "registry rewrote the project id" + ); + if let Some(alias) = alias { + runtime + .upsert_project_alias(alias, project_id) + .await + .unwrap_or_else(|error| panic!("alias {project_id}: {error}")); + } + RegisteredProject { + id: project_id.to_owned(), + label: label.to_owned(), + branch: branch.to_owned(), + display_root: record.display_root, + canonical_root: record.canonical_root, + created_at: record.created_at, + last_seen_at: record.last_seen_at, + alias_count, + } +} + +async fn search_json(server: &McpServer, arguments: Value) -> Value { + let result = handle_real_server_tool_call(server, TOOL, arguments).await; + let text = extract_real_server_text(&result); + serde_json::from_str(text) + .unwrap_or_else(|error| panic!("project search JSON for {text}: {error}")) +} + +async fn search_text(server: &McpServer, arguments: Value) -> String { + let result = handle_real_server_tool_call(server, TOOL, arguments).await; + extract_real_server_text(&result).to_owned() +} + +fn project_ids(payload: &Value) -> Vec { + payload["projects"] + .as_array() + .unwrap_or_else(|| panic!("projects is not an array: {payload}")) + .iter() + .map(|project| { + project["project_id"] + .as_str() + .unwrap_or_else(|| panic!("project id missing: {project}")) + .to_owned() + }) + .collect() +} + +fn newest_first(projects: &[&RegisteredProject]) -> Vec { + let mut projects = projects.to_vec(); + projects.sort_by(|left, right| { + right + .last_seen_at + .cmp(&left.last_seen_at) + .then_with(|| left.id.cmp(&right.id)) + }); + projects + .into_iter() + .map(|project| project.id.clone()) + .collect() +} + +fn public_project(project: &RegisteredProject, is_active: bool) -> Value { + json!({ + "project_id": project.id, + "label": project.label, + "project_root": project.display_root, + "display_root": project.display_root, + "canonical_root": project.canonical_root, + "git_common_dir": null, + "default_branch": project.branch, + "created_at": project.created_at, + "last_seen_at": project.last_seen_at, + "is_active": is_active, + }) +} + +fn tree_group(project: &RegisteredProject, is_active: bool) -> Value { + json!({ + "label": project.label, + "git_common_dir": null, + "project_count": 1, + "branches": [project.branch], + "projects": [{ + "project_id": project.id, + "label": project.label, + "project_root": project.display_root, + "canonical_root": project.canonical_root, + "kind": "project", + "default_branch": project.branch, + "branches": [project.branch], + "store_count": 0, + "artifact_count": 0, + "alias_count": project.alias_count, + "last_seen_at": project.last_seen_at, + "is_active": is_active, + }], + }) +} + +fn listing( + query: &str, + limit: i64, + truncated: bool, + registry_path: &str, + projects: Vec, + tree: Vec, +) -> Value { + json!({ + "status": "ok", + "title": format!("projects matching \"{query}\""), + "registry_path": registry_path, + "limit": limit, + "truncated": truncated, + "summary": { + "project_count": projects.len(), + "repo_count": tree.len(), + "truncated": truncated, + }, + "project_tree": tree, + "projects": projects, + "query": query, + }) +} + +fn assert_payload(actual: &Value, expected: Value) { + assert_eq!(actual, &expected, "project search payload"); +} + +fn markdown_hit(query: &str, project: &RegisteredProject, active: bool) -> String { + let marker = if active { " *" } else { "" }; + format!( + "Found 1 projects matching \"{query}\" across 1 repositories.\n\nRepositories:\n- {} (branches: {})\n - `{}`{marker} [project] branches: {}; stores: 0; path: {}\n", + project.label, project.branch, project.id, project.branch, project.display_root + ) +} + +#[tokio::test] +async fn project_search_returns_each_public_field_and_omits_the_credential_remote() { + let fixture = open_search_fixture().await; + let server = fixture.server.as_ref(); + + let by_id = search_json(server, json!({"query": "search-alpha", "format": "json"})).await; + assert_payload( + &by_id, + listing( + "search-alpha", + 10, + false, + &fixture.registry_path, + vec![public_project(&fixture.alpha, false)], + vec![tree_group(&fixture.alpha, false)], + ), + ); + assert!( + !by_id.to_string().contains(SECRET), + "a hit must not echo the credential-bearing remote: {by_id}" + ); + + let by_case = search_json(server, json!({"query": "SEARCH-ALPHA", "format": "json"})).await; + assert_payload( + &by_case, + listing( + "SEARCH-ALPHA", + 10, + false, + &fixture.registry_path, + vec![public_project(&fixture.alpha, false)], + vec![tree_group(&fixture.alpha, false)], + ), + ); + + let by_path = search_json( + server, + json!({"query": "search-alpha-root", "format": "json"}), + ) + .await; + assert_eq!(project_ids(&by_path), ["search-alpha"]); + + let by_alias = search_json( + server, + json!({"query": "shared-needle-alpha", "format": "json"}), + ) + .await; + assert_eq!(project_ids(&by_alias), ["search-alpha"]); + + let by_branch = search_json(server, json!({"query": "branch-alpha", "format": "json"})).await; + assert_eq!(project_ids(&by_branch), ["search-alpha"]); + + let by_remote_name = search_json( + server, + json!({"query": "search-alpha-repo.git", "format": "json"}), + ) + .await; + assert_eq!( + project_ids(&by_remote_name), + ["search-alpha"], + "the repository name is searchable; the credential must not be: {by_remote_name}" + ); + + let by_secret = search_json(server, json!({"query": SECRET, "format": "json"})).await; + assert_payload( + &by_secret, + listing(SECRET, 10, false, &fixture.registry_path, vec![], vec![]), + ); + + let by_active = search_json(server, json!({"query": "branch-active", "format": "json"})).await; + assert_payload( + &by_active, + listing( + "branch-active", + 10, + false, + &fixture.registry_path, + vec![public_project(&fixture.active, true)], + vec![tree_group(&fixture.active, true)], + ), + ); + + let markdown = search_text( + server, + json!({"query": "search-alpha", "format": "markdown"}), + ) + .await; + assert_eq!( + markdown, + markdown_hit("search-alpha", &fixture.alpha, false) + ); + + let active_markdown = search_text( + server, + json!({"query": "branch-active", "format": "markdown"}), + ) + .await; + assert_eq!( + active_markdown, + markdown_hit("branch-active", &fixture.active, true) + ); +} + +#[tokio::test] +async fn project_search_bounds_pages_and_does_not_expand_wildcards() { + let fixture = open_search_fixture().await; + let server = fixture.server.as_ref(); + let both = newest_first(&[&fixture.alpha, &fixture.beta]); + let page = search_json( + server, + json!({"query": "shared-needle", "limit": 1, "format": "json"}), + ) + .await; + assert_eq!(project_ids(&page).as_slice(), &both[..1]); + assert_eq!(page["status"], "ok"); + assert_eq!(page["limit"], 1); + assert_eq!(page["truncated"], true); + assert_eq!(page["summary"]["project_count"], 1); + assert_eq!(page["summary"]["repo_count"], 1); + assert_eq!(page["summary"]["truncated"], true); + assert_eq!(page["query"], "shared-needle"); + + let clamped_low = search_json( + server, + json!({"query": "shared-needle", "limit": 0, "format": "json"}), + ) + .await; + assert_eq!(project_ids(&clamped_low).as_slice(), &both[..1]); + assert_eq!(clamped_low["limit"], 1); + assert_eq!(clamped_low["truncated"], true); + + let clamped_high = search_json( + server, + json!({"query": "shared-needle", "limit": 99, "format": "json"}), + ) + .await; + assert_eq!(project_ids(&clamped_high), both); + assert_eq!(clamped_high["limit"], 50); + assert_eq!(clamped_high["truncated"], false); + assert_eq!(clamped_high["summary"]["project_count"], 2); + assert_eq!(clamped_high["summary"]["repo_count"], 2); + + let either_term = search_json( + server, + json!({"query": "branch-alpha branch-beta", "format": "json"}), + ) + .await; + assert_eq!(project_ids(&either_term), both); + assert_eq!(either_term["query"], "branch-alpha branch-beta"); + assert_eq!( + either_term["title"], + "projects matching \"branch-alpha branch-beta\"" + ); + + let escaped_percent = search_json(server, json!({"query": "search-%", "format": "json"})).await; + assert_payload( + &escaped_percent, + listing( + "search-%", + 10, + false, + &fixture.registry_path, + vec![], + vec![], + ), + ); + + let literal_percent = search_json(server, json!({"query": "%", "format": "json"})).await; + assert_eq!(project_ids(&literal_percent), Vec::::new()); + assert_eq!(literal_percent["status"], "ok"); + + let blank = search_json(server, json!({"query": " ", "format": "json"})).await; + assert_eq!(project_ids(&blank), Vec::::new()); + assert_eq!(blank["status"], "ok"); + assert_eq!(blank["title"], "projects matching \" \""); + + let missing = search_json( + server, + json!({"query": "no-such-project-token", "format": "json"}), + ) + .await; + assert_payload( + &missing, + listing( + "no-such-project-token", + 10, + false, + &fixture.registry_path, + vec![], + vec![], + ), + ); + let missing_markdown = search_text( + server, + json!({"query": "no-such-project-token", "format": "markdown"}), + ) + .await; + assert_eq!( + missing_markdown, + "No projects matching \"no-such-project-token\" found." + ); + + // The search ORs whitespace-separated tokens, so a spaced `OR` would be a + // legitimate two-letter substring token that can match a random temp + // path (`.tmpXoRyz`). Keep the quote breakout, drop the whitespace, so + // the query is one token that no fixture field contains. + let injected = search_json( + server, + json!({"query": "search-alpha'OR'1'='1", "format": "json"}), + ) + .await; + assert_eq!(project_ids(&injected), Vec::::new()); + assert_eq!(injected["status"], "ok"); + assert_eq!(injected["query"], "search-alpha'OR'1'='1"); +} + +#[tokio::test] +async fn project_search_rejects_a_non_string_query_and_an_unmounted_registry() { + let (cg, _env, _dir) = setup_empty_project().await; + let server = McpServer::new( + TraceDecay::open(cg.project_root()) + .await + .expect("open calling project"), + None, + ) + .await; + + for arguments in [json!({}), json!({"query": 12}), json!({"query": null})] { + let response = handle_real_server_tool_call_raw(&server, TOOL, arguments).await; + assert_eq!(response["jsonrpc"], "2.0"); + assert!(response["result"].is_null(), "{response}"); + assert_eq!( + response["error"], + json!({ + "code": -32602, + "message": "missing required parameter: query", + "data": { + "tool": TOOL, + "reason_code": "missing_required_parameter", + "retryable": false, + "detail": "missing required parameter: query" + } + }) + ); + } + + let unavailable = + search_json(&server, json!({"query": "search-alpha", "format": "json"})).await; + assert_eq!( + unavailable, + json!({ + "status": "unavailable", + "message": "project registry is not present for this profile", + "projects": [], + "title": "projects matching \"search-alpha\"", + "summary": { + "project_count": 0, + "repo_count": 0, + "truncated": false + }, + "project_tree": [], + "query": "search-alpha", + "limit": 10, + "truncated": false + }) + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs index 09534aa0b3..2d409392bd 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/session_search_test.rs @@ -11,6 +11,8 @@ use std::path::Path; use std::process::Command; #[cfg(feature = "test-transport")] use tracedecay::daemon::ProductionProjectCompositionHarnessV1; +#[cfg(feature = "test-transport")] +use tracedecay::project::TraceDecay; use tracedecay_domain::SessionId; #[cfg(feature = "test-transport")] use tracedecay_session_temporal_store::SessionTemporalStore; @@ -767,3 +769,362 @@ async fn completed_session_import_immediately_searches_canonical_message() { production_codex_message_search(&harness, &project).await; harness.shutdown().await; } + +/// `tracedecay_message_search` reads already-admitted messages through MCP +/// `tools/call`. A query that names a seeded message returns that message's +/// text, id, session, provider, and role. Those observations carry an unknown +/// valid time, so the hit is partial: one omitted record, coverage `unknown` +/// 1 and `visible` 0, not a complete answer. A query that matches nothing, the +/// wrong provider, an assistant message filtered as a tool result, and goals +/// with no goals are empty complete answers. Omitting `query` outside goals +/// mode, and naming an unknown provider, are typed invalid-request refusals. +#[cfg(feature = "test-transport")] +#[tokio::test] +async fn message_search_returns_literal_seeded_messages() { + let dir = test_temp_dir(); + let (cg, _env) = init_test_project(dir.path()).await; + + seed_temporal_lcm_session_message( + &cg, + "proof-plum-session", + "proof-plum-message", + "The plum quartz regulator holds at 41 degrees", + 1, + ) + .await; + seed_temporal_lcm_session_message( + &cg, + "proof-amber-session", + "proof-amber-message", + "The amber lattice stays closed", + 1, + ) + .await; + seed_temporal_lcm_session_message_for_provider( + &cg, + "codex", + "proof-orchid-session", + "proof-orchid-message", + "The orchid spool tension is 12 newtons", + 1, + ) + .await; + seed_temporal_lcm_tool_result_message( + &cg, + "proof-zinc-session", + "proof-zinc-message", + "zinc spindle torque reading 17", + 1, + ) + .await; + for session_id in [ + "proof-plum-session", + "proof-amber-session", + "proof-orchid-session", + "proof-zinc-session", + ] { + materialize_proof_session(&cg, session_id).await; + } + + let plum = message_search_payload( + &cg, + json!({ + "query": "plum quartz regulator", + "format": "json", + }), + ) + .await; + assert_eq!(plum["query"], "plum quartz regulator"); + assert_eq!(plum["outcome"], "partial"); + assert_eq!(plum["status"], "partial"); + assert_eq!(plum["count"], 1); + assert_eq!(plum["omitted"], 1); + assert_eq!(plum["provider"], "all"); + assert_eq!(plum["requested_provider"], Value::Null); + assert_eq!(plum["scope"], "all"); + assert_eq!(plum["message_type"], "all"); + assert_eq!(plum["goals"], false); + assert_eq!(plum["catch_up"], false); + assert_eq!(plum["catch_up_performed"], false); + assert_eq!(plum["catch_up_provider"], "all"); + assert_eq!(plum["include_subagents"], true); + assert_eq!(plum["refresh_required"], false); + assert_eq!(plum["store_scope"], "project"); + assert_eq!( + plum["temporal"]["coverage"], + json!({"hidden": 0, "redacted": 0, "unknown": 1, "visible": 0}) + ); + assert_eq!(plum["temporal"]["freshness"], json!({"state": "fresh"})); + assert_eq!(plum["results"].as_array().map(Vec::len), Some(1)); + let plum_hit = &plum["results"][0]; + assert_eq!( + plum_hit["message"]["text"], + "The plum quartz regulator holds at 41 degrees" + ); + assert_eq!(plum_hit["message"]["message_id"], "proof-plum-message"); + assert_eq!(plum_hit["message"]["session_id"], "proof-plum-session"); + assert_eq!(plum_hit["message"]["provider"], "cursor"); + assert_eq!(plum_hit["message"]["role"], "assistant"); + assert_eq!(plum_hit["message"]["model"], "test-model"); + assert_eq!(plum_hit["session"]["session_id"], "proof-plum-session"); + assert_eq!(plum_hit["session"]["provider"], "cursor"); + assert_eq!(plum_hit["session"]["is_subagent"], false); + + let amber = message_search_payload( + &cg, + json!({ + "query": "amber lattice", + "format": "json", + }), + ) + .await; + assert_eq!(amber["outcome"], "partial"); + assert_eq!(amber["status"], "partial"); + assert_eq!(amber["count"], 1); + assert_eq!(amber["omitted"], 1); + assert_eq!( + amber["results"][0]["message"]["text"], + "The amber lattice stays closed" + ); + assert_eq!( + amber["results"][0]["message"]["message_id"], + "proof-amber-message" + ); + assert_eq!( + amber["results"][0]["message"]["session_id"], + "proof-amber-session" + ); + assert_eq!(amber["results"][0]["message"]["provider"], "cursor"); + assert_eq!(amber["results"][0]["message"]["role"], "assistant"); + + let miss = message_search_payload( + &cg, + json!({ + "query": "no such nautilus phrase", + "format": "json", + }), + ) + .await; + assert_eq!(miss["query"], "no such nautilus phrase"); + assert_eq!(miss["outcome"], "complete_zero"); + assert_eq!(miss["status"], "ok"); + assert_eq!(miss["count"], 0); + assert_eq!(miss["results"], json!([])); + assert_eq!(miss["provider"], "all"); + assert_eq!(miss["refresh_required"], false); + assert_eq!( + miss["temporal"]["coverage"], + json!({"hidden": 0, "redacted": 0, "unknown": 0, "visible": 0}) + ); + + let cursor_only = message_search_payload( + &cg, + json!({ + "query": "orchid spool tension", + "provider": "cursor", + "format": "json", + }), + ) + .await; + assert_eq!(cursor_only["provider"], "cursor"); + assert_eq!(cursor_only["requested_provider"], "cursor"); + assert_eq!(cursor_only["outcome"], "complete_zero"); + assert_eq!(cursor_only["count"], 0); + assert_eq!(cursor_only["results"], json!([])); + + let codex_only = message_search_payload( + &cg, + json!({ + "query": "orchid spool tension", + "provider": "codex", + "format": "json", + }), + ) + .await; + assert_eq!(codex_only["provider"], "codex"); + assert_eq!(codex_only["requested_provider"], "codex"); + assert_eq!(codex_only["outcome"], "partial"); + assert_eq!(codex_only["status"], "partial"); + assert_eq!(codex_only["count"], 1); + assert_eq!(codex_only["omitted"], 1); + assert_eq!( + codex_only["results"][0]["message"]["text"], + "The orchid spool tension is 12 newtons" + ); + assert_eq!( + codex_only["results"][0]["message"]["message_id"], + "proof-orchid-message" + ); + assert_eq!(codex_only["results"][0]["message"]["provider"], "codex"); + assert_eq!(codex_only["results"][0]["message"]["role"], "assistant"); + assert_eq!( + codex_only["results"][0]["message"]["session_id"], + "proof-orchid-session" + ); + assert_eq!(codex_only["results"][0]["session"]["provider"], "codex"); + + let not_a_tool = message_search_payload( + &cg, + json!({ + "query": "plum quartz regulator", + "message_type": "tool_result", + "format": "json", + }), + ) + .await; + assert_eq!(not_a_tool["message_type"], "tool_result"); + assert_eq!(not_a_tool["outcome"], "complete_zero"); + assert_eq!(not_a_tool["count"], 0); + assert_eq!(not_a_tool["results"], json!([])); + + let tool_hit = message_search_payload( + &cg, + json!({ + "query": "zinc spindle torque", + "message_type": "tool_result", + "format": "json", + }), + ) + .await; + assert_eq!(tool_hit["message_type"], "tool_result"); + assert_eq!(tool_hit["outcome"], "partial"); + assert_eq!(tool_hit["status"], "partial"); + assert_eq!(tool_hit["count"], 1); + assert_eq!(tool_hit["omitted"], 1); + assert_eq!( + tool_hit["results"][0]["message"]["text"], + "zinc spindle torque reading 17" + ); + assert_eq!( + tool_hit["results"][0]["message"]["message_id"], + "proof-zinc-message" + ); + assert_eq!(tool_hit["results"][0]["message"]["role"], "tool"); + assert_eq!(tool_hit["results"][0]["message"]["model"], Value::Null); + assert_eq!(tool_hit["results"][0]["message"]["provider"], "cursor"); + assert_eq!( + tool_hit["results"][0]["message"]["session_id"], + "proof-zinc-session" + ); + + let goals = message_search_payload( + &cg, + json!({ + "goals": true, + "format": "json", + }), + ) + .await; + assert_eq!(goals["goals"], true); + assert_eq!(goals["query"], ""); + assert_eq!(goals["outcome"], "complete_zero"); + assert_eq!(goals["status"], "ok"); + assert_eq!(goals["count"], 0); + assert_eq!(goals["results"], json!([])); + + let missing_query = refusal_problem(&expect_tool_error( + handle_tool_call( + &cg, + "tracedecay_message_search", + json!({"format": "json"}), + None, + None, + ) + .await, + )); + assert_eq!(missing_query["kind"], "invalid_request"); + assert_eq!( + missing_query["code"], + "application.retained.invalid-request" + ); + assert_eq!( + missing_query["message"], + "The retained operation request is invalid." + ); + assert_eq!( + missing_query["diagnostic"]["code"], + "application.retained.invalid-request" + ); + assert_eq!( + missing_query["diagnostic"]["message"], + "The retained operation request is invalid." + ); + assert_eq!(missing_query["retry"], "never"); + assert_eq!(missing_query["legal_actions"], json!(["correct_request"])); + + let unknown_provider = refusal_problem(&expect_tool_error( + handle_tool_call( + &cg, + "tracedecay_message_search", + json!({ + "query": "plum quartz regulator", + "provider": "unknown-agent", + "format": "json", + }), + None, + None, + ) + .await, + )); + assert_eq!(unknown_provider["kind"], "invalid_request"); + assert_eq!( + unknown_provider["code"], + "application.retained.message-search-provider-invalid" + ); + assert_eq!( + unknown_provider["message"], + "unknown session provider 'unknown-agent' (expected all, cursor, claude, codex, vibe, cline, roo-code, kilo, kiro, kimi, opencode, or hermes)" + ); + assert_eq!( + unknown_provider["diagnostic"]["message"], + "unknown session provider 'unknown-agent' (expected all, cursor, claude, codex, vibe, cline, roo-code, kilo, kiro, kimi, opencode, or hermes)" + ); + assert_eq!(unknown_provider["retry"], "never"); + assert_eq!( + unknown_provider["legal_actions"], + json!(["correct_request"]) + ); +} + +#[cfg(feature = "test-transport")] +async fn materialize_proof_session(cg: &TraceDecay, session_id: &str) { + let runtime = open_active_project_session_db(cg).await; + SessionTemporalStore::new( + runtime + .registered_database(HostAdmissionScope::Project) + .expect("registered project session database"), + ) + .materialize_pending_session_refresh_for_test( + &SessionId::new(session_id).expect("fixture session id"), + ) + .await + .expect("materialize canonical temporal session"); +} + +#[cfg(feature = "test-transport")] +async fn message_search_payload(cg: &TraceDecay, arguments: Value) -> Value { + let result = handle_tool_call(cg, "tracedecay_message_search", arguments, None, None) + .await + .expect("tracedecay_message_search MCP call"); + let envelope = extract_json(&result.value); + envelope + .pointer("/outcome/value/payload") + .cloned() + .unwrap_or(envelope) +} + +#[cfg(feature = "test-transport")] +fn refusal_problem(error: &str) -> Value { + const MARKER: &str = "answered with a retained refusal: "; + let json = error + .split_once(MARKER) + .unwrap_or_else(|| panic!("expected a retained refusal, got {error}")) + .1; + let envelope: Value = serde_json::from_str(json) + .unwrap_or_else(|parse_error| panic!("{parse_error} in retained refusal: {json}")); + envelope + .pointer("/Err/problem") + .cloned() + .or_else(|| envelope.get("problem").cloned()) + .unwrap_or_else(|| panic!("retained refusal has no problem record: {envelope}")) +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs new file mode 100644 index 0000000000..1753dfc23b --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/source_edit_rollback_test.rs @@ -0,0 +1,498 @@ +//! `tracedecay_source_edit_rollback` through the production MCP `tools/call` +//! path. Callers pass the completed move receipt; the tool restores retained +//! preimages or refuses, and exact retries replay that receipt. + +use crate::support::{ + ProductionSourceEditFixture, TestTempDir, handle_real_server_tool_call_raw, + init_production_source_edit_project, test_temp_dir, warm_code_index_search, +}; +use serde_json::{Value, json}; +use std::fs; +use std::path::{Path, PathBuf}; + +const LIB_RS: &str = "pub mod source;\npub mod dest;\n"; +const SOURCE_RS: &str = "//! source\n\npub fn rollback_anchor() -> i32 {\n 7\n}\n"; +const DEST_RS: &str = "//! dest\n\npub fn dest_marker() -> i32 {\n 0\n}\n"; +const ANCHOR: &str = "pub fn rollback_anchor() -> i32 {\n 7\n}"; +const MOVE_KEY: &str = "mcp-test.source-edit.move.rollback-anchor"; +const FOREIGN_DEST: &str = "//! dest\n\npub fn peer_bytes() -> i32 {\n 9\n}\n"; + +struct MovedProject { + _dir: TestTempDir, + fixture: ProductionSourceEditFixture, + project: PathBuf, + effect_id: String, + input_digest: String, + committed_state: String, + prior_expected_state: String, +} + +fn read_project_file(project: &Path, relative: &str) -> String { + fs::read_to_string(project.join(relative)) + .unwrap_or_else(|error| panic!("read {relative}: {error}")) +} + +fn assert_original_sources(project: &Path) { + assert_eq!(read_project_file(project, "src/lib.rs"), LIB_RS); + assert_eq!(read_project_file(project, "src/source.rs"), SOURCE_RS); + assert_eq!(read_project_file(project, "src/dest.rs"), DEST_RS); +} + +fn require_str<'a>(value: &'a Value, pointer: &str) -> &'a str { + value + .pointer(pointer) + .and_then(Value::as_str) + .unwrap_or_else(|| panic!("missing {pointer} in {value}")) +} + +fn tool_payload(response: &Value) -> Value { + assert_eq!(response["jsonrpc"], "2.0", "{response}"); + assert!( + response.get("error").is_none_or(Value::is_null), + "{response}" + ); + let text = response["result"]["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("MCP tool result had no text: {response}")); + serde_json::from_str(text).unwrap_or_else(|error| panic!("{error}: {text}")) +} + +async fn call_tool(fixture: &ProductionSourceEditFixture, name: &str, arguments: Value) -> Value { + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + handle_real_server_tool_call_raw(&server, name, arguments).await +} + +fn rollback_args( + idempotency_key: &str, + effect_id: &str, + input_digest: &str, + expected_state: &str, + confirm: bool, +) -> Value { + json!({ + "effect_id": effect_id, + "original_idempotency_key": MOVE_KEY, + "idempotency_key": idempotency_key, + "original_input_digest": input_digest, + "expected_state": expected_state, + "confirm": confirm, + "format": "json", + }) +} + +async fn open_moved_project() -> MovedProject { + let dir = test_temp_dir(); + let project = dir.path().join("project"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/lib.rs"), LIB_RS).unwrap(); + fs::write(project.join("src/source.rs"), SOURCE_RS).unwrap(); + fs::write(project.join("src/dest.rs"), DEST_RS).unwrap(); + let (fixture, _) = init_production_source_edit_project(&project).await; + let server = fixture + .harness + .server(&fixture.project_root) + .expect("production project server"); + warm_code_index_search(&server, "rollback_anchor").await; + + let preview = tool_payload( + &call_tool( + &fixture, + "tracedecay_move_symbol", + json!({ + "symbol": "rollback_anchor", + "dest_file": "src/dest.rs", + "dry_run": true, + "format": "json", + }), + ) + .await, + ); + let expected_state = require_str(&preview, "/expected_state").to_owned(); + let applied = tool_payload( + &call_tool( + &fixture, + "tracedecay_move_symbol", + json!({ + "symbol": "rollback_anchor", + "dest_file": "src/dest.rs", + "dry_run": false, + "idempotency_key": MOVE_KEY, + "expected_state": expected_state, + "format": "json", + }), + ) + .await, + ); + assert_eq!(applied["success"], json!(true), "{applied}"); + assert_eq!(applied["replayed"], json!(false), "{applied}"); + assert_eq!(applied["effect"]["idempotency_key"], MOVE_KEY, "{applied}"); + assert_eq!( + applied["effect"]["receipt"]["outcome"], + json!("completed"), + "{applied}" + ); + assert!( + !read_project_file(&project, "src/source.rs").contains(ANCHOR), + "move must remove the anchor before rollback can restore it" + ); + assert!( + read_project_file(&project, "src/dest.rs").contains(ANCHOR), + "move must retain the anchor text in the destination" + ); + + MovedProject { + _dir: dir, + fixture, + project, + effect_id: require_str(&applied, "/effect/effect_id").to_owned(), + input_digest: require_str(&applied, "/effect/receipt/input_digest").to_owned(), + committed_state: require_str(&applied, "/effect/receipt/committed_state").to_owned(), + prior_expected_state: require_str(&applied, "/effect/receipt/expected_state").to_owned(), + } +} + +#[tokio::test] +async fn source_edit_rollback_restores_move_preimages_and_replays_the_receipt() { + let moved = open_moved_project().await; + + let unconfirmed = call_tool( + &moved.fixture, + "tracedecay_source_edit_rollback", + rollback_args( + "mcp-test.source-edit.rollback.unconfirmed", + &moved.effect_id, + &moved.input_digest, + &moved.committed_state, + false, + ), + ) + .await; + assert_eq!(unconfirmed["jsonrpc"], "2.0"); + assert_eq!(unconfirmed["id"], 1); + assert!(unconfirmed.get("result").is_none_or(Value::is_null)); + assert_eq!(unconfirmed["error"]["code"], -32603); + assert_eq!( + unconfirmed["error"]["message"], + "tool execution failed: config error: source edit rollback requires confirm=true from the caller after it checks the receipt; do not pause for a human" + ); + assert_eq!( + unconfirmed["error"]["data"]["tool"], + "tracedecay_source_edit_rollback" + ); + assert!( + !read_project_file(&moved.project, "src/source.rs").contains(ANCHOR), + "a refused confirmation must not restore the source preimage" + ); + + let same_key = call_tool( + &moved.fixture, + "tracedecay_source_edit_rollback", + rollback_args( + MOVE_KEY, + &moved.effect_id, + &moved.input_digest, + &moved.committed_state, + true, + ), + ) + .await; + assert_eq!(same_key["error"]["code"], -32603); + assert_eq!( + same_key["error"]["message"], + "tool execution failed: config error: rollback idempotency key must differ from the original edit key" + ); + assert!( + read_project_file(&moved.project, "src/dest.rs").contains(ANCHOR), + "a reused move key must not roll the destination back" + ); + + let restored_response = call_tool( + &moved.fixture, + "tracedecay_source_edit_rollback", + rollback_args( + "mcp-test.source-edit.rollback.anchor", + &moved.effect_id, + &moved.input_digest, + &moved.committed_state, + true, + ), + ) + .await; + let restored = tool_payload(&restored_response); + assert_eq!(restored["success"], json!(true)); + assert_eq!(restored["reconciled"], json!(true)); + assert_eq!(restored["replayed"], json!(false)); + assert_eq!( + restored["message"], + "source edit rollback restored every retained preimage" + ); + assert_eq!(restored["expected_state"], moved.committed_state); + assert_eq!(restored["predicted_state"], moved.prior_expected_state); + assert_eq!(restored["effect"]["effect_class"], "source_edit"); + assert_eq!( + restored["effect"]["idempotency_key"], + "mcp-test.source-edit.rollback.anchor" + ); + assert_eq!(restored["effect"]["receipt"]["outcome"], "completed"); + assert_eq!(restored["effect"]["receipt"]["effect_class"], "source_edit"); + assert_eq!(restored["effect"]["reconciliation"], "reconciled"); + assert_eq!( + restored["effect"]["payload"]["operation"], + "use-case.application.source-edit.rollback" + ); + assert_eq!(restored["effect"]["payload"]["files"], json!([])); + assert_eq!(restored["effect"]["payload"]["reconciled"], json!(true)); + assert_eq!( + restored["effect"]["payload"]["durable_metadata_only"], + json!(true) + ); + assert_eq!( + restored["effect"]["payload"]["message"], + "source edit reconciliation completed" + ); + assert_original_sources(&moved.project); + let rollback_effect_id = require_str(&restored, "/effect/effect_id").to_owned(); + + let replay_response = call_tool( + &moved.fixture, + "tracedecay_source_edit_rollback", + rollback_args( + "mcp-test.source-edit.rollback.anchor", + &moved.effect_id, + &moved.input_digest, + &moved.committed_state, + true, + ), + ) + .await; + let replay = tool_payload(&replay_response); + // The replay is the durable receipt, not a second live restore. The + // surface contract keeps `failed: false` beside `success: true` and puts + // the retained metadata on `effect.payload`; the files stay the preimages. + assert_eq!(replay["replayed"], json!(true)); + assert_eq!(replay["success"], json!(true)); + assert_eq!(replay["failed"], json!(false)); + assert_eq!(replay["message"], "source edit reconciliation completed"); + assert_eq!(replay["expected_state"], moved.committed_state); + assert_eq!(replay["predicted_state"], moved.prior_expected_state); + assert_eq!(replay["effect"]["effect_id"], rollback_effect_id); + assert_eq!( + replay["effect"]["idempotency_key"], + "mcp-test.source-edit.rollback.anchor" + ); + assert_eq!(replay["effect"]["receipt"]["outcome"], "completed"); + assert_eq!( + replay["effect"]["receipt"]["committed_state"], + moved.prior_expected_state + ); + assert_eq!( + replay["effect"]["payload"]["durable_metadata_only"], + json!(true) + ); + assert_eq!(replay["effect"]["payload"]["files"], json!([])); + assert_eq!(replay["effect"]["payload"]["reconciled"], json!(true)); + assert_eq!(replay["effect"]["payload"]["success"], json!(true)); + assert_original_sources(&moved.project); + + let mismatched = call_tool( + &moved.fixture, + "tracedecay_source_edit_rollback", + rollback_args( + "mcp-test.source-edit.rollback.mismatch", + &moved.effect_id, + &format!("sha256:{}", "0".repeat(64)), + &moved.committed_state, + true, + ), + ) + .await; + assert_eq!(mismatched["jsonrpc"], "2.0"); + assert_eq!(mismatched["id"], 1); + assert!(mismatched.get("result").is_none_or(Value::is_null)); + assert_eq!(mismatched["error"]["code"], -32602); + assert_eq!( + mismatched["error"]["message"], + "tool project route failed: reason_code=source_edit.execution_failed retryable=false: config error: source edit rollback identity does not match the completed original effect" + ); + assert_eq!( + mismatched["error"]["data"]["tool"], + "tracedecay_source_edit_rollback" + ); + assert_eq!( + mismatched["error"]["data"]["reason_code"], + "source_edit.execution_failed" + ); + assert_eq!(mismatched["error"]["data"]["retryable"], json!(false)); + assert_eq!( + mismatched["error"]["data"]["detail"], + "config error: source edit rollback identity does not match the completed original effect" + ); + assert_original_sources(&moved.project); +} + +#[tokio::test] +async fn source_edit_rollback_keeps_foreign_bytes_and_replays_the_refusal() { + let moved = open_moved_project().await; + let source_after_move = read_project_file(&moved.project, "src/source.rs"); + fs::write(moved.project.join("src/dest.rs"), FOREIGN_DEST).unwrap(); + + let refused_response = call_tool( + &moved.fixture, + "tracedecay_source_edit_rollback", + rollback_args( + "mcp-test.source-edit.rollback.foreign", + &moved.effect_id, + &moved.input_digest, + &moved.committed_state, + true, + ), + ) + .await; + assert_eq!(refused_response["jsonrpc"], "2.0"); + assert!(refused_response.get("error").is_none_or(Value::is_null)); + assert_eq!(refused_response["result"]["isError"], json!(true)); + let refused = tool_payload(&refused_response); + assert_eq!(refused["success"], json!(false)); + assert_eq!(refused["failed"], json!(true)); + assert_eq!(refused["replayed"], json!(false)); + assert_eq!( + refused["message"], + "source edit rollback refused stale or foreign workspace bytes" + ); + assert_eq!(refused["expected_state"], moved.committed_state); + assert_eq!(refused["effect"]["effect_class"], "source_edit"); + assert_eq!( + refused["effect"]["idempotency_key"], + "mcp-test.source-edit.rollback.foreign" + ); + assert_eq!(refused["effect"]["receipt"]["outcome"], "failed"); + assert_eq!(refused["effect"]["reconciliation"], "reconciled"); + assert_eq!( + read_project_file(&moved.project, "src/source.rs"), + source_after_move + ); + assert_eq!( + read_project_file(&moved.project, "src/dest.rs"), + FOREIGN_DEST + ); + assert_eq!(read_project_file(&moved.project, "src/lib.rs"), LIB_RS); + let refusal_effect_id = require_str(&refused, "/effect/effect_id").to_owned(); + + let replay_response = call_tool( + &moved.fixture, + "tracedecay_source_edit_rollback", + rollback_args( + "mcp-test.source-edit.rollback.foreign", + &moved.effect_id, + &moved.input_digest, + &moved.committed_state, + true, + ), + ) + .await; + assert_eq!(replay_response["result"]["isError"], json!(true)); + let replay = tool_payload(&replay_response); + assert_eq!(replay["replayed"], json!(true)); + assert_eq!(replay["success"], json!(false)); + assert_eq!(replay["failed"], json!(true)); + assert_eq!(replay["message"], "source edit failed before the effect"); + assert_eq!(replay["effect"]["effect_id"], refusal_effect_id); + assert_eq!(replay["effect"]["receipt"]["outcome"], "failed"); + assert_eq!( + replay["effect"]["payload"]["durable_metadata_only"], + json!(true) + ); + assert_eq!(replay["effect"]["payload"]["failed"], json!(true)); + assert_eq!(replay["effect"]["payload"]["files"], json!([])); + assert_eq!( + read_project_file(&moved.project, "src/dest.rs"), + FOREIGN_DEST + ); + assert_eq!( + read_project_file(&moved.project, "src/source.rs"), + source_after_move + ); +} + +#[tokio::test] +async fn source_edit_rollback_refuses_an_edit_without_retained_preimages() { + let dir = test_temp_dir(); + let project = dir.path().join("project"); + fs::create_dir_all(project.join("src")).unwrap(); + fs::write(project.join("src/main.rs"), "fn old_name() {}\n").unwrap(); + let (fixture, _) = init_production_source_edit_project(&project).await; + + let preview = tool_payload( + &call_tool( + &fixture, + "tracedecay_str_replace", + json!({ + "path": "src/main.rs", + "old_str": "old_name", + "new_str": "new_name", + "dry_run": true, + "format": "json", + }), + ) + .await, + ); + let expected_state = require_str(&preview, "/expected_state").to_owned(); + let applied = tool_payload( + &call_tool( + &fixture, + "tracedecay_str_replace", + json!({ + "path": "src/main.rs", + "old_str": "old_name", + "new_str": "new_name", + "idempotency_key": "mcp-test.source-edit.replace.no-preimage", + "expected_state": expected_state, + "format": "json", + }), + ) + .await, + ); + assert_eq!(applied["success"], json!(true), "{applied}"); + assert_eq!( + read_project_file(&project, "src/main.rs"), + "fn new_name() {}\n" + ); + + let refused = call_tool( + &fixture, + "tracedecay_source_edit_rollback", + json!({ + "effect_id": require_str(&applied, "/effect/effect_id"), + "original_idempotency_key": "mcp-test.source-edit.replace.no-preimage", + "idempotency_key": "mcp-test.source-edit.rollback.no-preimage", + "original_input_digest": require_str(&applied, "/effect/receipt/input_digest"), + "expected_state": require_str(&applied, "/effect/receipt/committed_state"), + "confirm": true, + "format": "json", + }), + ) + .await; + assert_eq!(refused["jsonrpc"], "2.0"); + assert_eq!(refused["id"], 1); + assert!(refused.get("result").is_none_or(Value::is_null)); + assert_eq!(refused["error"]["code"], -32602); + assert_eq!( + refused["error"]["message"], + "tool project route failed: reason_code=source_edit.execution_failed retryable=false: config error: source edit effect has no retained rollback material" + ); + assert_eq!( + refused["error"]["data"]["tool"], + "tracedecay_source_edit_rollback" + ); + assert_eq!( + refused["error"]["data"]["detail"], + "config error: source edit effect has no retained rollback material" + ); + assert_eq!( + read_project_file(&project, "src/main.rs"), + "fn new_name() {}\n" + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs index 81f5df66d6..b9f7eb3abe 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test.rs @@ -10,6 +10,7 @@ mod analytics_test; mod hooks_branch_test; mod protocol_test; pub(crate) mod support; +mod unused_imports_test; // Backwards-compatible path for `crate::mcp_server_test::…` consumers. pub(crate) use support::run_client_connection_with_messages; diff --git a/crates/tracedecay/tests/mcp_suite/mcp_server_test/unused_imports_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_server_test/unused_imports_test.rs new file mode 100644 index 0000000000..c813f2f711 --- /dev/null +++ b/crates/tracedecay/tests/mcp_suite/mcp_server_test/unused_imports_test.rs @@ -0,0 +1,49 @@ +//! Host-visible `tools/call` for the retired unused-import scan. +//! +//! The scan was removed because a graph-only walk reported a clean tree on +//! real code. An empty success would look the same. This test calls the MCP +//! name a host still sends and asserts the refusal the server returns. + +use std::fs; + +use serde_json::json; +use tempfile::TempDir; +use tracedecay::mcp::McpServer; + +use crate::mcp_server_test::support::call_tool; + +#[tokio::test] +async fn unused_imports_tool_call_refuses_the_retired_scan() { + let dir = TempDir::new().expect("temporary project"); + let project = dir.path(); + fs::create_dir_all(project.join("src")).expect("src directory"); + fs::write(project.join("src/main.rs"), "fn main() {}\n").expect("fixture source"); + let graph = crate::fixture::init_project_from_template(project) + .await + .expect("project fixture"); + let server = Box::pin(McpServer::new(graph, None)).await; + + let response = call_tool( + server, + 41, + "tracedecay_unused_imports", + json!({ "limit": 50 }), + ) + .await; + + assert_eq!( + response, + json!({ + "jsonrpc": "2.0", + "id": 41, + "error": { + "code": -32603, + "message": "tool execution failed: config error: unknown tool: tracedecay_unused_imports", + "data": { + "tool": "tracedecay_unused_imports", + "cli_fallback": "This tool is also available from the shell: `tracedecay tool unused_imports ...` (`tracedecay tool unused_imports --help` for parameters). If MCP calls keep failing or timing out, fall back to that CLI instead of querying .tracedecay databases directly." + } + } + }) + ); +} diff --git a/crates/tracedecay/tests/mcp_suite/support.rs b/crates/tracedecay/tests/mcp_suite/support.rs index 7e2900c1c9..19b2d997f0 100644 --- a/crates/tracedecay/tests/mcp_suite/support.rs +++ b/crates/tracedecay/tests/mcp_suite/support.rs @@ -211,6 +211,20 @@ pub(crate) async fn handle_real_server_tool_call_raw( .entry("format".to_string()) .or_insert_with(|| json!("json")); } + dispatch_mcp_tool_call(server, tool_name, arguments).await +} + +/// JSON-RPC `tools/call` with the caller's arguments left intact. +/// +/// [`handle_real_server_tool_call_raw`] inserts `format: "json"` when the +/// caller omitted it. Production default is markdown, so a journey that +/// proves that default must dispatch the arguments as the client sent them. +#[cfg(feature = "test-transport")] +pub(crate) async fn dispatch_mcp_tool_call( + server: &McpServer, + tool_name: &str, + arguments: Value, +) -> Value { let request = json!({ "jsonrpc": "2.0", "id": 1,