Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ jobs:
run: python3 .github/scripts/test_dco_check.py
- name: Test grounding qualification evidence gates
run: python3 -m unittest discover -s testing/grounding -p test_qualify.py
- name: Test search adoption metric
run: python3 -m unittest discover -s testing/adoption -p test_search_adoption.py
- name: Test npm launcher
run: npm test
- name: Inspect npm package payload
Expand Down
61 changes: 53 additions & 8 deletions crates/mcp-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5160,14 +5160,24 @@ impl ContextStreamClient {
return true;
}

let committed_files = body
.get("indexed_files")
.and_then(serde_json::Value::as_i64)
.or_else(|| {
body.get("indexed_file_count")
.and_then(serde_json::Value::as_i64)
})
.unwrap_or(0);
// The hosted status labels a project with no index row as
// `project_index_state: "ready"` / `status: "completed"`. A reported
// file count of zero is therefore authoritative: nothing is searchable,
// whatever the state label or a stale generation number says. State and
// generation only stand in for the count when the response omits it.
let reported_files = [
body.get("indexed_files")
.and_then(serde_json::Value::as_i64),
body.get("indexed_file_count")
.and_then(serde_json::Value::as_i64),
]
.into_iter()
.flatten()
.max();
let committed_files = reported_files.unwrap_or(0);
if reported_files.is_some() && committed_files <= 0 {
return false;
}
let ready_state = body
.get("project_index_state")
.or_else(|| body.get("status"))
Expand Down Expand Up @@ -21107,6 +21117,41 @@ mod tests {
);
}

#[test]
fn empty_project_labelled_ready_is_not_canonically_ready() {
// Shape the hosted status returns for a project that was never
// indexed: a "ready"/"completed" label with zero files and no `indexed`.
for status in [
serde_json::json!({
"project_index_state": "ready",
"status": "completed",
"status_detail": "no_files_indexed",
"indexed_files": 0,
"indexed_file_count": 0,
"total_files": 0,
"committed_generation": 0
}),
serde_json::json!({
"project_index_state": "ready",
"indexed_file_count": 0,
"committed_generation": 12
}),
serde_json::json!({"indexed_files": 0, "status": "completed"}),
] {
assert!(
!ContextStreamClient::project_index_status_reports_canonical_ready(&status),
"empty project must not report canonical readiness: {status}"
);
}
// A positive count still wins over a missing label.
assert!(
ContextStreamClient::project_index_status_reports_canonical_ready(&serde_json::json!({
"indexed_files": 0,
"indexed_file_count": 7
}))
);
}

#[tokio::test]
async fn unroutable_checkout_status_keeps_canonical_evidence_separate_from_checkout_readiness()
{
Expand Down
74 changes: 74 additions & 0 deletions crates/mcp-server/src/hook_handlers/pre_tool_use.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ const QUESTION_WORDS: &[&str] = &[
const DEFAULT_INDEX_WAIT_SECONDS: u64 = 20;
const MIN_INDEX_WAIT_SECONDS: u64 = 15;
const MAX_INDEX_WAIT_SECONDS: u64 = 20;
/// How long the "no index recorded for this checkout" shell-search nudge stays
/// quiet after it fires. Long enough that a session full of `rg` calls hears
/// it once, short enough to resurface if the agent keeps ignoring it.
const SHELL_SEARCH_NUDGE_COOLDOWN_SECONDS: u64 = 600;

/// Check if a glob pattern is a broad discovery pattern.
/// Allows targeted patterns like "src/models/*.rs", "web/src/**/*sidebar*",
Expand Down Expand Up @@ -1174,6 +1178,27 @@ fn is_local_discovery_tool_during_index_wait(tool_lower: &str, tool_input: &Valu
}
}

/// Nudge for shell code search (`rg`, `grep -r`, `find -name`, `fd`) in a
/// checkout that has no recorded index.
///
/// Telling the agent to "use search" here would send it to an empty index; one
/// empty result and most agents stop searching for the session. So the nudge is
/// honest: search has nothing yet, build the index once, then prefer search.
/// Returns `None` for commands that are not code discovery (log filtering,
/// process lists, a single targeted file), so those never get nudged.
fn unindexed_shell_search_nudge(editor: &EditorFormat, command: &str) -> Option<String> {
let (tool_name, query_hint) = detect_bash_code_search(command)?;
let (mode, _) = recommend_search_mode(&query_hint);
let search = search_call(editor, mode, &query_hint);
let project = contextstream_tool_name(editor, "project");
Some(format!(
"ContextStream has no index recorded for this checkout, so search may come back empty until one exists. \
Build it once with {project}(action=\"index\") (it runs in the background and search fills in as files commit), \
then use {search} instead of shell `{tool_name}` for code discovery. \
Shell search is fine in the meantime."
))
}

fn is_contextstream_read_only_operation(tool_name: &str, tool_input: &Value) -> bool {
let action = first_str(tool_input, &["action"])
.unwrap_or("")
Expand Down Expand Up @@ -2428,6 +2453,20 @@ pub async fn handle() -> Result<()> {
return Ok(());
}

// Shell code search is the same discovery the indexed path redirects,
// but it used to fall through to the silent Allow below, so a checkout
// missing from the local registry never got a nudge at all.
if tool_lower == "bash" {
let command = first_str(&tool_input, &["command"]).unwrap_or("").trim();
if let Some(msg) = unindexed_shell_search_nudge(&editor, command) {
if prompt_state::claim_shell_search_nudge(&cwd, SHELL_SEARCH_NUDGE_COOLDOWN_SECONDS)
{
emit(HookDecision::AllowWithContext(msg))?;
return Ok(());
}
}
}

// Non-discovery tools should continue while refresh runs in background.
emit(HookDecision::Allow)?;
return Ok(());
Expand Down Expand Up @@ -3611,6 +3650,41 @@ mod tests {
));
}

