diff --git a/src/daemon.rs b/src/daemon.rs index feac9ee74..35b606b6e 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -3344,7 +3344,8 @@ mod tests { "arguments": { "provider": "cursor", "storage_scope": "hermes_profile", - "hermes_home": hermes_home + "hermes_home": hermes_home, + "format": "json" } } })) diff --git a/src/mcp/tools/definitions.rs b/src/mcp/tools/definitions.rs index 30c4e506c..88202955b 100644 --- a/src/mcp/tools/definitions.rs +++ b/src/mcp/tools/definitions.rs @@ -439,9 +439,42 @@ const FORMAT_CAPABLE_TOOL_NAMES: &[&str] = &[ "tracedecay_redundancy", // memory "tracedecay_memory_status", + "tracedecay_fact_store", + "tracedecay_fact_feedback", // workflow "tracedecay_diagnose", "tracedecay_run_affected_tests", + // session / LCM + "tracedecay_message_search", + "tracedecay_lcm_status", + "tracedecay_lcm_doctor", + "tracedecay_lcm_load_session", + "tracedecay_lcm_grep", + "tracedecay_lcm_describe", + "tracedecay_lcm_expand", + "tracedecay_lcm_expand_query", + "tracedecay_lcm_session_boundary", + "tracedecay_lcm_preflight", + "tracedecay_lcm_compress", + // skills + "tracedecay_skill_list", + "tracedecay_skill_view", + "tracedecay_automation_run_artifact_view", + "tracedecay_hermes_skill_bridge", + // edit + "tracedecay_str_replace", + "tracedecay_multi_str_replace", + "tracedecay_insert_at", + "tracedecay_insert_at_symbol", + "tracedecay_replace_symbol", + "tracedecay_ast_grep_rewrite", + // git & info + "tracedecay_branch_list", + "tracedecay_active_project", + "tracedecay_storage_status", + // misc + "tracedecay_dashboard", + "tracedecay_retrieve", ]; pub fn format_capable_tool_names() -> &'static [&'static str] { @@ -1823,8 +1856,8 @@ fn def_dsm() -> ToolDefinition { }, "format": { "type": "string", - "enum": ["stats", "clusters", "matrix"], - "description": "Output format (default: stats)" + "enum": ["stats", "clusters", "matrix", "json"], + "description": "Data shape rendered as markdown: stats, clusters, or matrix (default: stats). Pass 'json' for compact machine-readable JSON of the default stats shape." }, "max_files": { "type": "number", diff --git a/src/mcp/tools/handlers/dashboard.rs b/src/mcp/tools/handlers/dashboard.rs index 58f4fa40f..03e7b3fd4 100644 --- a/src/mcp/tools/handlers/dashboard.rs +++ b/src/mcp/tools/handlers/dashboard.rs @@ -10,7 +10,7 @@ use serde_json::{json, Value}; use crate::errors::{Result, TraceDecayError}; use crate::tracedecay::TraceDecay; -use super::super::render::truncated_json_envelope_with_handle; +use super::super::render; use super::super::ToolResult; use crate::dashboard::{bind_dashboard, build_state, router, DEFAULT_PORT}; @@ -43,11 +43,13 @@ fn validate_mcp_dashboard_host(host: &str) -> Result<&str> { }) } -fn dashboard_tool_result(cg: &TraceDecay, payload: &Value) -> ToolResult { - let formatted = serde_json::to_string(payload).unwrap_or_default(); +fn dashboard_tool_result(cg: &TraceDecay, args: &Value, payload: &Value) -> ToolResult { + let text = render::finalize(Some(cg.project_root()), args, payload, || { + render::generic_md(payload) + }); ToolResult::new( json!({ - "content": [{ "type": "text", "text": truncated_json_envelope_with_handle(Some(cg.project_root()), &formatted) }] + "content": [{ "type": "text", "text": text }] }), vec![], ) @@ -70,7 +72,7 @@ pub(super) async fn handle_dashboard(cg: &TraceDecay, args: Value) -> Result { let host = args @@ -93,6 +95,7 @@ pub(super) async fn handle_dashboard(cg: &TraceDecay, args: Value) -> Result Result TraceDecayError { @@ -28,11 +29,18 @@ fn required_array<'a>(args: &'a Value, name: &str) -> Result<&'a [Value]> { .ok_or_else(|| missing_required_param(name)) } -fn text_tool_result(result: &T, touched_files: Vec) -> ToolResult { +fn text_tool_result( + cg: &TraceDecay, + args: &Value, + result: &T, + touched_files: Vec, +) -> ToolResult { + let value = serde_json::to_value(result).unwrap_or_default(); + let text = render::finalize(Some(cg.project_root()), args, &value, || { + render::generic_md(&value) + }); ToolResult::new( - json!({ - "content": [{ "type": "text", "text": serde_json::to_string(result).unwrap_or_default() }] - }), + json!({ "content": [{ "type": "text", "text": text }] }), touched_files, ) } @@ -44,7 +52,7 @@ pub(super) async fn handle_str_replace(cg: &TraceDecay, args: Value) -> Result Result { @@ -72,7 +80,7 @@ pub(super) async fn handle_multi_str_replace(cg: &TraceDecay, args: Value) -> Re let result = cg.multi_str_replace(path, &parsed_replacements).await?; let touched_files = vec![result.file_path.clone()]; - Ok(text_tool_result(&result, touched_files)) + Ok(text_tool_result(cg, &args, &result, touched_files)) } pub(super) async fn handle_insert_at(cg: &TraceDecay, args: Value) -> Result { @@ -84,7 +92,7 @@ pub(super) async fn handle_insert_at(cg: &TraceDecay, args: Value) -> Result Result { @@ -97,7 +105,7 @@ pub(super) async fn handle_replace_symbol(cg: &TraceDecay, args: Value) -> Resul } else { vec![] }; - Ok(text_tool_result(&result, touched_files)) + Ok(text_tool_result(cg, &args, &result, touched_files)) } pub(super) async fn handle_insert_at_symbol(cg: &TraceDecay, args: Value) -> Result { @@ -114,7 +122,7 @@ pub(super) async fn handle_insert_at_symbol(cg: &TraceDecay, args: Value) -> Res } else { vec![] }; - Ok(text_tool_result(&result, touched_files)) + Ok(text_tool_result(cg, &args, &result, touched_files)) } pub(super) async fn handle_ast_grep_rewrite(cg: &TraceDecay, args: Value) -> Result { @@ -128,5 +136,5 @@ pub(super) async fn handle_ast_grep_rewrite(cg: &TraceDecay, args: Value) -> Res } else { vec![] }; - Ok(text_tool_result(&result, touched_files)) + Ok(text_tool_result(cg, &args, &result, touched_files)) } diff --git a/src/mcp/tools/handlers/git.rs b/src/mcp/tools/handlers/git.rs index 6c8a5cb84..cdaa00be2 100644 --- a/src/mcp/tools/handlers/git.rs +++ b/src/mcp/tools/handlers/git.rs @@ -6,7 +6,7 @@ use std::collections::{HashMap, HashSet, VecDeque}; use serde_json::{json, Value}; -use super::super::render::{self, truncated_json_envelope_with_handle}; +use super::super::render; use super::super::ToolResult; use super::support::unique_file_paths; use crate::errors::{Result, TraceDecayError}; @@ -18,11 +18,7 @@ struct GitFileChange { status: &'static str, } -fn project_response_text(cg: &TraceDecay, text: &str) -> String { - truncated_json_envelope_with_handle(Some(cg.project_root()), text) -} - -fn git_error_result(cg: &TraceDecay, operation: &str, message: &str) -> ToolResult { +fn git_error_result(cg: &TraceDecay, args: &Value, operation: &str, message: &str) -> ToolResult { let output = json!({ "error": { "kind": "git", @@ -30,10 +26,12 @@ fn git_error_result(cg: &TraceDecay, operation: &str, message: &str) -> ToolResu "message": message, } }); - let formatted = serde_json::to_string(&output).unwrap_or_default(); + let text = render::finalize(Some(cg.project_root()), args, &output, || { + render::generic_md(&output) + }); ToolResult::new( json!({ - "content": [{ "type": "text", "text": project_response_text(cg, &formatted) }] + "content": [{ "type": "text", "text": text }] }), vec![], ) @@ -598,7 +596,7 @@ pub(super) async fn handle_changelog(cg: &TraceDecay, args: Value) -> Result files, Err(e) => { - return Ok(git_error_result(cg, "diff", &e)); + return Ok(git_error_result(cg, &args, "diff", &e)); } }; let changed_files: Vec = changes.iter().map(|change| change.path.clone()).collect(); @@ -678,7 +676,7 @@ pub(super) async fn handle_commit_context(cg: &TraceDecay, args: Value) -> Resul let changed_files = match git_changed_files(cg.project_root(), staged_only) { Ok(files) => files, Err(e) => { - return Ok(git_error_result(cg, "status", &e)); + return Ok(git_error_result(cg, &args, "status", &e)); } }; @@ -775,7 +773,7 @@ pub(super) async fn handle_pr_context(cg: &TraceDecay, args: Value) -> Result files, Err(e) => { - return Ok(git_error_result(cg, "diff", &e)); + return Ok(git_error_result(cg, &args, "diff", &e)); } }; let changed_files: Vec = changes.iter().map(|change| change.path.clone()).collect(); @@ -901,7 +899,7 @@ pub(super) async fn handle_pr_context(cg: &TraceDecay, args: Value) -> Result ToolResult { +pub(super) fn handle_branch_list(cg: &TraceDecay, args: &Value) -> ToolResult { let diagnostics = cg.branch_diagnostics(); let mut result = serde_json::to_value(&diagnostics).unwrap_or(json!({})); if let Some(object) = result.as_object_mut() { @@ -911,10 +909,12 @@ pub(super) fn handle_branch_list(cg: &TraceDecay) -> ToolResult { ); } - let output = serde_json::to_string(&result).unwrap_or_default(); + let text = render::finalize(Some(cg.project_root()), args, &result, || { + render::generic_md(&result) + }); ToolResult::new( json!({ - "content": [{ "type": "text", "text": project_response_text(cg, &output) }] + "content": [{ "type": "text", "text": text }] }), vec![], ) diff --git a/src/mcp/tools/handlers/health.rs b/src/mcp/tools/handlers/health.rs index 02db198d8..5174cdd83 100644 --- a/src/mcp/tools/handlers/health.rs +++ b/src/mcp/tools/handlers/health.rs @@ -25,7 +25,7 @@ use crate::graph::queries::GraphQueryManager; use crate::tracedecay::TraceDecay; use crate::types::{EdgeKind, NodeKind}; -use super::super::render::{self, truncated_json_envelope_with_handle}; +use super::super::render; use super::super::ToolResult; use super::support::{effective_path, unique_file_paths}; @@ -621,13 +621,15 @@ pub(super) async fn handle_dsm( } }; - // `dsm` owns its `format` argument (stats/clusters/list/matrix) for data - // shaping, so it stays compact JSON rather than routing through the - // markdown/json `render::finalize` selector. - let formatted = serde_json::to_string(&output).unwrap_or_default(); + // `dsm` overloads `format`: stats/clusters/matrix pick the data shape and + // render as markdown; "json" falls through to the default (stats) shape + // and `render::finalize` emits it as compact JSON. + let text = render::finalize(Some(cg.project_root()), &args, &output, || { + render::generic_md(&output) + }); Ok(ToolResult::new( json!({ - "content": [{ "type": "text", "text": truncated_json_envelope_with_handle(Some(cg.project_root()), &formatted) }] + "content": [{ "type": "text", "text": text }] }), vec![], )) diff --git a/src/mcp/tools/handlers/info.rs b/src/mcp/tools/handlers/info.rs index f43b9e73a..5aeb09ea2 100644 --- a/src/mcp/tools/handlers/info.rs +++ b/src/mcp/tools/handlers/info.rs @@ -13,14 +13,10 @@ use crate::storage::{ProjectPath, StorageMode, StoreKind}; use crate::tracedecay::{BranchDiagnostics, TraceDecay}; use crate::types::{NodeKind, Visibility}; -use super::super::render::{self, truncate_response, truncated_json_envelope_with_handle, Md}; +use super::super::render::{self, Md}; use super::super::ToolResult; use super::support::{effective_path, filter_by_scope, require_node_id, unique_file_paths}; -fn project_response_text(cg: &TraceDecay, text: &str) -> String { - truncated_json_envelope_with_handle(Some(cg.project_root()), text) -} - /// Handles `tracedecay_status` tool calls. pub(super) async fn handle_status( cg: &TraceDecay, @@ -254,15 +250,18 @@ fn store_kind_name(kind: &StoreKind) -> &'static str { /// Handles `tracedecay_active_project` tool calls. pub(super) fn handle_active_project( cg: &TraceDecay, + args: &Value, server_stats: Option, scope_prefix: Option<&str>, ) -> ToolResult { let branch = cg.branch_diagnostics(); let output = active_project_context(cg, &branch, server_stats, scope_prefix); - let formatted = serde_json::to_string(&output).unwrap_or_default(); + let text = render::finalize(Some(cg.project_root()), args, &output, || { + render::generic_md(&output) + }); ToolResult::new( json!({ - "content": [{ "type": "text", "text": project_response_text(cg, &formatted) }] + "content": [{ "type": "text", "text": text }] }), vec![], ) @@ -633,7 +632,7 @@ pub(super) async fn handle_files( Ok(ToolResult::new( json!({ - "content": [{ "type": "text", "text": truncate_response(&output) }] + "content": [{ "type": "text", "text": render::truncate_text_with_handle(Some(cg.project_root()), &output) }] }), touched_files, )) @@ -1536,7 +1535,7 @@ pub(super) async fn handle_type_hierarchy(cg: &TraceDecay, args: Value) -> Resul let touched_files = unique_file_paths(all_files.iter().map(std::string::String::as_str)); Ok(ToolResult::new( - json!({"content": [{"type": "text", "text": truncate_response(&output)}]}), + json!({"content": [{"type": "text", "text": render::truncate_text_with_handle(Some(cg.project_root()), &output)}]}), touched_files, )) } diff --git a/src/mcp/tools/handlers/memory.rs b/src/mcp/tools/handlers/memory.rs index 98545ad72..da04ee781 100644 --- a/src/mcp/tools/handlers/memory.rs +++ b/src/mcp/tools/handlers/memory.rs @@ -17,7 +17,7 @@ use crate::memory::types::{ }; use crate::tracedecay::TraceDecay; -use super::super::render::{self, truncated_json_envelope_with_handle}; +use super::super::render; use super::super::ToolResult; use super::support::{ profile_root_for_global_db, project_registry_context, project_selector_present, @@ -39,12 +39,6 @@ fn text_tool_result(text: &str) -> ToolResult { ) } -fn tool_json(project_root: Option<&Path>, value: &Value) -> ToolResult { - let formatted = serde_json::to_string(value).unwrap_or_default(); - let text = truncated_json_envelope_with_handle(project_root, &formatted); - text_tool_result(&text) -} - fn rendered_tool_json(project_root: Option<&Path>, args: &Value, value: &Value) -> ToolResult { let text = render::finalize(project_root, args, value, || render::generic_md(value)); text_tool_result(&text) @@ -493,7 +487,11 @@ pub(super) async fn handle_fact_store( if refresh_digest { refresh_memory_digest_after_memory_change(conn, &target_memory.project_root).await; } - Ok(tool_json(Some(&target_memory.project_root), &out)) + Ok(rendered_tool_json( + Some(&target_memory.project_root), + &args, + &out, + )) } pub(super) async fn handle_fact_feedback(cg: &TraceDecay, args: Value) -> Result { @@ -515,8 +513,9 @@ pub(super) async fn handle_fact_feedback(cg: &TraceDecay, args: Value) -> Result }) .await?; refresh_memory_digest_after_memory_change(db.conn(), cg.project_root()).await; - Ok(tool_json( + Ok(rendered_tool_json( Some(cg.project_root()), + &args, &json!({ "status": "recorded", "feedback": result }), )) } diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index a8ddeaeb9..bcaedf035 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -32,6 +32,7 @@ use crate::tracedecay::TraceDecay; use super::dispatch_policy::{ tool_accepts_registered_project_selector, tool_dispatches_registered_project_reader, }; +use super::render; use super::ToolResult; use support::{profile_root_for_global_db, project_registry_context, project_selector_present}; @@ -110,14 +111,36 @@ fn handle_retrieve(cg: &TraceDecay, args: &Value) -> Result { .to_string(), })?; let payload = match retrieve_response_handle(cg.project_root(), handle, current_timestamp())? { - ResponseHandleLookup::Found(record) => json!({ - "handle": record.handle, - "expired": false, - "original_chars": record.original_chars(), - "created_at": record.created_at, - "expires_at": record.expires_at, - "content": record.content, - }), + ResponseHandleLookup::Found(record) => { + // Retrieval never truncates: the stored content is by definition + // larger than the response cap, so neither output path may route + // through the truncating envelope again. Markdown (default) + // returns the stored text verbatim under a small header; JSON + // serializes the payload directly. + let text = if render::wants_json(args) { + serde_json::to_string(&json!({ + "handle": record.handle, + "expired": false, + "original_chars": record.original_chars(), + "created_at": record.created_at, + "expires_at": record.expires_at, + "content": record.content, + })) + .unwrap_or_default() + } else { + format!( + "## Retrieved Response\n**handle:** `{}` ({} chars, expires at {})\n\n{}", + record.handle, + record.original_chars(), + record.expires_at, + record.content, + ) + }; + return Ok(ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), + Vec::new(), + )); + } ResponseHandleLookup::Missing => json!({ "handle": handle, "expired": true, @@ -144,9 +167,11 @@ fn handle_retrieve(cg: &TraceDecay, args: &Value) -> Result { "expires_at": expires_at, }), }; - let formatted = serde_json::to_string(&payload).unwrap_or_default(); + let text = render::finalize(Some(cg.project_root()), args, &payload, || { + render::generic_md(&payload) + }); Ok(ToolResult::new( - json!({ "content": [{ "type": "text", "text": formatted }] }), + json!({ "content": [{ "type": "text", "text": text }] }), Vec::new(), )) } @@ -257,9 +282,12 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( "tracedecay_impact" => graph::handle_impact(cg, args).await, "tracedecay_node" => graph::handle_node(cg, args).await, "tracedecay_status" => info::handle_status(cg, args, server_stats, scope_prefix).await, - "tracedecay_active_project" => { - Ok(info::handle_active_project(cg, server_stats, scope_prefix)) - } + "tracedecay_active_project" => Ok(info::handle_active_project( + cg, + &args, + server_stats, + scope_prefix, + )), "tracedecay_storage_status" => info::handle_storage_status(cg, args, scope_prefix).await, "tracedecay_project_list" => { info::handle_project_list( @@ -319,7 +347,7 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( "tracedecay_type_hierarchy" => info::handle_type_hierarchy(cg, args).await, "tracedecay_branch_search" => git::handle_branch_search(cg, args).await, "tracedecay_branch_diff" => git::handle_branch_diff(cg, args).await, - "tracedecay_branch_list" => Ok(git::handle_branch_list(cg)), + "tracedecay_branch_list" => Ok(git::handle_branch_list(cg, &args)), "tracedecay_str_replace" => edit::handle_str_replace(cg, args).await, "tracedecay_multi_str_replace" => edit::handle_multi_str_replace(cg, args).await, "tracedecay_insert_at" => edit::handle_insert_at(cg, args).await, @@ -956,7 +984,8 @@ mod tests { "tracedecay_retrieve", json!({ "handle": handle, - "project_id": target.store_layout().identity.project_id.as_deref().unwrap() + "project_id": target.store_layout().identity.project_id.as_deref().unwrap(), + "format": "json" }), None, None, diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index b66ffdd51..d7db06b26 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -1,13 +1,17 @@ use std::collections::HashSet; +use std::fmt::Write as _; use std::path::{Component, Path, PathBuf}; use std::sync::{LazyLock, Mutex}; use serde_json::{json, Map, Value}; -use super::super::render::truncated_json_envelope_with_handle; +use super::super::render::{self, truncated_json_envelope_with_handle, Md}; use super::support::{profile_root_for_global_db, project_registry_context, safe_profile_relpath}; use crate::errors::{Result, TraceDecayError}; use crate::global_db::{GlobalDb, ProjectRegistryContext}; +use crate::mcp::response_handles::{ + observe_response_truncation, store_response_handle, RESPONSE_RETRIEVE_TOOL, +}; use crate::mcp::tools::{ToolResult, MAX_RESPONSE_CHARS}; use crate::sessions::cursor::HermesProfileDbReadOnly; use crate::sessions::lcm::compression_decision::{self, AssemblyCapInput}; @@ -18,7 +22,7 @@ use crate::sessions::lcm::{ LcmSummarizerMode, LCM_EXPAND_QUERY_SYNTHESIS_SYSTEM_PROMPT, }; use crate::sessions::{ProviderScope, SessionSearchScope}; -use crate::tracedecay::TraceDecay; +use crate::tracedecay::{current_timestamp, TraceDecay}; const DEFAULT_LCM_CONTENT_LIMIT: usize = 4096; const DEFAULT_LCM_EXPAND_QUERY_CONTEXT_LIMIT: usize = 32_000; @@ -31,19 +35,160 @@ const MAX_LCM_EXPAND_QUERY_QUERY_CHARS: usize = 1_024; const MAX_LCM_EXPAND_QUERY_SYNTHESIS_SYSTEM_CHARS: usize = 1_024; const MAX_LCM_EXPAND_QUERY_SYNTHESIS_PROMPT_CHARS: usize = 2_048; -fn tool_json(project_root: Option<&Path>, value: &Value) -> ToolResult { - let formatted = serde_json::to_string(value).unwrap_or_default(); - let text = if formatted.len() <= MAX_RESPONSE_CHARS { - formatted - } else { - truncated_json_envelope_with_handle(project_root, &formatted) - }; +fn tool_json(project_root: Option<&Path>, args: &Value, value: &Value) -> ToolResult { + tool_json_with_md(project_root, args, value, || render::generic_md(value)) +} + +/// Like [`tool_json`] but renders the markdown (default-format) body with a +/// caller-supplied closure instead of the generic key/value renderer. The +/// `format:"json"` path is unaffected — it always serializes `value` compactly. +fn tool_json_with_md String>( + project_root: Option<&Path>, + args: &Value, + value: &Value, + md: F, +) -> ToolResult { + let text = render::finalize(project_root, args, value, md); ToolResult::new( json!({ "content": [{ "type": "text", "text": text }] }), Vec::new(), ) } +const MESSAGE_SEARCH_SNIPPET_CHARS: usize = 240; + +/// Renders `tracedecay_message_search` results as compact markdown. Each hit +/// shows provider, session (id + title), role, timestamp, and score with a +/// plain-text snippet of the message body — deliberately dropping the raw +/// `metadata_json`, `source_path`, and `transcript_path` blobs that the generic +/// renderer would dump verbatim into table cells. Pass `format:"json"` to get +/// the full structured records. +fn render_message_search_md(value: &Value) -> String { + let mut md = Md::new(); + md.heading(2, "Transcript Search"); + for key in ["query", "provider", "scope"] { + let field = render::field_str(value, key); + if !field.is_empty() { + md.field(key, field); + } + } + md.field("count", &render::field_i64(value, "count").to_string()); + let results = value.get("results").and_then(Value::as_array); + match results { + Some(results) if !results.is_empty() => { + md.blank(); + for hit in results { + append_message_search_hit(&mut md, hit); + } + } + _ => { + md.blank().empty_note("No matching messages."); + } + } + md.render() +} + +fn append_message_search_hit(md: &mut Md, hit: &Value) { + let session = hit.get("session"); + let message = hit.get("message"); + let provider = message + .and_then(|m| m.get("provider")) + .or_else(|| session.and_then(|s| s.get("provider"))) + .and_then(Value::as_str) + .unwrap_or(""); + let role = message + .and_then(|m| m.get("role")) + .and_then(Value::as_str) + .unwrap_or(""); + let score = hit.get("score").and_then(Value::as_f64).unwrap_or(0.0); + let session_id = session + .and_then(|s| s.get("session_id")) + .and_then(Value::as_str) + .unwrap_or(""); + let title = session + .and_then(|s| s.get("title")) + .and_then(Value::as_str) + .filter(|title| !title.is_empty()); + let timestamp = message + .and_then(|m| m.get("timestamp")) + .and_then(Value::as_i64); + + let mut header = format!("**{role}** · {provider} · score {score:.1}"); + if let Some(ts) = timestamp { + let _ = write!(header, " · t={ts}"); + } + md.bullet(&header); + let mut locator = format!("session `{session_id}`"); + if let Some(title) = title { + let _ = write!(locator, " — {title}"); + } + md.line(&format!(" {locator}")); + let text = message + .and_then(|m| m.get("text")) + .and_then(Value::as_str) + .unwrap_or(""); + let snippet = message_text_snippet(text, MESSAGE_SEARCH_SNIPPET_CHARS); + if !snippet.is_empty() { + md.line(&format!(" {snippet}")); + } +} + +/// Best-effort single-line plain-text snippet from a stored message body. +/// Message text is frequently itself JSON (`tool_use` / `tool_result` blocks), so +/// pull the human-readable fields out rather than showing an escaped blob. +fn message_text_snippet(text: &str, max_chars: usize) -> String { + let readable = readable_message_text(text, max_chars.saturating_mul(8)); + let collapsed = readable.split_whitespace().collect::>().join(" "); + let (snippet, truncated) = truncate_chars(&collapsed, max_chars); + if truncated { + format!("{snippet}…") + } else { + snippet + } +} + +fn readable_message_text(text: &str, budget: usize) -> String { + let trimmed = text.trim_start(); + if trimmed.starts_with('[') || trimmed.starts_with('{') { + if let Ok(value) = serde_json::from_str::(text) { + let mut out = String::new(); + collect_readable_text(&value, &mut out, budget); + if !out.trim().is_empty() { + return out; + } + } + } + text.to_string() +} + +fn collect_readable_text(value: &Value, out: &mut String, budget: usize) { + if out.len() >= budget { + return; + } + match value { + Value::String(s) if !s.is_empty() => { + if !out.is_empty() { + out.push(' '); + } + out.push_str(s); + } + Value::Array(arr) => { + for item in arr { + collect_readable_text(item, out, budget); + } + } + Value::Object(map) => { + // Prefer human-facing fields; ignore ids, kinds, and metadata blobs. + for key in ["text", "content", "thinking", "input"] { + if let Some(field) = map.get(key) { + collect_readable_text(field, out, budget); + } + } + } + _ => {} + } +} + #[derive(Clone, Copy)] pub(super) struct LcmHandlerContext<'a> { project_root: Option<&'a Path>, @@ -124,14 +269,22 @@ fn registry_session_db_candidates( Ok(candidates) } -fn lcm_preflight_tool_json(value: &Value) -> ToolResult { +fn lcm_preflight_tool_json(project_root: Option<&Path>, args: &Value, value: &Value) -> ToolResult { + if !render::wants_json(args) { + // Markdown default: route through the normal renderer so an oversized + // preflight payload is truncated *with* a retrieval handle. Passing the + // project root is what lets `truncated_markdown_with_handle` store the + // full body — without it the truncation would be irreversible. + return tool_json(project_root, args, value); + } let formatted = serde_json::to_string(value).unwrap_or_default(); let text = if formatted.len() <= MAX_RESPONSE_CHARS { formatted } else { + let started = std::time::Instant::now(); let compact = compact_lcm_preflight_payload(value, formatted.len(), 8, 512); let compact_text = serde_json::to_string(&compact).unwrap_or_default(); - if compact_text.len() <= MAX_RESPONSE_CHARS { + let text = if compact_text.len() <= MAX_RESPONSE_CHARS { compact_text } else { let minimal = compact_lcm_preflight_payload(value, formatted.len(), 4, 256); @@ -142,7 +295,19 @@ fn lcm_preflight_tool_json(value: &Value) -> ToolResult { let floor = compact_lcm_preflight_payload(value, formatted.len(), 1, 64); bounded_lcm_contract_text(&floor) } - } + }; + // Contract-preserving compaction drops data without storing a handle, + // so record it as an irreversible truncation for telemetry parity with + // the render-layer truncation paths. + observe_response_truncation( + formatted.len(), + text.len(), + false, + current_timestamp(), + "compacted_no_handle", + started.elapsed(), + ); + text }; ToolResult::new( json!({ "content": [{ "type": "text", "text": text }] }), @@ -273,7 +438,14 @@ fn lcm_response_handle_root(project_root: Option<&Path>, args: &Value) -> Option None } -fn lcm_expand_query_tool_json(project_root: Option<&Path>, value: &Value) -> ToolResult { +fn lcm_expand_query_tool_json( + project_root: Option<&Path>, + args: &Value, + value: &Value, +) -> ToolResult { + if !render::wants_json(args) { + return tool_json(project_root, args, value); + } let formatted = serde_json::to_string(value).unwrap_or_default(); let needs_synthesis = value .get("needs_synthesis") @@ -282,25 +454,49 @@ fn lcm_expand_query_tool_json(project_root: Option<&Path>, value: &Value) -> Too let text = if formatted.len() <= MAX_RESPONSE_CHARS { formatted } else if needs_synthesis { + let started = std::time::Instant::now(); let compact = compact_lcm_expand_query_payload(value, formatted.len(), CompactTier::Standard); - let text = serde_json::to_string(&compact).unwrap_or_default(); - if text.len() <= MAX_RESPONSE_CHARS { - text + let compact_text = serde_json::to_string(&compact).unwrap_or_default(); + let (text, handle_status) = if compact_text.len() <= MAX_RESPONSE_CHARS { + (compact_text, "compacted_no_handle") } else { let fallback = compact_lcm_expand_query_payload( value, formatted.len(), CompactTier::Minimal { - compact_chars: text.len(), + compact_chars: compact_text.len(), }, ); - serde_json::to_string(&fallback).unwrap_or_default() - } + let fallback_text = serde_json::to_string(&fallback).unwrap_or_default(); + if fallback_text.len() <= MAX_RESPONSE_CHARS { + (fallback_text, "compacted_no_handle") + } else { + // Even the Minimal tier overflowed (e.g. oversized cloned + // pagination or match metadata). Enforce a hard floor that + // stays valid JSON and keeps the Hermes synthesis contract + // keys, storing the full payload behind a handle when we can. + bounded_lcm_expand_query_floor_text(project_root, value, &formatted) + } + }; + // The synthesis contract path shrinks the payload in place instead of + // going through the render-layer envelope, so record the truncation + // explicitly. It is reversible only when the floor stored a handle. + observe_response_truncation( + formatted.len(), + text.len(), + handle_status == "stored", + current_timestamp(), + handle_status, + started.elapsed(), + ); + text } else { truncated_json_envelope_with_handle(project_root, &formatted) }; - let text = if text.len() <= MAX_RESPONSE_CHARS || needs_synthesis { + // Safety net: every branch above is already bounded (the floor guarantees + // it for needs_synthesis), but never emit an unbounded body regardless. + let text = if text.len() <= MAX_RESPONSE_CHARS { text } else { truncated_json_envelope_with_handle(project_root, &text) @@ -311,6 +507,141 @@ fn lcm_expand_query_tool_json(project_root: Option<&Path>, value: &Value) -> Too ) } +/// Hard floor for a `needs_synthesis` expand-query payload that is still over +/// [`MAX_RESPONSE_CHARS`] after [`CompactTier::Minimal`] compaction. Emits a +/// bounded JSON object that preserves the Hermes bridge synthesis contract +/// (`status`, `needs_synthesis`, `synthesis_prompt`, bounded scalars) while +/// dropping the unbounded arrays (`context_blocks`, `matches`, `node_ids`, +/// `context_pagination`). When a project root is available the full original +/// payload is stored behind a retrieval handle so nothing is lost; the handle +/// is surfaced as `response_handle` (a key the Hermes plugin recognizes). +/// +/// Returns the serialized text plus the telemetry handle status +/// (`"stored"` when the full payload was cached, `"compacted_no_handle"` +/// otherwise). +fn bounded_lcm_expand_query_floor_text( + project_root: Option<&Path>, + value: &Value, + formatted: &str, +) -> (String, &'static str) { + const FLOOR_SCALAR_CHARS: usize = 512; + const FLOOR_AUX_JSON_CHARS: usize = 2_048; + + let handle = project_root + .and_then(|root| store_response_handle(root, formatted, current_timestamp()).ok()); + let handle_status: &'static str = if handle.is_some() { + "stored" + } else { + "compacted_no_handle" + }; + + let mut object = Map::new(); + for key in [ + "status", + "provider", + "session_id", + "storage_scope", + "answer", + ] { + insert_bounded_scalar_field(&mut object, value, key, FLOOR_SCALAR_CHARS); + } + for key in [ + "needs_synthesis", + "max_tokens", + "context_max_tokens", + "context_budget", + "context_truncated", + ] { + if let Some(field) = value.get(key) { + object.insert(key.to_string(), field.clone()); + } + } + insert_bounded_text_field(&mut object, value, "prompt", FLOOR_SCALAR_CHARS); + insert_bounded_text_field(&mut object, value, "query", FLOOR_SCALAR_CHARS); + // Contract-adjacent recovery metadata survives only when it is itself + // small; anything larger is recoverable via the response handle. + for key in ["context_recovery_hint", "summary_request"] { + if let Some(field) = value.get(key) { + let serialized_len = serde_json::to_string(field).map_or(usize::MAX, |s| s.len()); + if serialized_len <= FLOOR_AUX_JSON_CHARS { + object.insert(key.to_string(), field.clone()); + } + } + } + + // Drop the unbounded arrays entirely; the synthesis prompt below tells the + // bridge the context was elided and pagination/node ids are recoverable. + for key in [ + "context_blocks", + "matches", + "node_ids", + "context_pagination", + ] { + object.insert(key.to_string(), json!([])); + object.insert(format!("{key}_truncated_for_mcp"), json!(true)); + } + object.insert( + "synthesis_prompt".to_string(), + compact_synthesis_prompt_with_limits( + value, + &json!([]), + FLOOR_SCALAR_CHARS, + FLOOR_SCALAR_CHARS, + ), + ); + + object.insert("mcp_response_truncated".to_string(), json!(true)); + object.insert("contract_truncated".to_string(), json!(true)); + object.insert( + "mcp_original_response_chars".to_string(), + json!(formatted.len()), + ); + object.insert( + "mcp_truncation_reason".to_string(), + json!( + "expand-query response exceeded the minimal synthesis contract budget; unbounded context arrays were dropped" + ), + ); + if let Some(record) = &handle { + object.insert("response_handle".to_string(), json!(record.handle)); + object.insert("retrieve_tool".to_string(), json!(RESPONSE_RETRIEVE_TOOL)); + object.insert("retrieve_expires_at".to_string(), json!(record.expires_at)); + object.insert( + "retrieve_instruction".to_string(), + json!(format!( + "The full expand-query response ({} chars) was stored locally and expires at {}. Call `{RESPONSE_RETRIEVE_TOOL}` with handle `{}` to recover the dropped context_blocks, matches, node_ids, and context_pagination.", + formatted.len(), + record.expires_at, + record.handle + )), + ); + } + + let text = serde_json::to_string(&Value::Object(object)).unwrap_or_default(); + if text.len() <= MAX_RESPONSE_CHARS { + return (text, handle_status); + } + // Absolute floor: every retained field above is bounded, so this branch is + // effectively unreachable, but never emit an unbounded body. + ( + serde_json::to_string(&json!({ + "status": value.get("status").cloned().unwrap_or_else(|| json!("ok")), + "needs_synthesis": value + .get("needs_synthesis") + .cloned() + .unwrap_or(json!(true)), + "context_blocks": [], + "matches": [], + "mcp_response_truncated": true, + "contract_truncated": true, + "mcp_truncation_reason": + "expand-query response exceeded the minimum synthesis contract budget", + })) + .unwrap_or_default(), + handle_status, + ) +} + #[derive(Copy, Clone)] enum CompactTier { Standard, @@ -989,9 +1320,10 @@ fn lcm_error(err: crate::sessions::lcm::LcmError) -> TraceDecayError { } } -fn lcm_unavailable() -> ToolResult { +fn lcm_unavailable(args: &Value) -> ToolResult { tool_json( None, + args, &json!({ "status": "unavailable", "message": "could not open active project tracedecay session database", @@ -1004,9 +1336,10 @@ fn lcm_unavailable() -> ToolResult { /// so callers can tell "no data yet" apart from "open failed". /// The `store_exists: false` field is the machine-readable discriminator; /// other fields are backward-compatible additions. -fn lcm_not_yet_ingested(storage_scope: &str) -> ToolResult { +fn lcm_not_yet_ingested(args: &Value, storage_scope: &str) -> ToolResult { tool_json( None, + args, &json!({ "status": "not_ingested", "store_exists": false, @@ -1016,9 +1349,14 @@ fn lcm_not_yet_ingested(storage_scope: &str) -> ToolResult { ) } -fn lcm_scoped_unavailable(storage_scope: &str, message: impl Into) -> ToolResult { +fn lcm_scoped_unavailable( + args: &Value, + storage_scope: &str, + message: impl Into, +) -> ToolResult { tool_json( None, + args, &json!({ "status": "unavailable", "storage_scope": storage_scope, @@ -1027,8 +1365,9 @@ fn lcm_scoped_unavailable(storage_scope: &str, message: impl Into) -> To ) } -fn lcm_storage_scope_unavailable(storage_scope: &str) -> ToolResult { +fn lcm_storage_scope_unavailable(args: &Value, storage_scope: &str) -> ToolResult { lcm_scoped_unavailable( + args, storage_scope, format!( "{storage_scope} LCM status storage is not available from the active project handler" @@ -1036,8 +1375,9 @@ fn lcm_storage_scope_unavailable(storage_scope: &str) -> ToolResult { ) } -fn project_local_storage_without_project() -> ToolResult { +fn project_local_storage_without_project(args: &Value) -> ToolResult { lcm_scoped_unavailable( + args, "project_local", "project_local LCM storage requires an initialized TraceDecay project root", ) @@ -1103,19 +1443,21 @@ enum LcmStorageResolution { Unavailable(ToolResult), } -fn invalid_hermes_profile_home(message: impl Into) -> ToolResult { - lcm_scoped_unavailable("hermes_profile", message) +fn invalid_hermes_profile_home(args: &Value, message: impl Into) -> ToolResult { + lcm_scoped_unavailable(args, "hermes_profile", message) } fn hermes_profile_home(args: &Value) -> std::result::Result { let Some(hermes_home) = string_arg(args, "hermes_home") else { return Err(invalid_hermes_profile_home( + args, "hermes_profile LCM storage requires an explicit absolute hermes_home", )); }; let path = PathBuf::from(hermes_home); if !path.is_absolute() { return Err(invalid_hermes_profile_home( + args, "hermes_profile LCM storage requires an absolute hermes_home", )); } @@ -1124,20 +1466,27 @@ fn hermes_profile_home(args: &Value) -> std::result::Result .any(|component| matches!(component, Component::CurDir | Component::ParentDir)) { return Err(invalid_hermes_profile_home( + args, "hermes_profile LCM storage requires a normalized absolute hermes_home", )); } let Ok(canonical) = std::fs::canonicalize(&path) else { - return Err(invalid_hermes_profile_home(format!( - "hermes_home does not exist or is not a directory: {}", - path.display() - ))); + return Err(invalid_hermes_profile_home( + args, + format!( + "hermes_home does not exist or is not a directory: {}", + path.display() + ), + )); }; if !canonical.is_dir() { - return Err(invalid_hermes_profile_home(format!( - "hermes_home does not exist or is not a directory: {}", - path.display() - ))); + return Err(invalid_hermes_profile_home( + args, + format!( + "hermes_home does not exist or is not a directory: {}", + path.display() + ), + )); } Ok(canonical) } @@ -1194,17 +1543,24 @@ async fn open_lcm_storage( match storage_scope { "project_local" => { if context.project_root.is_none() { - return LcmStorageResolution::Unavailable(project_local_storage_without_project()); + return LcmStorageResolution::Unavailable(project_local_storage_without_project( + args, + )); } let Some(db_path) = context.project_session_db_path else { - return LcmStorageResolution::Unavailable(project_local_storage_without_project()); + return LcmStorageResolution::Unavailable(project_local_storage_without_project( + args, + )); }; let db_path = db_path.to_path_buf(); if mode == LcmOpenMode::ReadOnlyOrMissing && !db_path.is_file() { - return LcmStorageResolution::Unavailable(lcm_not_yet_ingested("project_local")); + return LcmStorageResolution::Unavailable(lcm_not_yet_ingested( + args, + "project_local", + )); } let Some(db) = open_lcm_db_at(&db_path, mode).await else { - return LcmStorageResolution::Unavailable(lcm_unavailable()); + return LcmStorageResolution::Unavailable(lcm_unavailable(args)); }; available_lcm_storage(db, "project_local") } @@ -1221,7 +1577,7 @@ async fn open_lcm_storage( Ok(db_path) => db_path, Err(message) => { return LcmStorageResolution::Unavailable(invalid_hermes_profile_home( - message, + args, message, )); } } @@ -1234,9 +1590,9 @@ async fn open_lcm_storage( HermesProfileDbReadOnly::NotIngested(db_path) => { return LcmStorageResolution::Unavailable(match mode { LcmOpenMode::ReadOnlyOrMissing => { - lcm_not_yet_ingested("hermes_profile") + lcm_not_yet_ingested(args, "hermes_profile") } - _ => invalid_hermes_profile_home(format!( + _ => invalid_hermes_profile_home(args, format!( "hermes_profile LCM storage requires an existing session database: {}", db_path.display() )), @@ -1244,7 +1600,7 @@ async fn open_lcm_storage( } HermesProfileDbReadOnly::ConfigError(msg) => { return LcmStorageResolution::Unavailable(invalid_hermes_profile_home( - msg, + args, msg, )); } } @@ -1252,12 +1608,13 @@ async fn open_lcm_storage( }; let Some(db) = open_lcm_db_at(&db_path, mode).await else { return LcmStorageResolution::Unavailable(invalid_hermes_profile_home( + args, "could not open hermes_profile tracedecay session database", )); }; available_lcm_storage(db, "hermes_profile") } - other => LcmStorageResolution::Unavailable(lcm_storage_scope_unavailable(other)), + other => LcmStorageResolution::Unavailable(lcm_storage_scope_unavailable(args, other)), } } @@ -1434,6 +1791,7 @@ pub(super) async fn handle_message_search( else { return Ok(tool_json( Some(cg.project_root()), + &args, &json!({ "status": "unavailable", "message": "could not resolve selected project tracedecay session database", @@ -1445,6 +1803,7 @@ pub(super) async fn handle_message_search( let Some(db) = open_session_db_with_cached_ensure(&db_path).await else { return Ok(tool_json( Some(cg.project_root()), + &args, &json!({ "status": "unavailable", "message": "could not open selected project tracedecay session database", @@ -1483,28 +1842,31 @@ pub(super) async fn handle_message_search( .await }; - Ok(tool_json( + let payload = json!({ + "status": "ok", + "provider": requested_provider.unwrap_or("all"), + "requested_provider": requested_provider, + "selected_project_root": target_root, + "project_key": project_key, + "parent_session_id": parent_session_id, + "include_subagents": include_subagents, + "catch_up": catch_up, + "catch_up_performed": catch_up_performed, + "catch_up_provider": provider_scope.response_label(), + "scope": match scope { + SessionSearchScope::All => "all", + SessionSearchScope::ParentsOnly => "parents_only", + SessionSearchScope::SubagentsOnly => "subagents_only", + }, + "query": query, + "count": results.len(), + "results": results, + }); + Ok(tool_json_with_md( Some(&target_root), - &json!({ - "status": "ok", - "provider": requested_provider.unwrap_or("all"), - "requested_provider": requested_provider, - "selected_project_root": target_root, - "project_key": project_key, - "parent_session_id": parent_session_id, - "include_subagents": include_subagents, - "catch_up": catch_up, - "catch_up_performed": catch_up_performed, - "catch_up_provider": provider_scope.response_label(), - "scope": match scope { - SessionSearchScope::All => "all", - SessionSearchScope::ParentsOnly => "parents_only", - SessionSearchScope::SubagentsOnly => "subagents_only", - }, - "query": query, - "count": results.len(), - "results": results, - }), + &args, + &payload, + || render_message_search_md(&payload), )) } @@ -1525,6 +1887,7 @@ pub(super) async fn handle_lcm_status( status.storage_scope = Some(storage.scope.to_string()); Ok(tool_json( context.project_root, + &args, &json!({ "status": "ok", "provider": provider, @@ -1548,6 +1911,7 @@ pub(super) async fn handle_lcm_doctor( if mode == "clean" && apply && !clean_apply_enabled { return Ok(tool_json( context.project_root, + &args, &json!({ "status": "denied", "provider": provider, @@ -1575,6 +1939,7 @@ pub(super) async fn handle_lcm_doctor( if mode == "gc" && apply && !gc_apply_enabled { return Ok(tool_json( context.project_root, + &args, &json!({ "status": "denied", "provider": provider, @@ -1627,7 +1992,7 @@ pub(super) async fn handle_lcm_doctor( ); } } - Ok(tool_json(context.project_root, &payload)) + Ok(tool_json(context.project_root, &args, &payload)) } pub(super) async fn handle_lcm_load_session( @@ -1676,7 +2041,7 @@ pub(super) async fn handle_lcm_load_session( ); } } - Ok(tool_json(context.project_root, &payload)) + Ok(tool_json(context.project_root, &args, &payload)) } pub(super) async fn handle_lcm_grep( @@ -1711,6 +2076,7 @@ pub(super) async fn handle_lcm_grep( .map_err(lcm_error)?; Ok(tool_json( context.project_root, + &args, &json!({ "status": "ok", "provider": provider, @@ -1743,6 +2109,7 @@ pub(super) async fn handle_lcm_describe( .map_err(lcm_error)?; Ok(tool_json( context.project_root, + &args, &json!({ "status": "ok", "provider": provider, @@ -1774,6 +2141,7 @@ pub(super) async fn handle_lcm_expand( .map_err(lcm_error)?; Ok(tool_json( context.project_root, + &args, &json!({ "status": "ok", "provider": provider, @@ -1833,7 +2201,11 @@ pub(super) async fn handle_lcm_expand_query( object.insert("session_id".to_string(), json!(session_id)); object.insert("storage_scope".to_string(), json!(storage.scope)); } - Ok(lcm_expand_query_tool_json(context.project_root, &payload)) + Ok(lcm_expand_query_tool_json( + context.project_root, + &args, + &payload, + )) } pub(super) async fn handle_lcm_session_boundary( @@ -1857,6 +2229,7 @@ pub(super) async fn handle_lcm_session_boundary( .map_err(lcm_error)?; Ok(tool_json( context.project_root, + &args, &json!({ "status": response.status, "provider": provider, @@ -1898,14 +2271,18 @@ pub(super) async fn handle_lcm_preflight( }) .await .map_err(lcm_error)?; - Ok(lcm_preflight_tool_json(&json!({ - "status": response.status, - "provider": provider, - "session_id": session_id, - "should_compress": response.should_compress, - "reason": response.reason, - "replay_messages": response.replay_messages, - }))) + Ok(lcm_preflight_tool_json( + context.project_root, + &args, + &json!({ + "status": response.status, + "provider": provider, + "session_id": session_id, + "should_compress": response.should_compress, + "reason": response.reason, + "replay_messages": response.replay_messages, + }), + )) } pub(super) async fn handle_lcm_compress( @@ -1948,6 +2325,7 @@ pub(super) async fn handle_lcm_compress( .map_err(lcm_error)?; Ok(tool_json( response_handle_root.as_deref(), + &args, &json!({ "status": response.status, "provider": provider, @@ -1967,3 +2345,305 @@ pub(super) async fn handle_lcm_compress( }), )) } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + fn sample_message_search_payload() -> Value { + json!({ + "status": "ok", + "provider": "all", + "query": "database backup", + "scope": "all", + "count": 1, + "results": [{ + "score": 18.42, + "session": { + "provider": "claude", + "session_id": "sess-abc-123", + "title": "Investigate backup failure", + "transcript_path": "/home/zack/.claude/projects/x/sess-abc-123.jsonl", + "metadata_json": "{\"claude_session_cwd\":\"/home/zack/proj\",\"secret\":\"do-not-leak\"}", + "project_path": "/home/zack/proj", + }, + "message": { + "provider": "claude", + "session_id": "sess-abc-123", + "message_id": "msg-1", + "role": "assistant", + "timestamp": 1_783_117_588, + "text": "[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_x\",\"content\":\"the database backup completed successfully at 03:00 UTC\"}]", + "source_path": "/home/zack/.claude/projects/x/sess-abc-123.jsonl", + "source_offset": 1_676_581, + "metadata_json": "{\"raw_type\":\"assistant\"}", + }, + }], + }) + } + + #[test] + fn message_search_markdown_drops_raw_json_blobs() { + let payload = sample_message_search_payload(); + let md = render_message_search_md(&payload); + + // Human-facing fields are present. + assert!(md.contains("## Transcript Search"), "{md}"); + assert!(md.contains("**query:** database backup"), "{md}"); + assert!(md.contains("**assistant**"), "{md}"); + assert!(md.contains("session `sess-abc-123`"), "{md}"); + assert!(md.contains("Investigate backup failure"), "{md}"); + assert!(md.contains("score 18.4"), "{md}"); + assert!(md.contains("t=1783117588"), "{md}"); + // The readable content is surfaced without the surrounding JSON block. + assert!( + md.contains("the database backup completed successfully"), + "{md}" + ); + + // None of the raw record blobs leak into the default output. + for forbidden in [ + "metadata_json", + "transcript_path", + "source_path", + "source_offset", + "do-not-leak", + "tool_use_id", + "claude_session_cwd", + ] { + assert!( + !md.contains(forbidden), + "default markdown must not embed `{forbidden}`:\n{md}" + ); + } + // And it must not be a JSON document. + assert!(serde_json::from_str::(&md).is_err(), "{md}"); + } + + #[test] + fn message_search_markdown_handles_empty_results() { + let payload = json!({ + "status": "ok", + "query": "nothing matches", + "count": 0, + "results": [], + }); + let md = render_message_search_md(&payload); + assert!(md.contains("## Transcript Search"), "{md}"); + assert!(md.contains("**count:** 0"), "{md}"); + assert!(md.contains("No matching messages."), "{md}"); + } + + #[test] + fn message_text_snippet_extracts_readable_content_from_json() { + let text = + "[{\"type\":\"tool_result\",\"content\":\"hello world\",\"tool_use_id\":\"toolu_1\"}]"; + let snippet = message_text_snippet(text, 240); + assert_eq!(snippet, "hello world"); + assert!(!snippet.contains("tool_use_id")); + } + + #[test] + fn message_text_snippet_falls_back_to_raw_and_truncates() { + let text = "x".repeat(500); + let snippet = message_text_snippet(&text, 240); + assert!(snippet.ends_with('…')); + assert_eq!(snippet.chars().count(), 241); // 240 chars + ellipsis + } + + #[test] + fn message_text_snippet_plain_text_is_collapsed() { + let text = "line one\n\n line two\ttabbed"; + assert_eq!(message_text_snippet(text, 240), "line one line two tabbed"); + } + + #[test] + fn lcm_preflight_markdown_truncation_stores_retrieval_handle() { + // Regression: the markdown-default preflight path must thread the + // project root so an oversized payload truncates *with* a recoverable + // handle rather than an irreversible clip. + let dir = tempfile::TempDir::new().unwrap(); + // Oversize the payload the way a real preflight does — via a large + // replay_messages array (what the compaction tiers actually target). + let replay: Vec = (0..200) + .map(|i| json!({"role": "user", "content": format!("message {i} {}", "y".repeat(200))})) + .collect(); + let payload = json!({ + "status": "ok", + "provider": "claude", + "session_id": "s1", + "should_compress": false, + "reason": "no_compression_needed", + "replay_messages": replay, + }); + + // Markdown default (no `format` arg): must produce the readable + // truncation envelope with a stored handle. + let result = lcm_preflight_tool_json(Some(dir.path()), &json!({}), &payload); + let text = result.value["content"][0]["text"].as_str().unwrap(); + assert!(text.starts_with("# Truncated Response"), "{text}"); + assert!(text.contains("Full response stored locally"), "{text}"); + assert!(text.contains("tracedecay_retrieve"), "{text}"); + assert!( + serde_json::from_str::(text).is_err(), + "markdown truncation must not be a JSON envelope: {text}" + ); + + // `format:"json"` still yields the compact Hermes bridge contract. + let json_result = + lcm_preflight_tool_json(Some(dir.path()), &json!({"format": "json"}), &payload); + let json_text = json_result.value["content"][0]["text"].as_str().unwrap(); + let parsed: Value = serde_json::from_str(json_text).unwrap(); + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["should_compress"], false); + } + + /// Builds an expand-query payload that overflows even the `Minimal` + /// compaction tier: `Minimal` clones `context_pagination` items whole (up + /// to 10) and `matches` metadata fields verbatim, so oversized entries + /// there survive both compaction passes and force the bounded floor. + fn oversized_needs_synthesis_expand_query_payload() -> Value { + let context_blocks: Vec = (0..60) + .map(|i| { + json!({ + "kind": "raw_message", + "node_id": format!("node-{i}"), + "content": "c".repeat(2_000), + }) + }) + .collect(); + let matches: Vec = (0..40) + .map(|i| { + json!({ + "kind": "match", + "node_id": format!("match-{i}-{}", "m".repeat(1_500)), + "snippet": "s".repeat(600), + }) + }) + .collect(); + let context_pagination: Vec = (0..10) + .map(|i| json!({ "cursor": format!("{i}-{}", "p".repeat(4_000)) })) + .collect(); + json!({ + "status": "ok", + "provider": "claude", + "session_id": "s1", + "storage_scope": "project", + "needs_synthesis": true, + "prompt": "What changed in the auth flow?", + "context_blocks": context_blocks, + "matches": matches, + "node_ids": (0..30).map(|i| format!("n{i}")).collect::>(), + "context_pagination": context_pagination, + }) + } + + #[test] + fn lcm_expand_query_needs_synthesis_floor_is_bounded_valid_json() { + // Regression (S3): a needs_synthesis payload that is still over budget + // after Minimal compaction must NOT be emitted unbounded. The floor + // must stay within MAX_RESPONSE_CHARS, remain valid JSON, and keep the + // Hermes synthesis contract keys — with a retrieval handle when a + // project root is available. + let dir = tempfile::TempDir::new().unwrap(); + let payload = oversized_needs_synthesis_expand_query_payload(); + + // Sanity: this payload really does defeat both compaction tiers. + let minimal = compact_lcm_expand_query_payload( + &payload, + serde_json::to_string(&payload).unwrap().len(), + CompactTier::Minimal { compact_chars: 0 }, + ); + assert!( + serde_json::to_string(&minimal).unwrap().len() > MAX_RESPONSE_CHARS, + "test payload must overflow the Minimal tier to exercise the floor" + ); + + let result = + lcm_expand_query_tool_json(Some(dir.path()), &json!({"format": "json"}), &payload); + let text = result.value["content"][0]["text"].as_str().unwrap(); + assert!( + text.len() <= MAX_RESPONSE_CHARS, + "floor must bound the response: {} chars", + text.len() + ); + let parsed: Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed["needs_synthesis"], true); + assert_eq!(parsed["status"], "ok"); + assert_eq!(parsed["mcp_response_truncated"], true); + assert_eq!(parsed["contract_truncated"], true); + // The synthesis contract survives: the bridge can still synthesize. + assert!(parsed["synthesis_prompt"]["user"].as_str().is_some()); + assert!(parsed["synthesis_prompt"]["system"].as_str().is_some()); + // Unbounded arrays are dropped but flagged. + assert_eq!(parsed["context_blocks"], json!([])); + assert_eq!(parsed["context_blocks_truncated_for_mcp"], true); + assert_eq!(parsed["matches_truncated_for_mcp"], true); + // Nothing is lost: the full payload is stored behind a handle. + let handle = parsed["response_handle"].as_str().unwrap(); + assert!(handle.starts_with("rh_"), "{handle}"); + assert_eq!(parsed["retrieve_tool"], "tracedecay_retrieve"); + } + + #[test] + fn lcm_expand_query_needs_synthesis_floor_is_bounded_without_project_root() { + // Even when no project root is available (no handle storage), the + // floor must still emit bounded, contract-preserving JSON. + let payload = oversized_needs_synthesis_expand_query_payload(); + let result = lcm_expand_query_tool_json(None, &json!({"format": "json"}), &payload); + let text = result.value["content"][0]["text"].as_str().unwrap(); + assert!( + text.len() <= MAX_RESPONSE_CHARS, + "floor must bound the response: {} chars", + text.len() + ); + let parsed: Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed["needs_synthesis"], true); + assert_eq!(parsed["mcp_response_truncated"], true); + assert!(parsed.get("response_handle").is_none()); + } + + #[test] + fn lcm_expand_query_in_budget_and_synthesis_compaction_paths_unchanged() { + // In-budget payloads pass through verbatim. + let small = json!({ + "status": "ok", + "needs_synthesis": true, + "prompt": "q", + "context_blocks": [], + }); + let result = lcm_expand_query_tool_json(None, &json!({"format": "json"}), &small); + let text = result.value["content"][0]["text"].as_str().unwrap(); + assert_eq!( + serde_json::from_str::(text).unwrap(), + small, + "in-budget payload must be emitted verbatim" + ); + + // Oversized-but-compactable synthesis payloads still use the tiers + // (no floor markers, no handle keys). + let blocks: Vec = (0..40) + .map(|i| json!({"kind": "raw_message", "node_id": format!("n{i}"), "content": "c".repeat(1_000)})) + .collect(); + let compactable = json!({ + "status": "ok", + "needs_synthesis": true, + "prompt": "q", + "context_blocks": blocks, + }); + let result = lcm_expand_query_tool_json(None, &json!({"format": "json"}), &compactable); + let text = result.value["content"][0]["text"].as_str().unwrap(); + assert!(text.len() <= MAX_RESPONSE_CHARS); + let parsed: Value = serde_json::from_str(text).unwrap(); + assert_eq!(parsed["needs_synthesis"], true); + assert!( + parsed.get("response_handle").is_none(), + "tier compaction must not reach the handle-storing floor" + ); + assert!( + !parsed["context_blocks"].as_array().unwrap().is_empty(), + "tier compaction keeps bounded context blocks" + ); + } +} diff --git a/src/mcp/tools/handlers/skills.rs b/src/mcp/tools/handlers/skills.rs index e824bb993..404727024 100644 --- a/src/mcp/tools/handlers/skills.rs +++ b/src/mcp/tools/handlers/skills.rs @@ -20,6 +20,8 @@ use crate::errors::{Result, TraceDecayError}; use crate::mcp::tools::ToolResult; use crate::tracedecay::TraceDecay; +use super::super::render; + const SKILL_ANALYTICS_IMPORT_LIMIT: usize = 10_000; const STALE_SKILL_AFTER_SECS: i64 = 60 * 60 * 24 * 90; @@ -29,10 +31,12 @@ fn config_error(message: impl Into) -> TraceDecayError { } } -fn tool_json(value: &Value) -> ToolResult { - let formatted = serde_json::to_string_pretty(value).unwrap_or_default(); +fn tool_json(cg: &TraceDecay, args: &Value, value: &Value) -> ToolResult { + let text = render::finalize(Some(cg.project_root()), args, value, || { + render::generic_md(value) + }); ToolResult::new( - json!({ "content": [{ "type": "text", "text": formatted }] }), + json!({ "content": [{ "type": "text", "text": text }] }), vec![], ) } @@ -138,7 +142,7 @@ pub(super) async fn handle_skill_list(cg: &TraceDecay, args: Value) -> Result>(), }); - Ok(tool_json(&payload)) + Ok(tool_json(cg, &args, &payload)) } pub(super) async fn handle_skill_view(cg: &TraceDecay, args: Value) -> Result { @@ -199,7 +203,7 @@ pub(super) async fn handle_skill_view(cg: &TraceDecay, args: Value) -> Result Result<()> { @@ -243,7 +247,7 @@ async fn sync_project_skill_analytics(cg: &TraceDecay, profile_root: &Path) -> R .map(|_| ()) } -pub(super) fn handle_hermes_skill_bridge(_cg: &TraceDecay, args: &Value) -> Result { +pub(super) fn handle_hermes_skill_bridge(cg: &TraceDecay, args: &Value) -> Result { let hermes_home = required_str(args, "hermes_home")?; let snapshot = load_hermes_skill_bridge( Path::new(hermes_home), @@ -256,5 +260,5 @@ pub(super) fn handle_hermes_skill_bridge(_cg: &TraceDecay, args: &Value) -> Resu "status": "ok", "bridge": snapshot, }); - Ok(tool_json(&payload)) + Ok(tool_json(cg, args, &payload)) } diff --git a/src/mcp/tools/handlers/workflow.rs b/src/mcp/tools/handlers/workflow.rs index a9b85358e..3e94af7b3 100644 --- a/src/mcp/tools/handlers/workflow.rs +++ b/src/mcp/tools/handlers/workflow.rs @@ -227,21 +227,25 @@ pub(super) async fn handle_run_affected_tests(cg: &TraceDecay, args: Value) -> R let project_root = cg.project_root().to_path_buf(); // 1) Resolve changed paths — explicit list, or fall back to `git diff`. - let changed_paths = match resolve_changed_paths(&project_root, run_args.explicit_paths).await { - Ok(paths) => paths, - Err(result) => return Ok(result), - }; + let changed_paths = + match resolve_changed_paths(&args, &project_root, run_args.explicit_paths).await { + Ok(paths) => paths, + Err(result) => return Ok(result), + }; if changed_paths.is_empty() { - return Ok(empty_result("no changed files detected")); + return Ok(empty_result(&args, "no changed files detected")); } let test_targets = collect_affected_test_targets(cg, &changed_paths).await?; if test_targets.is_empty() { - return Ok(empty_result(&format!( - "no tests cover the changed paths ({} file(s))", - changed_paths.len() - ))); + return Ok(empty_result( + &args, + &format!( + "no tests cover the changed paths ({} file(s))", + changed_paths.len() + ), + )); } let (selected_targets, test_names, truncated) = @@ -255,6 +259,7 @@ pub(super) async fn handle_run_affected_tests(cg: &TraceDecay, args: Value) -> R Ok(Ok(o)) => o, Ok(Err(e)) => { return Ok(error_result( + &args, "cargo", "test", &format!("failed to spawn cargo test: {e}"), @@ -262,6 +267,7 @@ pub(super) async fn handle_run_affected_tests(cg: &TraceDecay, args: Value) -> R } Err(_) => { return Ok(error_result( + &args, "cargo", "test", &format!("cargo test timed out after {}s", run_args.timeout_secs), @@ -296,6 +302,7 @@ pub(super) async fn handle_run_affected_tests(cg: &TraceDecay, args: Value) -> R } async fn resolve_changed_paths( + args: &Value, project_root: &Path, explicit_paths: Option>, ) -> std::result::Result, ToolResult> { @@ -303,7 +310,7 @@ async fn resolve_changed_paths( Some(paths) => Ok(paths), None => git_changed_paths(project_root) .await - .map_err(|message| error_result("git", "diff", &message)), + .map_err(|message| error_result(args, "git", "diff", &message)), } } @@ -485,30 +492,34 @@ fn covered_source_ids(name: &str, selected_targets: &[TestTarget]) -> Vec ToolResult { +fn empty_result(args: &Value, message: &str) -> ToolResult { + let value = json!({ + "passed": 0, "failed": 0, "results": [], "note": message + }); + let text = render::finalize(None, args, &value, || render::generic_md(&value)); ToolResult::new( json!({ - "content": [{ "type": "text", "text": serde_json::to_string(&json!({ - "passed": 0, "failed": 0, "results": [], "note": message - })).unwrap_or_default() }] + "content": [{ "type": "text", "text": text }] }), vec![], ) } -fn error_result(kind: &str, operation: &str, message: &str) -> ToolResult { +fn error_result(args: &Value, kind: &str, operation: &str, message: &str) -> ToolResult { + let value = json!({ + "passed": 0, + "failed": 0, + "results": [], + "error": { + "kind": kind, + "operation": operation, + "message": message, + } + }); + let text = render::finalize(None, args, &value, || render::generic_md(&value)); ToolResult::new( json!({ - "content": [{ "type": "text", "text": serde_json::to_string(&json!({ - "passed": 0, - "failed": 0, - "results": [], - "error": { - "kind": kind, - "operation": operation, - "message": message, - } - })).unwrap_or_default() }] + "content": [{ "type": "text", "text": text }] }), vec![], ) diff --git a/src/mcp/tools/render.rs b/src/mcp/tools/render.rs index 9bbdd45e9..8afcc3fb7 100644 --- a/src/mcp/tools/render.rs +++ b/src/mcp/tools/render.rs @@ -27,6 +27,11 @@ fn parse_format(args: &Value) -> OutputFormat { } } +/// True when the caller explicitly opted into JSON output via `format: "json"`. +pub(super) fn wants_json(args: &Value) -> bool { + parse_format(args) == OutputFormat::Json +} + pub(super) fn finalize(project_root: Option<&Path>, args: &Value, value: &Value, md: F) -> String where F: FnOnce() -> String, @@ -48,6 +53,10 @@ where /// Truncates a string to the maximum response character limit, appending /// a truncation notice if necessary. +/// +/// Legacy, irreversible truncation: no retrieval handle is stored. Prefer +/// [`truncate_text_with_handle`] for plain-text tool output. +#[cfg_attr(not(test), allow(dead_code))] pub(super) fn truncate_response(s: &str) -> String { debug_assert!(!s.is_empty(), "truncate_response called with empty string"); if s.len() <= MAX_RESPONSE_CHARS { @@ -138,7 +147,9 @@ pub(super) fn truncated_json_envelope_with_handle( observe_response_truncation( formatted.len(), text.len(), - true, + // Reversible only when the full body was actually stored; a + // failed/absent handle means the preview is all that survives. + handle.record.is_some(), now, truncation_handle_status(project_root, &handle), started.elapsed(), @@ -149,6 +160,14 @@ pub(super) fn truncated_json_envelope_with_handle( } } +/// Reversible truncation for plain-text tool output. Returns `text` unchanged +/// when it fits within [`MAX_RESPONSE_CHARS`]; otherwise stores the full text +/// via the response-handle machinery and returns the readable markdown +/// truncation envelope (preview plus `rh_` retrieval handle). +pub(super) fn truncate_text_with_handle(project_root: Option<&Path>, text: &str) -> String { + truncated_markdown_with_handle(project_root, text) +} + fn truncated_markdown_with_handle(project_root: Option<&Path>, text: &str) -> String { if text.len() <= MAX_RESPONSE_CHARS { return text.to_string(); @@ -630,6 +649,33 @@ mod tests { } } + #[test] + fn truncate_text_with_handle_returns_short_text_unchanged() { + let short = "hello world"; + assert_eq!(truncate_text_with_handle(None, short), short); + } + + #[test] + fn truncate_text_with_handle_stores_reversible_envelope() { + let dir = tempfile::TempDir::new().unwrap(); + let long = "- indexed file entry\n".repeat(3_000); + + let result = truncate_text_with_handle(Some(dir.path()), &long); + + assert!(result.len() <= MAX_RESPONSE_CHARS); + assert!(result.starts_with("# Truncated Response")); + assert!(result.contains("## Preview")); + assert!(result.contains("tracedecay_retrieve")); + let Some(handle) = result + .split("handle `") + .nth(1) + .and_then(|tail| tail.split('`').next()) + else { + panic!("truncate_text_with_handle envelope should include handle"); + }; + assert!(handle.starts_with("rh_")); + } + #[test] fn truncated_json_envelope_reports_store_failure() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/tests/hermes_suite/lcm_bridge.rs b/tests/hermes_suite/lcm_bridge.rs index bd9c29b07..5f887a2d9 100644 --- a/tests/hermes_suite/lcm_bridge.rs +++ b/tests/hermes_suite/lcm_bridge.rs @@ -1230,6 +1230,7 @@ assert argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_prefli args_index = argv.index("--args") args = json.loads(argv[args_index + 1]) assert args == { + "format": "json", "project_root": "/tmp/project", "provider": "cursor", "fresh_tail_count": 64, @@ -1315,6 +1316,7 @@ assert argv[0] == plugin.tools.TRACEDECAY_BIN assert argv[1:6] == ["tool", "--project", "/tmp/project", "tracedecay_lcm_session_boundary", "--json"] args = json.loads(argv[argv.index("--args") + 1]) assert args == { + "format": "json", "project_root": "/tmp/project", "provider": "cursor", "session_id": "session-b", @@ -1423,6 +1425,7 @@ else: args = json.loads(args_ref) # expanduser matches the plugin's fallback byte-for-byte on Windows too. assert args == { + "format": "json", "project_root": "/tmp/project", "response_handle_project_root": "/tmp/project", "provider": "cursor", diff --git a/tests/mcp_suite/mcp_cli_serve_test.rs b/tests/mcp_suite/mcp_cli_serve_test.rs index b7df8814d..20b486586 100644 --- a/tests/mcp_suite/mcp_cli_serve_test.rs +++ b/tests/mcp_suite/mcp_cli_serve_test.rs @@ -295,7 +295,7 @@ async fn serve_stdio_smokes_managed_skill_list_and_view() { "method": "tools/call", "params": { "name": "tracedecay_skill_list", - "arguments": { "state": "active" } + "arguments": { "state": "active", "format": "json" } } }) ) @@ -311,7 +311,8 @@ async fn serve_stdio_smokes_managed_skill_list_and_view() { "name": "tracedecay_skill_view", "arguments": { "id": "active-stdio-skill", - "include_support_files": false + "include_support_files": false, + "format": "json" } } }) @@ -442,7 +443,8 @@ async fn serve_stdio_smokes_automation_run_artifact_view() { "name": "tracedecay_automation_run_artifact_view", "arguments": { "run_id": run_id, - "kind": "codex_handoff" + "kind": "codex_handoff", + "format": "json" } } }) diff --git a/tests/mcp_suite/mcp_dashboard_tool_test.rs b/tests/mcp_suite/mcp_dashboard_tool_test.rs index c146cdc7c..db3e503fa 100644 --- a/tests/mcp_suite/mcp_dashboard_tool_test.rs +++ b/tests/mcp_suite/mcp_dashboard_tool_test.rs @@ -87,7 +87,7 @@ async fn tracedecay_dashboard_tool_starts_and_returns_url_and_serves_capabilitie let res = handle_tool_call( &cg, "tracedecay_dashboard", - json!({ "host": "127.0.0.1", "port": 0 }), + json!({ "host": "127.0.0.1", "port": 0, "format": "json" }), None, None, ) diff --git a/tests/mcp_suite/mcp_handler_test.rs b/tests/mcp_suite/mcp_handler_test.rs index 382abc55c..cf08db6a7 100644 --- a/tests/mcp_suite/mcp_handler_test.rs +++ b/tests/mcp_suite/mcp_handler_test.rs @@ -1107,7 +1107,7 @@ fn active_project_and_storage_status_tools_are_advertised_readonly() { assert!( tool.input_schema["properties"] .as_object() - .is_some_and(|properties| properties.is_empty()), + .is_some_and(|properties| properties.keys().all(|key| key == "format")), "{name} should not require callers to pass resolver internals" ); assert_eq!( @@ -1124,6 +1124,26 @@ fn active_project_and_storage_status_tools_are_advertised_readonly() { } } +#[tokio::test] +async fn active_project_tool_defaults_to_markdown() { + let (cg, _env, _dir) = setup_empty_project().await; + // Call the crate dispatch directly: the test-local wrapper injects + // format:"json", and this test asserts the true default. + let result = + tracedecay::mcp::handle_tool_call(&cg, "tracedecay_active_project", json!({}), None, None) + .await + .unwrap(); + let text = extract_text(&result.value); + assert!( + serde_json::from_str::(text).is_err(), + "default active_project output should be markdown, got: {text}" + ); + assert!( + text.contains("**project_root:**"), + "markdown field missing: {text}" + ); +} + #[tokio::test] async fn active_project_tool_reports_resolved_store_metadata() { let (cg, _env, _dir) = setup_empty_project().await; @@ -5418,6 +5438,32 @@ async fn test_dsm_stats() { .await .unwrap(); let text = extract_text(&result.value); + // Shape values (stats/clusters/matrix) render as markdown by default. + assert!( + text.contains("**files:**"), + "files field should exist, got: {}", + text + ); + assert!( + text.contains("**density:**"), + "density field should exist, got: {}", + text + ); +} + +#[tokio::test] +async fn test_dsm_json_returns_stats_shape() { + let (cg, _dir) = setup_project().await; + let result = handle_tool_call( + &cg, + "tracedecay_dsm", + json!({ "format": "json" }), + None, + None, + ) + .await + .unwrap(); + let text = extract_text(&result.value); let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); assert!( parsed.get("files").is_some(), @@ -5443,10 +5489,9 @@ async fn test_dsm_clusters() { .await .unwrap(); let text = extract_text(&result.value); - let parsed: serde_json::Value = serde_json::from_str(text).unwrap(); assert!( - parsed.get("clusters").is_some(), - "clusters array should exist, got: {}", + text.contains("## clusters") || text.contains("**clusters:**"), + "clusters section should exist, got: {}", text ); } @@ -10773,7 +10818,7 @@ async fn lcm_status_cli_bridge_accepts_json_args() { "tracedecay_lcm_status", "--json", "--args", - r#"{"provider":"cursor"}"#, + r#"{"provider":"cursor","format":"json"}"#, ]) .output() .unwrap(); @@ -10821,6 +10866,7 @@ async fn lcm_status_cli_profile_scope_dispatches_without_initialized_project() { "session_id": "lcm-cli-profile", "storage_scope": "hermes_profile", "hermes_home": hermes_home.path(), + "format": "json", }) .to_string(); let _daemon = common::spawn_tracedecay_daemon(home.path()); diff --git a/tests/memory_suite/memory_eval_test.rs b/tests/memory_suite/memory_eval_test.rs index c8ec2ed03..d6b25477d 100644 --- a/tests/memory_suite/memory_eval_test.rs +++ b/tests/memory_suite/memory_eval_test.rs @@ -637,6 +637,7 @@ fn run_search(fixture: &Fixture, query: &str, limit: usize) -> Vec