From 3ba642e2bc8669f813c26f8de147d082e93e3c6b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 01:39:34 +0000 Subject: [PATCH 1/2] fix(code-index): compile against non-enumerated reuse The reused partition is a count and digest, not a chunk list, and demand admission now returns NotApplicable. Cover both so the scheduler builds. Co-authored-by: Zack Jackson --- .../code_index_scheduler/branch_publication.rs | 4 +++- .../code_index_scheduler/publication_store.rs | 16 ++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs index 0fd136d005..cb975ff2a2 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/branch_publication.rs @@ -448,7 +448,9 @@ impl BranchPublicationContextV1 { .notify_hook_overflow(canonical_worktree_root) .await { - CodeIndexDemandAdmissionV1::Queued => {} + // NotApplicable means this overflow wake has no repository identity. + // The complete-generation request above already admitted, so keep waiting. + CodeIndexDemandAdmissionV1::Queued | CodeIndexDemandAdmissionV1::NotApplicable => {} CodeIndexDemandAdmissionV1::Terminal(parked) => { return Err(TraceDecayError::project_route( CODE_INDEX_PUBLICATION_AUTHORITY_CORRUPT, diff --git a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs index 79a860d5cd..dda818645e 100644 --- a/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs +++ b/crates/tracedecay-code-index-runtime/src/code_index_scheduler/publication_store.rs @@ -2634,20 +2634,8 @@ impl CodeChunkProjectionSink for DaemonProjectionSinkV1 { output_digest: None, }), ); - decisions.extend( - request - .changes - .reused - .iter() - .map(|change| ChunkProjectionDecisionV1 { - chunk_id: change.chunk_id.clone(), - prior_chunk_digest: change.prior_digest.clone(), - current_chunk_digest: change.current_digest.clone(), - operation: ProjectionOperationV1::Reused, - outcome: ProjectionOutcomeV1::Reused, - output_digest: None, - }), - ); + // Reused chunks are authenticated by `reused_count` + `reused_digest`, + // not enumerated. Receipts must not invent per-chunk reused rows. decisions.sort_by(|left, right| left.chunk_id.cmp(&right.chunk_id)); receipt_builder .build(&decisions) From ce5939b415fe34150a1c134ee77f0835555d3f22 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 01:39:39 +0000 Subject: [PATCH 2/2] fix(mcp): keep injected agent context on summaries Status, skill view, and truncated-response recovery were putting raw payloads back into the prompt. Default those surfaces to summaries and enforce the session steering budget. Co-authored-by: Zack Jackson --- .../src/hooks/steering.rs | 98 +++++++++++++++++-- .../src/definitions/admin.rs | 20 +++- .../src/definitions/graph.rs | 2 +- .../src/definitions/skills.rs | 5 +- .../src/definitions/tests.rs | 67 +++++++++++++ .../src/handlers/info/status.rs | 12 ++- crates/tracedecay-mcp/src/handlers/skills.rs | 20 +++- crates/tracedecay-mcp/src/tools/render.rs | 31 +++++- .../tracedecay-mcp/src/tools/render/tests.rs | 11 ++- crates/tracedecay-mcp/src/tools/renderers.rs | 28 +++++- .../skills_automation_test.rs | 9 +- .../mcp_handler_test/status_runtime_test.rs | 40 +++++++- plugin/README-cursor.md | 5 +- 13 files changed, 312 insertions(+), 36 deletions(-) diff --git a/crates/tracedecay-agent-hosts/src/hooks/steering.rs b/crates/tracedecay-agent-hosts/src/hooks/steering.rs index 98d154f78f..ab90d989a3 100644 --- a/crates/tracedecay-agent-hosts/src/hooks/steering.rs +++ b/crates/tracedecay-agent-hosts/src/hooks/steering.rs @@ -36,7 +36,10 @@ pub fn build_cursor_session_context( staleness_hint: Option<&str>, tokens_saved: Option, ) -> String { - let mut s = index_status_line(initialized, staleness_hint); + let mut s = index_status_line( + initialized, + staleness_hint.map(bounded_staleness_hint).as_deref(), + ); if initialized { s.reserve(CURSOR_SESSION_CONTEXT_BUDGET.saturating_sub(s.len())); append_tracedecay_bootstrap_context(&mut s); @@ -49,7 +52,7 @@ pub fn build_cursor_session_context( s.push_str(".\n"); } } - s + enforce_context_budget(s, CURSOR_SESSION_CONTEXT_BUDGET) } /// One-line index freshness signal. @@ -124,14 +127,16 @@ pub fn build_codex_session_context_for_workspace( s.push('\n'); append_codex_recall_and_registry_guidance(&mut s); match status { - HookWorkspaceStatus::Initialized => match staleness_hint { - Some(hint) => { - s.push_str("Index status: "); - s.push_str(hint); - s.push_str(".\n"); + HookWorkspaceStatus::Initialized => { + match staleness_hint.map(bounded_staleness_hint) { + Some(hint) => { + s.push_str("Index status: "); + s.push_str(&hint); + s.push_str(".\n"); + } + None => s.push_str("Index status: initialized.\n"), } - None => s.push_str("Index status: initialized.\n"), - }, + } HookWorkspaceStatus::UnindexedProject => s.push_str( "Index status: no project index found in this code workspace — \ run `tracedecay init` to enable tracedecay code-graph tools.\n", @@ -155,7 +160,7 @@ pub fn build_codex_session_context_for_workspace( "Continue authorized work through the requested outcome and relevant verification; \ pause for a missing decision or an external or destructive action outside that authority.\n", ); - s + enforce_context_budget(s, CODEX_SESSION_CONTEXT_BUDGET) } fn append_codex_recall_and_registry_guidance(s: &mut String) { @@ -169,6 +174,54 @@ fn append_codex_recall_and_registry_guidance(s: &mut String) { ); } +/// Freshness text is caller-supplied. Keep one short line so a diagnostic dump +/// cannot expand the session-start payload past its budget. +const MAX_STALENESS_HINT_CHARS: usize = 80; + +fn bounded_staleness_hint(hint: &str) -> String { + let line = hint.lines().next().unwrap_or("").trim(); + let mut chars = line.chars(); + let bounded: String = chars.by_ref().take(MAX_STALENESS_HINT_CHARS).collect(); + if chars.next().is_some() { + let mut shorter: String = line.chars().take(MAX_STALENESS_HINT_CHARS - 1).collect(); + shorter.push('…'); + shorter + } else { + bounded + } +} + +/// Drop trailing lines, then a byte tail, until `text` fits `budget`. +/// +/// The documented session budgets are production contracts. A caller-supplied +/// hint or a future prose addition must not overflow the injected context. +fn enforce_context_budget(mut text: String, budget: usize) -> String { + if text.len() <= budget { + return text; + } + while text.len() > budget { + let Some(idx) = text.rfind('\n') else { + break; + }; + if idx == 0 { + break; + } + text.truncate(idx); + } + if !text.is_empty() && !text.ends_with('\n') { + text.push('\n'); + } + if text.len() <= budget { + return text; + } + let mut end = budget.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + text.truncate(end); + text +} + /// Formats a short relative-age staleness hint from a sync age in seconds. pub fn cursor_staleness_hint(age_secs: i64) -> String { let age = age_secs.max(0); @@ -225,4 +278,29 @@ mod tests { ); } } + + #[test] + fn session_context_bounds_an_unbounded_staleness_hint() { + let dump = "x".repeat(8_000); + let cursor = build_cursor_session_context(true, Some(&dump), Some(12_345)); + assert!( + cursor.len() <= CURSOR_SESSION_CONTEXT_BUDGET, + "{}", + cursor.len() + ); + assert!(cursor.contains("TraceDecay project hint:")); + assert!(!cursor.contains(&dump)); + + let codex = build_codex_session_context_for_workspace( + HookWorkspaceStatus::Initialized, + Some(&dump), + ); + assert!( + codex.len() <= CODEX_SESSION_CONTEXT_BUDGET, + "{}", + codex.len() + ); + assert!(codex.contains("TraceDecay project hint:")); + assert!(!codex.contains(&dump)); + } } diff --git a/crates/tracedecay-mcp-catalog/src/definitions/admin.rs b/crates/tracedecay-mcp-catalog/src/definitions/admin.rs index b7adb2b676..4f2d56689f 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/admin.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/admin.rs @@ -9,14 +9,30 @@ pub(super) fn def_status() -> ToolDefinition { def_always_load( "tracedecay_status", "Graph Status", - "Return aggregate statistics about the code graph (node/edge/file counts, DB size, etc.).", + "Return a compact summary of the code graph (counts and freshness). Full branch diagnostics are opt-in.", json!({ "type": "object", "additionalProperties": false, "properties": { "include_branch_diagnostics": { "type": "boolean", - "description": "Include the full tracked-branch diagnostic list (default: true). Disable for compact status consumers." + "default": false, + "description": "Full tracked-branch diagnostic list. Default false." + }, + "include_storage_health": { + "type": "boolean", + "default": false, + "description": "Storage-health snapshot. Default false." + }, + "include_session_ingest": { + "type": "boolean", + "default": false, + "description": "Session-ingest health. Default false." + }, + "include_staleness": { + "type": "boolean", + "default": false, + "description": "Git staleness object. Default false." } } }), diff --git a/crates/tracedecay-mcp-catalog/src/definitions/graph.rs b/crates/tracedecay-mcp-catalog/src/definitions/graph.rs index 2277a497aa..2ebd865906 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/graph.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/graph.rs @@ -170,7 +170,7 @@ pub(super) fn def_retrieve() -> ToolDefinition { def( "tracedecay_retrieve", "Retrieve Truncated Response", - "Use `tracedecay_retrieve` with required argument `handle` to retrieve one bounded page of the exact cached original text for a local response handle emitted by a truncated MCP response. Start with offset 0, then pass each returned next_offset until has_more is false; concatenating content in offset order reconstructs the original byte-exactly. max_chars is clamped to the response-frame budget. This does not re-run the source tool or read a file/session/node again; handles are scoped to the active project store, expire automatically, and never reference remote storage. If the original truncated response used project_selector.project_id, pass the same selector here. Only call it when the missing details are needed to answer the user's request.", + "Use `tracedecay_retrieve` with required argument `handle` to read one bounded page of a truncated MCP response's locally cached text. Pass offset and max_chars for the omitted span you need. Do not walk next_offset until has_more is false or concatenate pages back into the conversation: the stored body already exceeded the response budget. max_chars is clamped to the response-frame budget. This does not re-run the source tool or read a file/session/node again; handles are scoped to the active project store, expire automatically, and never reference remote storage. If the original truncated response used project_selector.project_id, pass the same selector here. Only call it when the missing details are needed to answer the user's request.", json!({ "type": "object", "properties": with_project_selector_properties(json!({ diff --git a/crates/tracedecay-mcp-catalog/src/definitions/skills.rs b/crates/tracedecay-mcp-catalog/src/definitions/skills.rs index ca552e27f3..23a4b9809c 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/skills.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/skills.rs @@ -35,7 +35,7 @@ pub(super) fn def_skill_view() -> ToolDefinition { def( "tracedecay_skill_view", "Skill View", - "Read one agent-managed skill package from the active TraceDecay profile. Returns full metadata, body text, usage summary, stale/archive and improvement recommendation evidence, and support files by default.", + "Read one agent-managed skill package from the active TraceDecay profile. Returns metadata, body text, usage summary, and support-file path summaries. Support-file bytes are omitted unless include_support_files is true.", json!({ "type": "object", "properties": { @@ -45,7 +45,8 @@ pub(super) fn def_skill_view() -> ToolDefinition { }, "include_support_files": { "type": "boolean", - "description": "If false, omit support file byte payloads from the response (default: true)." + "default": false, + "description": "Include support-file byte payloads. Default false; the response still lists each path and byte length." } }, "required": ["id"] diff --git a/crates/tracedecay-mcp-catalog/src/definitions/tests.rs b/crates/tracedecay-mcp-catalog/src/definitions/tests.rs index 67a1bec969..02c598a77b 100644 --- a/crates/tracedecay-mcp-catalog/src/definitions/tests.rs +++ b/crates/tracedecay-mcp-catalog/src/definitions/tests.rs @@ -278,3 +278,70 @@ fn per_session_budget_does_not_leak_through_the_cached_registry() { "an unbudgeted caller inherited another session's budget: {neutral_description}" ); } + +/// Always-loaded schemas enter the model prompt on every turn. The agreed cap +/// is the small core; growing it is a context-window decision, not a drive-by. +#[test] +fn always_loaded_tools_stay_inside_the_agreed_core() { + let definitions = get_maximal_tool_definitions().expect("tool definitions"); + let mut always_loaded = definitions + .iter() + .filter(|definition| { + definition + .meta + .as_ref() + .and_then(|meta| meta.get("anthropic/alwaysLoad")) + .and_then(serde_json::Value::as_bool) + == Some(true) + }) + .map(|definition| definition.name.as_str()) + .collect::>(); + always_loaded.sort_unstable(); + assert_eq!( + always_loaded, + vec![ + "tracedecay_active_project", + "tracedecay_callers", + "tracedecay_context", + "tracedecay_grep", + "tracedecay_search", + "tracedecay_status", + "tracedecay_storage_status", + ] + ); +} + +#[test] +fn status_and_skill_view_default_to_summaries() { + let definitions = get_tool_definitions().expect("tool definitions"); + let status = definitions + .iter() + .find(|definition| definition.name == "tracedecay_status") + .expect("status"); + for key in [ + "include_branch_diagnostics", + "include_storage_health", + "include_session_ingest", + "include_staleness", + ] { + assert_eq!( + status.input_schema["properties"][key]["default"], + serde_json::json!(false), + "{key}" + ); + } + let view = definitions + .iter() + .find(|definition| definition.name == "tracedecay_skill_view") + .expect("skill view"); + assert_eq!( + view.input_schema["properties"]["include_support_files"]["default"], + serde_json::json!(false) + ); + let retrieve = definitions + .iter() + .find(|definition| definition.name == "tracedecay_retrieve") + .expect("retrieve"); + assert!(retrieve.description.contains("Do not walk next_offset")); + assert!(!retrieve.description.contains("byte-exactly")); +} diff --git a/crates/tracedecay-mcp/src/handlers/info/status.rs b/crates/tracedecay-mcp/src/handlers/info/status.rs index 9183d0d249..c068e7eb29 100644 --- a/crates/tracedecay-mcp/src/handlers/info/status.rs +++ b/crates/tracedecay-mcp/src/handlers/info/status.rs @@ -242,10 +242,14 @@ pub async fn handle_status( )); } - let include_branch_diagnostics = status_arg_flag(&args, "include_branch_diagnostics", true); - let include_storage_health = status_arg_flag(&args, "include_storage_health", true); - let include_session_ingest = status_arg_flag(&args, "include_session_ingest", true); - let include_staleness = status_arg_flag(&args, "include_staleness", true); + // Compact by default. The CLI already skips these sections because they + // commonly push status over the response-frame budget, and the truncated + // body is not something the caller should reassemble into context. Opt in + // when the full diagnostic section is the thing being asked for. + let include_branch_diagnostics = status_arg_flag(&args, "include_branch_diagnostics", false); + let include_storage_health = status_arg_flag(&args, "include_storage_health", false); + let include_session_ingest = status_arg_flag(&args, "include_session_ingest", false); + let include_staleness = status_arg_flag(&args, "include_staleness", false); let graph_statistics = graph_statistics_value(ctx.generation_census())?; let mut output = json!({ diff --git a/crates/tracedecay-mcp/src/handlers/skills.rs b/crates/tracedecay-mcp/src/handlers/skills.rs index ad4877b7c7..77af8ec2d4 100644 --- a/crates/tracedecay-mcp/src/handlers/skills.rs +++ b/crates/tracedecay-mcp/src/handlers/skills.rs @@ -62,6 +62,19 @@ fn parse_state(args: &Value) -> Result> { } } +fn support_file_summaries(skill: &ManagedSkill) -> Vec { + skill + .support_files + .iter() + .map(|file| { + json!({ + "path": file.path.display().to_string(), + "byte_len": file.bytes.len(), + }) + }) + .collect() +} + fn skill_summary(skill: &ManagedSkill, include_body: bool, usage_summary: &Value) -> Value { let mut summary = json!({ "metadata": skill.metadata, @@ -163,7 +176,10 @@ pub async fn handle_skill_view( ) -> Result { let profile_root = tracedecay_runtime_core::storage::default_profile_root()?; sync_project_skill_analytics(cg, &profile_root, analytics_db).await?; - let include_support_files = optional_bool(&args, "include_support_files", true); + // Path summaries stay in the response either way. Byte payloads are a + // separate read the caller opts into, so a view does not inline unused + // support files into the context window. + let include_support_files = optional_bool(&args, "include_support_files", false); let mut skill = hotpath::future!( load_managed_skill(&profile_root, required_str(&args, "id")?), label = "mcp.automation.skill_view.load" @@ -212,6 +228,7 @@ pub async fn handle_skill_view( skill_improvement_recommendations(std::slice::from_ref(&usage_summary)) .into_iter() .next(); + let support_file_summaries = support_file_summaries(&skill); if !include_support_files { skill.support_files.clear(); } @@ -223,6 +240,7 @@ pub async fn handle_skill_view( "stale_recommendation": stale_recommendation, "improvement_recommendation": improvement_recommendation, "support_files_included": include_support_files, + "support_file_summaries": support_file_summaries, }); Ok(tool_json_with_md( Some(cg.project_root()), diff --git a/crates/tracedecay-mcp/src/tools/render.rs b/crates/tracedecay-mcp/src/tools/render.rs index fc5ccd1b57..c051814365 100644 --- a/crates/tracedecay-mcp/src/tools/render.rs +++ b/crates/tracedecay-mcp/src/tools/render.rs @@ -21,6 +21,29 @@ pub use tracedecay_runtime_core::text::format_relative_time; const MARKDOWN_TRUNCATION_RESERVED_CHARS: usize = 2_048; +/// Agent-facing recovery text for a truncated response. +/// +/// The stored body is larger than one response frame by definition. The +/// instruction must not tell the caller to page through and join that body +/// back into the conversation. +fn truncated_response_recovery_instruction( + preview_chars: usize, + original_chars: usize, + expires_at: i64, + handle: &str, +) -> String { + format!( + "This response was truncated: `preview` contains only the first {preview_chars} of \ + {original_chars} characters. The original response is stored locally and expires at \ + {expires_at} (TTL {RESPONSE_HANDLE_TTL_SECS} seconds). Use the preview. Do not page \ + through and join the stored body back into the conversation. If one omitted detail \ + is required, call `{RESPONSE_RETRIEVE_TOOL}` once with required argument `handle` \ + set to `{handle}` and an offset/max_chars window for that span only. If the original \ + call used `project_selector.project_id`, pass the same selector. Only call it if the \ + missing details are needed." + ) +} + fn parse_format(args: &Value) -> RequestedOutputFormat { requested_output_format(args) } @@ -105,13 +128,11 @@ pub fn truncated_json_envelope_with_handle(project_root: Option<&Path>, formatte ); object.insert( "retrieve_instruction".to_string(), - serde_json::json!(format!( - "This response was truncated: `preview` contains only the first {} of {} characters. The full original response is stored locally in this project and expires at {} (TTL {} seconds). To recover it, call `{RESPONSE_RETRIEVE_TOOL}` with required argument `handle` set to `{}`. The stored body is by definition larger than one response frame, so each call returns one page: repeat the call with `offset` set to the `next_offset` the previous page reported until `has_more` is false, then join the pages in order. If the original call used `project_selector.project_id`, pass the same selector so the handle is read from that project cache. Only call it if the missing details are needed.", + serde_json::json!(truncated_response_recovery_instruction( preview.chars().count(), original_chars, record.expires_at, - RESPONSE_HANDLE_TTL_SECS, - record.handle + &record.handle, )), ); } else if let Some(status) = &handle.unavailable { @@ -403,7 +424,7 @@ fn render_markdown_truncation( if let Some(record) = &handle.record { let _ = writeln!( rendered, - "Full response stored locally. Retrieve it with `{RESPONSE_RETRIEVE_TOOL}` using handle `{}` before {}.", + "Preview is the context to use. Do not join stored pages back into the conversation. If one omitted detail is required, call `{RESPONSE_RETRIEVE_TOOL}` once using handle `{}` before {}.", record.handle, record.expires_at ); } else if let Some(status) = &handle.unavailable { diff --git a/crates/tracedecay-mcp/src/tools/render/tests.rs b/crates/tracedecay-mcp/src/tools/render/tests.rs index b68076acb2..aaf9124e04 100644 --- a/crates/tracedecay-mcp/src/tools/render/tests.rs +++ b/crates/tracedecay-mcp/src/tools/render/tests.rs @@ -65,6 +65,13 @@ fn truncated_json_envelope_includes_handle() { assert_eq!(parsed["truncated"], true); assert_eq!(parsed["retrieve_tool"], "tracedecay_retrieve"); + let instruction = parsed["retrieve_instruction"].as_str().unwrap(); + assert!(instruction.contains("Use the preview")); + assert!(instruction.contains("Only call it if the missing details are needed")); + assert!( + !instruction.contains("join the pages"), + "truncation must not instruct a full reassembly: {instruction}" + ); assert!(parsed.get("retrieve_handle").is_none()); let handle = parsed["handle"].as_str().unwrap(); assert!(handle.starts_with("rh_")); @@ -98,7 +105,9 @@ fn truncated_markdown_includes_readable_handle_guidance() { assert!(result.starts_with("# Truncated Response")); assert!(result.contains("## Preview")); - assert!(result.contains("Full response stored locally")); + assert!(result.contains("Preview is the context to use")); + assert!(result.contains("Do not join stored pages")); + assert!(!result.contains("until has_more is false")); assert!(result.contains("tracedecay_retrieve")); assert!( serde_json::from_str::(&result).is_err(), diff --git a/crates/tracedecay-mcp/src/tools/renderers.rs b/crates/tracedecay-mcp/src/tools/renderers.rs index 574638aefb..bf4303789d 100644 --- a/crates/tracedecay-mcp/src/tools/renderers.rs +++ b/crates/tracedecay-mcp/src/tools/renderers.rs @@ -64,11 +64,31 @@ pub fn skill_view_md(value: &Value) -> String { if !body.is_empty() { md.blank().heading(3, "Body").line(body); } - if let Some(files) = skill.get("support_files").and_then(Value::as_array) { + let summaries = value + .get("support_file_summaries") + .and_then(Value::as_array); + let files = skill.get("support_files").and_then(Value::as_array); + if summaries.is_some_and(|items| !items.is_empty()) + || files.is_some_and(|items| !items.is_empty()) + { md.blank().heading(3, "Support Files"); - if files.is_empty() { - md.empty_note("No support files."); - } else { + if let Some(summaries) = summaries.filter(|items| !items.is_empty()) { + let bodies_included = value + .get("support_files_included") + .and_then(Value::as_bool) + .unwrap_or(false); + for summary in summaries { + let path = value_str(summary, "/path"); + let bytes = summary.get("byte_len").and_then(Value::as_u64).unwrap_or(0); + if bodies_included { + md.bullet(&format!("**{path}** - {bytes} bytes")); + } else { + md.bullet(&format!( + "**{path}** - {bytes} bytes (pass include_support_files=true only for a required body)" + )); + } + } + } else if let Some(files) = files { for file in files { let path = value_str(file, "/path"); let bytes = file diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs index 37feb66cf6..7a9a37c9e8 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/skills_automation_test.rs @@ -272,7 +272,6 @@ async fn managed_skill_mcp_tools_list_and_view_profile_store() { "tracedecay_skill_view", json!({ "id": "active-skill", - "include_support_files": false, "__mcp_request_id": "req-active-view", "format": "json", }), @@ -309,6 +308,14 @@ async fn managed_skill_mcp_tools_list_and_view_profile_store() { 0 ); assert_eq!(payload["support_files_included"], false); + let summary = &payload["support_file_summaries"][0]; + assert_eq!(summary["path"], "references/checklist.md"); + assert!(summary["byte_len"].as_u64().unwrap() > 0); + let encoded = payload.to_string(); + assert!( + !encoded.contains("inspect context"), + "default skill view must not inline support-file bytes: {encoded}" + ); let usage_record = load_skill_usage_record(&profile_root, "active-skill") .await .unwrap() diff --git a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/status_runtime_test.rs b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/status_runtime_test.rs index 3d341cec14..21d41a29db 100644 --- a/crates/tracedecay/tests/mcp_suite/mcp_handler_test/status_runtime_test.rs +++ b/crates/tracedecay/tests/mcp_suite/mcp_handler_test/status_runtime_test.rs @@ -49,10 +49,44 @@ async fn test_status() { text.contains("server"), "status should include server stats" ); + assert!( + !text.contains("branch_diagnostics"), + "status should omit the full branch diagnostic list unless requested: {text}" + ); + assert!( + !text.contains("storage_health"), + "status should omit storage health unless requested" + ); + assert!( + !text.contains("session_ingest"), + "status should omit session ingest unless requested" + ); +} + +#[tokio::test] +async fn status_includes_verbose_sections_only_when_requested() { + let (cg, _env, _dir) = setup_empty_project().await; + let result = handle_tool_call( + &cg, + "tracedecay_status", + json!({ + "include_branch_diagnostics": true, + "include_storage_health": true, + "include_session_ingest": true, + "include_staleness": true, + }), + Some(json!({"uptime": 100})), + None, + ) + .await + .unwrap(); + let text = extract_text(&result.value); assert!( text.contains("branch_diagnostics"), - "status should include branch diagnostics" + "explicit opt-in should include branch diagnostics" ); + assert!(text.contains("storage_health")); + assert!(text.contains("git_staleness")); } #[tokio::test] @@ -112,7 +146,7 @@ async fn status_reports_daemon_owned_partial_history_catch_up() { let result = tracedecay::mcp::tools::handle_tool_call_with_registry_options( &cg, "tracedecay_status", - json!({"format": "json"}), + json!({"format": "json", "include_session_ingest": true}), None, None, tracedecay::mcp::tools::ToolCallRegistryOptions::with_session_authorities( @@ -206,7 +240,7 @@ async fn status_without_retained_session_authority_fails_closed() { let result = handle_tool_call( &cg, "tracedecay_status", - json!({ "format": "json" }), + json!({ "format": "json", "include_session_ingest": true }), None, None, ) diff --git a/plugin/README-cursor.md b/plugin/README-cursor.md index ab14a8e9f9..80c2e60bd7 100644 --- a/plugin/README-cursor.md +++ b/plugin/README-cursor.md @@ -299,8 +299,9 @@ Notes: (cargo/tsc/pyright) and `tracedecay_dashboard` starts a localhost server. Both are non-destructive, but remove those lines if you want a prompt first. - `tracedecay_retrieve` only dereferences the required `handle` from a - project-local truncated MCP response. Use it when omitted details are needed; - it restores that exact cached response and does not re-run the source tool. + project-local truncated MCP response. Use it for one omitted span; do not + reassemble the stored body into the conversation. It does not re-run the + source tool. - Do **not** use `tracedecay:*` — it would auto-approve the editing tools too. - Entries from per-user and per-repo files are concatenated; allowlists are a convenience, not a security boundary.