#[test]
fn unindexed_checkout_nudges_shell_code_search_toward_indexing() {
for command in [
"rg -n 'canonical_index_ready' crates",
"grep -rn \"PreToolUse\" .",
"cd crates && rg -n foo",
"find . -name '*.rs'",
] {
let nudge = unindexed_shell_search_nudge(&EditorFormat::Claude, command)
.unwrap_or_else(|| panic!("expected a nudge for: {command}"));
assert!(nudge.contains("mcp__contextstream__project(action=\"index\")"));
assert!(nudge.contains("mcp__contextstream__search"));
// Must not claim an index exists.
assert!(nudge.contains("no index recorded"));
}
}

#[test]
fn unindexed_checkout_does_not_nudge_non_discovery_shell_commands() {
for command in [
"ps aux | grep node",
"grep ERROR /var/log/app.log",
"grep -n foo src/main.rs",
"git status",
"cargo test",
"find . -newer Cargo.lock",
"",
] {
assert!(
unindexed_shell_search_nudge(&EditorFormat::Claude, command).is_none(),
"unexpected nudge for: {command}"
);
}
}

#[test]
fn index_wait_never_blocks_grep() {
assert!(!is_local_discovery_tool_during_index_wait(
Expand Down
89 changes: 89 additions & 0 deletions crates/mcp-server/src/hook_handlers/prompt_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ struct PromptStateEntry {
index_wait_started_at: Option<String>,
#[serde(default)]
index_wait_until: Option<String>,
/// When the "no index recorded for this checkout" shell-search nudge last
/// fired, so it repeats at most once per cooldown instead of on every `rg`.
#[serde(default)]
shell_search_nudged_at: Option<String>,
updated_at: String,
}

Expand Down Expand Up @@ -97,6 +101,7 @@ pub fn mark_context_required(cwd: &str) {
last_state_change_at: None,
index_wait_started_at: None,
index_wait_until: None,
shell_search_nudged_at: None,
updated_at: now.clone(),
});
entry.require_context = true;
Expand Down Expand Up @@ -135,6 +140,7 @@ pub fn mark_init_required(cwd: &str) {
last_state_change_at: None,
index_wait_started_at: None,
index_wait_until: None,
shell_search_nudged_at: None,
updated_at: now.clone(),
});
entry.require_init = true;
Expand Down Expand Up @@ -189,6 +195,7 @@ pub fn mark_state_changed(cwd: &str) {
last_state_change_at: None,
index_wait_started_at: None,
index_wait_until: None,
shell_search_nudged_at: None,
updated_at: now.clone(),
});
entry.last_state_change_at = Some(now.clone());
Expand Down Expand Up @@ -272,6 +279,7 @@ pub fn start_index_wait_window(cwd: &str, wait_seconds: u64) {
last_state_change_at: None,
index_wait_started_at: None,
index_wait_until: None,
shell_search_nudged_at: None,
updated_at: now_iso.clone(),
});

Expand Down Expand Up @@ -322,6 +330,55 @@ pub fn index_wait_remaining_seconds(cwd: &str) -> Option<u64> {
(remaining > 0).then_some(remaining as u64)
}

/// Whether a nudge last shown at `last` may be shown again at `now`.
fn nudge_cooldown_elapsed(
last: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
cooldown_seconds: u64,
) -> bool {
match last.and_then(parse_rfc3339_utc) {
Some(last) => now.signed_duration_since(last).num_seconds() >= cooldown_seconds as i64,
None => true,
}
}

/// Claim the right to show the shell-search "no index recorded" nudge for
/// `cwd`. Returns `true` at most once per `cooldown_seconds`; the caller emits
/// the nudge only when it does.
pub fn claim_shell_search_nudge(cwd: &str, cooldown_seconds: u64) -> bool {
if cwd.trim().is_empty() {
return false;
}
let mut state = read_state();
let now = chrono::Utc::now();
let now_iso = now.to_rfc3339();
let entry = state
.workspaces
.entry(cwd.to_string())
.or_insert(PromptStateEntry {
require_context: false,
require_init: false,
last_context_at: None,
last_state_change_at: None,
index_wait_started_at: None,
index_wait_until: None,
shell_search_nudged_at: None,
updated_at: now_iso.clone(),
});

if !nudge_cooldown_elapsed(
entry.shell_search_nudged_at.as_deref(),
now,
cooldown_seconds,
) {
return false;
}
entry.shell_search_nudged_at = Some(now_iso.clone());
entry.updated_at = now_iso;
write_state(&state);
true
}

pub fn cleanup_stale(max_age_minutes: u64) {
let mut state = read_state();
let now = chrono::Utc::now();
Expand Down Expand Up @@ -357,6 +414,7 @@ mod tests {
last_state_change_at: None,
index_wait_started_at: None,
index_wait_until: None,
shell_search_nudged_at: None,
updated_at: chrono::Utc::now().to_rfc3339(),
},
);
Expand All @@ -375,6 +433,37 @@ mod tests {
assert!(parsed.workspaces["/tmp/project"].index_wait_until.is_none());
}

#[test]
fn shell_search_nudge_cooldown_only_repeats_after_it_elapses() {
let now = chrono::Utc::now();
// Never nudged, or an unreadable timestamp: allowed.
assert!(nudge_cooldown_elapsed(None, now, 600));
assert!(nudge_cooldown_elapsed(Some("not a timestamp"), now, 600));
// Nudged a minute ago with a ten minute cooldown: suppressed.
let recent = (now - chrono::Duration::seconds(60)).to_rfc3339();
assert!(!nudge_cooldown_elapsed(Some(&recent), now, 600));
// Nudged eleven minutes ago: allowed again.
let old = (now - chrono::Duration::seconds(660)).to_rfc3339();
assert!(nudge_cooldown_elapsed(Some(&old), now, 600));
}

#[test]
fn legacy_prompt_state_defaults_shell_search_nudge_to_never() {
let legacy = serde_json::json!({
"workspaces": {
"/tmp/project": {
"require_context": false,
"updated_at": chrono::Utc::now().to_rfc3339()
}
}
});
let parsed: PromptStateFile =
serde_json::from_value(legacy).expect("parse legacy prompt state");
assert!(parsed.workspaces["/tmp/project"]
.shell_search_nudged_at
.is_none());
}

#[test]
fn legacy_prompt_state_defaults_require_init_false() {
let legacy = serde_json::json!({
Expand Down
11 changes: 8 additions & 3 deletions crates/mcp-server/src/setup/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1983,7 +1983,12 @@ pub async fn update_rules_scoped(
// 3. Resolved from the API (so the rule header shows a real UUID + name
// instead of the null UUID when neither 1 nor 2 is available)
// 4. Inferred from existing rule-file headers (offline fallback)
let (ws_id, ws_name): (Option<String>, Option<String>) = if workspace_id.is_some() {
// Only project rules carry a workspace identity (global rules are
// workspace-neutral), so a global-only refresh needs none and skips the API
// lookup entirely.
let (ws_id, ws_name): (Option<String>, Option<String>) = if !include_project {
(None, None)
} else if workspace_id.is_some() {
(
workspace_id.map(String::from),
workspace_name.map(String::from),
Expand Down Expand Up @@ -2022,7 +2027,7 @@ pub async fn update_rules_scoped(

// Update global rules
if scope == "global" || scope == "all" {
match rules::write_editor_rules(editor, ws_id_ref, ws_name_ref) {
match rules::write_editor_rules(editor) {
Ok(()) => updated.push("global rules"),
Err(e) => {
if !e.to_string().contains("Could not determine rules path") {
Expand Down Expand Up @@ -3641,7 +3646,7 @@ pub async fn configure_editor_with_workspace(
}

// Generate AI rules (global)
match rules::write_editor_rules(editor, workspace_id, workspace_name) {
match rules::write_editor_rules(editor) {
Ok(()) => {
rules_targets.push("global");
}
Expand Down
Loading
Loading