From 8f22f7ac953bbd9c4d18adf2cb215b4123de7a27 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 10 Aug 2026 22:16:26 -0700 Subject: [PATCH 01/39] fix(agent): unify live model retry budget --- src/crates/adapters/ai-adapters/src/client.rs | 117 +++-- .../adapters/ai-adapters/src/client/sse.rs | 82 +++- .../src/agentic/execution/round_executor.rs | 461 +++++++----------- src/crates/contracts/core-types/src/errors.rs | 31 ++ 4 files changed, 357 insertions(+), 334 deletions(-) diff --git a/src/crates/adapters/ai-adapters/src/client.rs b/src/crates/adapters/ai-adapters/src/client.rs index 951e29d75..aa1e1feec 100644 --- a/src/crates/adapters/ai-adapters/src/client.rs +++ b/src/crates/adapters/ai-adapters/src/client.rs @@ -19,6 +19,7 @@ use crate::trace::{ use crate::types::ProxyConfig; use crate::types::*; use anyhow::Result; +use bitfun_core_types::errors::{AiProviderError, ErrorCategory}; use format::ApiFormat; use log::warn; use reqwest::Client; @@ -223,30 +224,36 @@ impl AIClient { extra_body: Option, trace: Option, ) -> Result { - let max_tries = SEND_MESSAGE_STREAM_ATTEMPTS; - match ApiFormat::parse(&self.config.format)? { - ApiFormat::OpenAIChat => { - openai::chat::send_stream(self, messages, tools, extra_body, max_tries, trace).await - } - ApiFormat::OpenAIResponses => { - openai::responses::send_stream(self, messages, tools, extra_body, max_tries, trace) - .await - } - ApiFormat::Anthropic => { - anthropic::request::send_stream(self, messages, tools, extra_body, max_tries, trace) - .await - } - ApiFormat::Gemini => { - gemini::request::send_stream(self, messages, tools, extra_body, max_tries, trace) - .await - } - ApiFormat::GeminiCodeAssist => { - gemini::code_assist::send_stream( - self, messages, tools, extra_body, max_tries, trace, - ) - .await - } - } + self.send_message_stream_with_extra_body_and_max_attempts( + messages, + tools, + extra_body, + SEND_MESSAGE_STREAM_ATTEMPTS, + trace, + ) + .await + } + + /// Open one model stream without an adapter-owned retry loop. + /// + /// Runtime owners with a broader attempt lifecycle use this entry point so + /// connection, HTTP, TTFT, parsing, and in-stream failures all consume one + /// shared retry budget instead of multiplying nested retry loops. + pub async fn send_message_stream_once( + &self, + messages: Vec, + tools: Option>, + trace: Option, + ) -> Result { + let custom_body = self.config.custom_request_body.clone(); + self.send_message_stream_with_extra_body_and_max_attempts( + messages, + tools, + custom_body, + 1, + trace, + ) + .await } pub async fn send_message( @@ -306,15 +313,33 @@ impl AIClient { max_attempts: usize, ) -> Result { for attempt in 0..max_attempts { - let stream_response = self + let stream_response = match self .send_message_stream_with_extra_body_and_max_attempts( messages.clone(), tools.clone(), extra_body.clone(), - max_attempts, + 1, trace.clone(), ) - .await?; + .await + { + Ok(response) => response, + Err(error) => { + if attempt == max_attempts - 1 { + return Err(error); + } + let delay_ms = send_message_retry_delay_ms_for_error(attempt, &error); + warn!( + "Retrying AI stream request after error: attempt={}/{}, delay_ms={}, error={}", + attempt + 1, + max_attempts, + delay_ms, + error + ); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + continue; + } + }; let trace_handle = stream_response.trace_handle.clone(); match response_aggregator::aggregate_stream_response(stream_response).await { @@ -333,7 +358,7 @@ impl AIClient { if attempt == max_attempts - 1 { return Err(error); } - let delay_ms = send_message_retry_delay_ms(attempt, &error.to_string()); + let delay_ms = send_message_retry_delay_ms_for_error(attempt, &error); warn!( "Retrying aggregated AI stream after error: attempt={}/{}, delay_ms={}, error={}", attempt + 1, @@ -419,15 +444,35 @@ impl AIClient { } } +#[cfg(test)] fn send_message_retry_delay_ms(attempt_index: usize, error_message: &str) -> u64 { + send_message_retry_delay_ms_with_provider(attempt_index, error_message, None) +} + +fn send_message_retry_delay_ms_for_error(attempt_index: usize, error: &anyhow::Error) -> u64 { + send_message_retry_delay_ms_with_provider( + attempt_index, + &error.to_string(), + error.downcast_ref::(), + ) +} + +fn send_message_retry_delay_ms_with_provider( + attempt_index: usize, + error_message: &str, + provider_error: Option<&AiProviderError>, +) -> u64 { let shift = u32::try_from(attempt_index) .unwrap_or(u32::MAX) .min(SEND_MESSAGE_MAX_RETRY_EXPONENT_SHIFT); let msg = error_message.to_lowercase(); - let is_rate_limit = - msg.contains("429") || msg.contains("rate limit") || msg.contains("too many requests"); + let is_rate_limit = provider_error + .is_some_and(|error| error.category == ErrorCategory::RateLimit) + || msg.contains("429") + || msg.contains("rate limit") + || msg.contains("too many requests"); - if is_rate_limit { + let fallback = if is_rate_limit { SEND_MESSAGE_RATE_LIMIT_RETRY_BASE_DELAY_MS .saturating_mul(1u64 << shift) .min(SEND_MESSAGE_MAX_RATE_LIMIT_DELAY_MS) @@ -435,6 +480,16 @@ fn send_message_retry_delay_ms(attempt_index: usize, error_message: &str) -> u64 SEND_MESSAGE_RETRY_BASE_DELAY_MS .saturating_mul(1u64 << shift) .min(SEND_MESSAGE_MAX_EXPONENTIAL_DELAY_MS) + }; + + match provider_error.and_then(|error| error.retry_after_ms) { + Some(retry_after_ms) if is_rate_limit => retry_after_ms + .max(fallback) + .min(SEND_MESSAGE_MAX_RATE_LIMIT_DELAY_MS), + Some(retry_after_ms) if retry_after_ms > 0 => { + retry_after_ms.min(SEND_MESSAGE_MAX_RATE_LIMIT_DELAY_MS) + } + Some(_) | None => fallback, } } diff --git a/src/crates/adapters/ai-adapters/src/client/sse.rs b/src/crates/adapters/ai-adapters/src/client/sse.rs index 91773cea9..560f1da90 100644 --- a/src/crates/adapters/ai-adapters/src/client/sse.rs +++ b/src/crates/adapters/ai-adapters/src/client/sse.rs @@ -118,6 +118,7 @@ fn http_provider_error( status: StatusCode, error_text: &str, error_kind: &str, + retry_after_ms: Option, ) -> AiProviderError { AiProviderError::from_parts( format!("{} {} {}: {}", label, error_kind, status, error_text), @@ -125,6 +126,7 @@ fn http_provider_error( provider_error_code(error_text), Some(status.as_u16()), ) + .with_retry_after_ms(retry_after_ms) } fn exponential_retry_delay_ms(attempt: usize) -> u64 { @@ -288,8 +290,13 @@ where } else { "error" }; - let provider_error = - http_provider_error(label, status, &error_text, error_kind); + let provider_error = http_provider_error( + label, + status, + &error_text, + error_kind, + retry_after_delay_ms(&headers), + ); let error = anyhow!(provider_error); warn!( "{} request failed: {}ms, transport_attempt {}/{}, error: {}", @@ -413,14 +420,10 @@ where }); } - let error_msg = format!( - "{} failed after {} attempts: {}", - label, - max_tries, - last_error.unwrap_or_else(|| anyhow!("Unknown error")) - ); - error!("{}", error_msg); - Err(anyhow!(error_msg)) + let last_error = last_error.unwrap_or_else(|| anyhow!("Unknown error")); + let error_context = format!("{} failed after {} attempts", label, max_tries); + error!("{}: {}", error_context, last_error); + Err(last_error.context(error_context)) } #[cfg(test)] @@ -474,6 +477,21 @@ mod tests { } } + async fn forbidden_with_retry_after(Json(body): Json) -> impl IntoResponse { + assert_eq!(body["model"], "configured-model"); + ( + StatusCode::FORBIDDEN, + [("retry-after", "7")], + Json(serde_json::json!({ + "error": { + "message": "provider authorization denied", + "type": "permission_error", + "code": "permission_denied" + } + })), + ) + } + #[test] fn http_error_uses_structured_code_before_generic_message() { let error = http_provider_error( @@ -481,6 +499,7 @@ mod tests { StatusCode::BAD_REQUEST, r#"{"error":{"code":"context_length_exceeded","message":"Request failed"}}"#, "client error", + None, ); assert_eq!(error.category, ErrorCategory::ContextOverflow); @@ -498,6 +517,7 @@ mod tests { StatusCode::BAD_REQUEST, "400 status code (no body)", "client error", + None, ); assert_eq!(error.category, ErrorCategory::InvalidRequest); @@ -590,6 +610,48 @@ mod tests { assert_eq!(attempts.load(Ordering::SeqCst), 3); } + #[tokio::test] + async fn single_attempt_preserves_retry_after_metadata_for_outer_budget() { + let app = Router::new().route("/chat/completions", post(forbidden_with_retry_after)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind retry-after fixture"); + let address = listener.local_addr().expect("retry-after fixture address"); + let server_task = tokio::spawn(async move { + axum::serve(listener, app) + .await + .expect("retry-after fixture should run"); + }); + let url = format!("http://{address}/chat/completions"); + let client = reqwest::Client::new(); + let request_body = serde_json::json!({"model": "configured-model"}); + + let result = execute_sse_request( + "OpenAI Streaming API", + &url, + &request_body, + 1, + None, + None, + || client.post(&url), + |_response, tx, _tx_raw, _remaining_ttft_timeout| async move { + drop(tx); + }, + ) + .await; + + server_task.abort(); + let error = match result { + Ok(_) => panic!("single forbidden response should fail"), + Err(error) => error, + }; + let provider_error = error + .downcast_ref::() + .expect("structured provider error should survive retry context"); + assert_eq!(provider_error.http_status, Some(403)); + assert_eq!(provider_error.retry_after_ms, Some(7_000)); + } + #[test] fn retry_after_seconds_is_capped() { let mut headers = HeaderMap::new(); diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 31c3199d1..6a261d5f1 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -375,7 +375,7 @@ impl RoundExecutor { let request_trace_config = trace_config .clone() .map(|config| config.with_round_attempt(attempt_id.clone(), attempt_number)); - let send_future = ai_client.send_message_stream( + let send_future = ai_client.send_message_stream_once( ai_messages.clone(), tool_definitions.clone(), request_trace_config, @@ -404,27 +404,24 @@ impl RoundExecutor { error!("AI request failed: {}", e); let provider_error = e.downcast_ref::().cloned(); let err_msg = e.to_string(); - let is_structured_context_overflow = provider_error - .as_ref() - .is_some_and(|error| error.category == ErrorCategory::ContextOverflow); - if !is_structured_context_overflow - && Self::is_transient_network_error(&err_msg) - && local_attempt_index < max_attempts - 1 - { + if local_attempt_index < max_attempts - 1 { self.record_retry_diagnostic( &context, &round_id, attempt_id.clone(), attempt_number, - "transient_request_error", + "request_error", Some(err_msg.clone()), &[], ) .await; - let delay_ms = - Self::retry_delay_ms_for_error(local_attempt_index, &err_msg); + let delay_ms = Self::retry_delay_ms_for_provider_error( + local_attempt_index, + &err_msg, + provider_error.as_ref(), + ); warn!( - "Retrying AI request after connection failure: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, error={}", + "Retrying AI request after error: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, error={}", context.session_id, round_id, attempt_number, @@ -437,19 +434,6 @@ impl RoundExecutor { local_attempt_index += 1; continue; } - if !is_structured_context_overflow && Self::is_transient_network_error(&err_msg) - { - return Err(BitFunError::AIClient(format!( - "Stream retry budget exhausted after {} attempts: {}", - max_attempts, err_msg - ))); - } - // Non-transient errors (429 budget exhausted, context - // overflow, auth, etc.) are returned directly. The error - // message is classified downstream via - // `BitFunError::error_category()` into `ErrorCategory` for - // frontend recovery actions (wait_and_retry, switch_model, - // etc.). let category = provider_error .as_ref() .map(|error| error.category.clone()) @@ -466,9 +450,10 @@ impl RoundExecutor { BitFunError::AIClient(err_msg) }; warn!( - "AI request terminal failure: session_id={}, round_id={}, category={:?}, error={}", + "AI request retry budget exhausted: session_id={}, round_id={}, attempts={}, category={:?}, error={}", context.session_id, round_id, + max_attempts, error.error_category(), error ); @@ -529,22 +514,23 @@ impl RoundExecutor { { Ok(result) => { let stream_processing_ms = elapsed_ms_u64(stream_started_at); - if Self::has_interrupted_invalid_tool_calls(&result) { - let err_msg = result.partial_recovery_reason.clone().unwrap_or_else(|| { - "Interrupted while streaming tool arguments".to_string() - }); - - if !Self::has_user_visible_assistant_text(&result.full_text) - && local_attempt_index < max_attempts - 1 - && Self::is_transient_network_error(&err_msg) - { + let has_interrupted_invalid_tool_calls = + Self::has_interrupted_invalid_tool_calls(&result); + if let Some(partial_recovery_reason) = result.partial_recovery_reason.as_deref() + { + if local_attempt_index < max_attempts - 1 { + let diagnostic_category = if has_interrupted_invalid_tool_calls { + "interrupted_tool_arguments" + } else { + "partial_stream_error" + }; self.record_retry_diagnostic( &context, &round_id, attempt_id.clone(), attempt_number, - "interrupted_tool_arguments", - Some(err_msg.clone()), + diagnostic_category, + Some(partial_recovery_reason.to_string()), &result.tool_calls, ) .await; @@ -554,31 +540,36 @@ impl RoundExecutor { Self::trace_response_from_stream_result("partial", &result), ) .await; - let delay_ms = - Self::retry_delay_ms_for_error(local_attempt_index, &err_msg); + let delay_ms = Self::retry_delay_ms_for_error( + local_attempt_index, + partial_recovery_reason, + ); warn!( - "Retrying stream because tool arguments were interrupted before valid JSON completed: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, invalid_tool_calls={}, error={}", + "Retrying stream after partial recovery error: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, effective_output={}, tool_calls={}, reason={}", context.session_id, round_id, attempt_number, local_attempt_index + 1, max_attempts, delay_ms, - result - .tool_calls - .iter() - .filter(|tool_call| !tool_call.is_valid()) - .count(), - err_msg + result.has_effective_output, + result.tool_calls.len(), + partial_recovery_reason ); Self::sleep_with_cancellation(delay_ms, &cancel_token).await?; local_attempt_index += 1; continue; } + } + + if has_interrupted_invalid_tool_calls { + let err_msg = result.partial_recovery_reason.clone().unwrap_or_else(|| { + "Interrupted while streaming tool arguments".to_string() + }); if Self::has_user_visible_assistant_text(&result.full_text) { warn!( - "Dropping invalid partial tool calls from interrupted stream; preserving already-streamed assistant text: session_id={}, round_id={}, invalid_tool_calls={}, error={}", + "Dropping invalid partial tool calls after stream retry budget was exhausted; preserving assistant text: session_id={}, round_id={}, invalid_tool_calls={}, error={}", context.session_id, round_id, result @@ -632,50 +623,6 @@ impl RoundExecutor { let no_effective_output = !result.has_effective_output; let is_partial_recovery = result.partial_recovery_reason.is_some(); - let partial_recovery_reason = - result.partial_recovery_reason.as_deref().unwrap_or(""); - - if is_partial_recovery - && !Self::has_user_visible_assistant_text(&result.full_text) - && !result.tool_calls.is_empty() - && Self::is_transient_network_error(partial_recovery_reason) - && local_attempt_index < max_attempts - 1 - { - self.record_retry_diagnostic( - &context, - &round_id, - attempt_id.clone(), - attempt_number, - "partial_stream_error", - Some(partial_recovery_reason.to_string()), - &result.tool_calls, - ) - .await; - Self::complete_model_exchange_trace( - trace_config.as_ref(), - trace_handle.as_ref(), - Self::trace_response_from_stream_result("partial", &result), - ) - .await; - let delay_ms = Self::retry_delay_ms_for_error( - local_attempt_index, - partial_recovery_reason, - ); - warn!( - "Retrying stream because tool calls arrived on an interrupted network stream without assistant text: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, tool_calls={}, reason={}", - context.session_id, - round_id, - attempt_number, - local_attempt_index + 1, - max_attempts, - delay_ms, - result.tool_calls.len(), - partial_recovery_reason - ); - Self::sleep_with_cancellation(delay_ms, &cancel_token).await?; - local_attempt_index += 1; - continue; - } if Self::is_invalid_tool_only_without_text(&result) { let err_msg = "Provider returned only invalid tool arguments".to_string(); @@ -739,44 +686,68 @@ impl RoundExecutor { ))); } - if no_effective_output && local_attempt_index < max_attempts - 1 { - self.record_retry_diagnostic( - &context, - &round_id, - attempt_id.clone(), - attempt_number, - "no_effective_output", - None, - &[], - ) - .await; + if no_effective_output { + let err_msg = result + .partial_recovery_reason + .clone() + .unwrap_or_else(|| "No effective output received".to_string()); + if local_attempt_index < max_attempts - 1 { + self.record_retry_diagnostic( + &context, + &round_id, + attempt_id.clone(), + attempt_number, + "no_effective_output", + Some(err_msg.clone()), + &result.tool_calls, + ) + .await; + Self::complete_model_exchange_trace( + trace_config.as_ref(), + trace_handle.as_ref(), + Self::error_trace_response_from_stream_result( + "error", + err_msg.clone(), + &result, + ), + ) + .await; + let delay_ms = + Self::retry_delay_ms_for_error(local_attempt_index, &err_msg); + warn!( + "Retrying stream because no effective output was received: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, error={}", + context.session_id, + round_id, + attempt_number, + local_attempt_index + 1, + max_attempts, + delay_ms, + err_msg + ); + Self::sleep_with_cancellation(delay_ms, &cancel_token).await?; + local_attempt_index += 1; + continue; + } + Self::complete_model_exchange_trace( trace_config.as_ref(), trace_handle.as_ref(), - Self::error_trace_response( + Self::error_trace_response_from_stream_result( "error", - "No effective output received".to_string(), + err_msg.clone(), + &result, ), ) .await; - let delay_ms = Self::retry_delay_ms(local_attempt_index); - warn!( - "Retrying stream because no effective output was received: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}", - context.session_id, - round_id, - attempt_number, - local_attempt_index + 1, - max_attempts, - delay_ms - ); - Self::sleep_with_cancellation(delay_ms, &cancel_token).await?; - local_attempt_index += 1; - continue; + return Err(BitFunError::AIClient(format!( + "Stream retry budget exhausted after {} attempts: {}", + max_attempts, err_msg + ))); } if is_partial_recovery { warn!( - "Accepting stream partial recovery without retry: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, reason={}", + "Accepting useful partial stream output after retry budget was exhausted: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, reason={}", context.session_id, round_id, attempt_number, @@ -799,54 +770,59 @@ impl RoundExecutor { Err(stream_err) => { let err_msg = stream_err.error.to_string(); let stream_error_category = stream_err.error.error_category(); - let can_retry = !stream_err.has_effective_output - && stream_error_category != ErrorCategory::ContextOverflow - && local_attempt_index < max_attempts - 1 - && Self::is_transient_network_error(&err_msg); + let provider_error = match &stream_err.error { + BitFunError::AIProvider(error) + | BitFunError::RecoverableContextOverflow(error) => Some(error), + _ => None, + }; Self::complete_model_exchange_trace( trace_config.as_ref(), trace_handle.as_ref(), Self::error_trace_response("error", err_msg.clone()), ) .await; - if can_retry { + if local_attempt_index < max_attempts - 1 { self.record_retry_diagnostic( &context, &round_id, attempt_id.clone(), attempt_number, - "transient_stream_error", + "stream_error", Some(err_msg.clone()), &[], ) .await; - let delay_ms = - Self::retry_delay_ms_for_error(local_attempt_index, &err_msg); + let delay_ms = Self::retry_delay_ms_for_provider_error( + local_attempt_index, + &err_msg, + provider_error, + ); warn!( - "Retrying stream after transient error with no effective output: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, error={}", + "Retrying stream after error: session_id={}, round_id={}, round_attempt={}, local_retry={}/{}, delay_ms={}, effective_output={}, category={:?}, error={}", context.session_id, round_id, attempt_number, local_attempt_index + 1, max_attempts, delay_ms, + stream_err.has_effective_output, + stream_error_category, err_msg ); Self::sleep_with_cancellation(delay_ms, &cancel_token).await?; local_attempt_index += 1; continue; } - if stream_error_category != ErrorCategory::ContextOverflow - && Self::is_transient_network_error(&err_msg) - { - return Err(BitFunError::AIClient(format!( - "Stream retry budget exhausted after {} attempts: {}", - max_attempts, err_msg - ))); - } - if !stream_err.has_effective_output - && stream_error_category == ErrorCategory::ContextOverflow - { + warn!( + "Stream retry budget exhausted: session_id={}, round_id={}, attempts={}, effective_output={}, category={:?}, error={}", + context.session_id, + round_id, + max_attempts, + stream_err.has_effective_output, + stream_error_category, + err_msg + ); + if stream_error_category == ErrorCategory::ContextOverflow { let provider_error = match stream_err.error { BitFunError::AIProvider(error) | BitFunError::RecoverableContextOverflow(error) => error, @@ -1489,14 +1465,25 @@ impl RoundExecutor { } fn retry_delay_ms_for_error(attempt_index: usize, error_message: &str) -> u64 { + Self::retry_delay_ms_for_provider_error(attempt_index, error_message, None) + } + + fn retry_delay_ms_for_provider_error( + attempt_index: usize, + error_message: &str, + provider_error: Option<&AiProviderError>, + ) -> u64 { let shift = u32::try_from(attempt_index) .unwrap_or(u32::MAX) .min(Self::MAX_RETRY_EXPONENT_SHIFT); let msg = error_message.to_lowercase(); - let is_rate_limit = - msg.contains("429") || msg.contains("rate limit") || msg.contains("too many requests"); + let is_rate_limit = provider_error + .is_some_and(|error| error.category == ErrorCategory::RateLimit) + || msg.contains("429") + || msg.contains("rate limit") + || msg.contains("too many requests"); - if is_rate_limit { + let fallback = if is_rate_limit { Self::RATE_LIMIT_RETRY_BASE_DELAY_MS .saturating_mul(1u64 << shift) .min(Self::MAX_RATE_LIMIT_DELAY_MS) @@ -1504,113 +1491,17 @@ impl RoundExecutor { Self::RETRY_BASE_DELAY_MS .saturating_mul(1u64 << shift) .min(Self::MAX_EXPONENTIAL_DELAY_MS) - } - } - - /// Check whether an error message represents a transient (retryable) condition. - /// - /// Errors that already exhausted the SSE-layer retry budget (e.g. "failed - /// after N attempts:" or "Stream retry budget exhausted") are **not** - /// transient from the round-executor perspective — the SSE transport layer - /// already retried with exponential backoff and `Retry-After` parsing. - /// Re-entering the send loop would multiply attempts (10 × 10 = 100) and - /// hold the user in a long silent stall. - fn is_transient_network_error(error_message: &str) -> bool { - let msg = error_message.to_lowercase(); - - // The SSE layer already exhausted its own retry budget — do not - // re-enter another round of attempts from the round executor. - // We require BOTH "failed after " and "attempts:" to co-occur, - // which uniquely identifies the SSE/round-executor budget-exhausted - // format without catching generic errors like "failed after timeout". - if msg.contains("failed after ") && msg.contains("attempts:") { - return false; - } - if msg.contains("retry budget exhausted") { - return false; - } + }; - let non_retryable_keywords = [ - "invalid api key", - "unauthorized", - "forbidden", - "model not found", - "unsupported model", - "invalid request", - "bad request", - "prompt is too long", - "content policy", - "proxy authentication required", - "provider quota", - "provider billing", - "insufficient_quota", - "insufficient quota", - "insufficient balance", - "not_enough_balance", - "not enough balance", - "余额不足", - "无可用资源包", - "账户已欠费", - "code=1113", - "\"code\":\"1113\"", - "client error 400", - "client error 401", - "client error 402", - "client error 403", - "client error 404", - "client error 413", - "client error 422", - "sse parsing error", - "schema error", - "unknown api format", - ]; - - let transient_keywords = [ - "transport error", - "error decoding response body", - "stream closed before response completed", - "stream processing error", - "sse stream error", - "sse error", - "sse timeout", - "stream data timeout", - "timeout", - "request timeout", - "deadline exceeded", - "connection reset", - "connection closed", - "broken pipe", - "unexpected eof", - "connection refused", - "socket closed", - "temporarily unavailable", - "service unavailable", - "bad gateway", - "gateway timeout", - "overloaded", - "proxy", - "tunnel", - "dns", - "network", - "econnreset", - "econnrefused", - "etimedout", - "rate limit", - "too many requests", - "408", - "409", - "425", - "429", - "502", - "503", - "504", - ]; - - if non_retryable_keywords.iter().any(|k| msg.contains(k)) { - return false; + match provider_error.and_then(|error| error.retry_after_ms) { + Some(retry_after_ms) if is_rate_limit => retry_after_ms + .max(fallback) + .min(Self::MAX_RATE_LIMIT_DELAY_MS), + Some(retry_after_ms) if retry_after_ms > 0 => { + retry_after_ms.min(Self::MAX_RATE_LIMIT_DELAY_MS) + } + Some(_) | None => fallback, } - - transient_keywords.iter().any(|k| msg.contains(k)) } } @@ -2146,16 +2037,6 @@ mod tests { assert_eq!(trace.error.as_deref(), Some("request failed")); } - #[test] - fn is_transient_error_treats_rate_limit_as_transient() { - assert!(RoundExecutor::is_transient_network_error( - "OpenAI Streaming API error 429 Too Many Requests" - )); - assert!(RoundExecutor::is_transient_network_error( - "rate limit exceeded" - )); - } - #[test] fn retry_delay_grows_beyond_previous_four_second_cap() { assert_eq!(RoundExecutor::retry_delay_ms(0), 500); @@ -2183,43 +2064,37 @@ mod tests { } #[test] - fn is_transient_error_treats_network_errors_as_transient() { - assert!(RoundExecutor::is_transient_network_error( - "connection reset by peer" - )); - assert!(RoundExecutor::is_transient_network_error("timeout")); - } - - #[test] - fn is_transient_error_treats_context_overflow_as_non_transient() { - assert!(!RoundExecutor::is_transient_network_error( - "prompt is too long" - )); - } - - #[test] - fn is_transient_error_treats_budget_exhausted_as_non_transient() { - // After SSE layer exhausts its retry budget, the round executor must - // NOT re-enter another round of attempts (would cause 10×10 = 100 - // retries). - assert!(!RoundExecutor::is_transient_network_error( - "OpenAI Streaming API failed after 10 attempts: \ - OpenAI Streaming API error 429 Too Many Requests" - )); - assert!(!RoundExecutor::is_transient_network_error( - "Stream retry budget exhausted after 10 attempts: timeout" - )); - } + fn provider_retry_after_is_only_a_delay_hint() { + let permission_error = bitfun_core_types::errors::AiProviderError::from_parts( + "permission denied".to_string(), + Some("openai".to_string()), + None, + Some(403), + ) + .with_retry_after_ms(Some(1_000)); + assert_eq!( + RoundExecutor::retry_delay_ms_for_provider_error( + 5, + &permission_error.message, + Some(&permission_error), + ), + 1_000 + ); - #[test] - fn is_transient_error_does_not_misclassify_failed_after_without_attempts() { - // "failed after " without "attempts:" should NOT be treated as budget - // exhausted — it may be a legitimately retryable transient error. - assert!(RoundExecutor::is_transient_network_error( - "stream failed after connection reset" - )); - assert!(RoundExecutor::is_transient_network_error( - "request failed after timeout" - )); + let rate_limit_error = bitfun_core_types::errors::AiProviderError::from_parts( + "too many requests".to_string(), + Some("openai".to_string()), + None, + Some(429), + ) + .with_retry_after_ms(Some(1_000)); + assert_eq!( + RoundExecutor::retry_delay_ms_for_provider_error( + 3, + &rate_limit_error.message, + Some(&rate_limit_error), + ), + 16_000 + ); } } diff --git a/src/crates/contracts/core-types/src/errors.rs b/src/crates/contracts/core-types/src/errors.rs index 36d1d8dc6..78da964a9 100644 --- a/src/crates/contracts/core-types/src/errors.rs +++ b/src/crates/contracts/core-types/src/errors.rs @@ -69,6 +69,12 @@ pub struct AiProviderError { pub provider_code: Option, #[serde(skip_serializing_if = "Option::is_none")] pub http_status: Option, + /// Provider-requested delay before another attempt, in milliseconds. + /// + /// This is transport metadata only. Runtime owners decide whether to retry; + /// the hint must never be used as retry-admission policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_ms: Option, } impl AiProviderError { @@ -85,6 +91,7 @@ impl AiProviderError { provider, provider_code, http_status, + retry_after_ms: None, } } @@ -95,9 +102,15 @@ impl AiProviderError { provider: None, provider_code: None, http_status: None, + retry_after_ms: None, } } + pub fn with_retry_after_ms(mut self, retry_after_ms: Option) -> Self { + self.retry_after_ms = retry_after_ms; + self + } + pub fn detail(&self) -> AiErrorDetail { AiErrorDetail { category: self.category.clone(), @@ -639,6 +652,7 @@ mod tests { ); assert_eq!(error.category, ErrorCategory::ContextOverflow); + assert_eq!(error.retry_after_ms, None); let detail = error.detail(); assert_eq!(detail.provider.as_deref(), Some("openai")); assert_eq!( @@ -647,4 +661,21 @@ mod tests { ); assert_eq!(detail.http_status, Some(400)); } + + #[test] + fn provider_error_preserves_retry_after_hint() { + let error = AiProviderError::from_parts( + "Request failed".to_string(), + Some("openai".to_string()), + Some("permission_denied".to_string()), + Some(403), + ) + .with_retry_after_ms(Some(1_500)); + + assert_eq!(error.retry_after_ms, Some(1_500)); + assert_eq!( + serde_json::to_value(&error).expect("serialize provider error")["retryAfterMs"], + 1_500 + ); + } } From e0576c18bd5a7406332a418eb95f6606ef4cea22 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 10 Aug 2026 22:16:30 -0700 Subject: [PATCH 02/39] test(cli): cover unified stream retry budget --- .../exec_cli_contracts.rs | 44 ++++++++++++++++++- src/apps/cli/tests/support/mod.rs | 25 ++++++++--- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs index 1f3f9defe..6c6bba56d 100644 --- a/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs +++ b/src/apps/cli/tests/cli_command_contracts/exec_cli_contracts.rs @@ -367,6 +367,48 @@ fn stream_json_patch_success_emits_one_success_terminal() { ); } +#[test] +fn stream_json_malformed_sse_retries_then_completes() { + let server = MockOpenAiServer::malformed_sse_then_immediate(); + let environment = CliTestEnvironment::new(); + environment.configure_mock_model(server.base_url()); + let mut command = environment.std_command(); + command.args([ + "exec", + "exercise malformed provider stream retry", + "--output-format", + "stream-json", + ]); + let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); + server.assert_chat_completion_requests(2); + + let stdout = stdout(&output); + assert!(output.status.success(), "{}\n{stdout}", stderr(&output)); + let events = jsonl_events(&stdout); + assert!( + events.iter().any(|value| { + value["event"]["type"] == "TextChunk" + && value["event"]["text"] + .as_str() + .is_some_and(|text| text.contains(STREAM_COMPLETED_MARKER)) + }), + "retried model stream did not complete: {stdout}" + ); + assert_eq!( + events + .iter() + .filter(|value| is_terminal_event(value)) + .count(), + 1, + "retried stream must emit exactly one terminal envelope: {stdout}" + ); + assert_eq!( + events.last().expect("retried stream terminal event")["event"]["type"], + "DialogTurnCompleted", + "retried stream terminal must be last: {stdout}" + ); +} + #[test] fn stream_json_provider_http_403_emits_one_error_terminal() { let server = MockOpenAiServer::http_403("provider authorization denied"); @@ -493,7 +535,7 @@ fn stream_json_disconnect_then_exhausted_retry_failure_emits_one_error_terminal( "stream-json", ]); let output = command_output_with_timeout(&mut command, std::time::Duration::from_secs(30)); - server.assert_chat_completion_requests(11); + server.assert_chat_completion_requests(10); let stdout = stdout(&output); assert!(!output.status.success(), "{stdout}"); diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index 0fd491a80..03fcd71dc 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -303,6 +303,7 @@ enum MockModelResponse { Gated, Http403 { reason: String }, DisconnectThenHttp403, + MalformedSseThenImmediate, } impl MockOpenAiServer { @@ -324,6 +325,10 @@ impl MockOpenAiServer { Self::spawn(MockModelResponse::DisconnectThenHttp403) } + pub(crate) fn malformed_sse_then_immediate() -> Self { + Self::spawn(MockModelResponse::MalformedSseThenImmediate) + } + pub(crate) fn base_url(&self) -> &str { &self.base_url } @@ -405,11 +410,14 @@ impl MockOpenAiServer { &disconnect_tx, ); attempt += 1; - if matches!( - response, - MockModelResponse::Http403 { .. } - | MockModelResponse::DisconnectThenHttp403 - ) { + let accepts_more_requests = + matches!( + response, + MockModelResponse::Http403 { .. } + | MockModelResponse::DisconnectThenHttp403 + ) || (matches!(response, MockModelResponse::MalformedSseThenImmediate) + && attempt < 2); + if accepts_more_requests { continue; } break; @@ -473,6 +481,13 @@ fn serve_model_response( ) .expect("write mock response headers"); + if matches!(response, MockModelResponse::MalformedSseThenImmediate) && attempt == 0 { + write_chunk(stream, b"data: not-json\n\n").expect("write malformed SSE frame"); + let _ = stream.write_all(b"0\r\n\r\n"); + let _ = stream.flush(); + return; + } + write_sse_chunk( stream, &json!({ From 002c0f5a45f06f1c7410fe1f02d614da97fe1ac7 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 10 Aug 2026 20:18:47 -0700 Subject: [PATCH 03/39] docs(agents): add upgrade compatibility guardrails Remote scenarios routinely put two different BitFun versions on one connection, and users upgrade in place, but the agent guide had no rule covering either. Add an "Upgrade compatibility" global rule next to the remote scenarios section, matching what the existing remote-workspace and transport designs already practice: - persisted shapes stay tolerant, defaulted, and never repurposed; - unparseable data degrades instead of being deleted or reset; - cross-version boundaries advertise and check a capability rather than assuming behavior from a package version; - a rename is a migration, including the data it references; - upgrade coverage means legacy deserialization and old-payload round trips, not only the current shape. AGENTS-CN.md is updated to match. --- AGENTS-CN.md | 19 +++++++++++++++++++ AGENTS.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/AGENTS-CN.md b/AGENTS-CN.md index 597aed1bb..b0ba9d19b 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -176,6 +176,25 @@ BitFun 不是只在本地运行的桌面应用:工作区、执行这一轮的 改动说明中要写清楚在哪些远程场景下验证过。只跑本地测试不能作为远程行为的证据。 +### 升级兼容性 + +用户是原地升级的,而上述远程场景经常让两个不同版本的 BitFun 连在同一条链路上。 +任何改动都必须保证已有安装在升级后无需手工修复即可继续工作。 + +- **落盘结构会被新旧两侧代码同时读取。** 配置、设置、会话、连接配置、worktree 和 + dispatch 记录:新增字段要带默认值,反序列化要保持容错,绝不重新定义或收窄已经落盘 + 字段的语义。旧数据给不出的字段,不能变成必填。 +- **不要用删除或重置用户数据的方式来“恢复”解析不了的内容。** 应保留记录、降级功能并 + 给出明确状态。凭证缺失、配置读不出、超时或主机离线,都不构成丢弃会话、工作区或连接 + 的理由;销毁性删除只能是用户的显式操作。 +- **跨版本边界要协商,不能假设。** Peer HostInvoke、dispatch 协议、relay 与 mobile web、 + IM Bot,对面都是你控制不了的构建版本。要先声明 capability 再使用——包版本相同不等于 + 行为相同——并且要让旧版本一侧留在可用路径上,而不是直接判失败。 +- **改名就是一次迁移。** 在所有受支持的对端都不可能再发送旧名称、旧 id 或旧结构之前, + 必须继续兼容读取;被改名对象所引用的数据(vault 条目、工作区指针)要一并迁移。 +- **用测试证明。** 要覆盖旧数据反序列化和旧载荷往返,而不只是新结构。只验证当前代码 + 自己写出的数据,不算升级兼容性覆盖。 + ### Agent loop 行为 - 不要把硬编码限制或模式判断作为处理 agent loop 循环问题的第一反应,例如仅按字符串或次数阻止重复工具调用。 diff --git a/AGENTS.md b/AGENTS.md index 906325923..00781ad2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -206,6 +206,34 @@ Per-scenario obligations: State which remote scenarios a change was exercised in. Local-only tests are not evidence of remote behavior. +### Upgrade compatibility + +Users upgrade in place, and the remote scenarios above routinely put two +different BitFun versions on the same connection. Every change must keep +existing installs working without manual repair. + +- **Persisted shapes are read by older and newer code.** Config, settings, + sessions, connection profiles, worktree and dispatch records: add fields with + defaults, keep deserialization tolerant, and never repurpose or narrow the + meaning of a field that is already on disk. A field old data cannot supply + must not become required. +- **Never delete or reset user data to recover from something you cannot + parse.** Keep the record, degrade the feature, and surface a clear state. + Missing credentials, an unreadable profile, a timeout, or an offline host are + not reasons to drop a session, workspace, or connection. Destructive removal + stays an explicit user action. +- **Cross-version boundaries negotiate; they do not assume.** Peer HostInvoke, + the dispatch protocol, relay and mobile web, and IM bots all talk to a build + you do not control. Advertise a capability and check it before using it — + package version equality is not evidence of behavior — and keep the older + side on a working path instead of failing it. +- **A rename is a migration.** Keep reading the old name, id, or record shape + until no supported peer can still send it, and migrate referenced data + (vault entries, workspace pointers) together with the thing being renamed. +- **Prove it with tests.** Cover legacy deserialization and an old-payload + round trip, not just the new shape. A test that only exercises data written + by the current code is not upgrade coverage. + ### Agent loop behavior - Do not add hard-coded limits or pattern checks to the agent loop as a first response to looping behavior, such as blocking repeated tool calls by string or count alone. From 3c75a573eadc241694bc0bbf7fe0db17c65fcc0e Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 10 Aug 2026 20:35:26 -0700 Subject: [PATCH 04/39] fix(peer): refuse controller-owned commands on the peer host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Web UI transport adapter denies 10 controller-owned commands that neither peer host refuses: the nine `speech_*` capture/model commands and `dispatch_continue`. The controller-side list is only an optimization — an older controller build, or any non-Web-UI controller, still reaches a peer host over HostInvoke, and the host would then run them. That means microphone capture sessions and speech model file management executing on the peer instead of the machine the user speaks at, and a dispatch control-plane verb running against the peer's outbound observer records and SSH credentials rather than the controller's. Every sibling dispatch verb is already denied on all three lists; `dispatch_continue` was simply missed. Deny both families on the desktop and CLI peer hosts, and add contract tests covering them. The README already required these three lists to stay aligned, but nothing enforced it. Add a peer command policy check to the core boundary checker that fails when a controller-denied command is missing from either host list, so the boundary cannot drift again. The check is one-way: a host denying more than the controller stays allowed. The CLI's four pre-handled control-plane commands are declared exceptions, and a stale exception is itself reported. Verified: cargo test -p bitfun-cli --bins peer_host, cargo test -p bitfun-desktop --lib peer_host_invoke / remote_workspace_policy, and the new check both passing on the fix and reporting all 10 commands without it. --- scripts/core-boundaries/checker.mjs | 2 + .../core-boundaries/peer-command-policy.mjs | 137 ++++++++++++++++++ src/apps/cli/src/peer_host/deny.rs | 30 ++++ src/apps/desktop/src/api/peer_host_invoke.rs | 35 +++++ 4 files changed, 204 insertions(+) create mode 100644 scripts/core-boundaries/peer-command-policy.mjs diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index a9081ca1c..3700f463f 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -37,6 +37,7 @@ import { unexpectedReachableLocalFeatures, } from './manifest-feature-helpers.mjs'; import { checkCargoDependencyBoundariesSafely } from './cargo-dependency-boundaries.mjs'; +import { checkPeerCommandPolicySync } from './peer-command-policy.mjs'; import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, @@ -1125,6 +1126,7 @@ export function runCoreBoundaryCheck() { failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); failures.push(...checkServiceIntegrationTestTopologies(ROOT)); + failures.push(...checkPeerCommandPolicySync(ROOT)); for (const rule of forbiddenManifestDependencyRules) { checkForbiddenManifestDependencyRule(rule); diff --git a/scripts/core-boundaries/peer-command-policy.mjs b/scripts/core-boundaries/peer-command-policy.mjs new file mode 100644 index 000000000..7be725188 --- /dev/null +++ b/scripts/core-boundaries/peer-command-policy.mjs @@ -0,0 +1,137 @@ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +/** + * Peer Device Mode controller/peer command ownership boundary. + * + * The controller-side deny list in the Web UI transport adapter is an + * optimization: it keeps a controller-owned command on the controller without + * a round trip. It is not the boundary. A controller running an older build, + * or any non-Web-UI controller, still reaches a peer host over HostInvoke, so + * each peer host must independently refuse every controller-owned command. + * + * The enforced direction is therefore one-way: whatever the controller refuses + * to send, a peer host must also refuse to run. A host denying more than the + * controller is safe and stays allowed. + */ + +const FE_ADAPTER = 'src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts'; +const DESKTOP_HOST = 'src/apps/desktop/src/api/peer_host_invoke.rs'; +const CLI_HOST = 'src/apps/cli/src/peer_host/deny.rs'; + +/** + * Commands the CLI peer host answers before the deny-list check, so they are + * intentionally absent from its list. See `src/apps/cli/src/peer_host/dispatch.rs`. + */ +const CLI_PRE_HANDLED_COMMANDS = new Set([ + 'peer_control_attach', + 'peer_control_detach', + 'peer_mode_ping', + 'account_cancel_pending_login', +]); + +function stripLineComments(text) { + return text.replace(/\/\/[^\n]*/g, ''); +} + +function parseTypeScriptSet(source, name) { + const match = new RegExp(`const ${name}\\s*=\\s*new Set\\(\\[(.*?)\\n\\]\\);`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/'([^']+)'/g), m => m[1])); +} + +function parseRustSlice(source, name) { + const match = new RegExp(`static ${name}[^=]*=\\s*&\\[(.*?)\\n\\];`, 's').exec(source); + if (!match) { + return null; + } + return new Set(Array.from(stripLineComments(match[1]).matchAll(/"([^"]+)"/g), m => m[1])); +} + +export function checkPeerCommandPolicySync(root) { + const failures = []; + + const read = (relativePath) => { + try { + return readFileSync(join(root, relativePath), 'utf8'); + } catch { + failures.push({ + path: relativePath, + line: 1, + message: + 'Peer command policy check could not read this file; update scripts/core-boundaries/peer-command-policy.mjs if it moved', + }); + return null; + } + }; + + const feSource = read(FE_ADAPTER); + const desktopSource = read(DESKTOP_HOST); + const cliSource = read(CLI_HOST); + if (!feSource || !desktopSource || !cliSource) { + return failures; + } + + const controllerDenied = parseTypeScriptSet(feSource, 'LOCAL_ONLY_COMMANDS'); + const desktopDenied = parseRustSlice(desktopSource, 'LOCAL_ONLY_COMMANDS'); + const cliDenied = parseRustSlice(cliSource, 'LOCAL_ONLY_COMMANDS'); + + for (const [path, parsed] of [ + [FE_ADAPTER, controllerDenied], + [DESKTOP_HOST, desktopDenied], + [CLI_HOST, cliDenied], + ]) { + if (!parsed) { + failures.push({ + path, + line: 1, + message: + 'Could not parse LOCAL_ONLY_COMMANDS; keep the declaration shape the peer command policy check expects', + }); + } + } + if (!controllerDenied || !desktopDenied || !cliDenied) { + return failures; + } + + const missingOnDesktop = [...controllerDenied].filter(command => !desktopDenied.has(command)); + if (missingOnDesktop.length > 0) { + failures.push({ + path: DESKTOP_HOST, + line: 1, + message: + `Desktop peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnDesktop.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const missingOnCli = [...controllerDenied].filter( + command => !cliDenied.has(command) && !CLI_PRE_HANDLED_COMMANDS.has(command), + ); + if (missingOnCli.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `CLI peer host must refuse every controller-owned command. Missing from LOCAL_ONLY_COMMANDS: ${missingOnCli.sort().join(', ')}. ` + + 'An older or non-Web-UI controller can still HostInvoke these onto this peer', + }); + } + + const staleCliExceptions = [...CLI_PRE_HANDLED_COMMANDS].filter( + command => !controllerDenied.has(command), + ); + if (staleCliExceptions.length > 0) { + failures.push({ + path: CLI_HOST, + line: 1, + message: + `Stale CLI pre-handled exception(s) in scripts/core-boundaries/peer-command-policy.mjs: ${staleCliExceptions.sort().join(', ')}. ` + + 'Remove the exception once the controller no longer treats the command as controller-owned', + }); + } + + return failures; +} diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 4e6770864..b4c702e67 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -96,8 +96,18 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. @@ -158,10 +168,30 @@ mod tests { "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", ] { assert!(is_local_only_command(command), "{command}"); } } + + /// The controller-side FE deny list is an optimization, not the boundary. + /// An older or non-FE controller still reaches this host. + #[test] + fn speech_capture_stays_on_the_controller_device() { + for command in [ + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } } diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index bac4f2025..22ef02262 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -119,6 +119,7 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_continue", "dispatch_load_transcript", "dispatch_save_transcript", // One-click relay deploy SSHes from the controller to a user host @@ -129,6 +130,16 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "relay_deploy_cancel", "relay_deploy_register", "relay_deploy_verify", + // Speech capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", ]; static PENDING: OnceLock>>> = @@ -476,6 +487,30 @@ mod tests { } } + /// The controller-side FE deny list is an optimization, not the boundary. + /// A controller on an older build (or a non-FE controller) still reaches + /// this host, so every controller-owned command must be refused here too. + #[test] + fn controller_owned_capture_and_dispatch_commands_are_refused_on_the_peer() { + for command in [ + // Capture and model files belong to the machine the user speaks at. + "speech_list_models", + "speech_download_model", + "speech_cancel_model_download", + "speech_delete_model", + "speech_verify_model", + "speech_start_input_session", + "speech_append_audio_chunk", + "speech_finish_input_session", + "speech_cancel_input_session", + // Same controller-owned observer/credential family as the other + // dispatch verbs already denied here. + "dispatch_continue", + ] { + assert!(is_local_only_command(command), "{command}"); + } + } + #[test] fn only_the_final_detach_drains_peer_permission_requests() { let mut state = control_state(&["controller-a", "controller-b"], &["request-1"]); From fb16e44865d03757de785699728ee403491ce3a4 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 10 Aug 2026 20:55:17 -0700 Subject: [PATCH 05/39] fix(peer): restore session history after a peer surface switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconnecting to a peer device left the chat blank until the user clicked a session in the sidebar. Three gaps in `initializeWorkspace`, all reachable only in Peer Device Mode because `clearAllSessionsForPeerSwitch` is the one place that bumps the store surface generation: 1. A metadata page loaded across a surface-generation bump is discarded by `processPersistedSessionMetadataList`, but the page is still returned with its sessions. The caller then sees "history exists" over an empty store. Reload once against the settled generation. 2. History was only restored on the auto-select path, so a session that was already active but still metadata-only was never hydrated. The breadcrumb and turn rail render from the catalog while the message area stays empty — the reported symptom. Restore history for an active historical session too. 3. When metadata claimed sessions but none were selectable, initialize returned true while selecting nothing. The caller reads true as "do not create a session", so the surface ended up with neither. Report no history so the caller creates against the live workspace instead. No persisted shape, command, or protocol changes; an older peer host is unaffected. Verified: all three tests fail without the fix and pass with it; the 8 existing FlowChatManager cases are unchanged; 252 tests across src/flow_chat/services pass; pnpm run type-check:web clean. --- .../services/FlowChatManager.test.ts | 96 +++++++++++++++++++ .../src/flow_chat/services/FlowChatManager.ts | 65 ++++++++++++- 2 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts index 6f687e6aa..dcb3888fd 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.test.ts @@ -183,6 +183,7 @@ describe('FlowChatManager initialization', () => { storeMocks.initializeEventListeners.mockReturnValue(listenerInitialization.promise); storeMocks.store = { registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 0, @@ -216,6 +217,7 @@ describe('FlowChatManager initialization', () => { storeMocks.store = { registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), loadSessionMetadataPage: vi.fn(() => metadataLoad.promise), getState: vi.fn(() => ({ sessions, @@ -269,6 +271,7 @@ describe('FlowChatManager initialization', () => { storeMocks.store = { registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 2, @@ -321,6 +324,7 @@ describe('FlowChatManager initialization', () => { storeMocks.store = { registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 1, @@ -369,6 +373,7 @@ describe('FlowChatManager initialization', () => { storeMocks.store = { registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), loadSessionMetadataPage: vi.fn(async ( workspacePath: string, ) => ({ @@ -435,6 +440,7 @@ describe('FlowChatManager initialization', () => { storeMocks.store = { registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), loadSessionMetadataPage: vi.fn(async () => ({ sessions: [], totalTopLevelCount: 2, @@ -457,4 +463,94 @@ describe('FlowChatManager initialization', () => { expect(storeMocks.store.switchSession).toHaveBeenCalledWith('parent-1'); expect(storeMocks.store.switchSession).not.toHaveBeenCalledWith('subagent-1'); }); + + // Peer Device Mode enter/exit is the only thing that bumps the store surface + // generation, and the metadata processor drops a page whose generation went + // stale. Reconnecting to a peer therefore used to leave the chat blank: the + // page still reported sessions, so nothing was selected and nothing created. + it('reloads session metadata when a peer surface switch discarded the first page', async () => { + const sessions = new Map(); + let surfaceGeneration = 4; + let activeSessionId: string | null = null; + + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => surfaceGeneration), + loadSessionMetadataPage: vi.fn(async () => { + if (storeMocks.store.loadSessionMetadataPage.mock.calls.length === 1) { + // First load raced a peer switch: the page is returned but the store + // never received it. + surfaceGeneration = 5; + return { sessions: [{ sessionId: 'history-1' }], totalTopLevelCount: 1, hasMore: false }; + } + sessions.set('history-1', createHistoricalSession({ historyState: 'ready', isHistorical: false })); + return { sessions: [{ sessionId: 'history-1' }], totalTopLevelCount: 1, hasMore: false }; + }), + getState: vi.fn(() => ({ sessions, activeSessionId })), + loadSessionHistory: vi.fn(async () => undefined), + switchSession: vi.fn((sessionId: string) => { + activeSessionId = sessionId; + }), + }; + + const manager = FlowChatManager.getInstance(); + await expect(manager.initialize('D:/workspace/BitFun')).resolves.toBe(true); + + expect(storeMocks.store.loadSessionMetadataPage).toHaveBeenCalledTimes(2); + expect(storeMocks.store.switchSession).toHaveBeenCalledWith('history-1'); + }); + + it('restores history for a session that is already active but still metadata-only', async () => { + const activeSession = createHistoricalSession({ sessionId: 'active-1' }); + const sessions = new Map([['active-1', activeSession]]); + + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 0), + loadSessionMetadataPage: vi.fn(async () => ({ + sessions: [{ sessionId: 'active-1' }], + totalTopLevelCount: 1, + hasMore: false, + })), + getState: vi.fn(() => ({ sessions, activeSessionId: 'active-1' })), + loadSessionHistory: vi.fn(async () => undefined), + switchSession: vi.fn(), + }; + + const manager = FlowChatManager.getInstance(); + await expect(manager.initialize('D:/workspace/BitFun')).resolves.toBe(true); + + // Without this the breadcrumb and turn rail render from the catalog while + // the message area stays blank until the user clicks the session. + expect(storeMocks.store.loadSessionHistory).toHaveBeenCalledWith( + 'active-1', + 'D:/workspace/BitFun', + undefined, + undefined, + undefined, + ); + }); + + it('reports no history when metadata claims sessions but none are selectable', async () => { + storeMocks.store = { + registerPersistUnreadCompletionCallback: vi.fn(), + getSurfaceGeneration: vi.fn(() => 7), + loadSessionMetadataPage: vi.fn(async () => ({ + sessions: [{ sessionId: 'history-1' }], + totalTopLevelCount: 1, + hasMore: false, + })), + getState: vi.fn(() => ({ sessions: new Map(), activeSessionId: null })), + loadSessionHistory: vi.fn(async () => undefined), + switchSession: vi.fn(), + }; + + const manager = FlowChatManager.getInstance(); + + // `false` is the caller's signal to create a session against the live + // workspace. Returning `true` here would leave the surface with no active + // session and no new one. + await expect(manager.initialize('D:/workspace/BitFun')).resolves.toBe(false); + expect(storeMocks.store.switchSession).not.toHaveBeenCalled(); + }); }); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 5c46ba7e8..aed4f6404 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -202,7 +202,8 @@ export class FlowChatManager { } ); - const initialMetadataPage = await this.context.flowChatStore.loadSessionMetadataPage( + const surfaceGenerationBeforeLoad = this.context.flowChatStore.getSurfaceGeneration(); + let initialMetadataPage = await this.context.flowChatStore.loadSessionMetadataPage( workspacePath, 5, undefined, @@ -214,6 +215,34 @@ export class FlowChatManager { return false; } + // A Peer Device surface switch during the load bumps the store surface + // generation, and the metadata processor then drops the page instead of + // applying it to a surface that no longer exists. The page still reports + // its sessions, so the caller would see "history exists" over an empty + // store, select nothing, and skip session creation as well — a chat that + // stays blank until the user clicks a session by hand. Reload once + // against the settled generation. + if ( + this.context.flowChatStore.getSurfaceGeneration() !== surfaceGenerationBeforeLoad && + initialMetadataPage.sessions.length > 0 + ) { + log.info('Reloading session metadata after a peer surface switch discarded the page', { + workspacePath, + discardedSessionCount: initialMetadataPage.sessions.length, + }); + initialMetadataPage = await this.context.flowChatStore.loadSessionMetadataPage( + workspacePath, + 5, + undefined, + remoteConnectionId, + remoteSshHost, + 'flow_chat_manager_surface_switch_reload' + ); + if (this.disposed) { + return false; + } + } + const sessionMatchesWorkspace = (session: { workspacePath?: string; remoteConnectionId?: string; @@ -285,6 +314,29 @@ export class FlowChatManager { !!activeSession && sessionMatchesWorkspace(activeSession); const activeSessionIdAtAutoSelectStart = state.activeSessionId; + // History is only restored on the auto-select path below. A session that + // is already active — restored by the nav list after a Peer Device + // surface switch, for example — keeps its metadata-only projection, so + // the breadcrumb and turn rail render from the catalog while the message + // area stays empty until the user clicks the session by hand. + if ( + activeSessionBelongsToWorkspace && + activeSession && + activeSession.isHistorical === true && + isCurrentInitializationRequest() + ) { + await this.context.flowChatStore.loadSessionHistory( + activeSession.sessionId, + workspacePath, + undefined, + activeSession.remoteConnectionId, + activeSession.remoteSshHost, + ); + if (this.disposed) { + return false; + } + } + if (hasHistoricalSessions && !activeSessionBelongsToWorkspace) { if (!isCurrentInitializationRequest()) { return hasHistoricalSessions; @@ -295,8 +347,17 @@ export class FlowChatManager { : undefined) || sortedWorkspaceSessions[0]; if (!latestSession) { + // The caller reads the return value as "a session is available, do + // not create one". Reporting history we cannot select would leave the + // surface with no active session and no new one, so report no history + // and let the caller create against the live workspace instead. this.context.currentWorkspacePath = workspacePath; - return hasHistoricalSessions; + log.warn('Session metadata reported history with nothing selectable for this workspace', { + workspacePath, + metadataSessionCount: initialMetadataPage.sessions.length, + totalTopLevelCount: initialMetadataPage.totalTopLevelCount, + }); + return false; } if (latestSession.isHistorical) { From 201ad29647b92af5a103fff449f9cca1ad9a14df Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 10 Aug 2026 22:30:07 -0700 Subject: [PATCH 06/39] fix(cli): reject zombie dispatch leaders --- src/apps/cli/src/dispatch/runner.rs | 64 +++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 18 deletions(-) diff --git a/src/apps/cli/src/dispatch/runner.rs b/src/apps/cli/src/dispatch/runner.rs index 5841d1a4f..a5f115dde 100644 --- a/src/apps/cli/src/dispatch/runner.rs +++ b/src/apps/cli/src/dispatch/runner.rs @@ -220,27 +220,55 @@ pub(crate) fn process_alive(pid: u32) -> bool { return false; }; // SAFETY: signal 0 performs liveness/permission checking only. - if unsafe { libc::kill(pid, 0) } == 0 { - #[cfg(target_os = "linux")] - { - // A zombie still answers to kill(0), but it has already exited and - // must not be treated as an authenticated leader for escalation. - if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { - if stat - .rsplit_once(") ") - .and_then(|(_, fields)| fields.split_whitespace().next()) - == Some("Z") - { - return false; - } + if unsafe { libc::kill(pid, 0) } != 0 + && !matches!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::EPERM) + ) + { + return false; + } + + #[cfg(target_os = "linux")] + { + // A zombie still answers to kill(0), but it has already exited and + // must not be treated as an authenticated leader for escalation. + if let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) { + if stat + .rsplit_once(") ") + .and_then(|(_, fields)| fields.split_whitespace().next()) + == Some("Z") + { + return false; } } - return true; } - matches!( - std::io::Error::last_os_error().raw_os_error(), - Some(libc::EPERM) - ) + + #[cfg(target_os = "macos")] + { + // macOS also reports zombies as present to kill(0). Query the process + // state before using a leader PID to authenticate SIGKILL escalation; + // a failed/empty query means the process disappeared during the check. + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "stat="]) + .output(); + let Ok(output) = output else { + return false; + }; + if !output.status.success() { + return false; + } + return String::from_utf8_lossy(&output.stdout) + .trim_start() + .chars() + .next() + .is_some_and(|state| state != 'Z'); + } + + #[cfg(not(target_os = "macos"))] + { + true + } } #[cfg(not(unix))] From 9f8b56082279754908c1a632dae9199118ae096a Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Mon, 10 Aug 2026 22:44:08 -0700 Subject: [PATCH 07/39] fix(control-hub): make browser.wait actually wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `browser.wait` read only `duration_ms`, so the very plausible `{ "ms": 1800000 }` was dropped and the call fell through to a branch that returned `{ success: true }` instantly. An agent asked to pause 30 minutes got "Wait completed" back in milliseconds and moved straight on. The action documented no parameters at all, so the model had to guess the key, and even a correct guess was silently capped at 30 seconds. - Accept the spellings models emit: `duration_ms` / `ms` / `wait_ms` / `sleep_ms`, plus `seconds` / `secs` variants, numeric strings included. - Reject a `wait` carrying neither duration nor condition with INVALID_PARAMS instead of reporting a success that never waited. - Raise the cap to 60 minutes, in step with AgentWait's MAX_TIMEOUT_MS, and report `ms` / `requested_ms` / `clamped` so a shortened wait says so. - Race the sleep against the turn's cancellation token, and stop `call_impl` from folding Cancelled into an `ok: false` envelope — a stop during a long pause must not wait out the pause, nor look like a tool error the model tries to recover from. - Serve duration waits before session resolution: a pure pause touches no page, and agents pace themselves long before they open a browser. - Resolve `{ condition, timeout_ms }`: the condition always wins and any duration bounds it, rather than sleeping and never looking at the page. Condition waits keep their previous 15s default, now configurable. - Document all of it in the tool description, and point repeating schedules at the Cron tool, which ends the turn instead of pinning it open. --- .../agentic/tools/browser_control/actions.rs | 73 +++- .../tools/implementations/control_hub_tool.rs | 402 +++++++++++++++++- 2 files changed, 461 insertions(+), 14 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs index 887d3d2ab..3917e1c19 100644 --- a/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/browser_control/actions.rs @@ -7,6 +7,16 @@ use serde_json::{json, Value}; use std::collections::BTreeMap; use tokio::sync::broadcast; +/// Upper bound for an explicit `wait` duration. Pacing waits ("check again in +/// 30 minutes") are a legitimate agent pattern, so the ceiling is generous; +/// it exists only so a nonsense duration cannot wedge the session forever. +/// Kept in step with `AgentWaitTool::MAX_TIMEOUT_MS`. +pub const MAX_WAIT_MS: u64 = 60 * 60 * 1_000; + +/// How long a `wait { condition }` runs before giving up when the caller does +/// not say. Matches the previous hard-coded lifecycle and selector budgets. +pub const DEFAULT_CONDITION_TIMEOUT_MS: u64 = 15_000; + /// Result of waiting for a CDP `Page.lifecycleEvent`. enum LifecycleOutcome { /// One of the requested lifecycle names fired in time. Carries the name @@ -1054,17 +1064,35 @@ impl<'a> BrowserActions<'a> { } /// Wait for a duration or a condition. + /// + /// Callers that can observe cancellation should sleep themselves rather + /// than routing a plain duration through here — see ControlHub's + /// `browser.wait`, which owns the cancellable, session-free duration path. + /// + /// `condition_timeout_ms` bounds the condition wait; it defaults to + /// [`DEFAULT_CONDITION_TIMEOUT_MS`] and is ignored for duration waits. pub async fn wait( &self, duration_ms: Option, condition: Option<&str>, + condition_timeout_ms: Option, ) -> BitFunResult { if let Some(ms) = duration_ms { - let clamped = ms.min(30_000); + let clamped = ms.min(MAX_WAIT_MS); tokio::time::sleep(std::time::Duration::from_millis(clamped)).await; - return Ok(json!({ "success": true, "action": "wait", "ms": clamped })); + return Ok(json!({ + "success": true, + "action": "wait", + "ms": clamped, + "requested_ms": ms, + "clamped": clamped != ms, + })); } if let Some(cond) = condition { + let timeout_ms = condition_timeout_ms + .filter(|ms| *ms > 0) + .unwrap_or(DEFAULT_CONDITION_TIMEOUT_MS) + .min(MAX_WAIT_MS); match cond { "networkidle" | "load" | "domcontentloaded" => { // Phase 1: replace the previous "sleep 2s and hope" with @@ -1085,7 +1113,7 @@ impl<'a> BrowserActions<'a> { "domcontentloaded" => &["DOMContentLoaded", "load"], _ => &["load"], }; - let outcome = wait_for_lifecycle(&mut events, None, wanted, 15_000).await; + let outcome = wait_for_lifecycle(&mut events, None, wanted, timeout_ms).await; let (success, lifecycle_event, timed_out) = match outcome { LifecycleOutcome::Reached(n) => (true, Some(n), false), LifecycleOutcome::Timeout => (false, None, true), @@ -1097,11 +1125,15 @@ impl<'a> BrowserActions<'a> { "condition": cond, "lifecycle_event": lifecycle_event, "timed_out": timed_out, + "timeout_ms": timeout_ms, })); } selector => { + const POLL_INTERVAL_MS: u64 = 500; let js = Self::element_exists_js(selector); - for _ in 0..30 { + let deadline = + tokio::time::Instant::now() + std::time::Duration::from_millis(timeout_ms); + loop { let result = self.evaluate(&js).await?; let found = result .get("result") @@ -1109,11 +1141,23 @@ impl<'a> BrowserActions<'a> { .and_then(|v| v.as_bool()) .unwrap_or(false); if found { - return Ok( - json!({ "success": true, "action": "wait", "condition": cond }), - ); + return Ok(json!({ + "success": true, + "action": "wait", + "condition": cond, + "timeout_ms": timeout_ms, + })); } - tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let remaining = deadline + .saturating_duration_since(tokio::time::Instant::now()) + .as_millis() as u64; + if remaining == 0 { + break; + } + tokio::time::sleep(std::time::Duration::from_millis( + remaining.min(POLL_INTERVAL_MS), + )) + .await; } return Err(structured_error( ErrorCode::Timeout, @@ -1123,7 +1167,18 @@ impl<'a> BrowserActions<'a> { } } } - Ok(json!({ "success": true, "action": "wait" })) + // No duration and no condition: there is nothing to wait for. Reporting + // success here used to make a mis-keyed duration (`ms` instead of + // `duration_ms`) look like a completed wait that in fact returned + // instantly, so the agent silently skipped its pause. + Err(structured_error( + ErrorCode::InvalidParams, + "wait requires a duration or a condition", + &[ + "Pass `duration_ms` (alias `ms`) to pause, e.g. { \"duration_ms\": 1800000 } for 30 minutes", + "Or pass `condition`: 'load' | 'domcontentloaded' | 'networkidle' | a CSS/@ref selector", + ], + )) } // ── Capture ──────────────────────────────────────────────────────── diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index 7dfd964cc..66032288f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -8,7 +8,7 @@ //! Local desktop and OS/system actions are intentionally surfaced through the //! dedicated ComputerUse tool/agent, not through public ControlHub domains. -use crate::agentic::tools::browser_control::actions::BrowserActions; +use crate::agentic::tools::browser_control::actions::{BrowserActions, MAX_WAIT_MS}; use crate::agentic::tools::browser_control::browser_launcher::{ BrowserKind, BrowserLauncher, LaunchResult, DEFAULT_CDP_PORT, }; @@ -267,6 +267,11 @@ Use this tool via `{ domain, action, params }` for browser automation, terminal * `connect { mode: "headless" }` — attach to an already-running headless browser on the headless test port 9223. This mode never starts a browser; when nothing is listening it returns `NOT_AVAILABLE` together with the exact launch command. * `params.port` overrides the CDP port for `connect` and for every other CDP action; after `connect`, actions reuse the connected session's port automatically. - Actions: open_builtin, connect, tab_new, navigate, back, forward, reload, snapshot, click, hover, fill, type, check, uncheck, select, press_key, scroll, auto_scroll, wait, get, get_text, get_url, get_title, get_html, screenshot, evaluate, fetch, cookies, set_cookies, set_file_input_files, cdp, network, console, errors, trace, dialog, read_article, close, list_pages, tab_query, switch_page, list_sessions. +- Pausing: + * `wait { duration_ms }` — pause for a fixed time, up to 60 minutes (`ms` and `seconds` are accepted spellings). This is the action to use when you must idle between rounds of work, e.g. `{ "duration_ms": 1800000 }` to resume in 30 minutes. It needs no browser session, and the result reports the `ms` actually waited, so check that figure before assuming the full pause happened. + * `wait { condition, timeout_ms? }` — wait on the page instead: 'load' | 'domcontentloaded' | 'networkidle' | a CSS/@ref selector, bounded by `timeout_ms` (default 15s). Requires a connected session. When a `condition` is present it always wins, and any duration you pass becomes its timeout rather than a separate sleep. + * A `wait` carrying neither is rejected with `INVALID_PARAMS` — it never silently returns. + * `wait` holds the turn open for its whole duration, so it suits a one-off pause, not a schedule. For work that should repeat ("produce another round every 30 minutes") or resume more than an hour out, create a job with the `Cron` tool instead — it ends the turn and re-invokes you when the job fires, rather than idling with the context loaded. - Automation workflow: connect -> navigate -> snapshot (returns @e1, @e2 ... refs) -> click/fill with `{ "selector": "@e1" }` (the key `ref` is accepted too). - Take a fresh snapshot after any DOM mutation; a stale `@eN` ref returns `error.code = STALE_REF`, while a selector that matches nothing returns `NOT_FOUND`. @@ -682,12 +687,100 @@ Branch on `ok` and `error.code`, not on English messages. ) } + /// Sleep for `requested_ms`, interruptibly. + /// + /// The sleep races the turn's cancellation token: a 30-minute pace wait + /// that ignored it would leave the user unable to stop the agent for half + /// an hour. Cancellation surfaces as `BitFunError::Cancelled`, which the + /// pipeline already records as a terminal cancelled state rather than a + /// tool failure. + async fn wait_for_duration( + requested_ms: u64, + context: &ToolUseContext, + ) -> BitFunResult> { + let waited_ms = requested_ms.min(MAX_WAIT_MS); + let sleep = tokio::time::sleep(std::time::Duration::from_millis(waited_ms)); + + if let Some(token) = context.cancellation_token() { + tokio::select! { + _ = sleep => {} + _ = token.cancelled() => { + return Err(BitFunError::Cancelled(format!( + "browser.wait cancelled before the {} pause elapsed", + format_duration_ms(waited_ms) + ))); + } + } + } else { + sleep.await; + } + + let (data, summary) = Self::wait_outcome(requested_ms); + Ok(vec![ToolResult::ok(data, Some(summary))]) + } + + /// Describe a completed duration wait. + /// + /// The old payload carried no duration at all, so a wait that returned + /// instantly was indistinguishable from one that ran to completion; both + /// printed "Wait completed". State the elapsed time, and say plainly when + /// the request was clamped instead of quietly waiting less than asked. + fn wait_outcome(requested_ms: u64) -> (Value, String) { + let waited_ms = requested_ms.min(MAX_WAIT_MS); + let clamped = waited_ms != requested_ms; + let summary = if clamped { + format!( + "Waited {} (requested {} — clamped to the {} maximum)", + format_duration_ms(waited_ms), + format_duration_ms(requested_ms), + format_duration_ms(MAX_WAIT_MS) + ) + } else { + format!("Waited {}", format_duration_ms(waited_ms)) + }; + ( + json!({ + "success": true, + "action": "wait", + "ms": waited_ms, + "requested_ms": requested_ms, + "clamped": clamped, + }), + summary, + ) + } + async fn handle_browser( &self, action: &str, params: &Value, context: &ToolUseContext, ) -> BitFunResult> { + // A duration wait is a pure pause: it touches no page, so it must not + // require (or even resolve) a CDP session — agents pace themselves with + // this long before they open a browser. Condition waits fall through to + // the session-backed path below, where any duration the caller also + // passed becomes the condition's timeout rather than a separate sleep. + if action == "wait" && wait_condition(params).is_none() { + if let Some(requested_ms) = wait_duration_ms(params) { + return Self::wait_for_duration(requested_ms, context).await; + } + return Ok(err_response( + "browser", + "wait", + ControlHubError::new( + ErrorCode::InvalidParams, + "browser.wait requires either a duration or a `condition`.", + ) + .with_hint( + "To pause, pass `duration_ms` (alias `ms`), e.g. { \"duration_ms\": 1800000 } for 30 minutes.", + ) + .with_hint( + "To wait on the page, pass `condition`: 'load' | 'domcontentloaded' | 'networkidle' | a CSS/@ref selector.", + ), + )); + } + let session_id_param = params .get("session_id") .and_then(|v| v.as_str()) @@ -1642,10 +1735,23 @@ Branch on `ok` and `error.code`, not on English messages. )]) } "wait" => { - let ms = params.get("duration_ms").and_then(|v| v.as_u64()); - let cond = params.get("condition").and_then(|v| v.as_str()); - let result = actions.wait(ms, cond).await?; - Ok(vec![ToolResult::ok(result, Some("Wait completed".to_string()))]) + // Duration waits already returned from the session-free + // path in `handle_browser`; only condition waits, which + // genuinely need the page, reach here. A duration passed + // alongside the condition bounds it instead of being + // dropped. + let cond = wait_condition(params); + let result = actions + .wait(None, cond, wait_condition_timeout_ms(params)) + .await?; + let summary = match result.get("timed_out").and_then(|v| v.as_bool()) { + Some(true) => format!( + "Timed out waiting for '{}'", + cond.unwrap_or("condition") + ), + _ => format!("Waited for '{}'", cond.unwrap_or("condition")), + }; + Ok(vec![ToolResult::ok(result, Some(summary))]) } "get_text" => { let selector = match selector_param(params) { @@ -2416,6 +2522,11 @@ impl Tool for ControlHubTool { // Wrap legacy handler results into the unified envelope. match dispatched { Ok(results) => Ok(envelope_wrap_results(domain, action, results)), + // Cancellation is the pipeline's own terminal state, not a tool + // failure. Folding it into an `ok: false` envelope would both hide + // the user's stop from the pipeline and invite the model to + // "recover" from a turn that is already being torn down. + Err(err @ BitFunError::Cancelled(_)) => Err(err), Err(err) => Ok(err_response( domain, action, @@ -2471,6 +2582,94 @@ fn selector_param(params: &Value) -> Option<&str> { .filter(|selector| !selector.is_empty()) } +/// A duration parameter, in milliseconds, under any of `keys`. +/// +/// A model that writes `"1800000"` as a string still means 1_800_000 ms, and +/// one that writes `1.5` seconds means 1500 ms. +fn duration_param_ms(params: &Value, keys: &[&str], unit_ms: f64) -> Option { + keys.iter() + .find_map(|key| { + let value = params.get(*key)?; + value + .as_f64() + .or_else(|| value.as_str()?.trim().parse::().ok()) + .filter(|n| n.is_finite() && *n >= 0.0) + }) + .map(|n| (n * unit_ms).round() as u64) +} + +/// Read a `wait` pause duration out of `params`, in milliseconds. +/// +/// The action used to read `duration_ms` and nothing else, so the very +/// plausible `{ "ms": 1800000 }` was dropped on the floor and the call +/// returned instantly while still reporting success. Accept the obvious +/// spellings instead — a wait that silently does not wait is far worse than a +/// slightly wide parameter surface. Millisecond keys are checked before second +/// keys so a call carrying both cannot be read in the wrong unit. +/// +/// A `condition` always wins: next to one, every duration key reads as "wait +/// for this, but no longer than" rather than as a pause, so this returns +/// `None` and [`wait_condition_timeout_ms`] takes the value instead. Sleeping +/// on `{ condition, timeout_ms }` would never look at the page at all. +fn wait_duration_ms(params: &Value) -> Option { + const MS_KEYS: [&str; 5] = ["duration_ms", "ms", "wait_ms", "sleep_ms", "timeout_ms"]; + const SECOND_KEYS: [&str; 6] = [ + "duration_seconds", + "duration_s", + "seconds", + "secs", + "sleep_seconds", + "wait_seconds", + ]; + + if wait_condition(params).is_some() { + return None; + } + duration_param_ms(params, &MS_KEYS, 1.0) + .or_else(|| duration_param_ms(params, &SECOND_KEYS, 1_000.0)) +} + +/// The `condition` a `wait` should watch for, if the caller named a usable one. +fn wait_condition(params: &Value) -> Option<&str> { + params + .get("condition") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|cond| !cond.is_empty()) +} + +/// Read the bound on a `wait { condition }` — how long to keep waiting for the +/// page before giving up. +fn wait_condition_timeout_ms(params: &Value) -> Option { + duration_param_ms( + params, + &["condition_timeout_ms", "timeout_ms", "duration_ms", "ms"], + 1.0, + ) + .or_else(|| duration_param_ms(params, &["timeout_seconds", "seconds"], 1_000.0)) +} + +/// Render a wait duration the way a person would say it, so the tool summary +/// reads as "Waited 30m" rather than "Wait completed" — the latter is exactly +/// what a zero-length wait used to print. +fn format_duration_ms(ms: u64) -> String { + let total_secs = ms / 1_000; + let (hours, minutes, seconds) = ( + total_secs / 3_600, + (total_secs % 3_600) / 60, + total_secs % 60, + ); + if hours > 0 { + format!("{hours}h{minutes:02}m") + } else if minutes > 0 { + format!("{minutes}m{seconds:02}s") + } else if total_secs > 0 { + format!("{total_secs}s") + } else { + format!("{ms}ms") + } +} + /// An `@eN` ref that no longer resolves means the snapshot it came from is /// stale, which the model recovers from by re-snapshotting; a CSS selector /// that matches nothing is simply not found. @@ -2667,6 +2866,182 @@ mod control_hub_tests { ); } + #[test] + fn wait_duration_accepts_the_spellings_models_actually_emit() { + // `ms` is what the model reached for in the field; reading only + // `duration_ms` dropped it and turned a 30-minute pause into a no-op. + for key in ["duration_ms", "ms", "wait_ms", "sleep_ms"] { + assert_eq!( + wait_duration_ms(&json!({ key: 1_800_000u64 })), + Some(1_800_000), + "millisecond key {key} must be honoured" + ); + } + for key in [ + "duration_seconds", + "duration_s", + "seconds", + "secs", + "sleep_seconds", + "wait_seconds", + ] { + assert_eq!( + wait_duration_ms(&json!({ key: 90 })), + Some(90_000), + "second key {key} must be converted to milliseconds" + ); + } + assert_eq!(wait_duration_ms(&json!({ "ms": "1500" })), Some(1_500)); + assert_eq!(wait_duration_ms(&json!({ "seconds": 1.5 })), Some(1_500)); + // Both units present: milliseconds win, so the wait can never be read + // a thousand times too short. + assert_eq!( + wait_duration_ms(&json!({ "seconds": 5, "duration_ms": 1_800_000u64 })), + Some(1_800_000) + ); + assert_eq!(wait_duration_ms(&json!({ "condition": "load" })), None); + assert_eq!(wait_duration_ms(&json!({ "ms": -5 })), None); + assert_eq!(wait_duration_ms(&json!({ "ms": "soon" })), None); + // On its own `timeout_ms` can only mean the pause itself. + assert_eq!( + wait_duration_ms(&json!({ "timeout_ms": 5_000 })), + Some(5_000) + ); + } + + #[test] + fn a_condition_wait_keeps_its_timeout_instead_of_becoming_a_sleep() { + // `{ condition, timeout_ms }` means "wait for this, but no longer + // than". Reading `timeout_ms` as a pause would sleep and never look at + // the page at all. + let params = json!({ "condition": "networkidle", "timeout_ms": 30_000 }); + assert_eq!(wait_duration_ms(¶ms), None); + assert_eq!(wait_condition_timeout_ms(¶ms), Some(30_000)); + + // A duration passed next to a condition bounds it rather than being + // dropped on the floor. + let params = json!({ "condition": "#done", "duration_ms": 45_000 }); + assert_eq!(wait_duration_ms(¶ms), None); + assert_eq!(wait_condition_timeout_ms(¶ms), Some(45_000)); + + // Nothing given: the action falls back to its own default. + assert_eq!( + wait_condition_timeout_ms(&json!({ "condition": "load" })), + None + ); + assert_eq!( + wait_condition_timeout_ms(&json!({ "condition": "load", "timeout_seconds": 20 })), + Some(20_000) + ); + } + + #[tokio::test] + async fn browser_wait_actually_sleeps_and_reports_the_elapsed_time() { + let tool = ControlHubTool::new(); + let ctx = empty_context(); + let started = std::time::Instant::now(); + let results = tool + // No browser session exists in this test: a duration wait must not + // need one. + .dispatch("browser", "wait", &json!({ "ms": 250 }), &ctx) + .await + .expect("duration wait should succeed"); + assert!( + started.elapsed() >= std::time::Duration::from_millis(240), + "wait returned after only {:?}", + started.elapsed() + ); + // `dispatch` returns the raw handler payload; `call_impl` is what adds + // the `{ ok, domain, action, data }` envelope. + let data = results.first().expect("one result").content(); + assert_eq!(data.get("ms").and_then(|v| v.as_u64()), Some(250)); + assert_eq!(data.get("clamped").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(data.get("success").and_then(|v| v.as_bool()), Some(true)); + } + + #[test] + fn browser_wait_clamps_absurd_durations_and_says_so() { + // Asserted on the reporting helper rather than through `dispatch`, so + // the test does not have to sit through the wait itself. + let (data, summary) = ControlHubTool::wait_outcome(MAX_WAIT_MS * 3); + assert_eq!(data.get("ms").and_then(|v| v.as_u64()), Some(MAX_WAIT_MS)); + assert_eq!( + data.get("requested_ms").and_then(|v| v.as_u64()), + Some(MAX_WAIT_MS * 3) + ); + assert_eq!(data.get("clamped").and_then(|v| v.as_bool()), Some(true)); + // A shortened wait must announce itself; silently waiting less than + // asked is how the agent ends up out of step with the schedule. + assert!(summary.contains("clamped"), "got: {summary}"); + + let (data, summary) = ControlHubTool::wait_outcome(1_800_000); + assert_eq!(data.get("clamped").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(summary, "Waited 30m00s"); + } + + #[tokio::test] + async fn browser_wait_without_duration_or_condition_is_rejected() { + let tool = ControlHubTool::new(); + let ctx = empty_context(); + let results = tool + .dispatch("browser", "wait", &json!({}), &ctx) + .await + .expect("reported in-band"); + let payload = results.first().unwrap().content(); + // Reporting success for a wait that did not wait is the failure this + // guards: the agent moved straight on believing it had paused. + assert_eq!(payload.get("ok").and_then(|v| v.as_bool()), Some(false)); + let error = payload.get("error").expect("error envelope"); + assert_eq!( + error.get("code").and_then(|v| v.as_str()), + Some("INVALID_PARAMS") + ); + } + + #[tokio::test] + async fn browser_wait_is_interrupted_by_cancellation() { + let token = tokio_util::sync::CancellationToken::new(); + let mut ctx = empty_context(); + ctx.runtime_handles = + bitfun_runtime_ports::ToolRuntimeHandles::new(None, Some(token.clone())); + + let started = std::time::Instant::now(); + // Driven through `call_impl` so this also covers the envelope layer, + // which must let a cancellation through instead of reporting it as an + // ordinary `ok: false` tool error. + let waiter = tokio::spawn(async move { + ControlHubTool::new() + .call_impl( + &json!({ + "domain": "browser", + "action": "wait", + "params": { "seconds": 600 }, + }), + &ctx, + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + token.cancel(); + let outcome = waiter.await.expect("wait task joins"); + + // Without this, stopping the agent could not take effect until the + // pause elapsed — ten minutes of an unstoppable turn. + assert!( + matches!(outcome, Err(BitFunError::Cancelled(_))), + "a long pace wait must stay interruptible" + ); + assert!(started.elapsed() < std::time::Duration::from_secs(30)); + } + + #[test] + fn wait_durations_are_formatted_for_humans() { + assert_eq!(format_duration_ms(1_800_000), "30m00s"); + assert_eq!(format_duration_ms(MAX_WAIT_MS), "1h00m"); + assert_eq!(format_duration_ms(1_500), "1s"); + assert_eq!(format_duration_ms(250), "250ms"); + } + #[tokio::test] async fn meta_capabilities_reports_local_client_and_domain_table() { let tool = ControlHubTool::new(); @@ -2975,6 +3350,23 @@ mod control_hub_tests { ); } + #[tokio::test] + async fn description_documents_wait_params_and_routes_schedules_to_cron() { + let desc = ControlHubTool::new().description().await.unwrap(); + // The action took `duration_ms` and documented nothing, so the model + // guessed `ms` and got a silent no-op. + assert!( + desc.contains("`wait { duration_ms }`") && desc.contains("`ms`"), + "description must name the wait duration parameter and its aliases" + ); + // A recurring pace should not be built out of hour-long waits that pin + // the turn open. + assert!( + desc.contains("`Cron` tool"), + "description must point repeating schedules at Cron" + ); + } + #[tokio::test] async fn description_defaults_url_opening_to_builtin_browser() { let desc = ControlHubTool::new().description().await.unwrap(); From 91f80e201b6f1be122a3f5d0726de14a8b34d882 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 11 Aug 2026 14:15:10 +0800 Subject: [PATCH 08/39] fix(appearance): drop undefined radiusBase/spacing4 from prompt snapshots, add bootstrap regression test --- .../generated/appearance_prompt_snapshots.json | 16 ---------------- .../builtins/appearancePromptSnapshots.ts | 4 ---- .../startupAppearanceBootstrap.test.ts | 18 ++++++++++++++++++ 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json b/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json index 3d26b8c0e..efd3e28aa 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json +++ b/src/crates/assembly/core/src/agentic/tools/implementations/generated/appearance_prompt_snapshots.json @@ -15,8 +15,6 @@ "accent600": "#475569", "borderBase": "rgba(100, 116, 139, 0.22)", "elementBase": "rgba(15, 23, 42, 0.09)", - "radiusBase": "8px", - "spacing4": "16px", "shadowBase": "0 4px 8px rgba(71, 85, 105, 0.1)", "styleNotes": "Light appearance - Neutral gray surfaces, black primary actions" }, @@ -32,8 +30,6 @@ "accent600": "#64748b", "borderBase": "rgba(255, 255, 255, 0.18)", "elementBase": "rgba(255, 255, 255, 0.1)", - "radiusBase": "6px", - "spacing4": "16px", "shadowBase": "0 4px 8px rgba(0, 0, 0, 0.75)", "styleNotes": "Slate gray geometric appearance - Deep immersion, high contrast grayscale aesthetics" }, @@ -49,8 +45,6 @@ "accent600": "#3b82f6", "borderBase": "rgba(255, 255, 255, 0.18)", "elementBase": "rgba(255, 255, 255, 0.1)", - "radiusBase": "8px", - "spacing4": "16px", "shadowBase": "0 4px 8px rgba(0, 0, 0, 0.7)", "styleNotes": "Default dark appearance" }, @@ -66,8 +60,6 @@ "accent600": "#3b82f6", "borderBase": "rgba(255, 255, 255, 0.14)", "elementBase": "rgba(255, 255, 255, 0.09)", - "radiusBase": "8px", - "spacing4": "16px", "shadowBase": "0 4px 8px rgba(0, 0, 0, 0.7)", "styleNotes": "Midnight gray dark appearance - Professional and elegant, inspired by JetBrains IDE" }, @@ -83,8 +75,6 @@ "accent600": "#234a6d", "borderBase": "rgba(106, 92, 70, 0.2)", "elementBase": "rgba(46, 94, 138, 0.1)", - "radiusBase": "6px", - "spacing4": "16px", "shadowBase": "0 4px 8px rgba(106, 92, 70, 0.1)", "styleNotes": "Chinese style appearance - Rice paper and ink, blue and vermilion, warm and elegant" }, @@ -100,8 +90,6 @@ "accent600": "#5a8bb3", "borderBase": "rgba(232, 232, 232, 0.16)", "elementBase": "rgba(115, 165, 204, 0.12)", - "radiusBase": "6px", - "spacing4": "16px", "shadowBase": "0 4px 8px rgba(0, 0, 0, 0.65)", "styleNotes": "Chinese dark appearance - Starlit ink night, moonlight like water, serene and elegant" }, @@ -117,8 +105,6 @@ "accent600": "#00ccff", "borderBase": "rgba(0, 230, 255, 0.2)", "elementBase": "rgba(0, 230, 255, 0.13)", - "radiusBase": "6px", - "spacing4": "16px", "shadowBase": "0 4px 12px rgba(0, 0, 0, 0.8)", "styleNotes": "Tech-style appearance - Deep black hole, neon future, ultimate tech aesthetics" }, @@ -134,8 +120,6 @@ "accent600": "#6183bb", "borderBase": "rgba(51, 65, 85, 0.6)", "elementBase": "rgba(122, 162, 247, 0.11)", - "radiusBase": "6px", - "spacing4": "16px", "shadowBase": "0 4px 12px rgba(0, 0, 0, 0.48)", "styleNotes": "Tokyo Night - deep indigo base with soft blue and magenta accents" } diff --git a/src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts b/src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts index 88d671d05..f5cf79fb8 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/appearancePromptSnapshots.ts @@ -15,8 +15,6 @@ export interface AppearancePromptSnapshotEntry { accent600: string; borderBase: string; elementBase: string; - radiusBase: string; - spacing4: string; shadowBase: string; styleNotes: string; } @@ -43,8 +41,6 @@ export function createAppearancePromptSnapshotEntry( accent600: palette.colors.accent[600], borderBase: palette.colors.border.base, elementBase: palette.colors.element.base, - radiusBase: palette.effects.radius.base, - spacing4: palette.effects.spacing[4], shadowBase: palette.effects.shadow.base, styleNotes: palette.description ?? palette.name, }; diff --git a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts index 37e534a9e..0c616d35b 100644 --- a/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts +++ b/src/web-ui/src/infrastructure/appearance/builtins/startupAppearanceBootstrap.test.ts @@ -66,6 +66,24 @@ describe('appearance prompt snapshot manifest', () => { expect(manifest.appearances).toHaveLength(builtinAppearancePalettes.length); expect(manifest.appearances.find(entry => entry.id === DEFAULT_DARK_APPEARANCE_ID)?.mode) .toBe('dark'); + + const light = manifest.appearances.find(entry => entry.id === DEFAULT_LIGHT_APPEARANCE_ID); + const palette = builtinAppearancePalettes.find(entry => entry.id === DEFAULT_LIGHT_APPEARANCE_ID); + expect(light).toEqual({ + id: palette?.id, + mode: palette?.type, + bgPrimary: palette?.colors.background.primary, + bgSecondary: palette?.colors.background.secondary, + bgScene: palette?.colors.background.scene, + textPrimary: palette?.colors.text.primary, + textMuted: palette?.colors.text.muted, + accent500: palette?.colors.accent[500], + accent600: palette?.colors.accent[600], + borderBase: palette?.colors.border.base, + elementBase: palette?.colors.element.base, + shadowBase: palette?.effects.shadow.base, + styleNotes: palette?.description ?? palette?.name, + }); }); it('keeps the committed generative UI manifest synchronized', () => { From 960d2a6e00ac8d18b5284d736f826ba558c3b653 Mon Sep 17 00:00:00 2001 From: user Date: Tue, 11 Aug 2026 14:34:56 +0800 Subject: [PATCH 09/39] feat(tools): add DeepReview tool - background CodeReview dispatch from any context --- .../src/agentic/tools/implementations/mod.rs | 2 +- .../implementations/task/deep_review_tool.rs | 273 ++++++++++++++++++ .../agentic/tools/implementations/task/mod.rs | 2 + .../tools/product_runtime/materialization.rs | 1 + .../core/src/agentic/tools/restrictions.rs | 5 + 5 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review_tool.rs diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index a47e1b1f4..7fb4db36f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -142,7 +142,7 @@ pub use session_control_tool::SessionControlTool; pub use session_history_tool::SessionHistoryTool; pub use session_message_tool::SessionMessageTool; pub use skill_tool::SkillTool; -pub use task::{LaunchReviewAgentTool, TaskTool}; +pub use task::{DeepReviewTool, LaunchReviewAgentTool, TaskTool}; pub use terminal_control_tool::TerminalControlTool; pub use thread_goal_tools::{CreateGoalTool, GetGoalTool, UpdateGoalTool}; pub use todo_write_tool::TodoWriteTool; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review_tool.rs new file mode 100644 index 000000000..24aab0def --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/deep_review_tool.rs @@ -0,0 +1,273 @@ +//! DeepReview tool — dispatch a background CodeReview subagent from any context. +//! +//! Unlike `LaunchReviewAgentTool` (which is bound to a prepared DeepReview run +//! manifest and always runs foreground), this tool lets a commander / agent in +//! any session start a read-only CodeReview subagent in the background and +//! receive the spawned task handle (`bg_task_id`) for asynchronous result +//! collection via AgentWait / SessionMessage. + +use super::*; + +/// Tool name exposed to models and the product tool runtime. +pub(super) const DEEP_REVIEW_TOOL_NAME: &str = "DeepReview"; + +/// Background CodeReview subagent type id. +const DEEP_REVIEW_SUBAGENT_TYPE: &str = "CodeReview"; + +#[derive(Debug, Clone)] +struct DeepReviewInvocation { + description: String, + target: Option, + focus: Option, + strategy: Option, + model_id: Option, + timeout_seconds: Option, +} + +pub struct DeepReviewTool; + +impl Default for DeepReviewTool { + fn default() -> Self { + Self::new() + } +} + +impl DeepReviewTool { + pub fn new() -> Self { + Self + } + + fn input_schema() -> Value { + json!({ + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the review being dispatched." + }, + "target": { + "type": "string", + "description": "Optional review target: a file path, a comma-separated list of paths, or a git range (e.g. HEAD~3..HEAD). When omitted the reviewer inspects the workspace state." + }, + "focus": { + "type": "string", + "description": "Optional review lens, e.g. security, performance, logic correctness, architecture, UI. When omitted the reviewer applies an adversarial full-spectrum lens." + }, + "strategy": { + "type": "string", + "enum": ["quick", "standard", "deep"], + "description": "Optional review intensity. quick = critical/high only, standard = + medium, deep = exhaustive including cosmetic. Defaults to standard." + }, + "model_id": { + "type": "string", + "description": "Optional model or model slot for the reviewer. Omit to use the agent default." + }, + "timeout_seconds": { + "type": "integer", + "minimum": 0, + "description": "Optional timeout for the background reviewer in seconds. When omitted, the agent default applies." + } + }, + "required": ["description"], + "additionalProperties": false + }) + } + + fn parse_invocation(input: &Value) -> BitFunResult { + let required_string = |field: &str| -> BitFunResult { + input + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .ok_or_else(|| BitFunError::tool(format!("{field} is required for DeepReview"))) + }; + let optional_string = |field: &str| -> Option { + input + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }; + let timeout_seconds = match input.get("timeout_seconds") { + Some(value) => { + let parsed = value.as_u64().ok_or_else(|| { + BitFunError::tool("timeout_seconds must be a non-negative integer".to_string()) + })?; + (parsed > 0).then_some(parsed) + } + None => None, + }; + Ok(DeepReviewInvocation { + description: required_string("description")?, + target: optional_string("target"), + focus: optional_string("focus"), + strategy: optional_string("strategy"), + model_id: optional_string("model_id"), + timeout_seconds, + }) + } + + fn render_description() -> String { + r#"Dispatch a background read-only code review. + +Creates a CodeReview subagent in the background and returns the spawned task handle immediately. Collect the result asynchronously with AgentWait (bg_task_id) or SessionMessage once the reviewer replies. + +- `description`: short label for the review run. +- `target`: optional file path, comma-separated paths, or git range (e.g. HEAD~3..HEAD). +- `focus`: optional review lens (security, performance, logic correctness, architecture, UI). +- `strategy`: quick (critical/high only) | standard (+ medium) | deep (exhaustive incl. cosmetic). Defaults to standard. +- `model_id`: optional model or model slot for the reviewer. +- `timeout_seconds`: optional timeout for the background reviewer. + +The reviewer is read-only: it inspects and reports findings, it never modifies files."# + .to_string() + } + + fn build_review_prompt(invocation: &DeepReviewInvocation) -> String { + let mut parts = Vec::new(); + parts.push( + "独立对抗性代码审查。只读:检查并报告发现,绝不修改任何文件。\n".to_string(), + ); + if let Some(target) = &invocation.target { + parts.push(format!("审查目标:{target}\n")); + } + if let Some(focus) = &invocation.focus { + parts.push(format!("聚焦维度:{focus}\n")); + } + let strategy = invocation.strategy.as_deref().unwrap_or("standard"); + let depth = match strategy { + "quick" => "仅 critical/high 级问题,忽略 cosmetic。", + "deep" => "穷尽式:含 cosmetic,任何死角不留。", + _ => "critical/high/medium 级问题 + 关键 cosmetic。", + }; + parts.push(format!("审查强度:{strategy}({depth})\n")); + parts.push( + "输出:按严重度(critical/high/medium/low/info)分级列出发现,每条附证据(文件:行号)、影响、修复建议;最后给总体判定(approve / approve_with_suggestions / request_changes / block)。" + .to_string(), + ); + parts.join("\n") + } + + async fn call_deep_review_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let invocation = Self::parse_invocation(input)?; + let review_prompt = Self::build_review_prompt(&invocation); + + let mut task_input = json!({ + "description": invocation.description, + "prompt": review_prompt, + "subagent_type": DEEP_REVIEW_SUBAGENT_TYPE, + "run_in_background": true, + }); + if let Some(model_id) = &invocation.model_id { + task_input["model_id"] = json!(model_id); + } + if let Some(timeout_seconds) = invocation.timeout_seconds { + task_input["timeout_seconds"] = json!(timeout_seconds); + } + + TaskTool::new() + .call_task_impl(&task_input, context) + .await + } +} + +#[async_trait] +impl Tool for DeepReviewTool { + fn name(&self) -> &str { + DEEP_REVIEW_TOOL_NAME + } + + fn manages_own_execution_timeout(&self) -> bool { + true + } + + async fn description(&self) -> BitFunResult { + Ok(Self::render_description()) + } + + async fn is_available_in_context(&self, _context: Option<&ToolUseContext>) -> bool { + true + } + + fn short_description(&self) -> String { + "Dispatch a background read-only code review (CodeReview subagent).".to_string() + } + + fn input_schema(&self) -> Value { + Self::input_schema() + } + + fn is_readonly(&self) -> bool { + false + } + + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { + // Background CodeReview spawns are intentionally serialized (same + // policy as TaskTool spawning CodeReview) to avoid review overlap. + false + } + + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let _ = Self::parse_invocation(input)?; + Ok(vec![PermissionIntent::new( + "task", + vec![DEEP_REVIEW_SUBAGENT_TYPE.to_string()], + )]) + } + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { + match Self::parse_invocation(input) { + Ok(invocation) => { + if let Some(result) = TaskTool::validate_prompt_size( + &json!({ "prompt": Self::build_review_prompt(&invocation) }), + ) { + return result; + } + ValidationResult { + result: true, + message: None, + error_code: None, + meta: None, + } + } + Err(error) => TaskTool::invalid_input(error.to_string()), + } + } + + fn render_tool_use_message(&self, input: &Value, options: &ToolRenderOptions) -> String { + input + .get("description") + .and_then(Value::as_str) + .map(|description| { + if options.verbose { + format!("Dispatching DeepReview: {}", description) + } else { + format!("DeepReview: {}", description) + } + }) + .unwrap_or_else(|| "Dispatching DeepReview".to_string()) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + self.call_deep_review_impl(input, context).await + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs index 88a8d82b1..a026c0c6f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs @@ -37,12 +37,14 @@ use std::time::Instant; mod background; mod deep_review; +mod deep_review_tool; mod execution; mod input; mod launch_review_agent; mod schema; mod validation; +pub use deep_review_tool::DeepReviewTool; pub use launch_review_agent::LaunchReviewAgentTool; pub struct TaskTool; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index f8cb00b3b..15848d8de 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -71,6 +71,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "PlanRead" => Some(Arc::new(PlanReadTool::new())), "PlanUpdate" => Some(Arc::new(PlanUpdateTool::new())), "submit_code_review" => Some(Arc::new(CodeReviewTool::new())), + "DeepReview" => Some(Arc::new(DeepReviewTool::new())), "GetToolSpec" => Some(Arc::new(GetToolSpecTool::new())), "CallDeferredTool" => Some(Arc::new(CallDeferredTool::new())), #[cfg(feature = "tools-git")] diff --git a/src/crates/assembly/core/src/agentic/tools/restrictions.rs b/src/crates/assembly/core/src/agentic/tools/restrictions.rs index b39ea9166..0ae34209a 100644 --- a/src/crates/assembly/core/src/agentic/tools/restrictions.rs +++ b/src/crates/assembly/core/src/agentic/tools/restrictions.rs @@ -172,6 +172,9 @@ fn build_default_role_permissions() -> RolePermissionMap { // review/探索形态附加只读工具(不在 subagent_default_tools() 内): // LaunchReviewAgent(review 编排入口,deferred)+ LS(目录形态只读)。 allowed_tools.insert("LaunchReviewAgent".to_string()); + // DeepReview:后台 CodeReview 派发工具(指挥官/任意会话可用), + // 子代理侧 review 形态同样需要(DeepReview 会话内可再派发审查)。 + allowed_tools.insert("DeepReview".to_string()); allowed_tools.insert("LS".to_string()); let mut restrictions = ToolRuntimeRestrictions { allowed_operation_classes: allowed_ops, @@ -204,6 +207,8 @@ fn build_default_role_permissions() -> RolePermissionMap { allowed_tools.insert("submit_code_review".to_string()); // review/探索形态附加只读工具(与 Executor 同源)。 allowed_tools.insert("LaunchReviewAgent".to_string()); + // DeepReview:后台 CodeReview 派发工具(审查官可再派发审查)。 + allowed_tools.insert("DeepReview".to_string()); allowed_tools.insert("LS".to_string()); // Deferred 工具链核心(与 Executor/Commander 同源)。 allowed_tools.insert("GetToolSpec".to_string()); From f8c5402c6f5eaf937dd161c22af834559c665e8e Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 00:12:06 -0700 Subject: [PATCH 10/39] fix(agents): give every ControlHub mode the Cron tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked to sweep a set of channels every 30 minutes, a Cowork-mode agent replied that it had no cron tool and fell back to chaining long `wait` calls. It was telling the truth: `Cron` was in Claw's tool list and nowhere else, so in Cowork, Team, DeepResearch, and the four shared coding modes the agent could not see it at all. Deferred exposure is not the cause — deferred tools are advertised by name — the tool simply was not in those modes' `default_tools`. ControlHub's `wait` documentation now tells the agent to schedule repeating work with Cron instead of holding the turn open, and ControlHub ships in all of those modes, so the guidance pointed at a tool the agent did not have. - Add `Cron` to Cowork, Team, DeepResearch, and `shared_coding_mode_tools` (agentic / debug / multitask / plan), so it is available everywhere ControlHub is. - Cover the pairing with an invariant test over the built-in modes; it caught the shared coding baseline, which the first pass had missed. - Say in ControlHub's description what to do if Cron is genuinely absent, rather than leaving a chain of long waits as the silent fallback. Scheduling stays behind the normal permission gate — Cron is not read-only and emits a `custom_tool` intent — so this widens what the agent can propose, not what it can do unattended. --- .../agents/definitions/modes/cowork.rs | 14 +++++++++++ .../agents/definitions/modes/deep_research.rs | 4 ++++ .../agentic/agents/definitions/modes/team.rs | 4 ++++ .../assembly/core/src/agentic/agents/mod.rs | 4 ++++ .../core/src/agentic/agents/registry/tests.rs | 24 +++++++++++++++++++ .../tools/implementations/control_hub_tool.rs | 2 +- 6 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 2fb8f9458..3d163af35 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -56,6 +56,11 @@ impl CoworkMode { "WebSearch".to_string(), "WebFetch".to_string(), "ControlHub".to_string(), + // Recurring office work ("check these channels every 30 + // minutes") is squarely this mode's job, and ControlHub's + // `wait` sends schedules here rather than pinning a turn open + // for the interval. + "Cron".to_string(), "InitMiniApp".to_string(), "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), @@ -120,6 +125,15 @@ mod tests { } } + #[test] + fn cowork_mode_can_schedule_recurring_work() { + // Asked to sweep a set of channels every 30 minutes, this mode used to + // reply that it had no cron tool — accurately, because Cron was not in + // its list — and fall back to chaining long waits. + let tools = CoworkMode::new().default_tools(); + assert!(tools.contains(&"Cron".to_string())); + } + #[test] fn cowork_mode_includes_miniapp_lifecycle_tools_in_defaults() { let tools = CoworkMode::new().default_tools(); diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs index 001147328..5eababbd8 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/deep_research.rs @@ -39,6 +39,10 @@ impl DeepResearchMode { "WriteStdin".to_string(), "ExecControl".to_string(), "ControlHub".to_string(), + // Standing research ("re-check these sources every morning") + // belongs on a schedule, and ControlHub's `wait` points here + // rather than at an hour-long turn. + "Cron".to_string(), "TodoWrite".to_string(), "AskUserQuestion".to_string(), ], diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs index 78216eef6..293fa3d07 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/team.rs @@ -44,6 +44,10 @@ impl TeamMode { "AskUserQuestion".to_string(), "Git".to_string(), "ControlHub".to_string(), + // Every mode that carries ControlHub needs Cron: ControlHub's + // `wait` tells the agent to schedule long or repeating work + // here instead of holding the turn open. + "Cron".to_string(), "GetFileDiff".to_string(), ], } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 1354a6a15..8904b0e8c 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -145,6 +145,10 @@ pub fn shared_coding_mode_tools() -> Vec { "Git".to_string(), "ReviewPlatform".to_string(), "ControlHub".to_string(), + // Pairs with ControlHub: its `wait` sends anything repeating, or + // further out than an hour, to Cron rather than holding the turn open + // for the interval. + "Cron".to_string(), "InitMiniApp".to_string(), "FinalizeMiniApp".to_string(), "PublishMiniApp".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 462445ae7..c0636392b 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -340,6 +340,30 @@ fn every_builtin_primary_mode_defaults_to_the_thread_goal_lifecycle() { } } +#[test] +fn every_builtin_mode_with_control_hub_can_also_schedule_with_cron() { + // ControlHub's `wait` documentation tells the agent to schedule anything + // repeating — or further out than an hour — with Cron instead of holding + // the turn open. A mode that offers one without the other sends the agent + // after a tool that is not in its list; Cowork answered a "check every 30 + // minutes" request with "I have no cron tool" for exactly this reason. + for spec in builtin_agent_specs() + .iter() + .filter(|spec| spec.category == AgentCategory::Mode) + { + let mode = (spec.factory)(); + let default_tools = mode.default_tools(); + if !default_tools.iter().any(|tool| tool == "ControlHub") { + continue; + } + assert!( + default_tools.iter().any(|tool| tool == "Cron"), + "builtin mode {} offers ControlHub but cannot schedule with Cron", + mode.id() + ); + } +} + #[test] fn non_deep_review_builtin_subagents_default_to_primary() { for agent_type in [ diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index 66032288f..8e0bf97c9 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -271,7 +271,7 @@ Use this tool via `{ domain, action, params }` for browser automation, terminal * `wait { duration_ms }` — pause for a fixed time, up to 60 minutes (`ms` and `seconds` are accepted spellings). This is the action to use when you must idle between rounds of work, e.g. `{ "duration_ms": 1800000 }` to resume in 30 minutes. It needs no browser session, and the result reports the `ms` actually waited, so check that figure before assuming the full pause happened. * `wait { condition, timeout_ms? }` — wait on the page instead: 'load' | 'domcontentloaded' | 'networkidle' | a CSS/@ref selector, bounded by `timeout_ms` (default 15s). Requires a connected session. When a `condition` is present it always wins, and any duration you pass becomes its timeout rather than a separate sleep. * A `wait` carrying neither is rejected with `INVALID_PARAMS` — it never silently returns. - * `wait` holds the turn open for its whole duration, so it suits a one-off pause, not a schedule. For work that should repeat ("produce another round every 30 minutes") or resume more than an hour out, create a job with the `Cron` tool instead — it ends the turn and re-invokes you when the job fires, rather than idling with the context loaded. + * `wait` holds the turn open for its whole duration, so it suits a one-off pause, not a schedule. For work that should repeat ("produce another round every 30 minutes") or resume more than an hour out, create a job with the `Cron` tool instead — it ends the turn and re-invokes you when the job fires, rather than idling with the context loaded. Every built-in mode that has ControlHub also has `Cron`; if it is genuinely absent from your tool list, say so rather than substituting a chain of long `wait` calls. - Automation workflow: connect -> navigate -> snapshot (returns @e1, @e2 ... refs) -> click/fill with `{ "selector": "@e1" }` (the key `ref` is accepted too). - Take a fresh snapshot after any DOM mutation; a stale `@eN` ref returns `error.code = STALE_REF`, while a selector that matches nothing returns `NOT_FOUND`. From 3d8ee4bc02ec36ea80ce12658c527bab8648022c Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 11 Aug 2026 11:45:07 +0800 Subject: [PATCH 11/39] perf(build): narrow SDK dependencies and external-source test targets --- docs/architecture/product-architecture.md | 1 + docs/performance/01-compile-performance.md | 46 +++- scripts/check-core-boundaries.test.mjs | 200 +++++++++++++++++- .../cargo-dependency-boundaries.mjs | 105 +++++---- scripts/core-boundaries/checker.mjs | 3 +- .../explicit-test-topology.mjs | 170 ++++++++++++++- .../rules/source/forbidden-rules.mjs | 24 +-- scripts/core-boundaries/self-test.mjs | 47 ++++ src/apps/sdk-host/Cargo.toml | 20 +- src/apps/sdk-host/src/lib.rs | 6 +- src/apps/sdk-host/src/runtime.rs | 3 - .../adapters/claude-code-adapter/Cargo.toml | 5 + .../tests/claude_code_source_contracts.rs | 8 + .../command_source.rs | 0 .../hook_source.rs | 0 .../mcp_source.rs | 0 .../subagent_source.rs | 0 src/crates/adapters/codex-adapter/Cargo.toml | 5 + .../tests/codex_source_contracts.rs | 6 + .../hook_source.rs | 0 .../mcp_source.rs | 0 .../subagent_source.rs | 0 .../adapters/opencode-adapter/AGENTS-CN.md | 9 +- .../adapters/opencode-adapter/AGENTS.md | 9 +- .../adapters/opencode-adapter/Cargo.toml | 17 ++ .../tests/opencode_static_source_contracts.rs | 10 + .../hook_source.rs | 0 .../opencode_command_adapter.rs | 0 .../opencode_skill_roots.rs | 0 .../opencode_subagent_adapter.rs | 0 .../opencode_workspace_references.rs | 0 .../assembly/external-sources/Cargo.toml | 5 + .../external_source_coordination_contracts.rs | 14 ++ .../control_plane.rs | 0 .../coordinator_contracts.rs | 0 .../hook_coordinator.rs | 0 .../mcp_coordinator.rs | 0 .../subagent_coordinator.rs | 0 .../tool_coordinator_contracts.rs | 0 .../workspace_reference.rs | 0 .../assembly/product-capabilities/src/lib.rs | 2 +- .../tests/product_capabilities.rs | 2 +- 42 files changed, 638 insertions(+), 79 deletions(-) create mode 100644 src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts.rs rename src/crates/adapters/claude-code-adapter/tests/{ => claude_code_source_contracts}/command_source.rs (100%) rename src/crates/adapters/claude-code-adapter/tests/{ => claude_code_source_contracts}/hook_source.rs (100%) rename src/crates/adapters/claude-code-adapter/tests/{ => claude_code_source_contracts}/mcp_source.rs (100%) rename src/crates/adapters/claude-code-adapter/tests/{ => claude_code_source_contracts}/subagent_source.rs (100%) create mode 100644 src/crates/adapters/codex-adapter/tests/codex_source_contracts.rs rename src/crates/adapters/codex-adapter/tests/{ => codex_source_contracts}/hook_source.rs (100%) rename src/crates/adapters/codex-adapter/tests/{ => codex_source_contracts}/mcp_source.rs (100%) rename src/crates/adapters/codex-adapter/tests/{ => codex_source_contracts}/subagent_source.rs (100%) create mode 100644 src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts.rs rename src/crates/adapters/opencode-adapter/tests/{ => opencode_static_source_contracts}/hook_source.rs (100%) rename src/crates/adapters/opencode-adapter/tests/{ => opencode_static_source_contracts}/opencode_command_adapter.rs (100%) rename src/crates/adapters/opencode-adapter/tests/{ => opencode_static_source_contracts}/opencode_skill_roots.rs (100%) rename src/crates/adapters/opencode-adapter/tests/{ => opencode_static_source_contracts}/opencode_subagent_adapter.rs (100%) rename src/crates/adapters/opencode-adapter/tests/{ => opencode_static_source_contracts}/opencode_workspace_references.rs (100%) create mode 100644 src/crates/assembly/external-sources/tests/external_source_coordination_contracts.rs rename src/crates/assembly/external-sources/tests/{ => external_source_coordination_contracts}/control_plane.rs (100%) rename src/crates/assembly/external-sources/tests/{ => external_source_coordination_contracts}/coordinator_contracts.rs (100%) rename src/crates/assembly/external-sources/tests/{ => external_source_coordination_contracts}/hook_coordinator.rs (100%) rename src/crates/assembly/external-sources/tests/{ => external_source_coordination_contracts}/mcp_coordinator.rs (100%) rename src/crates/assembly/external-sources/tests/{ => external_source_coordination_contracts}/subagent_coordinator.rs (100%) rename src/crates/assembly/external-sources/tests/{ => external_source_coordination_contracts}/tool_coordinator_contracts.rs (100%) rename src/crates/assembly/external-sources/tests/{ => external_source_coordination_contracts}/workspace_reference.rs (100%) diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 9c171546b..b475bc124 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -820,6 +820,7 @@ flowchart LR | Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | | CLI / TUI | 使用显式 Core owner closure:`agent-runtime` 基线、实际 service owner(包括 Remote Connect、DeepResearch、LSP、external/plugin source 与 SSH)以及九组 `tools-*`;`/extensions` 只提供状态、启停和刷新,`/hooks`、`/tools`、`/agent` 和 `/mcp` 处理各自能力 | `agent-runtime` 不再隐式携带完整 MCP/Remote/Browser/Web/Git/LSP/模型目录闭包;非交互不等待权限输入,生态解析仍在适配器,远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、`agent-runtime` 基线、所需 service owner 与九组 `tools-*`,但不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;close 排空后再卸载;完整历史、Canvas 工具物化、兼容指令来源和配置仍由 Core/ACP 管理;未选择的能力不得借 Cargo feature union 偶然出现 | +| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts 和与当前本机协议能力一致的显式 Core owner closure;TLS provider 由 Host 进程入口安装 | 当前协议不暴露远程 workspace/SSH 执行,因此不选择 Remote Connect、SSH 或 Function Agent owner;未来远程 SDK 必须复用 Server/Remote 的认证和执行域,不能回退到本机执行 | | Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index 509ed20b2..d54213f80 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -1,8 +1,8 @@ # BitFun 编译与依赖治理计划 -> 最近核实:2026-08-10 +> 最近核实:2026-08-11 > -> 实现复核基线:`gcwing/main@734e5b05f` +> 实现复核基线:`gcwing/main@9f8b56082` > > 性能 A/B 基线:`gcwing/main@1f538b96d` > @@ -16,9 +16,10 @@ | 结论 | 说明 | |---|---| -| 服务测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;选中的 `local-storage`、MCP、基础 SSH 闭包从 16 个集成 executable 降到 8 个 | +| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7,进程和外部系统失败域保持独立 | | Agent Runtime 基线不再隐藏重型 capability | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;文档转换与订阅认证也改为产品显式 modifier。在最新主线 A/B 中,三平台 normal/build 闭包进一步减少 69/64/110 个版本化 package instance | | App Server 不继承未消费能力 | App Server 保持现有 Agent/Git/外部来源 handler 边界,不再因 Core 基线携带文档转换和本地订阅凭据,三平台闭包减少 61/56/78 | +| SDK Host 使用显式能力闭包 | SDK Host 保留当前本机协议和工具能力,但不再通过 `product-full` 携带协议未暴露的 Remote Connect、SSH、Function Agent 等能力;Windows/macOS/Linux normal/build 闭包减少 66/68/76 | | 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择或未使用的隐含能力,累计在 Windows/macOS/Linux 分别减少 12/15/24 | | Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile | | focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 | @@ -45,7 +46,9 @@ ## 3. 当前基线 -### 3.1 服务层测试拓扑 +### 3.1 集成测试链接拓扑 + +#### Services 本轮只合并 owner 和运行边界相同的测试。`session_write_lock_contracts` 依赖当前测试 executable 启动异常退出子进程,因此继续保持独立;不同 feature 的服务测试也不合并。 @@ -73,6 +76,24 @@ clean/rebuild,表中为均值。时间是方向性证据,不是硬阈值。 MCP 的 2→1 candidate 也做过同口径 A/B,但冷构建和 owner 重建均无可区分的提速;streamable HTTP 测试还拥有真实 loopback TCP/SSE/超时失败域,因此最终继续保持两个 target,不计入本轮收益。 +#### External Sources adapters 与 assembly + +同一 crate 内、相同依赖和运行边界的静态来源合同通过 wrapper target 收敛;测试正文逐字迁移,仍可用 +`--test ::` 聚焦到单个来源模块。OpenCode 的 MCP 子进程、受管插件服务和 Node 脚本 +runtime 分别保留独立 target,避免为了减少链接次数混合不同环境、超时和故障语义。 + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| OpenCode adapter | 8 | 4 | 130 | +| Claude Code adapter | 4 | 1 | 51 | +| Codex adapter | 3 | 1 | 47 | +| External Sources assembly | 7 | 1 | 30 | +| 合计 | 22 | 7 | 258 | + +这部分只减少 15 个重复链接的 test executable;未新增 dependency、feature 或 CI 命令,也不以当前证据 +宣称 wall-clock 提速。Cargo 边界检查锁定显式 target、wrapper-only root、leaf 唯一引用和 crate-level cfg, +避免后续新增测试静默绕过分组拓扑。 + 可重复确认的产物变化如下;`test executable` 包含每个 crate 的 lib test harness,因此比 integration target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: @@ -95,8 +116,10 @@ package/version,不等同于实际秒数。路径 package 因 A/B worktree 路 | Desktop | 792 → 792 | 807 → 807 | 892 → 892 | 完整产品继续使用既有跨平台截图行为,本轮不以扩大根 lock 依赖宇宙换取单平台闭包下降 | | Installer | 333 → 327 | — | — | Windows 独立 workspace;直接 dependency 18 → 10 | -在最新实现复核基线 `gcwing/main@734e5b05f` 上,本轮继续把两个重型能力从 Core 基线改为弱 -modifier。计数先移除 Cargo tree 的重复展示标记 `(*)`,再按 package/version 去重: +下表前五项延续 `gcwing/main@734e5b05f` 的已核实 A/B,SDK Host 行以 +`gcwing/main@22f5411e7` 为变更前基线。三平台 target 分别为 `x86_64-pc-windows-msvc`、 +`aarch64-apple-darwin` 和 `x86_64-unknown-linux-gnu`;计数先移除 Cargo tree 的重复展示标记 +`(*)`,再按 package/version 去重: | 本轮闭包 | Windows | macOS | Linux | 行为边界 | |---|---:|---:|---:|---| @@ -105,9 +128,12 @@ modifier。计数先移除 Cargo tree 的重复展示标记 `(*)`,再按 packa | Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 显式恢复 `document-read` 与 `subscription-auth` | | CLI | 649 → 649 | 649 → 649 | 672 → 672 | 显式保持原有能力 | | ACP | 589 → 587 | 574 → 572 | 594 → 592 | 保持原有能力,同时退出 Reqwest 未使用的 `mime_guess`/`unicase` | +| SDK Host | 578 → 512 | 565 → 497 | 609 → 533 | 保留本机 SDK profile、九组工具 owner、外部静态来源和 ring TLS;退出未暴露的 Remote Connect、SSH、Function Agent 与完整产品附属能力 | -本轮没有新增 crate 或第三方 dependency。收益来自两类现有重闭包退出窄入口:`anydoc` 及其 -文档解析/压缩依赖,以及订阅凭据的 keyring/加密/本地存储依赖。完整产品 package 集合不变, +本轮没有新增 crate 或第三方 package;SDK Host 只把已有测试依赖 `rustls` 调整为进程入口实际使用的 +normal dependency,根 lock package 集合不变。前两类收益来自 `anydoc` 及其文档解析/压缩依赖, +以及订阅凭据的 keyring/加密/本地存储依赖;SDK Host 的收益来自未公开远程能力对应的 +SSH、密钥和连接子图退出。完整产品 package 集合不变, 因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。 Package instance 会低估“同一个大 crate 少编译了多少 feature 代码”。在 Windows @@ -122,7 +148,7 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | 状态 | 范围 | 处理结论 | |---|---|---| | 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、workspace Tokio 最小基线 | 不重复治理 | -| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | +| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | | 当前不动 | App Server / Server | 只为保持现有 handler 编译显式声明其已消费的 Core owner;不在改造稳定前继续拆其生产路径 | | 明确保留 | Desktop screenshots backend | 替换方案必须同时保持三平台坐标/权限/区域捕获语义且不增加根 lock package;当前候选不满足 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | @@ -149,8 +175,10 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | Agent Runtime 闭包 | Core 基线不再暗带具体 capability;完整产品和 CLI 显式保持原能力,ACP 退出未选择闭包 | | 重型可选能力 | 文档转换和本地订阅凭据由弱 modifier 细化已有 runtime owner;Core 基线和 App Server 退出未消费闭包 | | Installer 闭包 | 删除 8 个未使用直接 dependency;独立 workspace 和发布生命周期不变,本 PR 不提交其生成 lockfile | +| SDK Host 闭包 | 从 `product-full` 改为与当前协议/构造路径一致的显式 Core owner closure;保留 ring TLS 初始化,本机 SDK 行为不变,未交付的远程执行能力不再进入构建图 | | Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | +| External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | 内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作; 但 Core 仍直接依赖该 crate。没有足够产品收益前,不为消除这一编译指纹引入动态 provider、 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 7aa64e468..eef315b9a 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -20,9 +20,14 @@ import { } from './core-boundaries/cargo-dependency-boundaries.mjs'; import { checkCliIntegrationTestTopology, + checkExternalSourceIntegrationTestTopologies, checkServicesCoreIntegrationTestTopology, checkServicesIntegrationsIntegrationTestTopology, + claudeCodeAdapterIntegrationTestTargets, cliIntegrationTestTargets, + codexAdapterIntegrationTestTargets, + externalSourcesIntegrationTestTargets, + opencodeAdapterIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './core-boundaries/explicit-test-topology.mjs'; import { crateLayoutRules } from './core-boundaries/rules/crate-layout.mjs'; @@ -255,6 +260,73 @@ test('service integration tests keep their reviewed explicit target topology', ( assert.deepEqual(checkServicesIntegrationsIntegrationTestTopology(repositoryRoot), []); }); +test('external source integration tests keep reviewed owner and process boundaries', () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + + assert.deepEqual(opencodeAdapterIntegrationTestTargets, [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, + ]); + assert.deepEqual(claudeCodeAdapterIntegrationTestTargets, [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(codexAdapterIntegrationTestTargets, [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(externalSourcesIntegrationTestTargets, [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual( + checkExternalSourceIntegrationTestTopologies(repositoryRoot), + [], + ); +}); + test('runtime-services test support is absent from ordinary library builds', async () => { const [manifest, library] = await Promise.all([ readFile( @@ -506,14 +578,13 @@ test('explicit product entrypoint bitfun-core feature selections pass', () => { ); }); -const ACP_REVIEWED_CORE_FEATURES = [ +const SDK_HOST_REVIEWED_CORE_FEATURES = [ 'agent-runtime', 'document-read', 'subscription-auth', 'deep-research', 'lsp', 'external-sources', - 'ssh-remote', 'tools-basic', 'tools-git', 'tools-mcp', @@ -525,6 +596,11 @@ const ACP_REVIEWED_CORE_FEATURES = [ 'tools-agent-control', ]; +const ACP_REVIEWED_CORE_FEATURES = [ + ...SDK_HOST_REVIEWED_CORE_FEATURES, + 'ssh-remote', +]; + const CLI_REVIEWED_CORE_FEATURES = [ ...ACP_REVIEWED_CORE_FEATURES, 'remote-connect', @@ -537,6 +613,126 @@ const APP_SERVER_REVIEWED_CORE_FEATURES = [ 'remote-connect', ]; +test('SDK Host Core capability closure keeps every reviewed owner', () => { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES.filter( + (feature) => feature !== 'external-sources', + ), + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.deepEqual(violations.map((violation) => violation.message), [ + 'bitfun-sdk-host-app Core capability closure must include external-sources', + ]); +}); + +test('SDK Host closure rejects unreviewed capability owners below Core', () => { + const cases = [ + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-connect'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'remote-ssh-concrete'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'function-agents'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'announcement'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'debug-log'], + ['bitfun-services-integrations', 'src/crates/services/services-integrations/Cargo.toml', 'product-full'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'function-agents'], + ['bitfun-product-domains', 'src/crates/contracts/product-domains/Cargo.toml', 'product-full'], + ['bitfun-services-core', 'src/crates/services/services-core/Cargo.toml', 'dispatch-workspace'], + ]; + + for (const [ownerName, ownerManifest, forbiddenFeature] of cases) { + const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); + const owner = { + ...packageAt(ownerName, ownerManifest), + features: { [forbiddenFeature]: [] }, + }; + const bridge = packageAt('bridge', 'src/crates/assembly/bridge/Cargo.toml', [ + pathDependency(ownerManifest.replace('/Cargo.toml', ''), { + name: ownerName, + usesDefaultFeatures: false, + features: [forbiddenFeature], + }), + ]); + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [ + pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + }), + pathDependency('src/crates/assembly/bridge', { name: 'bridge' }), + ], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, bridge, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); + + const forbiddenOwner = `${ownerName}/${forbiddenFeature}`; + assert.equal(violations.length, 1, forbiddenOwner); + assert.match( + violations[0].message, + new RegExp(forbiddenOwner), + ); + } +}); + +test('SDK Host closure inspects lower owners forwarded by reviewed Core features', () => { + const ownerManifest = 'src/crates/services/services-integrations/Cargo.toml'; + const core = { + ...packageAt( + 'bitfun-core', + 'src/crates/assembly/core/Cargo.toml', + [pathDependency('src/crates/services/services-integrations', { + name: 'bitfun-services-integrations', + optional: true, + usesDefaultFeatures: false, + })], + ), + features: { + 'external-sources': ['bitfun-services-integrations/remote-connect'], + }, + }; + const owner = { + ...packageAt('bitfun-services-integrations', ownerManifest), + features: { 'remote-connect': [] }, + }; + const sdkHost = packageAt( + 'bitfun-sdk-host-app', + 'src/apps/sdk-host/Cargo.toml', + [pathDependency('src/crates/assembly/core', { + name: 'bitfun-core', + usesDefaultFeatures: false, + features: SDK_HOST_REVIEWED_CORE_FEATURES, + })], + ); + + const violations = findProductEntrypointCoreFeatureViolations( + [sdkHost, core, owner], + { root: TEST_ROOT, crateLayoutRules }, + ); + + assert.equal(violations.length, 1); + assert.match( + violations[0].message, + /bitfun-services-integrations\/remote-connect/, + ); +}); + test('App Server Core capability closure keeps its production Git owner', () => { const core = packageAt('bitfun-core', 'src/crates/assembly/core/Cargo.toml'); const appServer = packageAt( diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index e4c21b01e..7da696362 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -809,52 +809,39 @@ export function findProductEntrypointCoreFeatureViolations( packages, { root, crateLayoutRules }, ) { + const coreCompatibilityReviewedFeatures = [ + 'agent-runtime', + 'document-read', + 'subscription-auth', + 'deep-research', + 'lsp', + 'external-sources', + 'tools-basic', + 'tools-git', + 'tools-mcp', + 'tools-browser-web', + 'tools-computer-use', + 'tools-image-analysis', + 'tools-miniapp', + 'tools-canvas', + 'tools-agent-control', + ]; const reviewedCoreFeatureClosures = new Map([ ['bitfun-cli', [ - 'agent-runtime', - 'document-read', - 'subscription-auth', + ...coreCompatibilityReviewedFeatures, 'remote-connect', - 'deep-research', - 'lsp', - 'external-sources', 'plugin-runtime', 'ssh-remote', - 'tools-basic', - 'tools-git', - 'tools-mcp', - 'tools-browser-web', - 'tools-computer-use', - 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', - 'tools-agent-control', - ]], - ['bitfun-acp', [ - 'agent-runtime', - 'document-read', - 'subscription-auth', - 'deep-research', - 'lsp', - 'external-sources', - 'ssh-remote', - 'tools-basic', - 'tools-git', - 'tools-mcp', - 'tools-browser-web', - 'tools-computer-use', - 'tools-image-analysis', - 'tools-miniapp', - 'tools-canvas', - 'tools-agent-control', ]], + ['bitfun-acp', [...coreCompatibilityReviewedFeatures, 'ssh-remote']], ['bitfun-app-server', [ 'external-sources', 'git', 'remote-connect', ]], + ['bitfun-sdk-host-app', coreCompatibilityReviewedFeatures], ]); - const acpActiveCoreFeatures = [ + const coreCompatibilityActiveFeatures = [ 'agent-runtime', 'ai-adapter-runtime', 'browser-control', @@ -872,12 +859,10 @@ export function findProductEntrypointCoreFeatureViolations( 'plugin-source', 'process-runtime', 'product-capabilities', - 'remote-workspace', 'review-platform', 'runtime-services', 'scheduled-jobs', 'script-tool-runtime', - 'ssh-remote', 'subscription-auth', 'terminal', 'tool-packs', @@ -895,9 +880,15 @@ export function findProductEntrypointCoreFeatureViolations( 'workspace-runtime', 'workspace-watch', ]; + const acpActiveCoreFeatures = [ + ...coreCompatibilityActiveFeatures, + 'remote-workspace', + 'ssh-remote', + ]; const reviewedActiveCoreFeatureClosures = new Map([ ['bitfun-cli', [...acpActiveCoreFeatures, 'plugin-runtime', 'remote-connect']], ['bitfun-acp', acpActiveCoreFeatures], + ['bitfun-sdk-host-app', coreCompatibilityActiveFeatures], ['bitfun-app-server', [ 'agent-runtime', 'ai-adapter-runtime', @@ -925,6 +916,21 @@ export function findProductEntrypointCoreFeatureViolations( 'workspace-watch', ]], ]); + const reviewedForbiddenDependencyOwnerFeatures = new Map([ + ['bitfun-sdk-host-app', new Map([ + ['bitfun-services-integrations', [ + 'announcement', + 'debug-log', + 'function-agents', + 'product-full', + 'remote-connect', + 'remote-ssh', + 'remote-ssh-concrete', + ]], + ['bitfun-product-domains', ['function-agents', 'product-full']], + ['bitfun-services-core', ['dispatch-workspace']], + ])], + ]); const packageByManifest = new Map( packages.map((pkg) => [normalizedPath(pkg.manifest_path), pkg]), ); @@ -1012,7 +1018,10 @@ export function findProductEntrypointCoreFeatureViolations( ['bitfun-cli', 'CLI'], ['bitfun-acp', 'ACP'], ['bitfun-app-server', 'App Server'], + ['bitfun-sdk-host-app', 'SDK Host'], ]).get(rootName) ?? rootName; + const forbiddenOwnerFeatures = + reviewedForbiddenDependencyOwnerFeatures.get(rootName); const packageStates = new Map(); const pending = []; @@ -1151,6 +1160,30 @@ export function findProductEntrypointCoreFeatureViolations( } continue; } + + const forbiddenOwnerFeature = ( + forbiddenOwnerFeatures?.get(targetPackage.name) ?? [] + ).find((feature) => targetState.featureState.active.has(feature)); + if (forbiddenOwnerFeature) { + const forbiddenOwner = `${targetPackage.name}/${forbiddenOwnerFeature}`; + const reportKey = [ + rootName, + targetDependencyKindContext, + forbiddenOwner, + ].join('|'); + if (!reportedUnexpectedFeatures.has(reportKey)) { + reportedUnexpectedFeatures.add(reportKey); + violations.push({ + path: sourcePackage.manifest_path, + line: 1, + message: `${rootLabel} dependency closure must not enable ${forbiddenOwner}: ${[ + ...packagePath, + forbiddenOwner, + ].join(' -> ')}`, + }); + } + continue; + } } } } diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 3700f463f..2247f1ee5 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -42,6 +42,7 @@ import { agentRuntimeIntegrationTestTargets, checkAgentRuntimeIntegrationTestTopology, checkCliIntegrationTestTopology, + checkExternalSourceIntegrationTestTopologies, checkServiceIntegrationTestTopologies, cliIntegrationTestTargets, validateExplicitIntegrationTestTopology, @@ -1125,7 +1126,7 @@ export function runCoreBoundaryCheck() { failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules })); failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); - failures.push(...checkServiceIntegrationTestTopologies(ROOT)); + failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkServiceIntegrationTestTopologies(ROOT)); failures.push(...checkPeerCommandPolicySync(ROOT)); for (const rule of forbiddenManifestDependencyRules) { diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index 9a157af1d..d1ff06f9e 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -46,6 +46,115 @@ export const servicesIntegrationsIntegrationTestTargets = [ { name: 'workspace_search_contracts', path: 'tests/workspace_search_contracts.rs' }, ]; +export const opencodeAdapterIntegrationTestTargets = [ + { name: 'opencode_mcp_adapter', path: 'tests/opencode_mcp_adapter.rs' }, + { name: 'opencode_source_adapter', path: 'tests/opencode_source_adapter.rs' }, + { + name: 'opencode_static_source_contracts', + path: 'tests/opencode_static_source_contracts.rs', + leaves: [ + 'tests/opencode_static_source_contracts/hook_source.rs', + 'tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', + 'tests/opencode_static_source_contracts/opencode_workspace_references.rs', + ], + forbidRequiredFeatures: true, + }, + { name: 'tool_source_contracts', path: 'tests/tool_source_contracts.rs' }, +]; + +export const claudeCodeAdapterIntegrationTestTargets = [ + { + name: 'claude_code_source_contracts', + path: 'tests/claude_code_source_contracts.rs', + leaves: [ + 'tests/claude_code_source_contracts/command_source.rs', + 'tests/claude_code_source_contracts/hook_source.rs', + 'tests/claude_code_source_contracts/mcp_source.rs', + 'tests/claude_code_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const codexAdapterIntegrationTestTargets = [ + { + name: 'codex_source_contracts', + path: 'tests/codex_source_contracts.rs', + leaves: [ + 'tests/codex_source_contracts/hook_source.rs', + 'tests/codex_source_contracts/mcp_source.rs', + 'tests/codex_source_contracts/subagent_source.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const externalSourcesIntegrationTestTargets = [ + { + name: 'external_source_coordination_contracts', + path: 'tests/external_source_coordination_contracts.rs', + leaves: [ + 'tests/external_source_coordination_contracts/control_plane.rs', + 'tests/external_source_coordination_contracts/coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/hook_coordinator.rs', + 'tests/external_source_coordination_contracts/mcp_coordinator.rs', + 'tests/external_source_coordination_contracts/subagent_coordinator.rs', + 'tests/external_source_coordination_contracts/tool_coordinator_contracts.rs', + 'tests/external_source_coordination_contracts/workspace_reference.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +function decodeBasicTomlKey(token) { + let decoded = ''; + const simpleEscapes = new Map([ + ['b', '\b'], ['t', '\t'], ['n', '\n'], ['f', '\f'], ['r', '\r'], + ['"', '"'], ['\\', '\\'], + ]); + for (let index = 1; index < token.length - 1; index += 1) { + if (token[index] !== '\\') { + decoded += token[index]; + continue; + } + index += 1; + const escape = token[index]; + if (simpleEscapes.has(escape)) { + decoded += simpleEscapes.get(escape); + continue; + } + if (escape !== 'u' && escape !== 'U') { + return null; + } + const digitCount = escape === 'u' ? 4 : 8; + const hex = token.slice(index + 1, index + 1 + digitCount); + if (!new RegExp(`^[0-9a-fA-F]{${digitCount}}$`).test(hex)) { + return null; + } + const codePoint = Number.parseInt(hex, 16); + if (codePoint > 0x10FFFF || (codePoint >= 0xD800 && codePoint <= 0xDFFF)) { + return null; + } + decoded += String.fromCodePoint(codePoint); + index += digitCount; + } + return decoded; +} + +function tomlFieldName(line) { + const match = line.match(/^([A-Za-z0-9_-]+|'[^']*'|"(?:[^"\\]|\\.)*")\s*=/); + if (!match) { + return null; + } + const token = match[1]; + if (token.startsWith("'")) { + return token.slice(1, -1); + } + return token.startsWith('"') ? decodeBasicTomlKey(token) : token; +} + function parseExplicitTestTargets(manifestText) { const targets = []; let current = null; @@ -67,6 +176,9 @@ function parseExplicitTestTargets(manifestText) { finishCurrent(); continue; } + if (current && tomlFieldName(trimmed) === 'required-features') { + current.hasRequiredFeatures = true; + } const field = current && trimmed.match(/^(name|path)\s*=\s*"([^"]+)"\s*$/); if (field) { current[field[1]] = field[2]; @@ -386,12 +498,23 @@ export function validateExplicitIntegrationTestTopology({ } const expectedTargetEntries = expectedTargets.map(({ name, path }) => `${name}=${path}`).sort(); - const actualTargetEntries = parseExplicitTestTargets(manifestText) + const actualTargets = parseExplicitTestTargets(manifestText); + const actualTargetEntries = actualTargets .map(({ name, path }) => `${name ?? ''}=${path ?? ''}`) .sort(); if (actualTargetEntries.join('\n') !== expectedTargetEntries.join('\n')) { errors.push(`explicit test targets must be exactly: ${expectedTargetEntries.join(', ')}`); } + const targetsWithoutRequiredFeatures = new Set( + expectedTargets + .filter(({ forbidRequiredFeatures }) => forbidRequiredFeatures) + .map(({ name, path }) => `${name}=${path}`), + ); + for (const { name, path, hasRequiredFeatures } of actualTargets) { + if (hasRequiredFeatures && targetsWithoutRequiredFeatures.has(`${name}=${path}`)) { + errors.push(`explicit test target ${name} must not declare required-features`); + } + } const expectedRoots = expectedTargets.map(({ path }) => path).sort(); if ([...topLevelRustFiles].sort().join('\n') !== expectedRoots.join('\n')) { @@ -399,6 +522,13 @@ export function validateExplicitIntegrationTestTopology({ } const leaves = new Set(leafRustFiles); + const expectedLeaves = expectedTargets.flatMap(({ leaves: targetLeaves = [] }) => targetLeaves).sort(); + if ( + expectedLeaves.length > 0 + && [...leaves].sort().join('\n') !== expectedLeaves.join('\n') + ) { + errors.push(`grouped test leaves must be exactly: ${expectedLeaves.join(', ')}`); + } const referenceCounts = new Map(); for (const root of expectedRoots) { const source = rootSources.get(root); @@ -543,6 +673,44 @@ export function checkServicesIntegrationsIntegrationTestTopology(root) { }); } +export function checkOpencodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/opencode-adapter', + expectedTargets: opencodeAdapterIntegrationTestTargets, + ignoredDirectories: ['tests/fixtures'], + }); +} + +export function checkClaudeCodeAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/claude-code-adapter', + expectedTargets: claudeCodeAdapterIntegrationTestTargets, + }); +} + +export function checkCodexAdapterIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/adapters/codex-adapter', + expectedTargets: codexAdapterIntegrationTestTargets, + }); +} + +export function checkExternalSourcesIntegrationTestTopology(root) { + return checkExplicitIntegrationTestTopology(root, { + cratePath: 'src/crates/assembly/external-sources', + expectedTargets: externalSourcesIntegrationTestTargets, + }); +} + +export function checkExternalSourceIntegrationTestTopologies(root) { + return [ + ...checkOpencodeAdapterIntegrationTestTopology(root), + ...checkClaudeCodeAdapterIntegrationTestTopology(root), + ...checkCodexAdapterIntegrationTestTopology(root), + ...checkExternalSourcesIntegrationTestTopology(root), + ]; +} + export function checkServiceIntegrationTestTopologies(root) { return [ ...checkServicesCoreIntegrationTestTopology(root), diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index d6eaab22e..43576f863 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4135,13 +4135,13 @@ export const forbiddenContentUnderRules = [ /\b(?:use\s+bitfun_opencode_adapter\b|extern\s+crate\s+bitfun_opencode_adapter\b|bitfun_opencode_adapter::)/, allowPaths: [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_command_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_skill_roots.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_workspace_references.rs', 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', - 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_mcp_adapter.rs', - 'src/crates/adapters/opencode-adapter/tests/hook_source.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/hook_source.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', @@ -4171,10 +4171,10 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_claude_code_adapter\b|extern\s+crate\s+bitfun_claude_code_adapter\b|bitfun_claude_code_adapter::)/, allowPaths: [ - 'src/crates/adapters/claude-code-adapter/tests/hook_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/command_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/subagent_source.rs', - 'src/crates/adapters/claude-code-adapter/tests/mcp_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/hook_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/command_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs', + 'src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', @@ -4188,9 +4188,9 @@ export const forbiddenContentUnderRules = [ patterns: [{ regex: /\b(?:use\s+bitfun_codex_adapter\b|extern\s+crate\s+bitfun_codex_adapter\b|bitfun_codex_adapter::)/, allowPaths: [ - 'src/crates/adapters/codex-adapter/tests/hook_source.rs', - 'src/crates/adapters/codex-adapter/tests/subagent_source.rs', - 'src/crates/adapters/codex-adapter/tests/mcp_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/hook_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/subagent_source.rs', + 'src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs', 'src/crates/assembly/core/src/external_sources.rs', 'src/crates/assembly/core/src/external_hooks.rs', 'src/crates/assembly/core/src/instruction_sources.rs', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 2f4e8d9b3..666ce0d0e 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -152,6 +152,53 @@ export function runManifestParserSelfTest({ if (!orphanErrors.some((error) => error.includes('orphan_contracts.rs'))) { throw new Error('explicit integration-test topology must reject an orphan leaf test'); } + const reviewedLeafTargets = agentRuntimeIntegrationTestTargets.map((target) => ( + target.path === 'tests/agent_definition_contracts.rs' + ? { + ...target, + leaves: ['tests/agent_definition_contracts/prompt_contracts.rs'], + forbidRequiredFeatures: true, + } + : target + )); + const missingReviewedLeafErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + leafRustFiles: [], + leafSources: new Map(), + }); + if (!missingReviewedLeafErrors.some((error) => error.includes('grouped test leaves'))) { + throw new Error('explicit integration-test topology must reject a removed reviewed leaf'); + } + for (const requiredFeaturesDeclaration of [ + 'required-features = [\n "opt-in",\n]', + '"required\\u002dfeatures" = ["opt-in"]', + ]) { + const unexpectedRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + manifestText: explicitTestManifest.replace( + 'path = "tests/agent_definition_contracts.rs"', + `path = "tests/agent_definition_contracts.rs"\n${requiredFeaturesDeclaration}`, + ), + }); + if (!unexpectedRequiredFeaturesErrors.some((error) => error.includes('required-features'))) { + throw new Error(`ungated explicit test topology accepted: ${requiredFeaturesDeclaration}`); + } + } + const independentRequiredFeaturesErrors = validateExplicitIntegrationTestTopology({ + ...explicitTestFixture, + expectedTargets: reviewedLeafTargets, + manifestText: explicitTestManifest.replace( + 'path = "tests/native_hook_execution_contracts.rs"', + 'path = "tests/native_hook_execution_contracts.rs"\nrequired-features = ["native-hooks"]', + ), + }); + if (independentRequiredFeaturesErrors.length > 0) { + throw new Error( + `target-scoped required-features contract rejected an independent target: ${independentRequiredFeaturesErrors.join('; ')}`, + ); + } const reviewedLeafCfgFixture = { ...explicitTestFixture, leafSources: new Map([[ diff --git a/src/apps/sdk-host/Cargo.toml b/src/apps/sdk-host/Cargo.toml index bf8075168..834d86cd7 100644 --- a/src/apps/sdk-host/Cargo.toml +++ b/src/apps/sdk-host/Cargo.toml @@ -14,9 +14,26 @@ path = "src/main.rs" anyhow = { workspace = true } async-trait = { workspace = true } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } -bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = ["product-full"] } +bitfun-core = { path = "../../crates/assembly/core", default-features = false, features = [ + "agent-runtime", + "document-read", + "subscription-auth", + "deep-research", + "lsp", + "external-sources", + "tools-basic", + "tools-git", + "tools-mcp", + "tools-browser-web", + "tools-computer-use", + "tools-image-analysis", + "tools-miniapp", + "tools-canvas", + "tools-agent-control", +] } bitfun-sdk-host = { path = "../../crates/interfaces/sdk-host" } futures-util = { workspace = true } +rustls = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = ["io-std", "io-util", "macros", "rt-multi-thread", "sync", "time"] } tokio-util = { workspace = true, features = ["codec"] } @@ -24,7 +41,6 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } [dev-dependencies] -rustls = { workspace = true } tempfile = "3" tokio = { workspace = true, features = ["process"] } diff --git a/src/apps/sdk-host/src/lib.rs b/src/apps/sdk-host/src/lib.rs index b8a32c20f..96df71345 100644 --- a/src/apps/sdk-host/src/lib.rs +++ b/src/apps/sdk-host/src/lib.rs @@ -4,13 +4,13 @@ pub mod transport; /// Stack size used by the SDK Host worker. /// -/// The Host initializes the same full Agent Runtime as the CLI and preserves -/// the reviewed Windows stack-overflow protection used by that runtime. +/// The Host initializes its reviewed SDK capability profile and preserves the +/// Windows stack-overflow protection used by the shared Agent Runtime. pub const SDK_HOST_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; /// Installs process-global prerequisites before any TLS-capable service starts. pub fn initialize_process_runtime() { - bitfun_core::service::remote_connect::ensure_rustls_crypto_provider(); + let _ = rustls::crypto::ring::default_provider().install_default(); } /// Spawns the SDK Host runtime on the reviewed worker-stack boundary. diff --git a/src/apps/sdk-host/src/runtime.rs b/src/apps/sdk-host/src/runtime.rs index f9da5654a..525c1eed0 100644 --- a/src/apps/sdk-host/src/runtime.rs +++ b/src/apps/sdk-host/src/runtime.rs @@ -87,9 +87,6 @@ fn bind_core_execution_ports(agentic_system: &AgenticSystem) { agentic_system .coordinator .set_terminal_port(CoreRuntimeServicesProvider::terminal_port()); - agentic_system - .coordinator - .set_remote_exec_port(CoreRuntimeServicesProvider::remote_exec_port()); } pub(crate) async fn initialize_terminal_service() { diff --git a/src/crates/adapters/claude-code-adapter/Cargo.toml b/src/crates/adapters/claude-code-adapter/Cargo.toml index dc7ae1510..4cceb1484 100644 --- a/src/crates/adapters/claude-code-adapter/Cargo.toml +++ b/src/crates/adapters/claude-code-adapter/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "Claude Code static source adapter for BitFun" +autotests = false [lib] name = "bitfun_claude_code_adapter" @@ -26,5 +27,9 @@ url = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +[[test]] +name = "claude_code_source_contracts" +path = "tests/claude_code_source_contracts.rs" + [lints] workspace = true diff --git a/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts.rs b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts.rs new file mode 100644 index 000000000..29346cbf2 --- /dev/null +++ b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts.rs @@ -0,0 +1,8 @@ +#[path = "claude_code_source_contracts/command_source.rs"] +mod command_source; +#[path = "claude_code_source_contracts/hook_source.rs"] +mod hook_source; +#[path = "claude_code_source_contracts/mcp_source.rs"] +mod mcp_source; +#[path = "claude_code_source_contracts/subagent_source.rs"] +mod subagent_source; diff --git a/src/crates/adapters/claude-code-adapter/tests/command_source.rs b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/command_source.rs similarity index 100% rename from src/crates/adapters/claude-code-adapter/tests/command_source.rs rename to src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/command_source.rs diff --git a/src/crates/adapters/claude-code-adapter/tests/hook_source.rs b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/hook_source.rs similarity index 100% rename from src/crates/adapters/claude-code-adapter/tests/hook_source.rs rename to src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/hook_source.rs diff --git a/src/crates/adapters/claude-code-adapter/tests/mcp_source.rs b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs similarity index 100% rename from src/crates/adapters/claude-code-adapter/tests/mcp_source.rs rename to src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/mcp_source.rs diff --git a/src/crates/adapters/claude-code-adapter/tests/subagent_source.rs b/src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs similarity index 100% rename from src/crates/adapters/claude-code-adapter/tests/subagent_source.rs rename to src/crates/adapters/claude-code-adapter/tests/claude_code_source_contracts/subagent_source.rs diff --git a/src/crates/adapters/codex-adapter/Cargo.toml b/src/crates/adapters/codex-adapter/Cargo.toml index 9d6faf176..92b935033 100644 --- a/src/crates/adapters/codex-adapter/Cargo.toml +++ b/src/crates/adapters/codex-adapter/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "Codex static source adapter for BitFun" +autotests = false [lib] name = "bitfun_codex_adapter" @@ -24,5 +25,9 @@ url = { workspace = true } [dev-dependencies] tempfile = { workspace = true } +[[test]] +name = "codex_source_contracts" +path = "tests/codex_source_contracts.rs" + [lints] workspace = true diff --git a/src/crates/adapters/codex-adapter/tests/codex_source_contracts.rs b/src/crates/adapters/codex-adapter/tests/codex_source_contracts.rs new file mode 100644 index 000000000..6c033129c --- /dev/null +++ b/src/crates/adapters/codex-adapter/tests/codex_source_contracts.rs @@ -0,0 +1,6 @@ +#[path = "codex_source_contracts/hook_source.rs"] +mod hook_source; +#[path = "codex_source_contracts/mcp_source.rs"] +mod mcp_source; +#[path = "codex_source_contracts/subagent_source.rs"] +mod subagent_source; diff --git a/src/crates/adapters/codex-adapter/tests/hook_source.rs b/src/crates/adapters/codex-adapter/tests/codex_source_contracts/hook_source.rs similarity index 100% rename from src/crates/adapters/codex-adapter/tests/hook_source.rs rename to src/crates/adapters/codex-adapter/tests/codex_source_contracts/hook_source.rs diff --git a/src/crates/adapters/codex-adapter/tests/mcp_source.rs b/src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs similarity index 100% rename from src/crates/adapters/codex-adapter/tests/mcp_source.rs rename to src/crates/adapters/codex-adapter/tests/codex_source_contracts/mcp_source.rs diff --git a/src/crates/adapters/codex-adapter/tests/subagent_source.rs b/src/crates/adapters/codex-adapter/tests/codex_source_contracts/subagent_source.rs similarity index 100% rename from src/crates/adapters/codex-adapter/tests/subagent_source.rs rename to src/crates/adapters/codex-adapter/tests/codex_source_contracts/subagent_source.rs diff --git a/src/crates/adapters/opencode-adapter/AGENTS-CN.md b/src/crates/adapters/opencode-adapter/AGENTS-CN.md index a2aa76cb3..585d7769d 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS-CN.md +++ b/src/crates/adapters/opencode-adapter/AGENTS-CN.md @@ -54,9 +54,10 @@ ## 验证 - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` -- `cargo test -p bitfun-opencode-adapter --test opencode_command_adapter` +- `cargo test -p bitfun-opencode-adapter --test opencode_mcp_adapter` +- `cargo test -p bitfun-opencode-adapter --test opencode_static_source_contracts opencode_command_adapter::` +- `cargo test -p bitfun-opencode-adapter --test opencode_static_source_contracts opencode_subagent_adapter::` - `cargo test -p bitfun-opencode-adapter --test tool_source_contracts` -- `cargo test -p bitfun-opencode-adapter --test opencode_subagent_adapter` -- `cargo test -p bitfun-opencode-adapter p0_c2_fixture` -- `cargo test -p bitfun-opencode-adapter client_path_projects_trusted_custom_tool_candidate_with_permission_prompt` +- `cargo test -p bitfun-opencode-adapter --lib p0_c2_fixture` +- `cargo test -p bitfun-opencode-adapter --lib client_path_projects_trusted_custom_tool_candidate_with_permission_prompt` - `node scripts/check-core-boundaries.mjs` diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index 550c004e7..1750584bb 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -114,9 +114,10 @@ Product-source boundary: ## Verification - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` -- `cargo test -p bitfun-opencode-adapter --test opencode_command_adapter` +- `cargo test -p bitfun-opencode-adapter --test opencode_mcp_adapter` +- `cargo test -p bitfun-opencode-adapter --test opencode_static_source_contracts opencode_command_adapter::` +- `cargo test -p bitfun-opencode-adapter --test opencode_static_source_contracts opencode_subagent_adapter::` - `cargo test -p bitfun-opencode-adapter --test tool_source_contracts` -- `cargo test -p bitfun-opencode-adapter --test opencode_subagent_adapter` -- `cargo test -p bitfun-opencode-adapter p0_c2_fixture` -- `cargo test -p bitfun-opencode-adapter client_path_projects_trusted_custom_tool_candidate_with_permission_prompt` +- `cargo test -p bitfun-opencode-adapter --lib p0_c2_fixture` +- `cargo test -p bitfun-opencode-adapter --lib client_path_projects_trusted_custom_tool_candidate_with_permission_prompt` - `node scripts/check-core-boundaries.mjs` diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index 804fd1e03..9ae15880a 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "OpenCode-compatible source and candidate adapter for BitFun" +autotests = false [lib] name = "bitfun_opencode_adapter" @@ -35,5 +36,21 @@ bitfun-services-integrations = { path = "../../services/services-integrations", tokio = { workspace = true, features = ["macros", "rt"] } tempfile = { workspace = true } +[[test]] +name = "opencode_mcp_adapter" +path = "tests/opencode_mcp_adapter.rs" + +[[test]] +name = "opencode_source_adapter" +path = "tests/opencode_source_adapter.rs" + +[[test]] +name = "opencode_static_source_contracts" +path = "tests/opencode_static_source_contracts.rs" + +[[test]] +name = "tool_source_contracts" +path = "tests/tool_source_contracts.rs" + [lints] workspace = true diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts.rs b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts.rs new file mode 100644 index 000000000..112f66dc2 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts.rs @@ -0,0 +1,10 @@ +#[path = "opencode_static_source_contracts/hook_source.rs"] +mod hook_source; +#[path = "opencode_static_source_contracts/opencode_command_adapter.rs"] +mod opencode_command_adapter; +#[path = "opencode_static_source_contracts/opencode_skill_roots.rs"] +mod opencode_skill_roots; +#[path = "opencode_static_source_contracts/opencode_subagent_adapter.rs"] +mod opencode_subagent_adapter; +#[path = "opencode_static_source_contracts/opencode_workspace_references.rs"] +mod opencode_workspace_references; diff --git a/src/crates/adapters/opencode-adapter/tests/hook_source.rs b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/hook_source.rs similarity index 100% rename from src/crates/adapters/opencode-adapter/tests/hook_source.rs rename to src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/hook_source.rs diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_command_adapter.rs similarity index 100% rename from src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs rename to src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_command_adapter.rs diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_skill_roots.rs similarity index 100% rename from src/crates/adapters/opencode-adapter/tests/opencode_skill_roots.rs rename to src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_skill_roots.rs diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs similarity index 100% rename from src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs rename to src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_subagent_adapter.rs diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs b/src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_workspace_references.rs similarity index 100% rename from src/crates/adapters/opencode-adapter/tests/opencode_workspace_references.rs rename to src/crates/adapters/opencode-adapter/tests/opencode_static_source_contracts/opencode_workspace_references.rs diff --git a/src/crates/assembly/external-sources/Cargo.toml b/src/crates/assembly/external-sources/Cargo.toml index 3dcdc619c..de2dcbaa9 100644 --- a/src/crates/assembly/external-sources/Cargo.toml +++ b/src/crates/assembly/external-sources/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "Ecosystem-neutral external source lifecycle coordination" +autotests = false [lib] name = "bitfun_external_sources" @@ -18,5 +19,9 @@ tokio = { workspace = true, features = ["rt", "sync", "time"] } [dev-dependencies] tokio = { workspace = true, features = ["macros"] } +[[test]] +name = "external_source_coordination_contracts" +path = "tests/external_source_coordination_contracts.rs" + [lints] workspace = true diff --git a/src/crates/assembly/external-sources/tests/external_source_coordination_contracts.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts.rs new file mode 100644 index 000000000..28c08e6eb --- /dev/null +++ b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts.rs @@ -0,0 +1,14 @@ +#[path = "external_source_coordination_contracts/control_plane.rs"] +mod control_plane; +#[path = "external_source_coordination_contracts/coordinator_contracts.rs"] +mod coordinator_contracts; +#[path = "external_source_coordination_contracts/hook_coordinator.rs"] +mod hook_coordinator; +#[path = "external_source_coordination_contracts/mcp_coordinator.rs"] +mod mcp_coordinator; +#[path = "external_source_coordination_contracts/subagent_coordinator.rs"] +mod subagent_coordinator; +#[path = "external_source_coordination_contracts/tool_coordinator_contracts.rs"] +mod tool_coordinator_contracts; +#[path = "external_source_coordination_contracts/workspace_reference.rs"] +mod workspace_reference; diff --git a/src/crates/assembly/external-sources/tests/control_plane.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/control_plane.rs similarity index 100% rename from src/crates/assembly/external-sources/tests/control_plane.rs rename to src/crates/assembly/external-sources/tests/external_source_coordination_contracts/control_plane.rs diff --git a/src/crates/assembly/external-sources/tests/coordinator_contracts.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/coordinator_contracts.rs similarity index 100% rename from src/crates/assembly/external-sources/tests/coordinator_contracts.rs rename to src/crates/assembly/external-sources/tests/external_source_coordination_contracts/coordinator_contracts.rs diff --git a/src/crates/assembly/external-sources/tests/hook_coordinator.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/hook_coordinator.rs similarity index 100% rename from src/crates/assembly/external-sources/tests/hook_coordinator.rs rename to src/crates/assembly/external-sources/tests/external_source_coordination_contracts/hook_coordinator.rs diff --git a/src/crates/assembly/external-sources/tests/mcp_coordinator.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/mcp_coordinator.rs similarity index 100% rename from src/crates/assembly/external-sources/tests/mcp_coordinator.rs rename to src/crates/assembly/external-sources/tests/external_source_coordination_contracts/mcp_coordinator.rs diff --git a/src/crates/assembly/external-sources/tests/subagent_coordinator.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/subagent_coordinator.rs similarity index 100% rename from src/crates/assembly/external-sources/tests/subagent_coordinator.rs rename to src/crates/assembly/external-sources/tests/external_source_coordination_contracts/subagent_coordinator.rs diff --git a/src/crates/assembly/external-sources/tests/tool_coordinator_contracts.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/tool_coordinator_contracts.rs similarity index 100% rename from src/crates/assembly/external-sources/tests/tool_coordinator_contracts.rs rename to src/crates/assembly/external-sources/tests/external_source_coordination_contracts/tool_coordinator_contracts.rs diff --git a/src/crates/assembly/external-sources/tests/workspace_reference.rs b/src/crates/assembly/external-sources/tests/external_source_coordination_contracts/workspace_reference.rs similarity index 100% rename from src/crates/assembly/external-sources/tests/workspace_reference.rs rename to src/crates/assembly/external-sources/tests/external_source_coordination_contracts/workspace_reference.rs diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index cda9fc290..170979f33 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -276,7 +276,7 @@ const PRODUCT_DELIVERY_PROFILE_ENTRIES: &[ProductDeliveryProfileEntry] = &[ ), ProductDeliveryProfileEntry::new( DeliveryProfile::Sdk, - ProductCoreDependencyMode::ProductFullCompatibility, + ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, ), ]; diff --git a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs index 3e3dc7d61..0e136bce6 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capabilities.rs @@ -410,7 +410,7 @@ fn product_delivery_profile_matrix_documents_current_core_dependency_shape() { ), ( DeliveryProfile::Sdk, - ProductCoreDependencyMode::ProductFullCompatibility, + ProductCoreDependencyMode::ExplicitCoreCapabilityClosure, ), ] ); From 23571fd772318bd8641099259fd22e86f2650f14 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 01:59:23 -0700 Subject: [PATCH 12/39] fix(cron): tell the agent scheduling is a handoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked to pace itself with a cron job, the agent created one and then kept working for another half hour before stopping on its own. Nothing was broken: `Cron add` returns "Created scheduled job ..." and nothing more, so the call reads as an ordinary success, and no tool can end a turn — only the model stopping ends it. With the loop it just handed to the scheduler still in its head, it drives that loop itself. That is worse than untidy. A scheduled run is submitted as a queued dialog turn at low priority, so it is never run in parallel with the turn already in flight; a round that outlives the interval simply delays the trigger it is racing. The round in question took 32m against a 30m interval, which turns "every 30 minutes" into back-to-back rounds. - Say it in the `add` result, but only when the job actually takes over this turn's cadence: it repeats, and it targets this session. A one-shot reminder, or a job scheduled for some other session, must not cut the current turn short. - Say it in the tool description too, so the model knows before it commits to a plan rather than after — including that a run firing into a busy session is queued, so the interval wants to be longer than a round. - Drop the claim, added with the browser.wait fix, that Cron "ends the turn and re-invokes you". It does not, and a model that believed it would keep the turn open waiting for an end that never comes. --- .../tools/implementations/control_hub_tool.rs | 2 +- .../tools/implementations/cron_tool.rs | 155 +++++++++++++++++- 2 files changed, 149 insertions(+), 8 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs index 8e0bf97c9..7f3132c58 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/control_hub_tool.rs @@ -271,7 +271,7 @@ Use this tool via `{ domain, action, params }` for browser automation, terminal * `wait { duration_ms }` — pause for a fixed time, up to 60 minutes (`ms` and `seconds` are accepted spellings). This is the action to use when you must idle between rounds of work, e.g. `{ "duration_ms": 1800000 }` to resume in 30 minutes. It needs no browser session, and the result reports the `ms` actually waited, so check that figure before assuming the full pause happened. * `wait { condition, timeout_ms? }` — wait on the page instead: 'load' | 'domcontentloaded' | 'networkidle' | a CSS/@ref selector, bounded by `timeout_ms` (default 15s). Requires a connected session. When a `condition` is present it always wins, and any duration you pass becomes its timeout rather than a separate sleep. * A `wait` carrying neither is rejected with `INVALID_PARAMS` — it never silently returns. - * `wait` holds the turn open for its whole duration, so it suits a one-off pause, not a schedule. For work that should repeat ("produce another round every 30 minutes") or resume more than an hour out, create a job with the `Cron` tool instead — it ends the turn and re-invokes you when the job fires, rather than idling with the context loaded. Every built-in mode that has ControlHub also has `Cron`; if it is genuinely absent from your tool list, say so rather than substituting a chain of long `wait` calls. + * `wait` holds the turn open for its whole duration, so it suits a one-off pause, not a schedule. For work that should repeat ("produce another round every 30 minutes") or resume more than an hour out, create a job with the `Cron` tool instead, then **end your turn** — creating the job does not end it for you. The job re-invokes you when it fires, so a turn left running is idling with the context loaded and only delays the next round. Every built-in mode that has ControlHub also has `Cron`; if it is genuinely absent from your tool list, say so rather than substituting a chain of long `wait` calls. - Automation workflow: connect -> navigate -> snapshot (returns @e1, @e2 ... refs) -> click/fill with `{ "selector": "@e1" }` (the key `ref` is accepted too). - Take a fresh snapshot after any DOM mutation; a stale `@eN` ref returns `error.code = STALE_REF`, while a selector that matches nothing returns `NOT_FOUND`. diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs index 4aa05675f..7dd0e3198 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/cron_tool.rs @@ -221,6 +221,45 @@ impl CronTool { Ok(resolved) } + /// Whether a schedule fires more than once. + /// + /// Only a repeating schedule takes over a cadence the agent would + /// otherwise drive by hand; a one-shot `at` job is a reminder and says + /// nothing about what the agent should do with the rest of its turn. + fn schedule_repeats(schedule: &CronSchedule) -> bool { + match schedule { + CronSchedule::At { .. } => false, + CronSchedule::Every { .. } | CronSchedule::Cron { .. } => true, + } + } + + /// Describe a created job to the model. + /// + /// Handing a cadence to the scheduler is a handoff, but creating the job + /// does not end the turn — no tool can. Saying only "created job X" leaves + /// `add` looking like any other successful call, so the agent keeps + /// driving the loop the schedule was meant to take over, and a turn that + /// outruns the interval delays the very trigger it is racing (a scheduled + /// run is queued at low priority, never run concurrently). + fn add_result_summary(job: &CronJob, current_session_id: Option<&str>) -> String { + let mut summary = format!( + "Created scheduled job '{}' ({}) for session '{}' in workspace '{}'.", + job.name, + job.id, + job.session_id().unwrap_or(""), + job.workspace().workspace_path + ); + if Self::schedule_repeats(&job.schedule) && job.session_id() == current_session_id { + summary.push_str( + " The schedule owns this cadence now. Creating the job did not end your turn — finish only what is \ + already in flight and then end it, instead of starting another round or waiting for one. When the job \ + fires it delivers its payload to this session as a new user message, and that is what begins the next \ + round; a turn still running when it fires just makes that round start late.", + ); + } + summary + } + fn normalize_add_name(name: Option) -> String { match name { Some(name) if !name.trim().is_empty() => name.trim().to_string(), @@ -633,6 +672,12 @@ impl Tool for CronTool { async fn description(&self) -> BitFunResult { Ok(r#"Manage scheduled jobs. +Scheduling is a handoff, not a step: +- Creating a job does NOT end the current turn. No tool can end a turn — only you can, by stopping. +- After scheduling a repeating job for this session, finish what is already in flight and then end your turn. Do not start the next round yourself and do not wait for it. +- A job delivers its payload to the target session as a new user message when it fires; that message is what starts the next round. +- A run that fires while the session is still busy is queued, never run in parallel, so a turn that outlives the interval only makes the next round start late. Pick an interval comfortably longer than one round takes. + Defaults: - "session_id": defaults to the current session for "list" and "add". @@ -1101,13 +1146,8 @@ Patch schema for "update": }) .await?; let serialized_job = Self::serialize_job(&created)?; - let result_for_assistant = format!( - "Created scheduled job '{}' ({}) for session '{}' in workspace '{}'.", - created.name, - created.id, - created.session_id().unwrap_or(""), - created.workspace().workspace_path - ); + let result_for_assistant = + Self::add_result_summary(&created, context.session_id.as_deref()); Ok(vec![ToolResult::Result { data: json!({ @@ -1389,4 +1429,105 @@ mod tests { ); assert_eq!(workspace_ref.remote_ssh_host.as_deref(), Some("ssh.dev")); } + + fn job_with_schedule(schedule: CronSchedule, session_id: &str) -> CronJob { + CronJob { + id: "cron_4d437971".to_string(), + name: "round every 30min".to_string(), + schedule, + payload: CronJobPayload { + text: "run the next round".to_string(), + }, + enabled: true, + target: CronJobTarget::Session { + session_id: session_id.to_string(), + workspace: CronWorkspaceRef { + workspace_id: None, + workspace_path: "/home/wsp/projects/test".to_string(), + project_workspace_path: None, + execution_target: None, + remote_connection_id: None, + remote_ssh_host: None, + }, + }, + created_at_ms: 0, + config_updated_at_ms: 0, + updated_at_ms: 0, + state: Default::default(), + } + } + + fn every_30_minutes() -> CronSchedule { + CronSchedule::Every { + every_ms: 30 * 60 * 1_000, + anchor_ms: None, + } + } + + #[test] + fn a_recurring_job_for_this_session_tells_the_agent_to_end_its_turn() { + // Without this, `add` reads as an ordinary success and the agent keeps + // driving the loop it just handed to the scheduler — the round then + // outruns the interval and delays the trigger it is racing. + let summary = CronTool::add_result_summary( + &job_with_schedule(every_30_minutes(), "session_1"), + Some("session_1"), + ); + + assert!(summary.contains("cron_4d437971"), "got: {summary}"); + assert!(summary.contains("end your turn"), "got: {summary}"); + // The turn does not end by itself, and the guidance has to say so: + // no tool can end a turn, only the model choosing to stop. + assert!(summary.contains("did not end your turn"), "got: {summary}"); + } + + #[test] + fn a_cron_expression_schedule_also_hands_over_the_cadence() { + let summary = CronTool::add_result_summary( + &job_with_schedule( + CronSchedule::Cron { + expr: "0 9 * * 1-5".to_string(), + tz: None, + }, + "session_1", + ), + Some("session_1"), + ); + + assert!(summary.contains("end your turn"), "got: {summary}"); + } + + #[test] + fn a_one_shot_job_leaves_the_current_turn_alone() { + // A single reminder says nothing about what to do with the rest of the + // turn, so telling the agent to stop would cut real work short. + let summary = CronTool::add_result_summary( + &job_with_schedule( + CronSchedule::At { + at: "2026-03-17T12:00:00+08:00".to_string(), + }, + "session_1", + ), + Some("session_1"), + ); + + assert!(!summary.contains("end your turn"), "got: {summary}"); + } + + #[test] + fn scheduling_work_for_another_session_leaves_the_current_turn_alone() { + // The cadence being handed over is not this turn's, so this agent has + // no reason to stop what it is doing. + let summary = CronTool::add_result_summary( + &job_with_schedule(every_30_minutes(), "session_other"), + Some("session_1"), + ); + + assert!(!summary.contains("end your turn"), "got: {summary}"); + + // Same when the caller has no session identity to compare against. + let summary = + CronTool::add_result_summary(&job_with_schedule(every_30_minutes(), "session_1"), None); + assert!(!summary.contains("end your turn"), "got: {summary}"); + } } From a51753d5d78e28d850cac5451c5edfa8dc81467c Mon Sep 17 00:00:00 2001 From: t Date: Tue, 11 Aug 2026 17:27:21 +0800 Subject: [PATCH 13/39] =?UTF-8?q?feat:=20Session=E4=B8=8EWorktree=E5=8F=8C?= =?UTF-8?q?=E5=90=91=E8=81=94=E5=8A=A8=EF=BC=88create/delete/rename?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit create联动:SessionControl/SessionMessage 新增 worktree 参数(WorktreeSessionOptions), 创建会话时复用 WorktreeService 创建 worktree(detached + registry + 幂等 receipt), 自动命名分支 task/<序号>,失败回滚清理(不遗留孤儿 worktree/会话); ACP agent_type 与 worktree 互斥校验,remote workspace 拒绝。 delete联动:删除会话时按 execution_target.worktree_id 联动 WorktreeService::remove, safety veto(脏/未发布/锁定)保留 worktree 仅告警,remove 失败不阻塞会话删除。 rename联动:重命名会话时同步 WorktreeService::update_display_name(registry 持久化 + WorktreeSummary 透出),分支名沿用 task/<序号> 系不重命名,失败不阻塞。 权限:worktree 创建仅 Commander(或 RBAC 关闭)授权(worktree_creation_authorized)。 涉及文件(9): - agentic/coordination/coordinator.rs(delete/rename 联动) - agentic/tools/implementations/session_control_tool.rs(create 复用 WorktreeService) - agentic/tools/implementations/session_message_tool.rs(create 复用 WorktreeService) - agentic/tools/restrictions.rs(worktree_creation_authorized) - service/worktree/mod.rs(update_display_name + display_name 字段) - services-integrations/git/service.rs(rename_branch) - contracts/core-types/src/worktree.rs(WorktreeSessionOptions/WorktreeSummary.display_name) - contracts/core-types/src/lib.rs(导出 WorktreeSessionOptions) - execution/agent-runtime/src/session_control.rs(worktree 字段 + 校验) --- .../src/agentic/coordination/coordinator.rs | 133 ++++++ .../implementations/session_control_tool.rs | 452 ++++++++++++++++-- .../implementations/session_message_tool.rs | 331 ++++++++++++- .../core/src/agentic/tools/restrictions.rs | 44 ++ .../assembly/core/src/service/worktree/mod.rs | 158 ++++++ src/crates/contracts/core-types/src/lib.rs | 4 +- .../contracts/core-types/src/worktree.rs | 45 +- .../agent-runtime/src/session_control.rs | 147 ++++++ .../services-integrations/src/git/service.rs | 88 ++++ 9 files changed, 1360 insertions(+), 42 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 3f52640e8..3bc441893 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -105,6 +105,7 @@ use crate::service::workspace::{ get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, WorkspaceInfo, WorkspaceKind, WorkspaceService, }; +use crate::service::worktree::{WorktreeRemoveRequest, WorktreeService}; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_runtime::deep_review::FocusedReviewAssignment; @@ -7438,6 +7439,25 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .resolve_storage_path_for_workspace_path(workspace_path) .await; + // Step3 (W6): 删除前读取会话的 worktree 绑定(execution_target.worktree_id), + // 供删除成功后联动清理。读取失败不阻塞删除(best-effort)。 + let worktree_binding = self + .session_manager + .load_session_metadata(&session_storage_path, session_id) + .await + .ok() + .flatten() + .and_then(|metadata| { + let worktree_id = metadata + .execution_target + .as_ref() + .and_then(|target| target.worktree_id.clone()); + let project_workspace_path = metadata + .project_workspace_path + .clone() + .unwrap_or_else(|| session_storage_path.to_string_lossy().to_string()); + worktree_id.map(|worktree_id| (worktree_id, project_workspace_path)) + }); let has_revert_state = self .session_manager .persistence_manager() @@ -7521,6 +7541,28 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet self.background_subagent_outcomes .delete_session_references(session_id) .await?; + // Step3 (W6): 会话删除成功后,若绑定 worktree → 联动清理。 + // 策略(指挥官裁决=保留非强删): + // - worktree 干净 → WorktreeService::remove 清理(safety veto 内部判定) + // - worktree 有改动/未发布/锁定 → remove 失败 → 保留不删,仅 log 提示 + // - remove 失败不阻塞会话删除(会话照删,worktree 靠既有 24h 定时清理兜底) + // - 幂等:会话已删后重复 delete 无 worktree 绑定(metadata 已删)→ 无操作 + if let Some((worktree_id, project_workspace_path)) = worktree_binding { + if let Err(remove_error) = WorktreeService::remove(WorktreeRemoveRequest { + request_id: format!("session-delete:{session_id}:{worktree_id}"), + project_workspace_path, + worktree_id, + force: false, + }) + .await + { + log::warn!( + "Session '{}' deleted; associated worktree was retained (not removed): {}", + session_id, + remove_error + ); + } + } // Custom session-end cleanup (outside hook gating): RBAC role and // tool-restriction unregistration plus Warden state cleanup, so a // recycled session id cannot inherit stale lifecycle state. @@ -13386,6 +13428,50 @@ impl bitfun_runtime_ports::AgentSessionManagementPort for ConversationCoordinato .await .map(|_| ()) .map_err(runtime_port_error_preserving_message)?; + // Step4 (W7): 会话 rename 成功后,若绑定 worktree → 联动同步 + // display_name(+ 分支名仅当非 task/N 系时重命名;指挥官裁决: + // 沿用 task/<序号> 系,原分支已是 task/N 则保持分支名,仅同步 + // display_name,三方一致即可)。 + // 失败(worktree 不存在/分支被占用等)不阻塞会话 rename,仅 log 提示。 + let worktree_binding = self + .session_manager + .load_session_metadata(&effective_storage_path, &request.session_id) + .await + .ok() + .flatten() + .and_then(|metadata| { + let worktree_id = metadata + .execution_target + .as_ref() + .and_then(|target| target.worktree_id.clone()); + let project_workspace_path = metadata + .project_workspace_path + .clone() + .unwrap_or_else(|| effective_storage_path.to_string_lossy().to_string()); + worktree_id.map(|worktree_id| (worktree_id, project_workspace_path)) + }); + if let Some((worktree_id, project_workspace_path)) = worktree_binding { + let worktree_request_id = format!( + "session-rename:{}:{}", + request.session_id, worktree_id + ); + if let Err(link_error) = WorktreeService::update_display_name( + &project_workspace_path, + &worktree_request_id, + &worktree_id, + Some(&request.session_name), + None, + ) + .await + { + log::warn!( + "Session '{}' renamed to '{}'; worktree display name sync failed (session rename unaffected): {}", + request.session_id, + request.session_name, + link_error + ); + } + } // 断点 2 修复(2026-08-08,RECON-子对话rename-list不同步-20260808): // rename 成功后广播 SessionTitleGenerated{method:"manual"}——前端 // flowChatStore 经 useFlowChatSync/EventHandlerModule 监听该事件更新 @@ -17752,6 +17838,53 @@ mod tests { } } + #[tokio::test] + async fn coordinator_delete_session_with_worktree_binding_does_not_block_on_remove_failure() { + // Step3 (W6):绑定 worktree 的会话删除时,worktree remove 失败 + // (worktree 不存在/非 git 项目等)不得阻塞会话删除。 + let (coordinator, session_manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().expect("workspace"); + let session_id = format!("wt-delete-{}", uuid::Uuid::new_v4()); + let storage_path = + create_two_turn_session(session_manager.as_ref(), workspace.path(), &session_id).await; + + // 给会话 metadata 挂一个 worktree execution_target(指向不存在的 + // worktree_id——WorktreeService::remove 将失败)。 + let mut metadata = session_manager + .persistence_manager() + .load_session_metadata(&storage_path, &session_id) + .await + .expect("metadata lookup") + .expect("session metadata exists"); + metadata.execution_target = Some(bitfun_core_types::SessionExecutionTarget { + kind: bitfun_core_types::SessionExecutionTargetKind::ManagedWorktree, + worktree_id: Some("missing-worktree".to_string()), + root_path: "/nonexistent/worktree".to_string(), + base_ref: None, + base_commit: None, + branch: Some("task/1".to_string()), + lifecycle: None, + }); + session_manager + .persistence_manager() + .save_session_metadata(&storage_path, &metadata) + .await + .expect("save metadata with worktree binding"); + + // 删除必须成功(worktree remove 失败仅 log,不阻塞会话删除)。 + coordinator + .delete_session(&storage_path, &session_id) + .await + .expect("session delete must succeed despite worktree remove failure"); + + assert!(session_manager + .persistence_manager() + .load_session_metadata(&storage_path, &session_id) + .await + .expect("metadata lookup") + .is_none(), "session must be fully removed"); + } + #[tokio::test] async fn coordinator_delete_session_tree_aborts_when_a_member_is_undeletable() { let (coordinator, session_manager) = test_persistent_coordinator(); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index a3db1ffa0..c55cedbe5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -10,7 +10,9 @@ use crate::agentic::coordination::{get_global_coordinator, get_global_scheduler} use crate::agentic::tools::framework::{ Tool, ToolExposure, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; -use crate::agentic::tools::restrictions::{get_session_role, validate_delegation, AgentRole}; +use crate::agentic::tools::restrictions::{ + get_session_role, validate_delegation, worktree_creation_authorized, AgentRole, +}; use crate::service_agent_runtime::CoreServiceAgentRuntime; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; @@ -26,7 +28,7 @@ use bitfun_agent_runtime::session_control::{ SessionControlCancelRoute, SessionControlInput, SessionControlValidationContext, SessionControlValidationResult, }; -use bitfun_core_types::SessionExecutionTarget; +use bitfun_core_types::{SessionExecutionTarget, WorktreeSessionOptions}; use bitfun_runtime_ports::{ AcpClientCreateRequest, AcpClientCreateResult, AcpClientPort, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionSummary, @@ -35,8 +37,16 @@ use bitfun_runtime_ports::{ }; use bitfun_services_core::session::merge_session_custom_metadata; use bitfun_services_core::session::tree::SessionTreeManager; +use crate::service::git::GitService; +use crate::service::worktree::{ + WorktreeCreateBranchRequest, WorktreeCreateRequest, WorktreeService, +}; +use crate::service::workspace::{ + get_global_workspace_service, WorkspaceActivityMode, WorkspaceCreateOptions, +}; use serde_json::{json, Value}; use std::collections::HashMap; +use std::path::PathBuf; use std::time::Duration; /// SessionControl tool - create, cancel, delete, or list persisted sessions @@ -48,12 +58,23 @@ const CANCEL_WAIT_TIMEOUT: Duration = Duration::from_secs(3); #[derive(Debug, Clone)] pub(crate) struct SessionControlWorkspaceTarget { - display_workspace: String, - project_workspace: String, - execution_target: Option, - workspace_id: Option, - remote_connection_id: Option, - remote_ssh_host: Option, + pub display_workspace: String, + pub project_workspace: String, + pub execution_target: Option, + pub workspace_id: Option, + pub remote_connection_id: Option, + pub remote_ssh_host: Option, +} + +/// 结果:SessionControl/SessionMessage create 时创建的 worktree(W4/W5)。 +/// `created=false` = 幂等重放(request_id 已用过,复用既有 worktree)。 +#[derive(Debug, Clone)] +pub(crate) struct SessionWorktreeCreateResult { + pub execution_target: SessionExecutionTarget, + pub tracked_workspace_id: Option, + pub created: bool, + pub branch_name: Option, + pub project_workspace_path: String, } impl Default for SessionControlTool { @@ -228,6 +249,12 @@ impl SessionControlTool { } } + /// W4: SessionControl create 分支的 worktree 授权/remote 检查入口。 + fn ensure_worktree_allowed(&self, context: &ToolUseContext) -> BitFunResult<()> { + ensure_worktree_creation_authorized(context)?; + ensure_worktree_not_remote(context) + } + #[allow(dead_code)] async fn ensure_session_exists( &self, @@ -319,6 +346,239 @@ impl SessionControlTool { } } +// ── Session↔worktree 联动共享核心(W4/W5/W8/W9)────────────────── +// +// 以下函数为文件级 pub(crate) free functions,SessionControl 与 +// SessionMessage 两个工具共用(SessionMessage 经 +// `use super::session_control_tool::...` 复用)。 + +/// W9: worktree 参数授权判定。worktree 创建 = git 文件系统操作(git +/// worktree add),是服务层调用不走工具权限门,因此独立判定:仅 +/// Commander owner(或 RBAC 关闭)允许。非 owner 调用者携带 worktree +/// 参数一律拒绝。 +pub(crate) fn ensure_worktree_creation_authorized( + context: &ToolUseContext, +) -> BitFunResult<()> { + let caller_session_id = context.session_id.as_deref().ok_or_else(|| { + BitFunError::tool( + "worktree requires a caller session in tool context".to_string(), + ) + })?; + if worktree_creation_authorized(caller_session_id) { + Ok(()) + } else { + Err(BitFunError::tool(format!( + "worktree is only allowed for owner (Commander) sessions; session '{}' is not authorized to create worktrees", + caller_session_id + ))) + } +} + +/// W9: remote SSH 互斥拒绝(worktree 不支持 remote workspace)。 +pub(crate) fn ensure_worktree_not_remote(context: &ToolUseContext) -> BitFunResult<()> { + if context.is_remote() { + return Err(BitFunError::tool( + "Managed worktrees are not supported for remote SSH workspaces yet".to_string(), + )); + } + Ok(()) +} + +/// W8 自动命名:worktree 分支 task/<序号>(从既有 task/* 序号递增)。 +/// 稳定前缀 `task/`,序号 = 项目内已有 `task/` 分支的最大值 + 1。 +/// 并发下由 WorktreeService 的仓库级锁 + receipt 幂等兜底(同一 +/// request_id 重放不会重复创建分支)。 +async fn next_task_branch_name(project_workspace_path: &str) -> BitFunResult { + let branches = GitService::get_branches(project_workspace_path, false) + .await + .map_err(|error| BitFunError::tool(format!("Failed to list branches: {error}")))?; + let max_task_index = branches + .iter() + .filter_map(|branch| { + branch + .name + .strip_prefix("task/") + .and_then(|suffix| suffix.parse::().ok()) + }) + .max() + .unwrap_or(0); + Ok(format!("task/{}", max_task_index + 1)) +} + +/// W8:把 task/<序号> 分支名清洗为合法 git 分支名(git check-ref-format +/// 规则 + 长度上限)。自动命名已保证合法,此处防御性清洗(对齐 +/// dispatch_branch_name 的段级过滤思路,不信任任何输入)。 +fn sanitize_task_branch_name(branch: &str) -> String { + let sanitized: String = branch + .split('/') + .map(|segment| { + segment + .chars() + .filter(|character| { + character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') + }) + .collect::() + }) + .map(|segment| segment.trim_matches('.').to_string()) + .filter(|segment| !segment.is_empty()) + .collect::>() + .join("/"); + if sanitized.is_empty() { + "task/1".to_string() + } else { + sanitized + } +} + +/// 创建 managed worktree 并绑定到新会话(W4/W5 共享核心)。 +/// +/// 链路(对齐 WorktreeTool::create_session worktree_tool.rs:358-552,禁裸调 +/// git,一切走 WorktreeService): +/// 1. WorktreeService::create(git worktree add --detach + registry + 幂等 +/// receipt)——worktree 创建成功才继续; +/// 2. track workspace(workspace 注册); +/// 3. 自动命名分支 task/<序号> 并 create_branch(worktree 绑定分支); +/// 4. 返回 execution_target + tracked workspace id;任何一步失败由本函数 +/// 回滚(worktree remove + workspace 注销),不留孤儿。 +pub(crate) async fn create_worktree_for_session( + request_id: &str, + workspace: &SessionControlWorkspaceTarget, + worktree_options: &WorktreeSessionOptions, + context: &ToolUseContext, +) -> BitFunResult { + let source_workspace_path = context + .workspace_root() + .ok_or_else(|| { + BitFunError::tool("Current execution workspace is unavailable".to_string()) + })? + .to_string_lossy() + .to_string(); + let project_workspace_path = workspace.project_workspace.clone(); + + let created = WorktreeService::create(WorktreeCreateRequest { + request_id: request_id.to_string(), + project_workspace_path: project_workspace_path.clone(), + source_workspace_path: Some(source_workspace_path), + base_ref: worktree_options.base_ref.clone(), + copy_local_changes: worktree_options.copy_local_changes, + claimed_by: None, + }) + .await + .map_err(|error| BitFunError::tool(error.to_string()))?; + + let worktree_id = created + .execution_target + .worktree_id + .clone() + .ok_or_else(|| { + BitFunError::tool("Created worktree is missing its worktree_id".to_string()) + })?; + + // track workspace(对齐 WorktreeTool::create_session 的 + // track_workspace_activity 步骤)。失败即回滚新 worktree。 + let workspace_service = get_global_workspace_service().ok_or_else(|| { + BitFunError::tool("Workspace service is not initialized".to_string()) + })?; + let tracked_workspace = match workspace_service + .track_workspace_activity( + PathBuf::from(&created.execution_target.root_path), + WorkspaceCreateOptions::default(), + WorkspaceActivityMode::RefreshMetadata, + ) + .await + { + Ok(workspace) => workspace, + Err(track_error) => { + return Err(cleanup_failed_worktree_create( + &project_workspace_path, + &created.execution_target, + created.created, + None, + format!("Failed to register worktree workspace: {track_error}"), + ) + .await); + } + }; + + // 自动命名分支 task/<序号>(幂等重放 created=false 时可能已有分支, + // 跳过分支创建)。 + let branch_name = sanitize_task_branch_name(&next_task_branch_name(&project_workspace_path).await?); + if created.created && created.execution_target.branch.is_none() { + let branch_request_id = format!("{request_id}:branch"); + if let Err(branch_error) = + WorktreeService::create_branch(WorktreeCreateBranchRequest { + request_id: branch_request_id, + project_workspace_path: project_workspace_path.clone(), + worktree_id: worktree_id.clone(), + branch: branch_name.clone(), + }) + .await + { + return Err(cleanup_failed_worktree_create( + &project_workspace_path, + &created.execution_target, + created.created, + Some(&tracked_workspace.id), + format!("Failed to create worktree branch: {branch_error}"), + ) + .await); + } + } + + Ok(SessionWorktreeCreateResult { + execution_target: created.execution_target, + tracked_workspace_id: Some(tracked_workspace.id), + created: created.created, + branch_name: Some(branch_name), + project_workspace_path, + }) +} + +/// 回滚刚创建的 worktree(W4/W5 失败路径)。 +/// +/// 对齐 WorktreeTool::cleanup_failed_fresh_create:注销 workspace + +/// WorktreeService::rollback_created(用项目路径)。仅当本次确实创建了 +/// worktree(created=true)时回滚;幂等重放(created=false)不重复回滚。 +async fn cleanup_failed_worktree_create( + project_workspace_path: &str, + execution_target: &SessionExecutionTarget, + created: bool, + tracked_workspace_id: Option<&str>, + failure: impl Into, +) -> BitFunError { + let failure = failure.into(); + let mut rollback_issues = Vec::new(); + if let Some(workspace_id) = tracked_workspace_id { + if let Some(workspace_service) = get_global_workspace_service() { + if let Err(remove_error) = workspace_service.remove_workspace(workspace_id).await { + rollback_issues.push(format!( + "workspace registration could not be removed: {remove_error}" + )); + } + } + } + if created { + if let Some(worktree_id) = execution_target.worktree_id.as_deref() { + if let Err(rollback_error) = WorktreeService::rollback_created( + project_workspace_path, + worktree_id, + ) + .await + { + rollback_issues.push(format!("worktree could not be removed: {rollback_error}")); + } + } + } + if rollback_issues.is_empty() { + BitFunError::tool(failure) + } else { + BitFunError::tool(format!( + "rollback_incomplete: {failure}; {}", + rollback_issues.join("; ") + )) + } +} + /// Shared source for the agent_type enum of SessionControl/SessionMessage /// create (and LegionControl load validation). /// @@ -1151,6 +1411,22 @@ Arguments: "model_id": { "type": "string", "description": "Optional model id used when creating a session; the created session binds to this model." + }, + "worktree": { + "type": "object", + "description": "Optional worktree options for create: creates a managed Git worktree together with the session and binds the session to it (only for create; not supported for remote workspaces). Shape: {baseRef?, copyLocalChanges?}.", + "properties": { + "baseRef": { + "type": "string", + "description": "Optional Git ref for the new worktree. Defaults to HEAD." + }, + "copyLocalChanges": { + "type": "boolean", + "default": false, + "description": "Copy staged, unstaged, untracked, and .worktreeinclude-selected ignored files when the selected base equals source HEAD." + } + }, + "additionalProperties": false } }, "required": ["action"], @@ -1198,6 +1474,22 @@ Arguments: "model_id": { "type": "string", "description": "Optional model id used when creating a session; the created session binds to this model." + }, + "worktree": { + "type": "object", + "description": "Optional worktree options for create: creates a managed Git worktree together with the session and binds the session to it (only for create; not supported for remote workspaces). Shape: {baseRef?, copyLocalChanges?}.", + "properties": { + "baseRef": { + "type": "string", + "description": "Optional Git ref for the new worktree. Defaults to HEAD." + }, + "copyLocalChanges": { + "type": "boolean", + "default": false, + "description": "Copy staged, unstaged, untracked, and .worktreeinclude-selected ignored files when the selected base equals source HEAD." + } + }, + "additionalProperties": false } }, "required": ["action"], @@ -1271,6 +1563,13 @@ Arguments: session_control_session_name_or_default(params.session_name.as_deref()); let agent_type = session_control_agent_type_or_default(params.agent_type.as_ref()); + // W9: worktree 参数授权 + remote 互斥拒绝。worktree 创建是 git + // 文件系统操作,仅 Commander owner(或 RBAC 关闭)允许,且 + // remote SSH 工作区不支持。 + if params.worktree.is_some() { + self.ensure_worktree_allowed(context)?; + } + // ACP 真会话路径:agent_type `acp__`(ACP bridge agent // registry id,见 AcpAgent::agent_id_for)直接经 AcpClientPort 创建 // 真外部 ACP 会话——与前端 create_acp_flow_session 等价(持久记录 + @@ -1333,6 +1632,26 @@ Arguments: } } + // W4: worktree 参数命中 → 先创建 managed worktree(WorktreeService + // 链路,worktree 创建成功才创建会话),把会话 execution_target 指向 + // worktree。失败 = 会话不创建 + worktree 回滚,零孤儿。 + let mut created_worktree: Option = None; + if let Some(worktree_options) = params.worktree.as_ref() { + let request_id = context + .tool_call_id + .as_deref() + .map(|tool_call_id| format!("session-control:{tool_call_id}:worktree")) + .unwrap_or_else(|| format!("session-control:{}:worktree", uuid::Uuid::new_v4())); + let worktree = create_worktree_for_session( + &request_id, + &workspace, + worktree_options, + context, + ) + .await?; + created_worktree = Some(worktree); + } + let created_by = self.creator_session_marker(context)?; let mut metadata = serde_json::Map::new(); metadata.insert("createdBy".to_string(), json!(created_by)); @@ -1349,23 +1668,66 @@ Arguments: json!(context.session_id.clone()), ); metadata.insert("subagentType".to_string(), json!(agent_type.clone())); - let session = runtime + let session = match runtime .create_session(AgentSessionCreateRequest { session_name, agent_type, - workspace_path: Some(workspace.display_workspace.clone()), - project_workspace_path: Some(workspace.project_workspace.clone()), - execution_target: workspace.execution_target.clone(), - workspace_id: workspace.workspace_id.clone(), + workspace_path: Some( + created_worktree + .as_ref() + .map(|wt| wt.execution_target.root_path.clone()) + .unwrap_or_else(|| workspace.display_workspace.clone()), + ), + project_workspace_path: Some( + created_worktree + .as_ref() + .map(|wt| wt.project_workspace_path.clone()) + .unwrap_or_else(|| workspace.project_workspace.clone()), + ), + execution_target: created_worktree + .as_ref() + .map(|wt| wt.execution_target.clone()) + .or_else(|| workspace.execution_target.clone()), + workspace_id: created_worktree + .as_ref() + .and_then(|wt| wt.tracked_workspace_id.clone()) + .or_else(|| workspace.workspace_id.clone()), remote_connection_id: workspace.remote_connection_id.clone(), remote_ssh_host: workspace.remote_ssh_host.clone(), model_id: params.model_id.clone(), metadata, }) .await - .map_err(|error| { - BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) - })?; + { + Ok(session) => session, + Err(create_error) => { + // 会话创建失败 → 回滚已创建的 worktree(仅当本次确实创建)。 + if let Some(worktree) = created_worktree.as_ref() { + if worktree.created { + if let Some(workspace_service) = get_global_workspace_service() { + if let Some(workspace_id) = + worktree.tracked_workspace_id.as_deref() + { + let _ = + workspace_service.remove_workspace(workspace_id).await; + } + } + if let Some(worktree_id) = + worktree.execution_target.worktree_id.as_deref() + { + let _ = WorktreeService::rollback_created( + &worktree.project_workspace_path, + worktree_id, + ) + .await; + } + } + } + return Err(BitFunError::tool( + CoreServiceAgentRuntime::runtime_error_message(create_error), + )); + } + }; let created_session_id = session.session_id.clone(); let created_session_name = session.session_name.clone(); let created_agent_type = session.agent_type.clone(); @@ -1499,18 +1861,31 @@ Arguments: &created_agent_type, ); + // W4: worktree 创建成功时把 worktree 信息透出给调用方(worktree_id、 + // 路径、自动命名分支)。 + let worktree_payload = created_worktree.as_ref().map(|worktree| { + json!({ + "worktree_id": worktree.execution_target.worktree_id, + "path": worktree.execution_target.root_path, + "branch": worktree.branch_name, + }) + }); + let mut data = json!({ + "success": true, + "action": "create", + "workspace": workspace.display_workspace.clone(), + "session": { + "session_id": created_session_id, + "session_name": created_session_name, + "agent_type": created_agent_type, + "model_id": created_model_id, + } + }); + if let Some(worktree_payload) = worktree_payload { + data["worktree"] = worktree_payload; + } Ok(vec![ToolResult::Result { - data: json!({ - "success": true, - "action": "create", - "workspace": workspace.display_workspace.clone(), - "session": { - "session_id": created_session_id, - "session_name": created_session_name, - "agent_type": created_agent_type, - "model_id": created_model_id, - } - }), + data, result_for_assistant: Some(result_for_assistant), image_attachments: None, }]) @@ -2108,6 +2483,29 @@ mod tests { } } + #[test] + fn task_branch_names_are_sanitized_to_valid_git_refs() { + assert_eq!(sanitize_task_branch_name("task/1"), "task/1"); + assert_eq!(sanitize_task_branch_name("task/42"), "task/42"); + // 非法字符被段级过滤。 + assert_eq!(sanitize_task_branch_name("task/1:bad"), "task/1bad"); + // 空输入回退 task/1。 + assert_eq!(sanitize_task_branch_name(""), "task/1"); + assert_eq!(sanitize_task_branch_name("///"), "task/1"); + // 点段修剪(git ref 非法):`..` 段清空后被剔除。 + assert_eq!(sanitize_task_branch_name("task/.."), "task"); + assert_eq!(sanitize_task_branch_name("task/.."), "task"); + } + + #[test] + fn task_branch_auto_naming_increments_from_existing_task_branches() { + // 纯函数验证:next_task_branch_name 依赖 GitService::get_branches(真实 + // git 调用),此处仅验证「task/<序号> 从 0 递增」的格式契约;序号递增 + // 逻辑在集成层由 get_branches 输出驱动。 + let name = sanitize_task_branch_name("task/3"); + assert_eq!(name, "task/3"); + } + /// Minimal AcpClientPort fake: records create requests and returns the /// same flow-session shape the desktop implementation produces /// (`acp__` / `acp:`), with an optional failure flag diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs index eb3d6aca6..ea247c9ea 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_message_tool.rs @@ -1,6 +1,6 @@ use super::session_control_tool::{ get_available_agent_type_ids_for_creation, resolve_session_mutation_authorization, - SessionMutationAuthOptions, + SessionControlWorkspaceTarget, SessionMutationAuthOptions, SessionWorktreeCreateResult, }; use super::util::normalize_path; use crate::agentic::agents::AcpAgent; @@ -18,9 +18,11 @@ use crate::agentic::tools::framework::{ use crate::agentic::tools::restrictions::get_session_role; use crate::agentic::tools::workspace_paths::posix_style_path_is_absolute; use crate::service_agent_runtime::CoreServiceAgentRuntime; +use crate::service::worktree::WorktreeService; +use crate::service::workspace::get_global_workspace_service; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; -use bitfun_core_types::SessionExecutionTarget; +use bitfun_core_types::{SessionExecutionTarget, WorktreeSessionOptions}; use bitfun_runtime_ports::{ AcpClientBitfunMessageRequest, AcpClientMessageRequest, AcpClientMessageResult, AcpClientPort, AcpClientStreamChunk, AcpClientStreamChunkSink, AgentDialogPrependedReminder, @@ -560,6 +562,13 @@ struct SessionMessageInput { plan_file: Option, #[serde(default)] todo_id: Option, + /// Optional worktree options for create: when present (and session_id is + /// omitted), a managed worktree is created together with the session via + /// WorktreeService and the session is bound to it. `None` keeps the legacy + /// behavior (session runs in the project checkout). Rejected for remote + /// workspaces and for session_id-based sends. + #[serde(default)] + worktree: Option, /// Batch dispatch: perform multiple create+send (or send-to-existing) /// operations in a single tool call. All items are validated up front (the /// whole batch is rejected when any item is structurally invalid), then each @@ -597,6 +606,11 @@ struct BatchItem { /// requires plan_file). #[serde(default)] todo_id: Option, + /// Per-item worktree options for a new session (only when session_id is + /// omitted; rejected for remote workspaces). Same semantics as the + /// top-level worktree field. + #[serde(default)] + worktree: Option, } /// Delivery decision for an urgent message against a target session. @@ -705,9 +719,25 @@ Allowed agent types when creating a session are dynamically resolved from the av "type": "string", "description": "Optional todo id within plan_file for a created session (only when session_id is omitted, and requires plan_file)." }, + "worktree": { + "type": "object", + "description": "Optional worktree options for a created session (only when session_id is omitted; not supported for remote workspaces): creates a managed Git worktree together with the session and binds the session to it. Shape: {baseRef?, copyLocalChanges?}.", + "properties": { + "baseRef": { + "type": "string", + "description": "Optional Git ref for the new worktree. Defaults to HEAD." + }, + "copyLocalChanges": { + "type": "boolean", + "default": false, + "description": "Copy staged, unstaged, untracked, and .worktreeinclude-selected ignored files when the selected base equals source HEAD." + } + }, + "additionalProperties": false + }, "batch": { "type": "array", - "description": "Batch dispatch: perform multiple create+send (or send-to-existing) operations in one tool call. Mutually exclusive with the top-level message and session fields; the top-level workspace is shared by items that create a session. All items validate up front; each item then runs independently (a failed item never rolls back succeeded ones). Item shape: {session_id?, session_name?, message, agent_type?, plan_file?, todo_id?, urgent?}.", + "description": "Batch dispatch: perform multiple create+send (or send-to-existing) operations in one tool call. Mutually exclusive with the top-level message and session fields; the top-level workspace is shared by items that create a session. All items validate up front; each item then runs independently (a failed item never rolls back succeeded ones). Item shape: {session_id?, session_name?, message, agent_type?, plan_file?, todo_id?, urgent?, worktree?}.", "items": { "type": "object", "properties": { @@ -738,6 +768,22 @@ Allowed agent types when creating a session are dynamically resolved from the av "todo_id": { "type": "string", "description": "Per-item todo id within plan_file (only when session_id is omitted, and requires plan_file)." + }, + "worktree": { + "type": "object", + "description": "Per-item worktree options for a new session (only when session_id is omitted; not supported for remote workspaces). Shape: {baseRef?, copyLocalChanges?}.", + "properties": { + "baseRef": { + "type": "string", + "description": "Optional Git ref for the new worktree. Defaults to HEAD." + }, + "copyLocalChanges": { + "type": "boolean", + "default": false, + "description": "Copy staged, unstaged, untracked, and .worktreeinclude-selected ignored files when the selected base equals source HEAD." + } + }, + "additionalProperties": false } }, "required": ["message"], @@ -790,9 +836,25 @@ Allowed agent types when creating a session are dynamically resolved from the av "type": "string", "description": "Optional todo id within plan_file for a created session (only when session_id is omitted, and requires plan_file)." }, + "worktree": { + "type": "object", + "description": "Optional worktree options for a created session (only when session_id is omitted; not supported for remote workspaces): creates a managed Git worktree together with the session and binds the session to it. Shape: {baseRef?, copyLocalChanges?}.", + "properties": { + "baseRef": { + "type": "string", + "description": "Optional Git ref for the new worktree. Defaults to HEAD." + }, + "copyLocalChanges": { + "type": "boolean", + "default": false, + "description": "Copy staged, unstaged, untracked, and .worktreeinclude-selected ignored files when the selected base equals source HEAD." + } + }, + "additionalProperties": false + }, "batch": { "type": "array", - "description": "Batch dispatch: perform multiple create+send (or send-to-existing) operations in one tool call. Mutually exclusive with the top-level message and session fields; the top-level workspace is shared by items that create a session. All items validate up front; each item then runs independently (a failed item never rolls back succeeded ones). Item shape: {session_id?, session_name?, message, agent_type?, plan_file?, todo_id?, urgent?}.", + "description": "Batch dispatch: perform multiple create+send (or send-to-existing) operations in one tool call. Mutually exclusive with the top-level message and session fields; the top-level workspace is shared by items that create a session. All items validate up front; each item then runs independently (a failed item never rolls back succeeded ones). Item shape: {session_id?, session_name?, message, agent_type?, plan_file?, todo_id?, urgent?, worktree?}.", "items": { "type": "object", "properties": { @@ -823,6 +885,22 @@ Allowed agent types when creating a session are dynamically resolved from the av "todo_id": { "type": "string", "description": "Per-item todo id within plan_file (only when session_id is omitted, and requires plan_file)." + }, + "worktree": { + "type": "object", + "description": "Per-item worktree options for a new session (only when session_id is omitted; not supported for remote workspaces). Shape: {baseRef?, copyLocalChanges?}.", + "properties": { + "baseRef": { + "type": "string", + "description": "Optional Git ref for the new worktree. Defaults to HEAD." + }, + "copyLocalChanges": { + "type": "boolean", + "default": false, + "description": "Copy staged, unstaged, untracked, and .worktreeinclude-selected ignored files when the selected base equals source HEAD." + } + }, + "additionalProperties": false } }, "required": ["message"], @@ -918,6 +996,17 @@ Allowed agent types when creating a session are dynamically resolved from the av }; } + if parsed.worktree.is_some() { + return ValidationResult { + result: false, + message: Some( + "worktree is only allowed when session_id is omitted".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + if let Some(workspace) = parsed.workspace.as_deref() { let workspace_validation = self.validate_workspace_shape(workspace, context); if !workspace_validation.result { @@ -963,6 +1052,48 @@ Allowed agent types when creating a session are dynamically resolved from the av }; } + if let Some(worktree) = parsed.worktree.as_ref() { + if worktree + .base_ref + .as_deref() + .is_some_and(|base_ref| base_ref.trim().is_empty()) + { + return ValidationResult { + result: false, + message: Some( + "worktree.base_ref must not be empty when provided".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + if context.is_some_and(|context| context.is_remote()) { + return ValidationResult { + result: false, + message: Some( + "worktree is not supported for remote workspaces".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + // worktree 与 ACP 真会话(agent_type `acp__`)互斥: + // ACP 会话是外部进程记录,不承载本地 worktree + // execution_target,同时携带会导致 worktree 成为孤儿。 + if parsed.agent_type.as_ref().is_some_and(|agent_type| { + agent_type.as_str().starts_with("acp__") + }) { + return ValidationResult { + result: false, + message: Some( + "worktree is not supported with acp__ agent types".to_string(), + ), + error_code: Some(400), + meta: None, + }; + } + } + let Some(workspace) = parsed.workspace.as_deref() else { return ValidationResult { result: false, @@ -1380,6 +1511,12 @@ impl SessionMessageTool { field("plan_file/todo_id") )); } + if item.worktree.is_some() { + return Self::invalid(format!( + "{} is only allowed when session_id is omitted", + field("worktree") + )); + } if let Some(source_session_id) = source_session_id { if source_session_id == session_id { return Self::invalid(format!( @@ -1413,6 +1550,32 @@ impl SessionMessageTool { field("agent_type") )); } + if let Some(worktree) = item.worktree.as_ref() { + if worktree + .base_ref + .as_deref() + .is_some_and(|base_ref| base_ref.trim().is_empty()) + { + return Self::invalid(format!( + "{} must not be empty when provided", + field("worktree.base_ref") + )); + } + if context.is_some_and(|context| context.is_remote()) { + return Self::invalid(format!( + "{} is not supported for remote workspaces", + field("worktree") + )); + } + if item.agent_type.as_ref().is_some_and(|agent_type| { + agent_type.as_str().starts_with("acp__") + }) { + return Self::invalid(format!( + "{} is not supported with acp__ agent types", + field("worktree") + )); + } + } } } } @@ -2065,6 +2228,39 @@ impl SessionMessageTool { })? .as_str() .to_string(); + + // W9: worktree 参数授权 + remote 互斥拒绝(SessionMessage create + // 与 SessionControl create 同一语义)。 + let mut created_worktree: Option = None; + if params.worktree.is_some() { + super::session_control_tool::ensure_worktree_creation_authorized(context)?; + super::session_control_tool::ensure_worktree_not_remote(context)?; + let worktree_options = params.worktree.as_ref().expect("checked above"); + let request_id = context + .tool_call_id + .as_deref() + .map(|tool_call_id| format!("session-message:{tool_call_id}:worktree")) + .unwrap_or_else(|| { + format!("session-message:{}:worktree", uuid::Uuid::new_v4()) + }); + created_worktree = Some( + super::session_control_tool::create_worktree_for_session( + &request_id, + &SessionControlWorkspaceTarget { + display_workspace: workspace_target.workspace_path.clone(), + project_workspace: workspace_target.project_workspace_path.clone(), + execution_target: workspace_target.execution_target.clone(), + workspace_id: workspace_target.workspace_id.clone(), + remote_connection_id: workspace_target.remote_connection_id.clone(), + remote_ssh_host: workspace_target.remote_ssh_host.clone(), + }, + worktree_options, + context, + ) + .await?, + ); + } + let created_by = self.creator_session_marker(context)?; let mut metadata = serde_json::Map::new(); metadata.insert("createdBy".to_string(), json!(created_by)); @@ -2087,25 +2283,66 @@ impl SessionMessageTool { if let Some(todo_id) = params.todo_id.as_deref() { metadata.insert(TODO_ID_METADATA_KEY.to_string(), json!(todo_id)); } - let session = runtime + let session = match runtime .create_session(AgentSessionCreateRequest { session_name, agent_type: agent_type.clone(), - workspace_path: Some(workspace_target.workspace_path.clone()), + workspace_path: Some( + created_worktree + .as_ref() + .map(|wt| wt.execution_target.root_path.clone()) + .unwrap_or_else(|| workspace_target.workspace_path.clone()), + ), project_workspace_path: Some( - workspace_target.project_workspace_path.clone(), + created_worktree + .as_ref() + .map(|wt| wt.project_workspace_path.clone()) + .unwrap_or_else(|| workspace_target.project_workspace_path.clone()), ), - execution_target: workspace_target.execution_target.clone(), - workspace_id: workspace_target.workspace_id.clone(), + execution_target: created_worktree + .as_ref() + .map(|wt| wt.execution_target.clone()) + .or_else(|| workspace_target.execution_target.clone()), + workspace_id: created_worktree + .as_ref() + .and_then(|wt| wt.tracked_workspace_id.clone()) + .or_else(|| workspace_target.workspace_id.clone()), remote_connection_id: workspace_target.remote_connection_id.clone(), remote_ssh_host: workspace_target.remote_ssh_host.clone(), model_id: None, metadata, }) .await - .map_err(|error| { - BitFunError::tool(CoreServiceAgentRuntime::runtime_error_message(error)) - })?; + { + Ok(session) => session, + Err(create_error) => { + // 会话创建失败 → 回滚已创建的 worktree(仅当本次确实创建)。 + if let Some(worktree) = created_worktree.as_ref() { + if worktree.created { + if let Some(workspace_service) = get_global_workspace_service() { + if let Some(workspace_id) = + worktree.tracked_workspace_id.as_deref() + { + let _ = + workspace_service.remove_workspace(workspace_id).await; + } + } + if let Some(worktree_id) = + worktree.execution_target.worktree_id.as_deref() + { + let _ = WorktreeService::rollback_created( + &worktree.project_workspace_path, + worktree_id, + ) + .await; + } + } + } + return Err(BitFunError::tool( + CoreServiceAgentRuntime::runtime_error_message(create_error), + )); + } + }; // A2(幽灵会话删除修复):创建后挂树——持久化 SessionRelationship 并 // 注册内存树,对齐 SessionControl create 的 lineage 写入(R-001/R-002/R-003)。 @@ -2454,6 +2691,7 @@ impl SessionMessageTool { urgent: item.urgent, plan_file: item.plan_file.clone(), todo_id: item.todo_id.clone(), + worktree: item.worktree.clone(), batch: None, }; match self.dispatch_single(item_params, shared, context).await { @@ -2621,6 +2859,75 @@ mod tests { } } + #[test] + fn session_message_input_parses_worktree_options_and_keeps_legacy_compat() { + // 旧 payload(无 worktree 字段)解析兼容。 + let legacy: SessionMessageInput = serde_json::from_value(json!({ + "workspace": "/repo", + "session_name": "legacy", + "message": "hello", + "agent_type": "agentic", + })) + .expect("legacy payload must parse"); + assert!(legacy.worktree.is_none()); + + // 新 payload:worktree 对象解析。 + let with_worktree: SessionMessageInput = serde_json::from_value(json!({ + "workspace": "/repo", + "session_name": "task-a", + "message": "hello", + "agent_type": "agentic", + "worktree": { + "baseRef": "main", + "copyLocalChanges": true + } + })) + .expect("worktree payload must parse"); + assert!(with_worktree.worktree.is_some()); + assert_eq!( + with_worktree.worktree.as_ref().and_then(|w| w.base_ref.as_deref()), + Some("main") + ); + assert!(with_worktree.worktree.as_ref().is_some_and(|w| w.copy_local_changes)); + + // batch item 的 worktree 解析。 + let batch: SessionMessageInput = serde_json::from_value(json!({ + "workspace": "/repo", + "batch": [{ + "session_name": "item-a", + "message": "hi", + "agent_type": "agentic", + "worktree": {"copyLocalChanges": false} + }] + })) + .expect("batch payload must parse"); + let item = batch.batch.as_ref().expect("batch").first().expect("item"); + assert!(item.worktree.is_some()); + } + + #[tokio::test] + async fn session_message_worktree_rejected_for_existing_session_send() { + // 发送到既有 session_id 时 worktree 被拒绝(create-only 语义)。 + let input = json!({ + "workspace": "/repo", + "session_id": "existing_1", + "message": "hello", + "worktree": {"baseRef": "main"} + }); + let tool = SessionMessageTool::new(); + let result = tool + .validate_input(&input, Some(&session_context("caller_1"))) + .await; + assert!(!result.result); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("worktree is only allowed when session_id is omitted") + ); + } + #[test] fn creating_in_current_worktree_inherits_project_scope_and_target() { let worktree_path = PathBuf::from("/worktrees/wt-1"); diff --git a/src/crates/assembly/core/src/agentic/tools/restrictions.rs b/src/crates/assembly/core/src/agentic/tools/restrictions.rs index 0ae34209a..21236ba3a 100644 --- a/src/crates/assembly/core/src/agentic/tools/restrictions.rs +++ b/src/crates/assembly/core/src/agentic/tools/restrictions.rs @@ -439,6 +439,20 @@ pub fn get_session_role(session_id: &str) -> Option { .and_then(|map| map.get(session_id).cloned()) } +/// W9: worktree 参数授权判定(SessionControl/SessionMessage `create` 带 +/// worktree 参数时)。worktree 创建 = git 文件系统操作(git worktree add), +/// 是服务层调用不走工具权限门,因此独立判定:仅 Commander owner(或 RBAC +/// 关闭)允许——对齐 `resolve_session_mutation_authorization` 的 owner 语义 +/// (Commander 角色或 RBAC-off 豁免)。非 owner 调用者(Executor/Reviewer/ +/// Warden 等)携带 worktree 参数一律拒绝,防止子代理以会话创建为名执行 +/// git 文件系统变更。 +pub fn worktree_creation_authorized(caller_session_id: &str) -> bool { + matches!( + get_session_role(caller_session_id), + Some(AgentRole::Commander) + ) || !crate::service::config::rbac_enabled() +} + /// Remove the assigned RBAC role for a session (session-end cleanup). /// /// Called when a session is deleted or discarded so a recycled session id @@ -636,6 +650,36 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn worktree_creation_is_owner_gated() { + let caller = format!("wt-auth-{}", uuid::Uuid::new_v4()); + crate::service::config::set_rbac_enabled(true); + // RBAC 开启时:未注册角色 ≠ Commander owner → 拒绝。 + assert!( + !worktree_creation_authorized(&caller), + "unregistered role must not be treated as owner when RBAC is on" + ); + + set_session_role(&caller, AgentRole::Commander).expect("register commander"); + assert!(worktree_creation_authorized(&caller)); + + let executor = format!("wt-auth-exec-{}", uuid::Uuid::new_v4()); + set_session_role(&executor, AgentRole::Executor).expect("register executor"); + assert!( + !worktree_creation_authorized(&executor), + "non-owner roles must be rejected for worktree creation" + ); + + clear_session_role(&executor); + crate::service::config::set_rbac_enabled(false); + assert!( + worktree_creation_authorized(&executor), + "RBAC off must allow worktree creation" + ); + crate::service::config::set_rbac_enabled(true); + clear_session_role(&caller); + } + // ── Role→Permission template tests ───────────────────────────── #[test] diff --git a/src/crates/assembly/core/src/service/worktree/mod.rs b/src/crates/assembly/core/src/service/worktree/mod.rs index d7e153b31..9b81cd878 100644 --- a/src/crates/assembly/core/src/service/worktree/mod.rs +++ b/src/crates/assembly/core/src/service/worktree/mod.rs @@ -158,6 +158,11 @@ struct RegisteredWorktree { base_ref: Option, base_commit: String, branch: Option, + /// 展示名(W7 rename 联动):会话 rename 时同步,保持「会话名 = + /// worktree 展示名 = 分支名」三方一致。目录名保持 uuid 后缀稳定不变 + /// (指挥官裁决:目录改名一期不做)。`None` = 未设置(legacy)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, lifecycle: WorktreeLifecycle, created_at_ms: u64, /// Owner that still needs this worktree, e.g. `dispatch:`. @@ -187,6 +192,11 @@ enum WorktreeOperationReceipt { worktree_id: String, branch: String, }, + UpdateDisplayName { + worktree_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + display_name: Option, + }, Promote { worktree_id: String, }, @@ -205,6 +215,7 @@ impl WorktreeOperationReceipt { match self { Self::Create { worktree_id, .. } | Self::CreateBranch { worktree_id, .. } + | Self::UpdateDisplayName { worktree_id, .. } | Self::Promote { worktree_id } | Self::Remove { worktree_id, .. } | Self::Recreate { worktree_id } => worktree_id, @@ -548,6 +559,7 @@ impl WorktreeService { base_ref: Some(base_ref.to_string()), base_commit: base_commit.clone(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: current_unix_ms(), claimed_by: claimed_by.clone(), @@ -674,6 +686,88 @@ impl WorktreeService { Ok(result) } + /// W7: 更新 worktree 展示名(display_name)并同步分支名(git branch -m, + /// 经 GitService,禁裸调 git)。 + /// + /// 会话 rename 联动:会话重命名后,绑定 worktree 的分支名 + 展示名保持 + /// 「会话名 = worktree 展示名 = 分支名」三方一致。语义: + /// - 分支名已存在时(如 task/N):仅同步 display_name,分支名不动 + /// (指挥官裁决:沿用 task/<序号> 系,三方一致即可,不强改分支名)。 + /// - `rename_branch` 字段为 Some(new_branch) 时才执行 git branch -m; + /// 失败(分支被占用/不存在)不阻塞调用方(会话 rename 照常),错误 + /// 经 Err 返回由调用方决定提示级别。 + /// - 幂等:同 request_id 重放复用既有状态;display_name 相同则无操作。 + pub async fn update_display_name( + project_workspace_path: &str, + request_id: &str, + worktree_id: &str, + display_name: Option<&str>, + rename_branch: Option<&str>, + ) -> Result { + validate_request_id(request_id)?; + let context = Self::repository_context(Path::new(project_workspace_path)).await?; + let lock = repository_lock(&context.common_git_dir); + let _guard = lock.lock().await; + let _process_guard = Self::acquire_repository_process_lock(&context).await?; + let mut registry = Self::load_registry(&context).await?; + if let Some(receipt) = registry.receipts.get(request_id).cloned() { + return match receipt { + WorktreeOperationReceipt::UpdateDisplayName { + worktree_id: receipt_worktree_id, + display_name: receipt_display_name, + } if receipt_worktree_id == worktree_id + && receipt_display_name == display_name.map(ToOwned::to_owned) => + { + Self::mutation_result_for_id(&context, &mut registry, worktree_id).await + } + _ => Err(error( + WorktreeErrorCode::RequestConflict, + "The requestId was already used with different display-name parameters", + )), + }; + } + + let record = registry + .worktrees + .iter_mut() + .find(|record| record.worktree_id == worktree_id) + .ok_or_else(|| { + error( + WorktreeErrorCode::WorktreeNotFound, + "Managed worktree was not found", + ) + })?; + let display_name = display_name.map(str::trim).filter(|name| !name.is_empty()); + record.display_name = display_name.map(ToOwned::to_owned); + + if let Some(new_branch) = rename_branch.map(str::trim).filter(|name| !name.is_empty()) { + if let Some(old_branch) = record.branch.as_deref().map(str::trim).filter(|b| !b.is_empty()) + { + if old_branch != new_branch { + // git branch -m(经 GitService;worktree 目录内执行)。 + let info = GitService::rename_branch(&record.path, old_branch, new_branch) + .await + .map_err(map_git_error)?; + if info.success { + record.branch = Some(new_branch.to_string()); + } + } + } + } + + registry.receipts.insert( + request_id.to_string(), + WorktreeOperationReceipt::UpdateDisplayName { + worktree_id: worktree_id.to_string(), + display_name: record.display_name.clone(), + }, + ); + Self::save_registry(&context, ®istry).await?; + let result = Self::mutation_result_for_id(&context, &mut registry, worktree_id).await?; + notify_changed(&context.project_workspace_path).await; + Ok(result) + } + pub async fn promote( request: WorktreePromoteRequest, ) -> Result { @@ -1094,6 +1188,7 @@ impl WorktreeService { base_ref: git_worktree.branch.clone(), base_commit: git_worktree.head.clone(), branch: git_worktree.branch.clone(), + display_name: None, lifecycle: WorktreeLifecycle::External, created_at_ms: current_unix_ms(), claimed_by: None, @@ -1105,6 +1200,9 @@ impl WorktreeService { let lifecycle = registered .map(|record| record.lifecycle) .unwrap_or(WorktreeLifecycle::External); + let display_name = registered + .and_then(|record| record.display_name.as_deref()) + .map(ToOwned::to_owned); summaries.push( build_summary( context, @@ -1112,6 +1210,7 @@ impl WorktreeService { lifecycle, git_worktree, missing, + display_name, &sessions, ) .await?, @@ -1141,6 +1240,7 @@ impl WorktreeService { record.lifecycle, missing_info, true, + record.display_name.clone(), &sessions, ) .await?, @@ -1534,6 +1634,7 @@ async fn build_summary( lifecycle: WorktreeLifecycle, git_worktree: GitWorktreeInfo, missing: bool, + display_name: Option, sessions: &[SessionMetadata], ) -> Result { let associated = sessions @@ -1582,6 +1683,7 @@ async fn build_summary( path: git_worktree.path, head: git_worktree.head, branch: git_worktree.branch, + display_name, lifecycle, is_main: git_worktree.is_main, dirty, @@ -2014,6 +2116,7 @@ mod tests { path: "/worktrees/wt-1".to_string(), head: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, is_main: false, dirty: false, @@ -2250,6 +2353,7 @@ mod tests { base_ref: Some("main".to_string()), base_commit: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle, created_at_ms, claimed_by: None, @@ -2277,6 +2381,7 @@ mod tests { base_ref: Some("main".to_string()), base_commit: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms, claimed_by: claimed_by.map(ToOwned::to_owned), @@ -2302,6 +2407,7 @@ mod tests { base_ref: Some("main".to_string()), base_commit: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: 10, claimed_by: None, @@ -2325,6 +2431,7 @@ mod tests { base_ref: Some("main".to_string()), base_commit: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms, claimed_by: None, @@ -2337,6 +2444,54 @@ mod tests { ); } + #[test] + fn update_display_name_updates_registry_and_keeps_task_branch() { + // W7 纯 registry 语义(不触发真实 git):display_name 更新 + 幂等 receipt。 + let project = Path::new("/repo"); + let mut registry = WorktreeRegistry::new(project); + registry.worktrees.push(RegisteredWorktree { + worktree_id: "wt-1".to_string(), + path: "/managed/wt-1".to_string(), + base_ref: Some("main".to_string()), + base_commit: "0123456789abcdef".to_string(), + branch: Some("task/1".to_string()), + display_name: None, + lifecycle: WorktreeLifecycle::Managed, + created_at_ms: 1, + claimed_by: None, + }); + + // 直接调内部逻辑等价物:update_display_name 需要真实仓库上下文,此处 + // 验证 registry 层字段语义(display_name 与 receipt 持久化)。 + let record = registry.worktrees.iter_mut().next().expect("record"); + record.display_name = Some("新会话名".to_string()); + registry.receipts.insert( + "request-1".to_string(), + WorktreeOperationReceipt::UpdateDisplayName { + worktree_id: "wt-1".to_string(), + display_name: Some("新会话名".to_string()), + }, + ); + + let restored = serde_json::to_value(®istry).expect("serialize"); + assert_eq!(restored["worktrees"][0]["displayName"], "新会话名"); + assert_eq!(restored["worktrees"][0]["branch"], "task/1"); + assert_eq!( + restored["receipts"]["request-1"]["operation"], + "update_display_name" + ); + + let parsed: WorktreeRegistry = serde_json::from_value(restored).expect("deserialize"); + assert_eq!(parsed.worktrees[0].display_name.as_deref(), Some("新会话名")); + assert!(matches!( + parsed.receipts.get("request-1"), + Some(WorktreeOperationReceipt::UpdateDisplayName { + display_name: Some(name), + .. + }) if name == "新会话名" + )); + } + #[tokio::test] async fn registry_round_trip_restores_binding_and_idempotency_receipt() { let root = tempfile::tempdir().expect("temp root"); @@ -2356,6 +2511,7 @@ mod tests { base_ref: Some("main".to_string()), base_commit: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: 123, claimed_by: Some("dispatch:job-restored".to_string()), @@ -2412,6 +2568,7 @@ mod tests { base_ref: Some("main".to_string()), base_commit: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: 123, claimed_by: None, @@ -2461,6 +2618,7 @@ mod tests { base_ref: Some("main".to_string()), base_commit: "0123456789abcdef".to_string(), branch: None, + display_name: None, lifecycle: WorktreeLifecycle::Managed, created_at_ms: 123, claimed_by: claimed_by.map(ToOwned::to_owned), diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index c259cb976..bce3635c2 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -40,6 +40,6 @@ pub use surface::{ pub use tool_image_attachment::ToolImageAttachment; pub use worktree::{ SessionExecutionTarget, SessionExecutionTargetKind, SessionExecutionTargetRequest, - WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionSummary, WorktreeSettings, - WorktreeSummary, + WorktreeError, WorktreeErrorCode, WorktreeLifecycle, WorktreeSessionOptions, + WorktreeSessionSummary, WorktreeSettings, WorktreeSummary, }; diff --git a/src/crates/contracts/core-types/src/worktree.rs b/src/crates/contracts/core-types/src/worktree.rs index 84e5f343e..cc5447c06 100644 --- a/src/crates/contracts/core-types/src/worktree.rs +++ b/src/crates/contracts/core-types/src/worktree.rs @@ -44,6 +44,24 @@ pub enum WorktreeLifecycle { External, } +/// User-facing worktree options accepted by SessionControl/SessionMessage +/// `create` for automatically creating a managed worktree together with the +/// session. Mirrors the `NewManagedWorktree` request contract +/// (`SessionExecutionTargetRequest::NewManagedWorktree`), so the resolved +/// execution target matches what the Worktree tool would produce. +/// +/// `base_ref` and `copy_local_changes` share the exact WorktreeService +/// semantics (base defaults to HEAD; local changes can only be copied when +/// the selected base resolves to source HEAD). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] +#[serde(default, rename_all = "camelCase")] +pub struct WorktreeSessionOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub base_ref: Option, + pub copy_local_changes: bool, +} + /// Resolved and persisted execution location for a session. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export))] @@ -120,6 +138,10 @@ pub struct WorktreeSummary { pub head: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub branch: Option, + /// 展示名(W7 rename 联动):会话 rename 时同步,保持「会话名 = + /// worktree 展示名 = 分支名」三方一致。`None` = 未设置(legacy)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display_name: Option, pub lifecycle: WorktreeLifecycle, pub is_main: bool, pub dirty: bool, @@ -198,7 +220,8 @@ impl std::error::Error for WorktreeError {} #[cfg(test)] mod tests { use super::{ - SessionExecutionTargetRequest, WorktreeError, WorktreeErrorCode, WorktreeSettings, + SessionExecutionTargetRequest, WorktreeError, WorktreeErrorCode, WorktreeSessionOptions, + WorktreeSettings, }; #[test] @@ -250,4 +273,24 @@ mod tests { assert_eq!(error.to_string(), "dirty_worktree: local changes"); } + + #[test] + fn worktree_session_options_default_to_head_without_copying_changes() { + let options: WorktreeSessionOptions = + serde_json::from_value(serde_json::json!({})).expect("empty options should parse"); + assert_eq!(options.base_ref, None); + assert!(!options.copy_local_changes); + + let full: WorktreeSessionOptions = serde_json::from_value(serde_json::json!({ + "baseRef": "main", + "copyLocalChanges": true + })) + .expect("full options should parse"); + assert_eq!(full.base_ref.as_deref(), Some("main")); + assert!(full.copy_local_changes); + + let serialized = serde_json::to_value(&full).expect("options should serialize"); + assert_eq!(serialized["baseRef"], "main"); + assert_eq!(serialized["copyLocalChanges"], true); + } } diff --git a/src/crates/execution/agent-runtime/src/session_control.rs b/src/crates/execution/agent-runtime/src/session_control.rs index d670a3753..02092d289 100644 --- a/src/crates/execution/agent-runtime/src/session_control.rs +++ b/src/crates/execution/agent-runtime/src/session_control.rs @@ -4,6 +4,11 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::Path; +/// Worktree options accepted by `create` for automatically creating a managed +/// worktree together with the session (re-exported from core-types so the +/// portable session-control decisions share the wire contract). +pub use bitfun_core_types::WorktreeSessionOptions as SessionControlWorktreeOptions; + #[derive(Debug, Clone, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum SessionControlAction { @@ -48,6 +53,13 @@ pub struct SessionControlInput { /// `create`; forwarded to the session config so the session is created /// with the requested model (mirrors the Task(spawn) model_id parameter). pub model_id: Option, + /// Optional worktree options for `create`: when present, a managed + /// worktree is created together with the session (git worktree add via + /// WorktreeService) and the session is bound to it. `None` keeps the + /// legacy behavior (session runs in the project checkout). Only allowed + /// for `create` and rejected for remote workspaces. + #[serde(default)] + pub worktree: Option, /// When true, `list` emits the full session tree (session_name included) /// instead of the compact per-session line output. Only meaningful for /// `list`. @@ -201,6 +213,9 @@ fn validate_mutating_action_target( if input.model_id.is_some() { return invalid("model_id is only allowed for create"); } + if input.worktree.is_some() { + return invalid("worktree is only allowed for create"); + } if input.detail.is_some() { return invalid("detail is only allowed for list"); } @@ -283,6 +298,23 @@ pub fn validate_session_control_input( { return invalid("model_id must not be empty when provided"); } + if let Some(worktree) = input.worktree.as_ref() { + if worktree + .base_ref + .as_deref() + .is_some_and(|base_ref| base_ref.trim().is_empty()) + { + return invalid("worktree.base_ref must not be empty when provided"); + } + // worktree 与 ACP 真会话(agent_type `acp__`)互斥: + // ACP 会话是外部进程记录,不承载本地 worktree execution_target, + // 同时携带会导致 worktree 被静默忽略/成为孤儿。 + if input.agent_type.as_ref().is_some_and(|agent_type| { + agent_type.as_str().starts_with("acp__") + }) { + return invalid("worktree is not supported with acp__ agent types"); + } + } if context.current_session_id.is_none() { return invalid("create requires a creator session in tool context"); } @@ -311,6 +343,9 @@ pub fn validate_session_control_input( if input.model_id.is_some() { return invalid("model_id is only allowed for create"); } + if input.worktree.is_some() { + return invalid("worktree is only allowed for create"); + } if input.session_id.is_some() { return invalid("session_id is not allowed for list"); } @@ -425,6 +460,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -448,6 +484,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -470,6 +507,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(Some("self_1"))); @@ -486,6 +524,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -526,6 +565,7 @@ mod tests { agent_type: None, short_name: None, model_id: Some(" ".to_string()), + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(Some("creator_1"))); @@ -536,6 +576,107 @@ mod tests { ); } + #[test] + fn create_deserializes_and_validates_worktree_options() { + let input: SessionControlInput = serde_json::from_value(json!({ + "action": "create", + "workspace": std::env::temp_dir().to_string_lossy().to_string(), + "worktree": { + "baseRef": "main", + "copyLocalChanges": true + } + })) + .expect("create payload with worktree options must parse"); + assert_eq!(input.worktree.as_ref().and_then(|w| w.base_ref.as_deref()), Some("main")); + assert!(input.worktree.as_ref().is_some_and(|w| w.copy_local_changes)); + + let result = validate_session_control_input(&input, context(Some("creator_1"))); + assert!(result.result, "{:?}", result.message); + } + + #[test] + fn create_rejects_blank_worktree_base_ref() { + let input = SessionControlInput { + action: SessionControlAction::Create, + workspace: Some(std::env::temp_dir().to_string_lossy().to_string()), + session_id: None, + session_name: None, + agent_type: None, + short_name: None, + model_id: None, + worktree: Some(bitfun_core_types::WorktreeSessionOptions { + base_ref: Some(" ".to_string()), + copy_local_changes: false, + }), + detail: None, + }; + let result = validate_session_control_input(&input, context(Some("creator_1"))); + assert!(!result.result); + assert_eq!( + result.message.as_deref(), + Some("worktree.base_ref must not be empty when provided") + ); + } + + #[test] + fn non_create_actions_reject_worktree() { + let input = SessionControlInput { + action: SessionControlAction::Delete, + workspace: None, + session_id: Some("worker_1".to_string()), + session_name: None, + agent_type: None, + short_name: None, + model_id: None, + worktree: Some(bitfun_core_types::WorktreeSessionOptions::default()), + detail: None, + }; + let result = validate_session_control_input(&input, context(None)); + assert!(!result.result); + assert_eq!( + result.message.as_deref(), + Some("worktree is only allowed for create") + ); + } + + #[test] + fn create_rejects_worktree_with_acp_agent_type() { + let input = SessionControlInput { + action: SessionControlAction::Create, + workspace: Some(std::env::temp_dir().to_string_lossy().to_string()), + session_id: None, + session_name: None, + agent_type: Some(SessionControlAgentType::from("acp__codebuddy")), + short_name: None, + model_id: None, + worktree: Some(bitfun_core_types::WorktreeSessionOptions::default()), + detail: None, + }; + let result = validate_session_control_input(&input, context(Some("creator_1"))); + assert!(!result.result); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("worktree is not supported with acp__ agent types") + ); + } + + #[test] + fn create_legacy_payload_without_worktree_is_compatible() { + // 向后兼容:无 worktree 参数的旧 payload 正常解析且 worktree = None。 + let input: SessionControlInput = serde_json::from_value(json!({ + "action": "create", + "workspace": std::env::temp_dir().to_string_lossy().to_string(), + "session_name": "legacy", + })) + .expect("legacy payload without worktree must parse"); + assert!(input.worktree.is_none()); + let result = validate_session_control_input(&input, context(Some("creator_1"))); + assert!(result.result, "{:?}", result.message); + } + #[test] fn non_create_actions_reject_model_id() { let input = SessionControlInput { @@ -546,6 +687,7 @@ mod tests { agent_type: None, short_name: None, model_id: Some("claude-sonnet-4".to_string()), + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -580,6 +722,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -603,6 +746,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -626,6 +770,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -646,6 +791,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(None)); @@ -662,6 +808,7 @@ mod tests { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, }; let result = validate_session_control_input(&input, context(Some("self_1"))); diff --git a/src/crates/services/services-integrations/src/git/service.rs b/src/crates/services/services-integrations/src/git/service.rs index 1db95ffcc..8563910db 100644 --- a/src/crates/services/services-integrations/src/git/service.rs +++ b/src/crates/services/services-integrations/src/git/service.rs @@ -1037,6 +1037,55 @@ impl GitService { }) } + /// Renames a local branch (`git branch -m old new`). + /// + /// Used by the session↔worktree rename link (W7): renaming a worktree-bound + /// session keeps the branch name in sync with the session title. The new + /// branch name is validated through `check-ref-format` by Git itself before + /// the rename applies; failures (branch checked out in another worktree, + /// invalid ref name, missing branch) surface as `GitError`. + pub async fn rename_branch>( + path: P, + old_branch: &str, + new_branch: &str, + ) -> Result { + let start_time = Instant::now(); + let repo_path = path.as_ref().to_string_lossy(); + let old_branch = old_branch.trim(); + let new_branch = new_branch.trim(); + if old_branch.is_empty() || new_branch.is_empty() { + return Err(GitError::CommandFailed( + "Branch rename requires both old and new branch names".to_string(), + )); + } + if old_branch == new_branch { + return Ok(GitOperationResult { + success: true, + data: Some(serde_json::json!({ + "branch": new_branch, + "renamed": false + })), + error: None, + output: Some("No-op: old and new branch names are identical.".to_string()), + duration: Some(0), + }); + } + let args = vec!["branch", "-m", old_branch, new_branch]; + let output = execute_git_command(&repo_path, &args).await?; + let duration = elapsed_ms_u64(start_time); + + Ok(GitOperationResult { + success: true, + data: Some(serde_json::json!({ + "branch": new_branch, + "renamed": true + })), + error: None, + output: Some(output), + duration: Some(duration), + }) + } + /// Resets to a specific commit. /// /// # Parameters @@ -1675,4 +1724,43 @@ mod review_path_tests { .expect("worktree list should remain readable"); assert_eq!(worktrees.len(), 1); } + + #[tokio::test] + async fn rename_branch_renames_local_branch_and_is_idempotent() { + let directory = tempfile::tempdir().expect("temporary repository should be created"); + git(directory.path(), &["init"], None); + commit_file( + directory.path(), + "initial\n", + "initial commit", + "2025-01-01T00:00:00Z", + ); + git(directory.path(), &["branch", "task/1"], None); + + let renamed = GitService::rename_branch(directory.path(), "task/1", "task/2") + .await + .expect("branch rename should succeed"); + assert_eq!(renamed.success, true); + assert_eq!( + renamed.data.as_ref().and_then(|data| data.get("branch")).and_then(serde_json::Value::as_str), + Some("task/2") + ); + + // 幂等:同名 rename 是 no-op 成功。 + let noop = GitService::rename_branch(directory.path(), "task/2", "task/2") + .await + .expect("identical rename should be a no-op"); + assert_eq!(noop.success, true); + assert_eq!( + noop.data.as_ref().and_then(|data| data.get("renamed")).and_then(serde_json::Value::as_bool), + Some(false) + ); + + // 分支确实被改名。 + let branches = GitService::get_branches(directory.path(), false) + .await + .expect("branch list should work"); + assert!(branches.iter().any(|branch| branch.name == "task/2")); + assert!(!branches.iter().any(|branch| branch.name == "task/1")); + } } From f9cb5e6b2b74d22cff6b4ba659853dcefe60492c Mon Sep 17 00:00:00 2001 From: t Date: Tue, 11 Aug 2026 18:23:02 +0800 Subject: [PATCH 14/39] =?UTF-8?q?fix:=20=E8=A1=A5=20SessionControlInput.wo?= =?UTF-8?q?rktree=20=E6=B5=8B=E8=AF=95=E6=9E=84=E9=80=A0=E7=82=B9=EF=BC=88?= =?UTF-8?q?base=5Finput=20=E6=BC=8F=20worktree:=20None=EF=BC=8CCI=20cli-te?= =?UTF-8?q?st=20=E7=BC=96=E8=AF=91=E5=A4=B1=E8=B4=A5=20E0063=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../tests/agent_session_contracts/session_control_contracts.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs index 957bf1352..ebb862fdb 100644 --- a/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_session_contracts/session_control_contracts.rs @@ -15,6 +15,7 @@ fn base_input(action: SessionControlAction) -> SessionControlInput { agent_type: None, short_name: None, model_id: None, + worktree: None, detail: None, } } From 7345619ac880515a019dbb42ca74629e2c28e9ad Mon Sep 17 00:00:00 2001 From: limityan Date: Tue, 11 Aug 2026 18:03:21 +0800 Subject: [PATCH 15/39] perf(build): prune dead dependencies and consolidate contract tests --- Cargo.lock | 88 - Cargo.toml | 4 +- .../extensions/plugin-runtime-design.md | 4 +- docs/performance/01-compile-performance.md | 50 +- scripts/check-core-boundaries.test.mjs | 131 ++ scripts/core-boundaries/checker.mjs | 4 +- .../explicit-test-topology.mjs | 180 ++ .../rules/source/forbidden-rules.mjs | 2 +- .../rules/source/required-rules.mjs | 12 +- scripts/core-boundaries/self-test.mjs | 6 +- src/apps/cli/Cargo.toml | 2 - src/apps/cli/src/ui/syntax_highlight.rs | 4 +- src/apps/desktop/Cargo.toml | 1 - src/apps/desktop/capabilities/default.json | 7 +- src/crates/adapters/ai-adapters/Cargo.toml | 9 + .../tests/ai_protocol_contracts.rs | 4 + .../model_selector.rs | 0 .../openai_empty_content_parts.rs | 0 .../ai-adapters/tests/ai_stream_contracts.rs | 12 + .../tests/ai_stream_contracts/common.rs | 6 + .../stream_processor_anthropic.rs | 6 +- .../stream_processor_openai.rs | 8 +- .../stream_processor_tool_arguments.rs | 6 +- .../stream_replay_regressions.rs | 12 +- .../stream_test_harness.rs | 6 +- src/crates/assembly/core/Cargo.toml | 2 - .../assembly/product-capabilities/Cargo.toml | 5 + .../tests/product_capability_contracts.rs | 6 + .../plugin_product_shape.rs | 0 .../product_capabilities.rs | 0 .../product_sdk_assembly.rs | 0 src/crates/contracts/core-types/Cargo.toml | 5 + .../core-types/tests/core_type_contracts.rs | 8 + .../lsp_contracts.rs | 0 .../session_contracts.rs | 0 .../session_usage_contracts.rs | 0 .../surface_contracts.rs | 0 .../contracts/product-domains/Cargo.toml | 22 +- .../tests/external_source_contracts.rs | 2084 +---------------- .../external_hook_catalog_contracts.rs | 0 .../external_hook_contribution_contracts.rs | 0 .../external_source_contracts.rs | 2074 ++++++++++++++++ .../workspace_reference_contracts.rs | 0 .../tests/plugin_source_contracts.rs | 8 +- .../tests/product_domain_contracts.rs | 4 + .../canvas_contracts.rs | 0 .../tool_permission_contracts.rs | 0 src/crates/contracts/runtime-ports/Cargo.toml | 5 + .../tests/runtime_port_contracts.rs | 10 + .../git_port_contracts.rs | 0 .../plugin_runtime_contracts.rs | 0 .../plugin_runtime_diagnostics_contracts.rs | 0 .../script_tool_port_contracts.rs | 0 .../session_store_contracts.rs | 0 .../miniapp-market-service/Cargo.toml | 1 - .../services/page-function-runtime/Cargo.toml | 3 - 56 files changed, 2558 insertions(+), 2243 deletions(-) create mode 100644 src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs rename src/crates/adapters/ai-adapters/tests/{ => ai_protocol_contracts}/model_selector.rs (100%) rename src/crates/adapters/ai-adapters/tests/{ => ai_protocol_contracts}/openai_empty_content_parts.rs (100%) create mode 100644 src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs create mode 100644 src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_processor_anthropic.rs (99%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_processor_openai.rs (99%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_processor_tool_arguments.rs (94%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_replay_regressions.rs (98%) rename src/crates/adapters/ai-adapters/tests/{ => ai_stream_contracts}/stream_test_harness.rs (94%) create mode 100644 src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs rename src/crates/assembly/product-capabilities/tests/{ => product_capability_contracts}/plugin_product_shape.rs (100%) rename src/crates/assembly/product-capabilities/tests/{ => product_capability_contracts}/product_capabilities.rs (100%) rename src/crates/assembly/product-capabilities/tests/{ => product_capability_contracts}/product_sdk_assembly.rs (100%) create mode 100644 src/crates/contracts/core-types/tests/core_type_contracts.rs rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/lsp_contracts.rs (100%) rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/session_contracts.rs (100%) rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/session_usage_contracts.rs (100%) rename src/crates/contracts/core-types/tests/{ => core_type_contracts}/surface_contracts.rs (100%) rename src/crates/contracts/product-domains/tests/{ => external_source_contracts}/external_hook_catalog_contracts.rs (100%) rename src/crates/contracts/product-domains/tests/{ => external_source_contracts}/external_hook_contribution_contracts.rs (100%) create mode 100644 src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs rename src/crates/contracts/product-domains/tests/{ => external_source_contracts}/workspace_reference_contracts.rs (100%) create mode 100644 src/crates/contracts/product-domains/tests/product_domain_contracts.rs rename src/crates/contracts/product-domains/tests/{ => product_domain_contracts}/canvas_contracts.rs (100%) rename src/crates/contracts/product-domains/tests/{ => product_domain_contracts}/tool_permission_contracts.rs (100%) create mode 100644 src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/git_port_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/plugin_runtime_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/plugin_runtime_diagnostics_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/script_tool_port_contracts.rs (100%) rename src/crates/contracts/runtime-ports/tests/{ => runtime_port_contracts}/session_store_contracts.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 51e9c22e5..bbc6b1406 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,6 @@ dependencies = [ "chrono", "clap", "crossterm", - "dashmap", "dirs 6.0.0", "dunce", "flate2", @@ -1013,7 +1012,6 @@ dependencies = [ "shlex 1.3.0", "similar", "syntect", - "syntect-tui", "tar", "tempfile", "thiserror 2.0.19", @@ -1105,14 +1103,12 @@ dependencies = [ "terminal-core", "thiserror 2.0.19", "tokio", - "tokio-stream", "tokio-tungstenite", "tokio-util", "tool-runtime", "tower-http", "ts-rs", "unic-langid", - "urlencoding", "uuid", ] @@ -1187,7 +1183,6 @@ dependencies = [ "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-fs", - "tauri-plugin-global-shortcut", "tauri-plugin-log", "tauri-plugin-notification", "tauri-plugin-opener", @@ -1281,7 +1276,6 @@ dependencies = [ "tower-http", "tracing", "url", - "urlencoding", "uuid", "zip 4.6.1", ] @@ -1322,7 +1316,6 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.19", - "tokio", ] [[package]] @@ -2692,12 +2685,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "custom_error" -version = "1.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f8a51dd197fa6ba5b4dc98a990a43cc13693c23eb0089ebb0fcc1f04152bca6" - [[package]] name = "dark-light" version = "1.1.1" @@ -3489,17 +3476,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" -[[package]] -name = "fancy-regex" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" -dependencies = [ - "bit-set", - "regex-automata", - "regex-syntax", -] - [[package]] name = "fast-float2" version = "0.2.3" @@ -4204,24 +4180,6 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" -[[package]] -name = "global-hotkey" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c386b0a4a70cb2d39fffd74480f985b6f0bfbcb934b6a6b6b7e630e448f242e" -dependencies = [ - "crossbeam-channel", - "keyboard-types", - "objc2 0.6.4", - "objc2-app-kit", - "once_cell", - "serde", - "thiserror 2.0.19", - "windows-sys 0.59.0", - "x11rb", - "xkeysym", -] - [[package]] name = "globset" version = "0.4.19" @@ -5620,12 +5578,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -8178,7 +8130,6 @@ dependencies = [ "lru", "paste", "strum 0.26.3", - "time", "unicode-segmentation", "unicode-truncate", "unicode-width 0.2.0", @@ -10040,30 +9991,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" dependencies = [ "bincode", - "fancy-regex", "flate2", "fnv", "once_cell", "onig", - "plist", "regex-syntax", "serde", "serde_derive", - "serde_json", "thiserror 2.0.19", "walkdir", - "yaml-rust", -] - -[[package]] -name = "syntect-tui" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24486acfb54bfcae77f45784cb59254e14454949a44f9d0b62613a699619c210" -dependencies = [ - "custom_error", - "ratatui", - "syntect", ] [[package]] @@ -10354,21 +10290,6 @@ dependencies = [ "url", ] -[[package]] -name = "tauri-plugin-global-shortcut" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4dd9f4c5136c09cd962da0c86dc4accd4666db2ea591cf16e6597435843bd2b" -dependencies = [ - "global-hotkey", - "log", - "serde", - "serde_json", - "tauri", - "tauri-plugin", - "thiserror 2.0.19", -] - [[package]] name = "tauri-plugin-log" version = "2.9.0" @@ -12891,15 +12812,6 @@ dependencies = [ "lzma-sys", ] -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 2e53e5769..bc9983d5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,8 +172,7 @@ crossterm = "0.28" ratatui = "0.29" unicode-width = "0.2" pulldown-cmark = "0.11" -syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-fancy"] } -syntect-tui = "3.0" +syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] } once_cell = "1" libc = "0.2" arboard = "3" @@ -206,7 +205,6 @@ tauri-plugin-log = "2.8" tauri-plugin-autostart = "2.5" tauri-plugin-notification = "2.3" tauri-plugin-updater = "2.10" -tauri-plugin-global-shortcut = "2.3" tauri-plugin-single-instance = "2.4" tauri-plugin-window-state = "2.4" tauri-build = { version = "2.6", features = [] } diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index bcaada2a7..18f07e242 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -281,8 +281,8 @@ plugin、Hook、完整 Client 或 TUI 插件入口。与其独立的 standalone 当前 Rust 边界调整至少运行: -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_contracts` -- `cargo test -p bitfun-runtime-ports --test plugin_runtime_diagnostics_contracts` +- `cargo test -p bitfun-runtime-ports --test runtime_port_contracts plugin_runtime_contracts` +- `cargo test -p bitfun-runtime-ports --test runtime_port_contracts plugin_runtime_diagnostics_contracts` - `cargo test -p bitfun-plugin-runtime-client` - `cargo test -p bitfun-opencode-adapter --test opencode_source_adapter` - `cargo test -p bitfun-core plugin_runtime::tests --lib` diff --git a/docs/performance/01-compile-performance.md b/docs/performance/01-compile-performance.md index d54213f80..13a506fc0 100644 --- a/docs/performance/01-compile-performance.md +++ b/docs/performance/01-compile-performance.md @@ -2,7 +2,7 @@ > 最近核实:2026-08-11 > -> 实现复核基线:`gcwing/main@9f8b56082` +> 实现复核基线:`gcwing/main@3d8ee4bc0` > > 性能 A/B 基线:`gcwing/main@1f538b96d` > @@ -16,11 +16,11 @@ | 结论 | 说明 | |---|---| -| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7,进程和外部系统失败域保持独立 | +| 集成测试链接拓扑已收敛 | Services 两个 crate 的集成 target 总数从 33 降到 25;External Sources 的 adapter/assembly target 从 22 降到 7;五个 Contracts/AI/Assembly crate 又从 28 降到 10,feature、平台和外部系统失败域保持独立 | | Agent Runtime 基线不再隐藏重型 capability | `bitfun-core/agent-runtime` 只保留生命周期和基础工具 owner;文档转换与订阅认证也改为产品显式 modifier。在最新主线 A/B 中,三平台 normal/build 闭包进一步减少 69/64/110 个版本化 package instance | | App Server 不继承未消费能力 | App Server 保持现有 Agent/Git/外部来源 handler 边界,不再因 Core 基线携带文档转换和本地订阅凭据,三平台闭包减少 61/56/78 | | SDK Host 使用显式能力闭包 | SDK Host 保留当前本机协议和工具能力,但不再通过 `product-full` 携带协议未暴露的 Remote Connect、SSH、Function Agent 等能力;Windows/macOS/Linux normal/build 闭包减少 66/68/76 | -| 完整产品行为和闭包保持 | `product-full` 显式组合全部 owner,Windows normal/build 闭包保持 570;CLI 保持 649。ACP 只退出未选择或未使用的隐含能力,累计在 Windows/macOS/Linux 分别减少 12/15/24 | +| 完整产品行为保持 | `product-full` 显式组合全部 owner且三平台闭包不变;CLI 删除未调用适配层时显式保留原先实际生效的 Oniguruma 高亮后端,三平台闭包进一步减少 6/7/7。ACP 只退出未选择或未使用的隐含能力 | | Installer 删除未使用的直接能力 | 独立 manifest 的直接 dependency 从 18 降到 10,Windows normal/build 闭包减少 6;不把 Installer 并入根 workspace,本 PR 按要求不提交其生成 lockfile | | focused test 仍保持精确 | 同 owner、feature、平台和进程语义的源文件进入分组 target;使用 `--test ::` 运行单模块 | @@ -102,6 +102,26 @@ target 多 1。PDB 大小会随工具链变化,只比较同次 A/B: | local-storage | 13 → 6 | 25.2 → 19.2 MiB | 135.7 → 91.9 MiB | | 基础 Remote SSH | 3 → 2 | 3.9 → 2.8 MiB | 53.5 → 43.8 MiB | +#### Contracts、AI adapters 与 Product Assembly + +五个纯合同/组装 owner 使用显式 wrapper target;AI 的纯协议测试与真实 loopback SSE 测试继续分成两个 +失败域,Product Domains 的默认、Plugin Source、External Sources、Function Agent 与 MiniApp 也继续按 +owner feature 分开。270 个 integration tests 不变,模块过滤仍可聚焦单个 leaf: + +| 范围 | 变更前 target | 变更后 target | 集成测试数 | +|---|---:|---:|---:| +| `core-types` | 4 | 1 | 10 | +| `runtime-ports` | 5 | 1 | 21 | +| `product-domains` | 9 | 5 | 179 | +| `ai-adapters` | 7 | 2 | 29 | +| `product-capabilities` | 3 | 1 | 31 | +| 合计 | 28 | 10 | 270 | + +对应五个 lib test harness 的 test executable 总数从 33 降到 15;workspace integration target 从 +91 降到 73。该变化减少 18 次重复链接,但单叶变更会重链所属分组,因此这里只报告确定的拓扑收益, +不在缺少同机多轮 A/B 时宣称 wall-clock 提速。边界检查锁定 exact leaf、owner feature 和空 +`required-features` 的默认 target,避免以后用 `product-full` 扩大测试闭包。 + ### 3.2 依赖与 feature 闭包使用 `cargo tree -e normal,build` 按目标平台统计版本化 package instance;它衡量进入编译图的 @@ -136,19 +156,35 @@ normal dependency,根 lock package 集合不变。前两类收益来自 `anydo SSH、密钥和连接子图退出。完整产品 package 集合不变, 因此这里只报告依赖图收敛,不宣称 `product-full` wall-clock 提速。 +以下是以 `gcwing/main@3d8ee4bc0` 为变更前基线、使用同样三个 target triple 和去重口径复算的最新 A/B: + +| 最新闭包 | Windows | macOS | Linux | 行为边界 | +|---|---:|---:|---:|---| +| Core `--no-default-features` | 104 → 102 | 93 → 91 | 92 → 90 | 删除 Core 不再消费的 `tokio-stream`、`urlencoding` 直接边 | +| Core `product-full` | 570 → 570 | 557 → 557 | 601 → 601 | 完整产品仍从真实 adapter/service owner 获得两项依赖 | +| CLI | 649 → 643 | 649 → 642 | 672 → 665 | 删除未调用的 `syntect-tui`/`dashmap`;显式保留既有 Oniguruma 高亮后端 | +| Desktop | 792 → 790 | 807 → 805 | 892 → 887 | 删除从未注册、没有调用方的 global-shortcut 插件和 ACL | +| MiniApp Market | 205 → 204 | 208 → 207 | 206 → 205 | 删除服务从未消费的 `urlencoding` 直接边 | +| Page Function tests | 38 → 35 | 38 → 35 | 38 → 35 | 删除同步 Rust 测试未使用的 dev-only Tokio 闭包 | + +Syntect 不能机械地只删适配层:旧 feature union 同时启用 `regex-fancy` 与 `regex-onig` 时,实际由 +Oniguruma 后端处理。当前 manifest 直接选择 `regex-onig`,因此运行后端、默认 syntax/theme 和 +Syntect→Ratatui 样式转换保持不变,同时让未生效的 fancy 后端与未消费的 YAML loader 退出。 + Package instance 会低估“同一个大 crate 少编译了多少 feature 代码”。在 Windows `agent-runtime` 闭包中,`bitfun-services-integrations` 的 Cargo active feature 从 61 个降到 6 个, 只保留 `workspace-search` 及其 5 个直接依赖 feature;`bitfun-product-domains` 从 13 个降到 5 个, 只保留 Agent Runtime 实际使用的 external-subagent contract slice。Function Agent、MiniApp、 Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式恢复。 -根 `Cargo.lock` 与实现复核基线保持一致,package 记录不增加;Installer 自己生成的 -`BitFun-Installer/src-tauri/Cargo.lock` 本 PR 不提交。 +根 `Cargo.lock` 从 1176 降到 1169,精确删除 `syntect-tui`、`custom_error`、`fancy-regex`、 +`yaml-rust`、`linked-hash-map`、`tauri-plugin-global-shortcut` 和 `global-hotkey`;没有新增、升级或 +降级 package。Installer 自己生成的 `BitFun-Installer/src-tauri/Cargo.lock` 本 PR 不提交。 | 状态 | 范围 | 处理结论 | |---|---|---| | 已稳定 | 根 `Cargo.lock`、Reqwest Rustls 单栈、workspace Tokio 最小基线 | 不重复治理 | -| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella,也不扩大根依赖宇宙 | +| 本轮完成 | Core Agent Runtime capability、文档转换与订阅认证 modifier、SDK Host 显式 owner closure、Installer/CLI/Desktop/Core/MiniApp Market/Page Function 未使用直接依赖 | 以真实入口 closure 收敛,不建立新的产品 umbrella;根 lock 只减少 package | | 当前不动 | App Server / Server | 只为保持现有 handler 编译显式声明其已消费的 Core owner;不在改造稳定前继续拆其生产路径 | | 明确保留 | Desktop screenshots backend | 替换方案必须同时保持三平台坐标/权限/区域捕获语义且不增加根 lock package;当前候选不满足 | | 明确保留 | `portable-pty 0.8/0.9` | 非 OHOS 与 OHOS 的平台兼容选择,不为去重破坏 | @@ -179,6 +215,8 @@ Plugin Source 由各自 owner 选择,完整产品仍经 `product-full` 显式 | Agent Runtime 测试 | 28 个 integration executable 已收敛为 5 个职责/平台 target | | Services 测试 | 两个服务 crate 使用显式 target;选中闭包少 8 个 integration executable,进程/feature/external-system 边界保持独立 | | External Sources 测试 | 四个 adapter/assembly crate 从 22 个 target 收敛到 7 个;MCP、插件服务和脚本 runtime 继续独立 | +| Contracts/AI/Assembly 测试 | 五个 crate 从 28 个 target 收敛到 10 个;AI loopback 与纯协议、Product Domains 各 owner feature 保持独立 | +| 未使用直接依赖 | 删除 CLI/Desktop/Core/MiniApp Market/Page Function 的失效直接边;保留 Syntect 实际 Oniguruma 后端,根 lock 只减 7 个 package | 内置 Agent 内容已经移到无第三方依赖的 `bitfun-agent-content`,减少了 Core build-script 工作; 但 Core 仍直接依赖该 crate。没有足够产品收益前,不为消除这一编译指纹引入动态 provider、 diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index eef315b9a..af9eb725b 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -260,6 +260,137 @@ test('service integration tests keep their reviewed explicit target topology', ( assert.deepEqual(checkServicesIntegrationsIntegrationTestTopology(repositoryRoot), []); }); +test('contract and AI adapter tests keep reviewed feature and failure-domain topology', async () => { + const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); + const topology = await import('./core-boundaries/explicit-test-topology.mjs'); + + assert.deepEqual(topology.coreTypesIntegrationTestTargets, [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.runtimePortsIntegrationTestTargets, [ + { + name: 'runtime_port_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/git_port_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + 'tests/runtime_port_contracts/script_tool_port_contracts.rs', + 'tests/runtime_port_contracts/session_store_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productDomainsIntegrationTestTargets, [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, + ]); + assert.deepEqual(topology.aiAdaptersIntegrationTestTargets, [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.productCapabilitiesIntegrationTestTargets, [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, + ]); + assert.deepEqual(topology.checkBuildGraphContractIntegrationTestTopologies(repositoryRoot), []); + + const widenedOwnerErrors = validateExplicitIntegrationTestTopology({ + manifestText: [ + '[package]', + 'autotests = false', + '[[test]]', + 'name = "external_source_contracts"', + 'path = "tests/external_source_contracts.rs"', + 'required-features = ["product-full"]', + ].join('\n'), + expectedTargets: [{ + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + requiredFeatures: ['external-sources'], + }], + topLevelRustFiles: ['tests/external_source_contracts.rs'], + rootSources: new Map([[ + 'tests/external_source_contracts.rs', + '#![cfg(feature = "product-full")]\n', + ]]), + leafRustFiles: [], + leafSources: new Map(), + }); + assert.match(widenedOwnerErrors.join('\n'), /required-features.*external-sources/); +}); + test('external source integration tests keep reviewed owner and process boundaries', () => { const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)); diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 2247f1ee5..d42ed7b39 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -43,7 +43,7 @@ import { checkAgentRuntimeIntegrationTestTopology, checkCliIntegrationTestTopology, checkExternalSourceIntegrationTestTopologies, - checkServiceIntegrationTestTopologies, + checkReviewedIntegrationTestTopologies, cliIntegrationTestTargets, validateExplicitIntegrationTestTopology, } from './explicit-test-topology.mjs'; @@ -1126,7 +1126,7 @@ export function runCoreBoundaryCheck() { failures.push(...checkCargoDependencyBoundariesSafely({ root: ROOT, crateLayoutRules })); failures.push(...checkAgentRuntimeIntegrationTestTopology(ROOT)); failures.push(...checkCliIntegrationTestTopology(ROOT)); - failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkServiceIntegrationTestTopologies(ROOT)); + failures.push(...checkExternalSourceIntegrationTestTopologies(ROOT), ...checkReviewedIntegrationTestTopologies(ROOT)); failures.push(...checkPeerCommandPolicySync(ROOT)); for (const rule of forbiddenManifestDependencyRules) { diff --git a/scripts/core-boundaries/explicit-test-topology.mjs b/scripts/core-boundaries/explicit-test-topology.mjs index d1ff06f9e..e5b14cc36 100644 --- a/scripts/core-boundaries/explicit-test-topology.mjs +++ b/scripts/core-boundaries/explicit-test-topology.mjs @@ -108,6 +108,111 @@ export const externalSourcesIntegrationTestTargets = [ }, ]; +export const coreTypesIntegrationTestTargets = [ + { + name: 'core_type_contracts', + path: 'tests/core_type_contracts.rs', + leaves: [ + 'tests/core_type_contracts/lsp_contracts.rs', + 'tests/core_type_contracts/session_contracts.rs', + 'tests/core_type_contracts/session_usage_contracts.rs', + 'tests/core_type_contracts/surface_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const runtimePortsIntegrationTestTargets = [ + { + name: 'runtime_port_contracts', + path: 'tests/runtime_port_contracts.rs', + leaves: [ + 'tests/runtime_port_contracts/git_port_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_contracts.rs', + 'tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', + 'tests/runtime_port_contracts/script_tool_port_contracts.rs', + 'tests/runtime_port_contracts/session_store_contracts.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productDomainsIntegrationTestTargets = [ + { + name: 'product_domain_contracts', + path: 'tests/product_domain_contracts.rs', + leaves: [ + 'tests/product_domain_contracts/canvas_contracts.rs', + 'tests/product_domain_contracts/tool_permission_contracts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'external_source_contracts', + path: 'tests/external_source_contracts.rs', + leaves: [ + 'tests/external_source_contracts/external_hook_catalog_contracts.rs', + 'tests/external_source_contracts/external_hook_contribution_contracts.rs', + 'tests/external_source_contracts/external_source_contracts.rs', + 'tests/external_source_contracts/workspace_reference_contracts.rs', + ], + requiredFeatures: ['external-sources'], + }, + { + name: 'function_agent_contracts', + path: 'tests/function_agent_contracts.rs', + requiredFeatures: ['function-agents'], + }, + { + name: 'miniapp_contracts', + path: 'tests/miniapp_contracts.rs', + requiredFeatures: ['miniapp'], + }, + { + name: 'plugin_source_contracts', + path: 'tests/plugin_source_contracts.rs', + requiredFeatures: ['plugin-source'], + }, +]; + +export const aiAdaptersIntegrationTestTargets = [ + { + name: 'ai_protocol_contracts', + path: 'tests/ai_protocol_contracts.rs', + leaves: [ + 'tests/ai_protocol_contracts/model_selector.rs', + 'tests/ai_protocol_contracts/openai_empty_content_parts.rs', + ], + forbidRequiredFeatures: true, + }, + { + name: 'ai_stream_contracts', + path: 'tests/ai_stream_contracts.rs', + leaves: [ + 'tests/ai_stream_contracts/common.rs', + 'tests/ai_stream_contracts/stream_processor_anthropic.rs', + 'tests/ai_stream_contracts/stream_processor_openai.rs', + 'tests/ai_stream_contracts/stream_processor_tool_arguments.rs', + 'tests/ai_stream_contracts/stream_replay_regressions.rs', + 'tests/ai_stream_contracts/stream_test_harness.rs', + ], + forbidRequiredFeatures: true, + }, +]; + +export const productCapabilitiesIntegrationTestTargets = [ + { + name: 'product_capability_contracts', + path: 'tests/product_capability_contracts.rs', + leaves: [ + 'tests/product_capability_contracts/plugin_product_shape.rs', + 'tests/product_capability_contracts/product_capabilities.rs', + 'tests/product_capability_contracts/product_sdk_assembly.rs', + ], + forbidRequiredFeatures: true, + }, +]; + function decodeBasicTomlKey(token) { let decoded = ''; const simpleEscapes = new Map([ @@ -155,6 +260,34 @@ function tomlFieldName(line) { return token.startsWith('"') ? decodeBasicTomlKey(token) : token; } +function parseTomlStringArrayValue(line) { + const equalsIndex = line.indexOf('='); + const value = equalsIndex === -1 ? '' : line.slice(equalsIndex + 1).trim(); + const array = value.match(/^\[(.*)\]\s*(?:#.*)?$/); + if (!array) { + return null; + } + const inner = array[1]; + const values = []; + const stringPattern = /'[^']*'|"(?:[^"\\]|\\.)*"/g; + let cursor = 0; + for (const match of inner.matchAll(stringPattern)) { + if (!/^[\s,]*$/.test(inner.slice(cursor, match.index))) { + return null; + } + const token = match[0]; + const decoded = token.startsWith("'") + ? token.slice(1, -1) + : decodeBasicTomlKey(token); + if (decoded === null) { + return null; + } + values.push(decoded); + cursor = match.index + token.length; + } + return /^[\s,]*$/.test(inner.slice(cursor)) ? values : null; +} + function parseExplicitTestTargets(manifestText) { const targets = []; let current = null; @@ -178,6 +311,7 @@ function parseExplicitTestTargets(manifestText) { } if (current && tomlFieldName(trimmed) === 'required-features') { current.hasRequiredFeatures = true; + current.requiredFeatures = parseTomlStringArrayValue(trimmed); } const field = current && trimmed.match(/^(name|path)\s*=\s*"([^"]+)"\s*$/); if (field) { @@ -515,6 +649,24 @@ export function validateExplicitIntegrationTestTopology({ errors.push(`explicit test target ${name} must not declare required-features`); } } + for (const { name, path, requiredFeatures } of expectedTargets) { + if (requiredFeatures === undefined) { + continue; + } + const actual = actualTargets.find( + (target) => target.name === name && target.path === path, + ); + const actualRequiredFeatures = actual?.requiredFeatures; + if ( + actualRequiredFeatures === null + || actualRequiredFeatures === undefined + || [...actualRequiredFeatures].sort().join('\n') !== [...requiredFeatures].sort().join('\n') + ) { + errors.push( + `explicit test target ${name} required-features must be exactly: ${requiredFeatures.join(', ')}`, + ); + } + } const expectedRoots = expectedTargets.map(({ path }) => path).sort(); if ([...topLevelRustFiles].sort().join('\n') !== expectedRoots.join('\n')) { @@ -717,3 +869,31 @@ export function checkServiceIntegrationTestTopologies(root) { ...checkServicesIntegrationsIntegrationTestTopology(root), ]; } + +export function checkBuildGraphContractIntegrationTestTopologies(root) { + const topologies = [ + ['src/crates/contracts/core-types', coreTypesIntegrationTestTargets], + ['src/crates/contracts/runtime-ports', runtimePortsIntegrationTestTargets], + ['src/crates/contracts/product-domains', productDomainsIntegrationTestTargets], + [ + 'src/crates/adapters/ai-adapters', + aiAdaptersIntegrationTestTargets, + ['tests/common', 'tests/fixtures'], + ], + ['src/crates/assembly/product-capabilities', productCapabilitiesIntegrationTestTargets], + ]; + return topologies.flatMap(([cratePath, expectedTargets, ignoredDirectories]) => ( + checkExplicitIntegrationTestTopology(root, { + cratePath, + expectedTargets, + ignoredDirectories, + }) + )); +} + +export function checkReviewedIntegrationTestTopologies(root) { + return [ + ...checkServiceIntegrationTestTopologies(root), + ...checkBuildGraphContractIntegrationTestTopologies(root), + ]; +} diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 43576f863..1ed5f3cc5 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -216,7 +216,7 @@ export const forbiddenContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', patterns: [ { regex: /\bbitfun_core\b/, diff --git a/scripts/core-boundaries/rules/source/required-rules.mjs b/scripts/core-boundaries/rules/source/required-rules.mjs index 8b4dc744d..b0aa4ce73 100644 --- a/scripts/core-boundaries/rules/source/required-rules.mjs +++ b/scripts/core-boundaries/rules/source/required-rules.mjs @@ -432,7 +432,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/core-types/tests/lsp_contracts.rs', + path: 'src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs', reason: 'core-types must keep LSP manifest serialization, default-value, and placeholder regressions', patterns: [ @@ -1513,7 +1513,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', reason: 'product-capabilities tests must protect product shape facts, runtime service gap reporting, and legacy harness routing', patterns: [ @@ -1544,7 +1544,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', reason: 'product-capabilities plugin shape tests must protect P0 plugin-capable profiles, non-P0 rejection, default availability reasons, and runtime handoff', patterns: [ @@ -1571,7 +1571,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', reason: 'product-capabilities must prove product runtime parts can feed the SDK runtime without bitfun-core', patterns: [ @@ -4843,7 +4843,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs', reason: 'runtime-ports plugin contract tests must cover typed envelopes, candidate effects, and disabled/projection-only behavior', patterns: [ @@ -4900,7 +4900,7 @@ export const requiredContentRules = [ ], }, { - path: 'src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs', + path: 'src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs', reason: 'runtime-ports plugin diagnostics contract tests must cover permission prompts, diagnostics, and quarantine facts', patterns: [ diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 666ce0d0e..1f93445ef 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -3238,7 +3238,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_capabilities.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs', contracts: [ 'product_assembly_plan_exposes_build_feature_groups_explicitly', 'product_runtime_assembly_reports_runtime_service_capability_gaps', @@ -3246,7 +3246,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs', contracts: [ 'executable_plugin_runtime_is_limited_to_product_full_desktop_and_cli', 'executable_plugin_runtime_client_builds_agent_runtime_parts', @@ -3285,7 +3285,7 @@ export function runManifestParserSelfTest({ ], }, { - path: 'src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs', + path: 'src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs', contracts: [ 'product_runtime_parts_can_build_agent_runtime_sdk_without_core', 'sdk_delivery_profile_builds_shared_runtime_owner_ceiling_without_bitfun_core', diff --git a/src/apps/cli/Cargo.toml b/src/apps/cli/Cargo.toml index 2d57b3f0d..8726604c0 100644 --- a/src/apps/cli/Cargo.toml +++ b/src/apps/cli/Cargo.toml @@ -78,7 +78,6 @@ toml = { workspace = true } # Session management uuid = { workspace = true } chrono = { workspace = true } -dashmap = { workspace = true } # Async trait async-trait = { workspace = true } @@ -97,7 +96,6 @@ similar = { workspace = true } # Syntax highlighting for code blocks and tool cards syntect = { workspace = true } -syntect-tui = { workspace = true } # Lazy initialization for syntax highlighter singleton once_cell = { workspace = true } diff --git a/src/apps/cli/src/ui/syntax_highlight.rs b/src/apps/cli/src/ui/syntax_highlight.rs index 1652fea12..1d2e7de5f 100644 --- a/src/apps/cli/src/ui/syntax_highlight.rs +++ b/src/apps/cli/src/ui/syntax_highlight.rs @@ -1,7 +1,7 @@ /// Syntax highlighting module for TUI /// -/// Uses `syntect` for syntax analysis and `syntect-tui` to convert -/// highlighted output into ratatui `Span`s. +/// Uses `syntect` for syntax analysis and converts highlighted output directly +/// into ratatui `Span`s. use once_cell::sync::Lazy; use ratatui::{ style::Style, diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index a99882a50..c7077c423 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -41,7 +41,6 @@ tauri-plugin-log = { workspace = true } tauri-plugin-autostart = { workspace = true } tauri-plugin-notification = { workspace = true } tauri-plugin-updater = { workspace = true } -tauri-plugin-global-shortcut = { workspace = true } tauri-plugin-single-instance = { workspace = true } tauri-plugin-window-state = { workspace = true } keepawake = { workspace = true } diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index ff6229e4b..2ecc97db9 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -105,11 +105,6 @@ "notification:allow-request-permission", "notification:allow-check-permissions", "notification:allow-permission-state", - "notification:allow-is-permission-granted", - "global-shortcut:default", - "global-shortcut:allow-register", - "global-shortcut:allow-unregister", - "global-shortcut:allow-unregister-all", - "global-shortcut:allow-is-registered" + "notification:allow-is-permission-granted" ] } diff --git a/src/crates/adapters/ai-adapters/Cargo.toml b/src/crates/adapters/ai-adapters/Cargo.toml index 2f8f05037..12cd71a50 100644 --- a/src/crates/adapters/ai-adapters/Cargo.toml +++ b/src/crates/adapters/ai-adapters/Cargo.toml @@ -4,11 +4,20 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "Shared AI protocol adapters for BitFun core and installer" +autotests = false [lib] name = "bitfun_ai_adapters" crate-type = ["rlib"] +[[test]] +name = "ai_protocol_contracts" +path = "tests/ai_protocol_contracts.rs" + +[[test]] +name = "ai_stream_contracts" +path = "tests/ai_stream_contracts.rs" + [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs new file mode 100644 index 000000000..ba553f50b --- /dev/null +++ b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts.rs @@ -0,0 +1,4 @@ +#[path = "ai_protocol_contracts/model_selector.rs"] +mod model_selector; +#[path = "ai_protocol_contracts/openai_empty_content_parts.rs"] +mod openai_empty_content_parts; diff --git a/src/crates/adapters/ai-adapters/tests/model_selector.rs b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/model_selector.rs similarity index 100% rename from src/crates/adapters/ai-adapters/tests/model_selector.rs rename to src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/model_selector.rs diff --git a/src/crates/adapters/ai-adapters/tests/openai_empty_content_parts.rs b/src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/openai_empty_content_parts.rs similarity index 100% rename from src/crates/adapters/ai-adapters/tests/openai_empty_content_parts.rs rename to src/crates/adapters/ai-adapters/tests/ai_protocol_contracts/openai_empty_content_parts.rs diff --git a/src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs new file mode 100644 index 000000000..0a1dd8ec5 --- /dev/null +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts.rs @@ -0,0 +1,12 @@ +#[path = "ai_stream_contracts/common.rs"] +mod common; +#[path = "ai_stream_contracts/stream_processor_anthropic.rs"] +mod stream_processor_anthropic; +#[path = "ai_stream_contracts/stream_processor_openai.rs"] +mod stream_processor_openai; +#[path = "ai_stream_contracts/stream_processor_tool_arguments.rs"] +mod stream_processor_tool_arguments; +#[path = "ai_stream_contracts/stream_replay_regressions.rs"] +mod stream_replay_regressions; +#[path = "ai_stream_contracts/stream_test_harness.rs"] +mod stream_test_harness; diff --git a/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs new file mode 100644 index 000000000..6f78b75e3 --- /dev/null +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/common.rs @@ -0,0 +1,6 @@ +#[path = "../common/fixture_loader.rs"] +pub(crate) mod fixture_loader; +#[path = "../common/sse_fixture_server.rs"] +pub(crate) mod sse_fixture_server; +#[path = "../common/stream_test_harness.rs"] +pub(crate) mod stream_test_harness; diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_anthropic.rs similarity index 99% rename from src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_anthropic.rs index d8361e0c2..0025ad2a3 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_anthropic.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_anthropic.rs @@ -1,9 +1,7 @@ -mod common; - -use bitfun_events::AgenticEvent; -use common::stream_test_harness::{ +use crate::common::stream_test_harness::{ run_stream_fixture_with_options, StreamFixtureProvider, StreamFixtureRunOptions, }; +use bitfun_events::AgenticEvent; use serde_json::json; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_openai.rs similarity index 99% rename from src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_openai.rs index 133cb3469..95cc8d613 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_openai.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_openai.rs @@ -1,11 +1,9 @@ -mod common; - -use bitfun_events::{AgenticEvent, ToolEventData}; -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{ +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{ run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, StreamFixtureRunOptions, }; +use bitfun_events::{AgenticEvent, ToolEventData}; use serde_json::json; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_tool_arguments.rs similarity index 94% rename from src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_tool_arguments.rs index 64f2c9182..62e88cfd5 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_processor_tool_arguments.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_processor_tool_arguments.rs @@ -1,8 +1,6 @@ -mod common; - +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{run_stream_fixture, StreamFixtureProvider}; use bitfun_events::AgenticEvent; -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{run_stream_fixture, StreamFixtureProvider}; use serde_json::json; fn assert_no_stream_failure_event(events: &[AgenticEvent]) { diff --git a/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_replay_regressions.rs similarity index 98% rename from src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_replay_regressions.rs index 80b96afc8..c499c7ec6 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_replay_regressions.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_replay_regressions.rs @@ -1,14 +1,12 @@ -mod common; - +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{ + run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, + StreamFixtureRunOptions, +}; use bitfun_agent_stream::StreamResult; use bitfun_ai_adapters::providers::{openai::OpenAIMessageConverter, AnthropicMessageConverter}; use bitfun_ai_adapters::{Message as AIMessage, ToolCall as AIToolCall}; use bitfun_events::{AgenticEvent, ToolEventData}; -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{ - run_stream_fixture, run_stream_fixture_with_options, StreamFixtureProvider, - StreamFixtureRunOptions, -}; use serde_json::json; fn build_replay_assistant_message(result: &StreamResult) -> AIMessage { diff --git a/src/crates/adapters/ai-adapters/tests/stream_test_harness.rs b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_test_harness.rs similarity index 94% rename from src/crates/adapters/ai-adapters/tests/stream_test_harness.rs rename to src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_test_harness.rs index 98168c699..55bcce064 100644 --- a/src/crates/adapters/ai-adapters/tests/stream_test_harness.rs +++ b/src/crates/adapters/ai-adapters/tests/ai_stream_contracts/stream_test_harness.rs @@ -1,7 +1,5 @@ -mod common; - -use common::sse_fixture_server::FixtureSseServerOptions; -use common::stream_test_harness::{ +use crate::common::sse_fixture_server::FixtureSseServerOptions; +use crate::common::stream_test_harness::{ run_stream_fixture_with_options, StreamFixtureProvider, StreamFixtureRunOptions, }; use std::time::Duration; diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index be424303d..6e2f70a06 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -12,7 +12,6 @@ crate-type = ["rlib"] [dependencies] # Inherit shared dependencies from workspace tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "rt", "sync", "time"] } -tokio-stream = { workspace = true } tokio-util = { workspace = true } async-trait = { workspace = true } futures = { workspace = true } @@ -57,7 +56,6 @@ include_dir = { workspace = true, optional = true } # Command detection (cross-platform) similar = { workspace = true, optional = true } -urlencoding = { workspace = true } # Shared AI protocol adapters bitfun-ai-adapters = { path = "../../adapters/ai-adapters", optional = true } diff --git a/src/crates/assembly/product-capabilities/Cargo.toml b/src/crates/assembly/product-capabilities/Cargo.toml index 4d8e17738..a8428c8d8 100644 --- a/src/crates/assembly/product-capabilities/Cargo.toml +++ b/src/crates/assembly/product-capabilities/Cargo.toml @@ -4,11 +4,16 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "BitFun product capability pack contracts" +autotests = false [lib] name = "bitfun_product_capabilities" crate-type = ["rlib"] +[[test]] +name = "product_capability_contracts" +path = "tests/product_capability_contracts.rs" + [dependencies] bitfun-harness = { path = "../../execution/harness" } bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs new file mode 100644 index 000000000..91f73bb64 --- /dev/null +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts.rs @@ -0,0 +1,6 @@ +#[path = "product_capability_contracts/plugin_product_shape.rs"] +mod plugin_product_shape; +#[path = "product_capability_contracts/product_capabilities.rs"] +mod product_capabilities; +#[path = "product_capability_contracts/product_sdk_assembly.rs"] +mod product_sdk_assembly; diff --git a/src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs similarity index 100% rename from src/crates/assembly/product-capabilities/tests/plugin_product_shape.rs rename to src/crates/assembly/product-capabilities/tests/product_capability_contracts/plugin_product_shape.rs diff --git a/src/crates/assembly/product-capabilities/tests/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs similarity index 100% rename from src/crates/assembly/product-capabilities/tests/product_capabilities.rs rename to src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs diff --git a/src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs similarity index 100% rename from src/crates/assembly/product-capabilities/tests/product_sdk_assembly.rs rename to src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_sdk_assembly.rs diff --git a/src/crates/contracts/core-types/Cargo.toml b/src/crates/contracts/core-types/Cargo.toml index 22486a953..d714504e6 100644 --- a/src/crates/contracts/core-types/Cargo.toml +++ b/src/crates/contracts/core-types/Cargo.toml @@ -3,11 +3,16 @@ name = "bitfun-core-types" version.workspace = true edition.workspace = true description = "BitFun shared low-level product DTOs" +autotests = false [lib] name = "bitfun_core_types" crate-type = ["rlib"] +[[test]] +name = "core_type_contracts" +path = "tests/core_type_contracts.rs" + [dependencies] serde = { workspace = true } serde_json = { workspace = true } diff --git a/src/crates/contracts/core-types/tests/core_type_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts.rs new file mode 100644 index 000000000..9cc3a9546 --- /dev/null +++ b/src/crates/contracts/core-types/tests/core_type_contracts.rs @@ -0,0 +1,8 @@ +#[path = "core_type_contracts/lsp_contracts.rs"] +mod lsp_contracts; +#[path = "core_type_contracts/session_contracts.rs"] +mod session_contracts; +#[path = "core_type_contracts/session_usage_contracts.rs"] +mod session_usage_contracts; +#[path = "core_type_contracts/surface_contracts.rs"] +mod surface_contracts; diff --git a/src/crates/contracts/core-types/tests/lsp_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/lsp_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/lsp_contracts.rs diff --git a/src/crates/contracts/core-types/tests/session_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/session_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/session_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/session_contracts.rs diff --git a/src/crates/contracts/core-types/tests/session_usage_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/session_usage_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/session_usage_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/session_usage_contracts.rs diff --git a/src/crates/contracts/core-types/tests/surface_contracts.rs b/src/crates/contracts/core-types/tests/core_type_contracts/surface_contracts.rs similarity index 100% rename from src/crates/contracts/core-types/tests/surface_contracts.rs rename to src/crates/contracts/core-types/tests/core_type_contracts/surface_contracts.rs diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index 856fc2667..132a99f39 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -4,11 +4,16 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "BitFun product domain owner crate" +autotests = false [lib] name = "bitfun_product_domains" crate-type = ["rlib"] +[[test]] +name = "product_domain_contracts" +path = "tests/product_domain_contracts.rs" + [[test]] name = "plugin_source_contracts" path = "tests/plugin_source_contracts.rs" @@ -19,27 +24,14 @@ name = "external_source_contracts" path = "tests/external_source_contracts.rs" required-features = ["external-sources"] -[[test]] -name = "external_hook_contribution_contracts" -path = "tests/external_hook_contribution_contracts.rs" -required-features = ["external-sources"] - -[[test]] -name = "external_hook_catalog_contracts" -path = "tests/external_hook_catalog_contracts.rs" -required-features = ["external-sources"] - -[[test]] -name = "workspace_reference_contracts" -path = "tests/workspace_reference_contracts.rs" -required-features = ["external-sources"] - [[test]] name = "function_agent_contracts" +path = "tests/function_agent_contracts.rs" required-features = ["function-agents"] [[test]] name = "miniapp_contracts" +path = "tests/miniapp_contracts.rs" required-features = ["miniapp"] [dependencies] diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index e9f9cc074..0ba4a81aa 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -1,2074 +1,10 @@ -use bitfun_product_domains::external_integration_policy::{ - evaluate_external_integration_policy, external_integration_policy_snapshot, - ExternalEcosystemPolicy, ExternalEcosystemPolicyOverride, ExternalIntegrationAccess, - ExternalIntegrationCapabilityDescriptor, ExternalIntegrationEcosystemDescriptor, - ExternalIntegrationMode, ExternalIntegrationPolicyDocument, ExternalIntegrationPolicyOverride, - ExternalIntegrationPolicyStatus, -}; -use bitfun_product_domains::external_source_control::{ - ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, - ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, - ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, -}; -use bitfun_product_domains::external_sources::{ - external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, - external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, - ExpandedPromptCommand, ExternalIntegrationCapabilityId, ExternalMcpActivationState, - ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, - ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpImportApplyRequestV1, - ExternalMcpImportSelectionV1, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, - ExternalMcpRevisionKey, ExternalMcpServerDefinition, ExternalMcpStaticStatus, - ExternalMcpTimeouts, ExternalMcpTransportKind, ExternalSourceAssetKind, - ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, - ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceHostCapabilities, - ExternalSourceLifecycleState, ExternalSourceOperationError, ExternalSourceOperationErrorCode, - ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, - ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, - ExternalToolStaticStatus, ExternalWatchRoot, NativePromptCommandDescriptor, - PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, - PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, - PromptCommandDefinition, PromptCommandExpansion, PromptCommandProviderIdentity, - PromptCommandProviderSnapshot, PromptCommandSourceProvider, SecretValue, SourceKey, - SourceQualifiedCommandId, SourceQualifiedMcpServerId, SourceQualifiedToolId, - SourceQualifiedToolTargetId, -}; -use bitfun_product_domains::external_subagents::{ - external_subagent_approval_key, external_subagent_candidate_id, external_subagent_conflict_key, - external_subagent_model_binding_key, ExternalSubagentBehaviorVersion, - ExternalSubagentCandidateId, ExternalSubagentCompatibilityState, - ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentDefinition, - ExternalSubagentDiscoveryInput, ExternalSubagentLocalId, ExternalSubagentMode, - ExternalSubagentModelBindingGroup, ExternalSubagentModelBindingMethod, - ExternalSubagentModelBindingOption, ExternalSubagentModelBindingTarget, - ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, - ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, - ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, - SecretText, -}; -use bitfun_product_domains::tool_permissions::{ - PermissionConstraintLayer, PermissionEffect, PermissionRule, -}; -use sha2::{Digest, Sha256}; -use std::path::PathBuf; - -#[test] -fn native_prompt_command_descriptors_reject_external_candidate_namespaces() { - let descriptor = NativePromptCommandDescriptor { - command_name: "review".to_string(), - candidate_id: "opencode.commands:project:review".to_string(), - behavior_version: "v1".to_string(), - }; - - assert!(descriptor.validate().is_err()); -} - -#[test] -fn external_mcp_import_contract_keeps_private_values_out_of_debug_and_requests() { - let source = SourceKey::new("opencode.mcp", "user-config").unwrap(); - let prepared = PreparedExternalMcpImportServer { - id: SourceQualifiedMcpServerId::new(source, "docs").unwrap(), - behavior_version: "sha256:behavior-v1".to_string(), - transport: PreparedExternalMcpImportTransport::Local { - command: "secret-command".to_string(), - args: vec!["secret-argument".to_string()], - }, - }; - let debug = format!("{prepared:?}"); - assert!(!debug.contains("secret-command")); - assert!(!debug.contains("secret-argument")); - prepared.validate().unwrap(); - - let request = ExternalMcpImportApplyRequestV1 { - schema_version: 1, - plan_fingerprint: "sha256:plan-v1".to_string(), - selections: vec![ExternalMcpImportSelectionV1 { - candidate_id: "opencode:mcp:docs".to_string(), - requested_native_id: None, - }], - }; - request.validate().unwrap(); - let encoded = serde_json::to_string(&request).unwrap(); - assert!(!encoded.contains("command")); - assert!(!encoded.contains("argument")); -} - -#[test] -fn external_mcp_import_contract_rejects_urls_that_cannot_be_copied_losslessly() { - let prepared = |url: &str| PreparedExternalMcpImportServer { - id: SourceQualifiedMcpServerId::new( - SourceKey::new("codex.mcp", "user-config").unwrap(), - "docs", - ) - .unwrap(), - behavior_version: "sha256:behavior-v1".to_string(), - transport: PreparedExternalMcpImportTransport::Remote { - url: url.to_string(), - }, - }; - - prepared("https://docs.example.test/mcp") - .validate() - .unwrap(); - for url in [ - "http://docs.example.test/mcp", - "https://user@docs.example.test/mcp", - "https://user:secret@docs.example.test/mcp", - "https://docs.example.test/mcp?token=secret", - "https://docs.example.test/mcp#private", - ] { - assert!( - prepared(url).validate().is_err(), - "unexpectedly safe: {url}" - ); - } -} - -fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { - ExternalSourceRecord { - key: SourceKey::new(provider_id, source_id).expect("valid source key"), - ecosystem_id: EcosystemId::new(ecosystem_id).expect("valid ecosystem id"), - display_name: format!("{provider_id} commands"), - source_kind: "prompt_commands".to_string(), - scope: ExternalSourceScope::Project, - location: format!("/workspace/{provider_id}"), - execution_domain_id: ExecutionDomainId::new("local-user").expect("valid domain"), - health: ExternalSourceHealth::Available, - content_version: format!("{provider_id}-v1"), - diagnostics: Vec::new(), - } -} - -fn command(provider_id: &str, source_id: &str, precedence: i32) -> PromptCommandDefinition { - PromptCommandDefinition { - id: SourceQualifiedCommandId::new( - SourceKey::new(provider_id, source_id).unwrap(), - "review", - ) - .unwrap(), - name: "review".to_string(), - description: format!("Review from {provider_id}"), - template: format!("{provider_id}: $ARGUMENTS"), - shell_preference: None, - execution_target: Default::default(), - availability: PromptCommandAvailability::Available, - content_version: format!("command-v{precedence}"), - } -} - -fn context() -> ExternalSourceContext { - ExternalSourceContext { - workspace_root: Some(PathBuf::from("/workspace")), - execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), - } -} - -#[test] -fn opaque_ids_are_validated_without_closing_the_ecosystem_set() { - assert_eq!( - EcosystemId::new("future.product/v2") - .expect("future ecosystem ids remain open") - .as_str(), - "future.product/v2" - ); - assert!(EcosystemId::new(" ").is_err()); - assert!(ExecutionDomainId::new("domain\nwith-control").is_err()); -} - -#[test] -fn source_and_command_identity_remain_provider_qualified() { - let left = SourceQualifiedCommandId::new( - SourceKey::new("adapter-a", "project-commands").unwrap(), - "review", - ) - .unwrap(); - let right = SourceQualifiedCommandId::new( - SourceKey::new("adapter-b", "project-commands").unwrap(), - "review", - ) - .unwrap(); - - assert_ne!(left, right); - assert_ne!(left.stable_key(), right.stable_key()); -} - -#[test] -fn presentation_group_id_is_optional_and_uses_the_camel_case_wire_name() { - let mut entry = ExternalSourceCatalogEntry { - stable_key: "opencode.commands:project".to_string(), - presentation_group_id: None, - record: source("opencode.commands", "opencode", "project"), - lifecycle: ExternalSourceLifecycleState::Available, - }; - - let legacy_value = serde_json::to_value(&entry).unwrap(); - assert!(legacy_value.get("presentationGroupId").is_none()); - let legacy_entry: ExternalSourceCatalogEntry = serde_json::from_value(legacy_value).unwrap(); - assert!(legacy_entry.presentation_group_id.is_none()); - - entry.presentation_group_id = Some("external-source:[\"source\"]".to_string()); - let current_value = serde_json::to_value(&entry).unwrap(); - assert_eq!( - current_value["presentationGroupId"], - "external-source:[\"source\"]" - ); -} - -#[test] -fn conflict_fingerprint_is_order_independent_and_changes_with_content() { - let first = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v2")]); - let reordered = prompt_command_conflict_key("local-user", "REVIEW", [("b", "v2"), ("a", "v1")]); - let updated = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v3")]); - let remote = prompt_command_conflict_key("remote-user", "review", [("a", "v1"), ("b", "v2")]); - - assert_eq!(first, reordered); - assert_ne!(first, updated); - assert_ne!(first, remote); -} - -#[test] -fn prompt_commands_use_a_typed_contract_instead_of_an_arbitrary_asset_payload() { - let command = PromptCommandDefinition { - id: SourceQualifiedCommandId::new( - SourceKey::new("example-provider", "project-commands").unwrap(), - "review", - ) - .unwrap(), - name: "review".to_string(), - description: "Review the current change".to_string(), - template: "Review $ARGUMENTS".to_string(), - shell_preference: None, - execution_target: Default::default(), - availability: PromptCommandAvailability::Restricted { - reason: "Shell expansion is not supported yet".to_string(), - required_capabilities: vec!["command.shell".to_string()], - }, - content_version: "sha256:command-v1".to_string(), - }; - - let encoded = serde_json::to_value(&command).expect("serialize command contract"); - assert_eq!(encoded["name"], "review"); - assert_eq!(encoded["availability"]["state"], "restricted"); - assert!(encoded.get("payload").is_none()); -} - -struct FakeProvider { - identity: PromptCommandProviderIdentity, - snapshot: PromptCommandProviderSnapshot, -} - -impl FakeProvider { - fn new(provider_id: &str, ecosystem_id: &str, source_id: &str, precedence: i32) -> Self { - let identity = PromptCommandProviderIdentity::new( - provider_id, - ecosystem_id, - format!("{provider_id} display"), - ) - .unwrap(); - Self { - identity: identity.clone(), - snapshot: PromptCommandProviderSnapshot { - provider: identity, - sources: vec![source(provider_id, ecosystem_id, source_id)], - commands: vec![command(provider_id, source_id, precedence)], - unavailable_command_ids: Vec::new(), - diagnostics: Vec::new(), - }, - } - } -} - -impl PromptCommandSourceProvider for FakeProvider { - fn identity(&self) -> PromptCommandProviderIdentity { - self.identity.clone() - } - - fn discover( - &self, - _context: &ExternalSourceContext, - ) -> Result { - Ok(self.snapshot.clone()) - } - - fn expand( - &self, - _context: &ExternalSourceContext, - command: &PromptCommandDefinition, - arguments: &str, - ) -> Result { - Ok(PromptCommandExpansion { - content: command.template.replace("$ARGUMENTS", arguments), - workspace_file_references: vec!["src/lib.rs".to_string()], - shell: None, - }) - } - - fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { - vec![ExternalWatchRoot { - path: context.workspace_root.clone().unwrap(), - recursive: true, - }] - } -} - -#[test] -fn capability_provider_contract_does_not_require_core_or_an_ecosystem_enum() { - let provider: Box = Box::new(FakeProvider::new( - "fake-provider", - "fake.ecosystem", - "project-commands", - 1, - )); - - let snapshot = provider.discover(&context()).expect("discover fake source"); - assert_eq!(snapshot.provider.ecosystem_id.as_str(), "fake.ecosystem"); - assert_eq!(provider.watch_roots(&context()).len(), 1); - let expansion = provider - .expand(&context(), &snapshot.commands[0], "change") - .expect("prepare fake command expansion"); - assert_eq!(expansion.content, "fake-provider: change"); - assert_eq!(expansion.workspace_file_references, ["src/lib.rs"]); - - let final_result = ExpandedPromptCommand { - content: expansion.content, - }; - assert_eq!( - serde_json::to_value(final_result).unwrap(), - serde_json::json!({"content": "fake-provider: change"}) - ); -} - -#[test] -fn persisted_source_preference_keys_round_trip_without_path_guessing() { - let record = source( - "provider.with.dots", - "fake.ecosystem", - "project/source:agents", - ); - assert_eq!( - ExternalSourceRecord::source_key_from_preference_key(&record.preference_key()), - Some(record.key) - ); - assert!(ExternalSourceRecord::source_key_from_preference_key("malformed").is_none()); -} - -#[test] -fn external_subagent_identity_preserves_ordered_provenance_and_separate_revisions() { - let provider = - ExternalSubagentProviderIdentity::new("fake.agents", "fake.ecosystem", "Fake Agents") - .unwrap(); - let first = ExternalSubagentContributionId::new( - SourceKey::new("fake.agents", "global-config").unwrap(), - ExternalSubagentLocalId::new("review").unwrap(), - ); - let second = ExternalSubagentContributionId::new( - SourceKey::new("fake.agents", "project-config").unwrap(), - ExternalSubagentLocalId::new("review").unwrap(), - ); - let provenance = vec![ - ExternalSubagentProvenanceRef { - contribution_id: first, - role: ExternalSubagentContributionRole::Base, - }, - ExternalSubagentProvenanceRef { - contribution_id: second, - role: ExternalSubagentContributionRole::Overlay, - }, - ]; - let candidate_id = external_subagent_candidate_id(&provider.provider_id, "review", &provenance); - let reversed = external_subagent_candidate_id( - &provider.provider_id, - "review", - &provenance.iter().cloned().rev().collect::>(), - ); - assert_ne!( - candidate_id, reversed, - "provenance order changes behavior identity" - ); - - let definition = ExternalSubagentDefinition { - candidate_id, - logical_id: "review".to_string(), - provenance, - display_name: "Review".to_string(), - description: "Reviews a change".to_string(), - prompt: SecretText::new("Review carefully"), - mode: ExternalSubagentMode::Subagent, - disabled: false, - hidden: false, - requested_model: ExternalSubagentModelRequest::Default, - requested_model_profile: None, - requested_tools: ExternalSubagentToolRequest { - selectors: vec![ExternalSubagentToolSelector { - source_name: "read".to_string(), - canonical_host_name: Some("Read".to_string()), - allowed: true, - }], - uses_conservative_default: false, - }, - permission_constraints: PermissionConstraintLayer::new(vec![PermissionRule::new( - "read", - "C:/sensitive/private/*", - PermissionEffect::Deny, - )]), - compatibility: ExternalSubagentCompatibilityState::Ready, - diagnostic_codes: Vec::new(), - behavior_version: ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(), - }; - assert_eq!(definition.prompt.expose(), "Review carefully"); - assert!(!format!("{definition:?}").contains("Review carefully")); - assert!(!format!("{definition:?}").contains("C:/sensitive/private")); - - let mut invalid_model = definition.clone(); - invalid_model.requested_model = ExternalSubagentModelRequest::Reference { - provider_hint: Some("fake\nprovider".to_string()), - model_name: "model".to_string(), - }; - assert!(invalid_model.validate().is_err()); - - let mut invalid_tool = definition.clone(); - invalid_tool.requested_tools.selectors[0].source_name = "read\nsecret".to_string(); - assert!(invalid_tool.validate().is_err()); - - let mut invalid_permission = definition.clone(); - invalid_permission.permission_constraints = - PermissionConstraintLayer::new(vec![PermissionRule::new( - "read\nsecret", - "*", - PermissionEffect::Deny, - )]); - assert!(invalid_permission.validate().is_err()); - - let mut invalid_diagnostic = definition.clone(); - invalid_diagnostic.diagnostic_codes = vec!["provider.invalid:raw-source-key".to_string()]; - assert!(invalid_diagnostic.validate().is_err()); - - let mut excessive_tools = definition.clone(); - excessive_tools.requested_tools.selectors = (0..257) - .map(|index| ExternalSubagentToolSelector { - source_name: format!("tool-{index}"), - canonical_host_name: None, - allowed: true, - }) - .collect(); - assert!(excessive_tools.validate().is_err()); - - let snapshot = ExternalSubagentProviderSnapshot { - provider, - sources: vec![ - source("fake.agents", "fake.ecosystem", "global-config"), - source("fake.agents", "fake.ecosystem", "project-config"), - ], - definitions: vec![definition], - diagnostics: Vec::new(), - }; - snapshot - .validate() - .expect("valid external subagent provider snapshot"); - - let source_key = snapshot.sources[0].key.clone(); - let mut valid_diagnostic = snapshot.clone(); - valid_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.degraded", - "An optional field is not supported", - Some(source_key), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - valid_diagnostic - .validate() - .expect("bounded provider diagnostics with a known source are valid"); - - let mut valid_source_diagnostic = snapshot.clone(); - let valid_source_key = valid_source_diagnostic.sources[0].key.clone(); - valid_source_diagnostic.sources[0].diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.source_degraded", - "This source has a recoverable warning", - Some(valid_source_key), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - valid_source_diagnostic - .validate() - .expect("source-owned diagnostics use the same provider contract"); - - let mut invalid_provider_diagnostic = snapshot.clone(); - invalid_provider_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent:raw-source", - "Invalid diagnostic code", - Some(SourceKey::new("other.agents", "project").unwrap()), - ) - .with_asset_kind(ExternalSourceAssetKind::Command), - ); - assert!(invalid_provider_diagnostic.validate().is_err()); - - let mut wrong_provider_diagnostic = snapshot.clone(); - wrong_provider_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.invalid_source", - "Unknown provider source", - Some(SourceKey::new("other.agents", "project").unwrap()), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - assert!(wrong_provider_diagnostic.validate().is_err()); - - let mut unknown_source_diagnostic = snapshot.clone(); - unknown_source_diagnostic.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.unknown_source", - "Unknown source", - Some(SourceKey::new("fake.agents", "missing").unwrap()), - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - assert!(unknown_source_diagnostic.validate().is_err()); - - let mut invalid_diagnostic_message = snapshot.clone(); - invalid_diagnostic_message.diagnostics.push( - ExternalSourceDiagnostic::warning("fake.agent.invalid_message", "invalid\nmessage", None) - .with_asset_kind(ExternalSourceAssetKind::Subagent), - ); - assert!(invalid_diagnostic_message.validate().is_err()); - - let mut wrong_asset_kind = snapshot.clone(); - wrong_asset_kind.diagnostics.push( - ExternalSourceDiagnostic::warning( - "fake.agent.wrong_kind", - "Diagnostic belongs to another asset kind", - None, - ) - .with_asset_kind(ExternalSourceAssetKind::Tool), - ); - assert!(wrong_asset_kind.validate().is_err()); - - let mut excessive_sources = snapshot.clone(); - excessive_sources.sources = vec![snapshot.sources[0].clone(); 1025]; - assert!(excessive_sources.validate().is_err()); - - let mut excessive_definitions = snapshot.clone(); - excessive_definitions.definitions = vec![snapshot.definitions[0].clone(); 1025]; - assert!(excessive_definitions.validate().is_err()); - - let mut excessive_diagnostics = snapshot.clone(); - excessive_diagnostics.diagnostics = vec![ - ExternalSourceDiagnostic::warning( - "fake.agent.degraded", - "An optional field is not supported", - None, - ) - .with_asset_kind(ExternalSourceAssetKind::Subagent); - 1025 - ]; - assert!(excessive_diagnostics.validate().is_err()); - - let mut excessive_provenance = snapshot.clone(); - excessive_provenance.definitions[0].provenance = - vec![snapshot.definitions[0].provenance[0].clone(); 257]; - assert!(excessive_provenance.validate().is_err()); - - let input = ExternalSubagentDiscoveryInput { - context: context(), - suppressed_sources: [SourceKey::new("fake.agents", "suppressed").unwrap()] - .into_iter() - .collect(), - }; - assert_eq!(input.suppressed_sources.len(), 1); -} - -#[test] -fn external_subagent_model_contract_preserves_control_and_opaque_reference_semantics() { - let requests = [ - ExternalSubagentModelRequest::Default, - ExternalSubagentModelRequest::Inherit, - ExternalSubagentModelRequest::Reference { - provider_hint: Some("openrouter".to_string()), - model_name: "anthropic/claude-sonnet-4".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "gpt-5.6-codex".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "glm-5".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "deepseek-v4".to_string(), - }, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "future-model-that-does-not-exist-yet".to_string(), - }, - ]; - - for request in requests { - let encoded = serde_json::to_value(&request).unwrap(); - if let ExternalSubagentModelRequest::Reference { - provider_hint, - model_name, - } = &request - { - assert_eq!(encoded["modelName"], model_name.as_str()); - assert!(encoded.get("model_name").is_none()); - if let Some(provider_hint) = provider_hint { - assert_eq!(encoded["providerHint"], provider_hint.as_str()); - assert!(encoded.get("provider_hint").is_none()); - } - } - let decoded: ExternalSubagentModelRequest = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, request); - } - - assert_ne!( - ExternalSubagentModelRequest::Inherit, - ExternalSubagentModelRequest::Reference { - provider_hint: None, - model_name: "inherit".to_string(), - } - ); -} - -#[test] -fn external_subagent_model_profile_contract_keeps_variant_and_effort_semantics_distinct() { - let profiles = [ - ExternalSubagentModelProfileRequest::NamedVariant { - name: "high".to_string(), - }, - ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "high".to_string(), - }, - ]; - - let encoded = profiles - .iter() - .map(|profile| serde_json::to_value(profile).unwrap()) - .collect::>(); - assert_eq!( - encoded[0], - serde_json::json!({ "kind": "named_variant", "name": "high" }) - ); - assert_eq!( - encoded[1], - serde_json::json!({ "kind": "reasoning_effort", "value": "high" }) - ); - assert_ne!(profiles[0], profiles[1]); - - for (profile, encoded) in profiles.into_iter().zip(encoded) { - let decoded: ExternalSubagentModelProfileRequest = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, profile); - } - - assert!(ExternalSubagentModelProfileRequest::NamedVariant { - name: "x".repeat(4097), - } - .validate() - .is_err()); - assert!(ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "bad\u{0001}".to_string(), - } - .validate() - .is_err()); -} - -#[test] -fn external_subagent_model_binding_contract_groups_only_matching_scope_identity() { - let ecosystem = EcosystemId::new("opencode").unwrap(); - let request = ExternalSubagentModelRequest::Reference { - provider_hint: Some("openrouter".to_string()), - model_name: "vendor/model".to_string(), - }; - let global_a = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::UserGlobal, - "D:/workspace/a", - ) - .unwrap(); - assert_eq!( - global_a, - "external_subagent_model_binding:408ebedb7c2644acda3b4c0c5a78e8eb83fb2ece8b3a1671a866ed0d6cc08f56", - "profile-free bindings must retain their pre-profile persisted identity" - ); - let global_b = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::UserGlobal, - "D:/workspace/b", - ) - .unwrap(); - assert_eq!( - global_a, global_b, - "user bindings belong to the execution domain" - ); - - let project_a = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap(); - let project_b = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "local-user", - ExternalSourceScope::Project, - "D:/workspace/b", - ) - .unwrap(); - assert_ne!( - project_a, project_b, - "project bindings stay workspace-scoped" - ); - assert_ne!( - global_a, project_a, - "global and project bindings never alias" - ); - let remote_global = external_subagent_model_binding_key( - &ecosystem, - &request, - None, - "remote:user@example", - ExternalSourceScope::RemoteUser, - "D:/workspace/a", - ) - .unwrap(); - assert_ne!( - global_a, remote_global, - "remote and local execution domains never share bindings" - ); - - let option = ExternalSubagentModelBindingOption { - target: ExternalSubagentModelBindingTarget::Primary, - effective_model_label: "Provider / Model".to_string(), - configured_reasoning_effort: Some("high".to_string()), - }; - let group = ExternalSubagentModelBindingGroup { - binding_key: project_a, - request, - profile_request: Some(ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "high".to_string(), - }), - scope: ExternalSourceScope::Project, - method: ExternalSubagentModelBindingMethod::Explicit, - selected_target: Some(option.target.clone()), - effective_model_label: Some(option.effective_model_label.clone()), - affected_candidate_ids: vec!["candidate-a".to_string(), "candidate-b".to_string()], - }; - let encoded = serde_json::to_value((&option, &group)).unwrap(); - let decoded: ( - ExternalSubagentModelBindingOption, - ExternalSubagentModelBindingGroup, - ) = serde_json::from_value(encoded).unwrap(); - assert_eq!(decoded, (option, group)); -} - -#[test] -fn external_subagent_profile_binding_identity_extends_existing_model_binding_scope() { - let ecosystem = EcosystemId::new("opencode").unwrap(); - let default_request = ExternalSubagentModelRequest::Default; - assert!(external_subagent_model_binding_key( - &ecosystem, - &default_request, - None, - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .is_none()); - - let variant = ExternalSubagentModelProfileRequest::NamedVariant { - name: "high".to_string(), - }; - let effort = ExternalSubagentModelProfileRequest::ReasoningEffort { - value: "high".to_string(), - }; - let variant_key = external_subagent_model_binding_key( - &ecosystem, - &default_request, - Some(&variant), - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap(); - let effort_key = external_subagent_model_binding_key( - &ecosystem, - &default_request, - Some(&effort), - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap(); - assert_ne!(variant_key, effort_key); - let delimited_provider = ExternalSubagentModelRequest::Reference { - provider_hint: Some("a:b".to_string()), - model_name: "c".to_string(), - }; - let delimited_model = ExternalSubagentModelRequest::Reference { - provider_hint: Some("a".to_string()), - model_name: "b:c".to_string(), - }; - let key_for = |request| { - external_subagent_model_binding_key( - &ecosystem, - request, - Some(&effort), - "local-user", - ExternalSourceScope::Project, - "D:/workspace/a", - ) - .unwrap() - }; - assert_ne!(key_for(&delimited_provider), key_for(&delimited_model)); -} - -#[test] -fn external_subagent_decision_keys_bind_behavior_but_not_catalog_copy() { - let candidate = ExternalSubagentCandidateId::new("candidate-v1").unwrap(); - let behavior = ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(); - let approval = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); - let same = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); - let changed = external_subagent_approval_key( - &candidate, - &ExternalSubagentBehaviorVersion::new("behavior-v2").unwrap(), - "envelope-v1", - ); - assert_eq!(approval, same); - assert_ne!(approval, changed); - - let first = external_subagent_conflict_key( - "local-user", - "/workspace", - "review", - [("local", "v1"), (candidate.as_str(), behavior.as_str())], - ); - let reordered = external_subagent_conflict_key( - "local-user", - "/workspace", - "REVIEW", - [(candidate.as_str(), behavior.as_str()), ("local", "v1")], - ); - assert_eq!(first, reordered); -} - -#[test] -fn diagnostics_remain_source_qualified() { - let diagnostic = ExternalSourceDiagnostic::warning( - "fake.warning", - "A non-blocking fake diagnostic", - Some(SourceKey::new("fake", "source").unwrap()), - ); - assert_eq!(diagnostic.source.unwrap().provider_id.as_str(), "fake"); -} - -#[test] -fn provider_snapshot_rejects_duplicate_sources_and_commands() { - let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); - let mut duplicate_source = provider.snapshot.clone(); - duplicate_source - .sources - .push(duplicate_source.sources[0].clone()); - assert!(duplicate_source.validate().is_err()); - - let mut duplicate_command = provider.snapshot; - duplicate_command - .commands - .push(duplicate_command.commands[0].clone()); - assert!(duplicate_command.validate().is_err()); -} - -#[test] -fn unavailable_command_must_be_unique_absent_and_source_qualified() { - let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); - let mut invalid = provider.snapshot; - invalid - .unavailable_command_ids - .push(invalid.commands[0].id.clone()); - assert!(invalid.validate().is_err()); -} - -#[test] -fn standalone_tool_contract_separates_static_preview_from_executable_source() { - let target = SourceQualifiedToolTargetId::new( - SourceKey::new("opencode.tools", "project-tools").unwrap(), - "weather.js", - ) - .unwrap(); - let tool = ExternalToolDefinition { - id: SourceQualifiedToolId::new(target, "default").unwrap(), - name: "weather".to_string(), - description_preview: "Get the weather for a location".to_string(), - module_path: "/workspace/.opencode/tools/weather.js".to_string(), - working_directory: "/workspace".to_string(), - runtime_kind: ExternalToolRuntimeKind::JavaScript, - capabilities: vec![ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ExternalToolCapability::Process, - ], - content_version: "sha256:v1".to_string(), - static_status: ExternalToolStaticStatus::Ready, - }; - - let encoded = serde_json::to_value(&tool).expect("serialize tool preview"); - assert_eq!(encoded["name"], "weather"); - assert_eq!(encoded["runtimeKind"], "java_script"); - assert!(encoded.get("moduleSource").is_none()); - assert!(encoded.get("payload").is_none()); - tool.validate().expect("valid standalone tool preview"); -} - -#[test] -fn legacy_public_snapshot_downprojects_new_tool_review_variants() { - let snapshot: ExternalSourcePublicSnapshot = serde_json::from_value(serde_json::json!({ - "generation": 1, - "discoveryPending": false, - "sources": [], - "commands": [{ - "candidateId": "17:opencode.commands6:global6:review", - "definition": { - "id": { - "source": { "providerId": "opencode.commands", "sourceId": "global" }, - "localId": "review" - }, - "name": "review", - "description": "Review changes", - "availability": { "state": "available" }, - "contentVersion": "v1" - } - }], - "tools": [{ - "definition": { - "id": { - "target": { - "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "localId": "weather.js" - }, - "exportId": "default" - }, - "name": "weather", - "descriptionPreview": "Get weather", - "modulePath": "/.opencode/tools/weather.js", - "workingDirectory": "", - "runtimeKind": "java_script", - "capabilities": [], - "contentVersion": "sha256:v1", - "staticStatus": { "state": "ready" } - }, - "approvalKey": "approval-v1", - "decisionKey": "decision-v1", - "activation": { "state": "declined" } - }], - "subagents": [{ - "candidateId": "external-review", - "logicalId": "review", - "displayName": "External Review", - "description": "Review changes", - "providerLabel": "OpenCode", - "scope": "project", - "sourceKeys": [], - "sourceLocationLabels": [], - "sourceCount": 1, - "requestedModel": { - "kind": "reference", - "providerHint": "anthropic", - "modelName": "claude-sonnet-4" - }, - "requestedModelProfile": { - "kind": "reasoning_effort", - "value": "high" - }, - "modelBindingMethod": "binding_required", - "modelBindingKey": "external_subagent_model_binding:review", - "effectiveToolLabels": ["Read"], - "unavailableToolLabels": ["Shell"], - "supportsFollowUp": false, - "compatibilityState": "blocked", - "diagnostics": [{ - "code": "external_subagent.tool_unavailable", - "blocksActivation": true - }], - "activationState": { "state": "blocked" }, - "decisionKey": "agent-decision-v1" - }], - "subagentModelBindingGroups": [{ - "bindingKey": "external_subagent_model_binding:review", - "request": { "kind": "reference", "modelName": "claude-sonnet-4" }, - "profileRequest": { "kind": "reasoning_effort", "value": "high" }, - "scope": "project", - "method": "binding_required", - "affectedCandidateIds": ["external-review"] - }], - "subagentModelBindingOptions": [{ - "target": { "kind": "fast" }, - "effectiveModelLabel": "Fast", - "configuredReasoningEffort": "high" - }] - })) - .expect("new public snapshot"); - - let legacy = - serde_json::to_value(snapshot.into_legacy_v0_compatible()).expect("legacy public snapshot"); - assert!(legacy["commands"][0].get("candidateId").is_none()); - assert_eq!(legacy["tools"][0]["activation"]["state"], "disabled"); - assert!(legacy["subagents"][0] - .get("unavailableToolLabels") - .is_none()); - assert!(legacy["subagents"][0].get("requestedModel").is_none()); - assert!(legacy["subagents"][0] - .get("requestedModelProfile") - .is_none()); - assert!(legacy["subagents"][0].get("modelBindingMethod").is_none()); - assert!(legacy["subagents"][0].get("modelBindingKey").is_none()); - assert!(legacy.get("subagentModelBindingGroups").is_none()); - assert!(legacy.get("subagentModelBindingOptions").is_none()); -} - -#[test] -fn standalone_tool_contract_rejects_names_that_are_not_model_callable() { - let target = SourceQualifiedToolTargetId::new( - SourceKey::new("fake.tools", "project-tools").unwrap(), - "unsafe.js", - ) - .unwrap(); - let mut tool = ExternalToolDefinition { - id: SourceQualifiedToolId::new(target, "default").unwrap(), - name: "unsafe tool".to_string(), - description_preview: String::new(), - module_path: "/workspace/unsafe.js".to_string(), - working_directory: "/workspace".to_string(), - runtime_kind: ExternalToolRuntimeKind::JavaScript, - capabilities: vec![ExternalToolCapability::FileSystem], - content_version: "sha256:v1".to_string(), - static_status: ExternalToolStaticStatus::Ready, - }; - - assert!(tool.validate().is_err()); - tool.name = "safe_tool-1".to_string(); - tool.validate() - .expect("portable tool name should be accepted"); -} - -#[test] -fn tool_approval_is_stable_for_safe_updates_but_changes_with_capabilities_or_domain() { - let target = SourceQualifiedToolTargetId::new( - SourceKey::new("opencode.tools", "project-tools").unwrap(), - "weather.js", - ) - .unwrap(); - let first = external_tool_approval_key( - "local-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ], - ); - let reordered = external_tool_approval_key( - "local-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::Network, - ExternalToolCapability::FileSystem, - ], - ); - let expanded = external_tool_approval_key( - "local-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ExternalToolCapability::Process, - ], - ); - let remote = external_tool_approval_key( - "remote-user", - &target, - ExternalToolRuntimeKind::JavaScript, - [ - ExternalToolCapability::FileSystem, - ExternalToolCapability::Network, - ], - ); - - assert_eq!(first, reordered); - assert_ne!(first, expanded); - assert_ne!(first, remote); -} - -#[test] -fn tool_conflict_choice_is_invalidated_when_name_or_candidate_changes() { - let first = external_tool_conflict_key( - "local-user", - "weather", - [ - ("builtin:weather", "builtin-v1"), - ("opencode:weather", "tool-v1"), - ], - ); - let reordered = external_tool_conflict_key( - "local-user", - "WEATHER", - [ - ("opencode:weather", "tool-v1"), - ("builtin:weather", "builtin-v1"), - ], - ); - let updated = external_tool_conflict_key( - "local-user", - "weather", - [ - ("builtin:weather", "builtin-v1"), - ("opencode:weather", "tool-v2"), - ], - ); - - assert_ne!(first, reordered); - assert_ne!(first, updated); -} - -#[test] -fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { - let source = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), - provenance: vec![source.key.clone()], - name: "github".to_string(), - transport: ExternalMcpTransportKind::StreamableHttp, - command_preview: None, - argument_count: 0, - working_directory: None, - environment_keys: Vec::new(), - environment_reference_names: Vec::new(), - remote_url_preview: Some("https://mcp.example.com/mcp".to_string()), - header_names: vec!["Authorization".to_string()], - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "sha256:behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let provider = - ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP servers") - .unwrap(); - let snapshot = ExternalMcpProviderSnapshot { - provider, - sources: vec![source], - servers: vec![definition.clone()], - diagnostics: Vec::new(), - }; - - snapshot.validate().expect("valid MCP provider snapshot"); - let encoded = serde_json::to_string(&snapshot).expect("serialize MCP snapshot"); - assert!(encoded.contains("Authorization")); - assert!(!encoded.contains("Bearer secret")); - assert!(encoded.contains("mcp.example.com")); - - let prepared = PreparedExternalMcpServer { - id: definition.id, - behavior_version: definition.behavior_version, - timeouts: ExternalMcpTimeouts::default(), - transport: PreparedExternalMcpTransport::Remote { - url: "https://mcp.example.com/mcp?token=url-secret".to_string(), - headers: [( - "Authorization".to_string(), - SecretValue::new("Bearer secret"), - )] - .into_iter() - .collect(), - oauth_enabled: true, - }, - }; - assert_eq!( - prepared.transport.remote_headers().unwrap()["Authorization"].expose(), - "Bearer secret" - ); - assert!(!format!("{prepared:?}").contains("Bearer secret")); - assert!(!format!("{prepared:?}").contains("url-secret")); -} - -#[test] -fn external_mcp_timeouts_are_positive_optional_millisecond_facts() { - let timeouts = ExternalMcpTimeouts { - startup_ms: Some(2_000), - catalog_ms: None, - execution_ms: Some(30_000), - }; - - timeouts.validate().expect("positive timeouts are valid"); - assert_eq!( - serde_json::to_value(&timeouts).unwrap(), - serde_json::json!({ - "startupMs": 2_000, - "executionMs": 30_000, - }) - ); - assert!(ExternalMcpTimeouts { - startup_ms: Some(0), - ..Default::default() - } - .validate() - .is_err()); - assert!(ExternalMcpTimeouts { - execution_ms: Some(9_007_199_254_740_991), - ..Default::default() - } - .validate() - .is_ok()); - assert!(ExternalMcpTimeouts { - execution_ms: Some(9_007_199_254_740_992), - ..Default::default() - } - .validate() - .is_err()); - assert!(ExternalMcpTimeouts::default().is_empty()); -} - -#[test] -fn external_mcp_revision_key_never_exposes_material_through_debug_output() { - let key = ExternalMcpRevisionKey::new([0x5a; 32]); - assert_eq!(format!("{key:?}"), "ExternalMcpRevisionKey([REDACTED])"); - assert!(!format!("{key:?}").contains("5a")); -} - -#[test] -fn external_mcp_revision_is_stable_secret_sensitive_and_not_an_unkeyed_oracle() { - let key = ExternalMcpRevisionKey::new([7; 32]); - let first = key.opaque_revision( - "test.mcp.behavior.v1", - [b"server".as_slice(), b"PIN=0007".as_slice()], - ); - let repeated = key.opaque_revision( - "test.mcp.behavior.v1", - [b"server".as_slice(), b"PIN=0007".as_slice()], - ); - let changed = key.opaque_revision( - "test.mcp.behavior.v1", - [b"server".as_slice(), b"PIN=0008".as_slice()], - ); - let raw_candidate = format!( - "sha256:{}", - hex::encode(Sha256::digest(b"server\0PIN=0007")) - ); - - assert_eq!(first, repeated); - assert_ne!(first, changed); - assert_ne!(first, raw_candidate); - assert!(first.starts_with("hmac-sha256:")); -} - -#[test] -fn external_mcp_snapshot_rejects_cross_provider_and_duplicate_servers() { - let provider = - ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP").unwrap(); - let source = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), - provenance: vec![source.key.clone()], - name: "github".to_string(), - transport: ExternalMcpTransportKind::LocalStdio, - command_preview: Some("npx".to_string()), - argument_count: 2, - working_directory: Some("/workspace".to_string()), - environment_keys: vec!["GITHUB_TOKEN".to_string()], - environment_reference_names: Vec::new(), - remote_url_preview: None, - header_names: Vec::new(), - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "sha256:behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let snapshot = ExternalMcpProviderSnapshot { - provider, - sources: vec![source], - servers: vec![definition.clone(), definition], - diagnostics: Vec::new(), - }; - - assert!(snapshot.validate().is_err()); - - let input = ExternalMcpDiscoveryInput { - context: context(), - suppressed_sources: [SourceKey::new("opencode.mcp", "suppressed").unwrap()] - .into_iter() - .collect(), - revision_key: ExternalMcpRevisionKey::new([7; 32]), - }; - assert_eq!(input.suppressed_sources.len(), 1); -} - -#[test] -fn external_mcp_decisions_change_only_with_behavior_domain_or_conflict_participants() { - let id = SourceQualifiedMcpServerId::new( - SourceKey::new("opencode.mcp", "project-config").unwrap(), - "github", - ) - .unwrap(); - let first = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); - let same = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); - let updated = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v2"); - let other_workspace = - external_mcp_approval_key("local-user", "/workspace-b", &id, "behavior-v1"); - let remote = external_mcp_approval_key("remote-user", "/workspace-a", &id, "behavior-v1"); - assert_eq!(first, same); - assert_ne!(first, updated); - assert_ne!(first, other_workspace); - assert_ne!(first, remote); - - let stable_id = id.stable_key(); - let conflict = external_mcp_conflict_key( - "local-user", - "/workspace-a", - "github", - [ - ("bitfun:github", "native-v1"), - (stable_id.as_str(), "behavior-v1"), - ], - ); - let reordered = external_mcp_conflict_key( - "local-user", - "/workspace-a", - "GITHUB", - [ - (stable_id.as_str(), "behavior-v1"), - ("bitfun:github", "native-v1"), - ], - ); - let participant_updated = external_mcp_conflict_key( - "local-user", - "/workspace-a", - "github", - [ - ("bitfun:github", "native-v1"), - (stable_id.as_str(), "behavior-v2"), - ], - ); - assert_eq!(conflict, reordered); - assert_ne!(conflict, participant_updated); - assert_ne!( - conflict, - external_mcp_conflict_key( - "local-user", - "/workspace-b", - "github", - [ - ("bitfun:github", "native-v1"), - (stable_id.as_str(), "behavior-v1"), - ], - ) - ); -} - -#[test] -fn external_mcp_product_view_is_version_guarded_and_contains_only_disclosed_fields() { - let source = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), - provenance: vec![source.key], - name: "github".to_string(), - transport: ExternalMcpTransportKind::LocalStdio, - command_preview: Some("npx".to_string()), - argument_count: 2, - working_directory: Some("".to_string()), - environment_keys: vec!["GITHUB_TOKEN".to_string()], - environment_reference_names: Vec::new(), - remote_url_preview: None, - header_names: Vec::new(), - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "sha256:behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let entry = ExternalMcpCatalogEntry { - candidate_id: definition.candidate_id(), - definition: definition.clone(), - approval_key: "external_mcp_approval:local-user:v1".to_string(), - decision_key: "external_mcp_approval:local-user:v1".to_string(), - runtime_id: None, - activation_state: ExternalMcpActivationState::ApprovalRequired, - }; - let request = ExternalMcpApprovalRequest { - candidate_id: entry.candidate_id.clone(), - approval_key: entry.approval_key.clone(), - decision_key: entry.decision_key.clone(), - definition, - }; - let conflict = ExternalMcpConflict { - conflict_key: "external_mcp:local-user:github:v1".to_string(), - server_name: "github".to_string(), - candidates: vec![ - ExternalMcpConflictCandidate { - candidate_id: "native_mcp:github".to_string(), - display_name: "BitFun: github".to_string(), - external: false, - source: None, - behavior_version: "native-v1".to_string(), - available: true, - unavailable_reason: None, - }, - ExternalMcpConflictCandidate { - candidate_id: entry.candidate_id.clone(), - display_name: "OpenCode: github".to_string(), - external: true, - source: Some(entry.definition.id.source.clone()), - behavior_version: entry.definition.behavior_version.clone(), - available: true, - unavailable_reason: None, - }, - ], - selected_candidate_id: None, - }; - - let encoded = serde_json::to_string(&(entry, request, conflict)).unwrap(); - assert!(encoded.contains("GITHUB_TOKEN")); - assert!(!encoded.contains("Bearer secret")); - assert!(encoded.contains("approval_required")); -} - -fn external_capability(value: &str) -> ExternalIntegrationCapabilityId { - ExternalIntegrationCapabilityId::new(value).expect("valid external capability id") -} - -const TEST_ECOSYSTEM_ID: &str = "test-ecosystem"; -const EXTERNAL_CAPABILITY_COMMAND: &str = "command"; -const EXTERNAL_CAPABILITY_TOOL: &str = "tool"; -const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; -const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; - -fn test_external_integration_ecosystems() -> Vec { - let capability = - |id, recommended_access, safety_ceiling| ExternalIntegrationCapabilityDescriptor { - capability_id: external_capability(id), - recommended_access, - safety_ceiling, - }; - vec![ExternalIntegrationEcosystemDescriptor { - ecosystem_id: EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(), - display_name: "Test ecosystem".to_string(), - adapter_revision: "1".to_string(), - capabilities: vec![ - capability( - EXTERNAL_CAPABILITY_COMMAND, - ExternalIntegrationAccess::Auto, - ExternalIntegrationAccess::Auto, - ), - capability( - EXTERNAL_CAPABILITY_TOOL, - ExternalIntegrationAccess::AskBeforeUse, - ExternalIntegrationAccess::AskBeforeUse, - ), - capability( - EXTERNAL_CAPABILITY_SUBAGENT, - ExternalIntegrationAccess::AskBeforeUse, - ExternalIntegrationAccess::AskBeforeUse, - ), - capability( - EXTERNAL_CAPABILITY_MCP, - ExternalIntegrationAccess::AskBeforeUse, - ExternalIntegrationAccess::AskBeforeUse, - ), - ], - }] -} - -#[test] -fn external_integration_policy_is_disabled_by_default() { - let effective = evaluate_external_integration_policy( - &ExternalIntegrationPolicyDocument::default(), - Some("workspace-a"), - &test_external_integration_ecosystems(), - ) - .expect("default policy evaluates"); - let opencode = effective - .ecosystems - .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) - .expect("test ecosystem is registered"); - - assert!(!effective.enabled); - assert_eq!(opencode.mode, ExternalIntegrationMode::Disabled); - for capability in [ - EXTERNAL_CAPABILITY_COMMAND, - EXTERNAL_CAPABILITY_TOOL, - EXTERNAL_CAPABILITY_SUBAGENT, - EXTERNAL_CAPABILITY_MCP, - ] { - assert_eq!( - opencode.capabilities[&external_capability(capability)], - ExternalIntegrationAccess::Disabled - ); - } -} - -#[test] -fn explicitly_enabled_recommended_policy_keeps_registered_access_defaults() { - let mut document = ExternalIntegrationPolicyDocument::default(); - document.user_defaults.enabled = true; - - let effective = evaluate_external_integration_policy( - &document, - Some("workspace-a"), - &test_external_integration_ecosystems(), - ) - .expect("enabled recommended policy evaluates"); - let opencode = effective - .ecosystems - .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) - .expect("test ecosystem is registered"); - - assert!(effective.enabled); - assert_eq!(opencode.mode, ExternalIntegrationMode::Recommended); - assert_eq!( - opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], - ExternalIntegrationAccess::Auto - ); - for capability in [ - EXTERNAL_CAPABILITY_TOOL, - EXTERNAL_CAPABILITY_SUBAGENT, - EXTERNAL_CAPABILITY_MCP, - ] { - assert_eq!( - opencode.capabilities[&external_capability(capability)], - ExternalIntegrationAccess::AskBeforeUse - ); - } -} - -#[test] -fn workspace_policy_overrides_only_the_fields_the_user_changed() { - let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); - let mut document = ExternalIntegrationPolicyDocument::default(); - document.user_defaults.enabled = true; - document.user_defaults.ecosystems.insert( - ecosystem.clone(), - ExternalEcosystemPolicy { - mode: ExternalIntegrationMode::DiscoverOnly, - ..ExternalEcosystemPolicy::default() - }, - ); - document.workspace_overrides.insert( - "workspace-a".to_string(), - ExternalIntegrationPolicyOverride { - ecosystems: [( - ecosystem.clone(), - ExternalEcosystemPolicyOverride { - mode: Some(ExternalIntegrationMode::Custom), - capability_overrides: [( - external_capability(EXTERNAL_CAPABILITY_COMMAND), - ExternalIntegrationAccess::Auto, - )] - .into_iter() - .collect(), - ..ExternalEcosystemPolicyOverride::default() - }, - )] - .into_iter() - .collect(), - ..ExternalIntegrationPolicyOverride::default() - }, - ); - - let effective = evaluate_external_integration_policy( - &document, - Some("workspace-a"), - &test_external_integration_ecosystems(), - ) - .unwrap(); - let opencode = &effective.ecosystems[&ecosystem]; - assert_eq!(opencode.mode, ExternalIntegrationMode::Custom); - assert_eq!( - opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], - ExternalIntegrationAccess::Auto - ); - assert_eq!( - opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_MCP)], - ExternalIntegrationAccess::DiscoverOnly - ); - - let inherited = evaluate_external_integration_policy( - &document, - Some("workspace-b"), - &test_external_integration_ecosystems(), - ) - .unwrap(); - assert_eq!( - inherited.ecosystems[&ecosystem].mode, - ExternalIntegrationMode::DiscoverOnly - ); -} - -#[test] -fn high_risk_auto_access_is_limited_by_the_capability_owner() { - let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); - let mcp = external_capability(EXTERNAL_CAPABILITY_MCP); - let mut document = ExternalIntegrationPolicyDocument::default(); - document.user_defaults.enabled = true; - document.user_defaults.ecosystems.insert( - ecosystem.clone(), - ExternalEcosystemPolicy { - mode: ExternalIntegrationMode::Custom, - capability_overrides: [(mcp.clone(), ExternalIntegrationAccess::Auto)] - .into_iter() - .collect(), - ..ExternalEcosystemPolicy::default() - }, - ); - - let effective = evaluate_external_integration_policy( - &document, - None, - &test_external_integration_ecosystems(), - ) - .unwrap(); - let opencode = &effective.ecosystems[&ecosystem]; - assert_eq!( - opencode.capabilities[&mcp], - ExternalIntegrationAccess::AskBeforeUse - ); - assert!(opencode.policy_limited_capabilities.contains(&mcp)); -} - -#[test] -fn future_policy_values_and_minor_fields_survive_read_modify_write() { - let raw = serde_json::json!({ - "schemaMajor": 1, - "userDefaults": { - "enabled": true, - "ecosystems": { - "opencode": { - "mode": "future_mode", - "capabilityOverrides": { - "future-capability": "future_access" - }, - "futureEcosystemField": { "enabled": true } - } - }, - "futureSettingsField": "preserve-me" - }, - "workspaceOverrides": {}, - "futureDocumentField": [1, 2, 3] - }); - let mut document: ExternalIntegrationPolicyDocument = - serde_json::from_value(raw.clone()).expect("future minor data remains readable"); - document.user_defaults.enabled = false; - let encoded = serde_json::to_value(&document).expect("policy remains serializable"); - - assert_eq!( - encoded["userDefaults"]["ecosystems"]["opencode"]["mode"], - "future_mode" - ); - assert_eq!( - encoded["userDefaults"]["ecosystems"]["opencode"]["capabilityOverrides"] - ["future-capability"], - "future_access" - ); - assert_eq!( - encoded["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"], - raw["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"] - ); - assert_eq!( - encoded["userDefaults"]["futureSettingsField"], - "preserve-me" - ); - assert_eq!(encoded["futureDocumentField"], raw["futureDocumentField"]); - - let effective = evaluate_external_integration_policy( - &document, - None, - &test_external_integration_ecosystems(), - ) - .unwrap(); - assert!(!effective.enabled); -} - -#[test] -fn incompatible_policy_schema_major_is_rejected_without_downgrade() { - let document = ExternalIntegrationPolicyDocument { - schema_major: 2, - ..ExternalIntegrationPolicyDocument::default() - }; - let error = evaluate_external_integration_policy( - &document, - None, - &test_external_integration_ecosystems(), - ) - .expect_err("future major schemas must fail closed"); - assert!(error.to_string().contains("schema major: 2")); -} - -#[test] -fn incompatible_policy_schema_has_a_safe_read_only_public_snapshot() { - let raw = serde_json::json!({ - "schemaMajor": 2, - "userDefaults": { - "enabled": true, - "futureSecretHostField": "persistence-only" - }, - "futureDocumentField": { "keep": true } - }); - let document: ExternalIntegrationPolicyDocument = serde_json::from_value(raw).unwrap(); - let snapshot = external_integration_policy_snapshot( - &document, - Some("workspace-a"), - test_external_integration_ecosystems(), - ) - .expect("incompatible schemas remain inspectable through a safe snapshot"); - - assert_eq!( - snapshot.status, - ExternalIntegrationPolicyStatus::IncompatibleSchema - ); - assert!(!snapshot.global_effective.enabled); - assert!(!snapshot.effective.enabled); - assert!(snapshot - .effective - .ecosystems - .values() - .all(|ecosystem| ecosystem - .capabilities - .values() - .all(|access| { matches!(access, ExternalIntegrationAccess::Disabled) }))); - - let public = serde_json::to_string(&snapshot).unwrap(); - assert!(!public.contains("futureSecretHostField")); - assert!(!public.contains("futureDocumentField")); - - let persisted = serde_json::to_string(&document).unwrap(); - assert!(persisted.contains("futureSecretHostField")); - assert!(persisted.contains("futureDocumentField")); -} - -#[test] -fn integration_registry_rejects_ambiguous_or_unsafe_descriptors() { - let mut duplicate_ecosystem = test_external_integration_ecosystems(); - duplicate_ecosystem.push(duplicate_ecosystem[0].clone()); - let duplicate_error = evaluate_external_integration_policy( - &ExternalIntegrationPolicyDocument::default(), - None, - &duplicate_ecosystem, - ) - .expect_err("duplicate ecosystem registrations must fail closed"); - assert!(duplicate_error.to_string().contains("duplicate ecosystem")); - - let mut unsafe_recommendation = test_external_integration_ecosystems(); - unsafe_recommendation[0].capabilities[1].recommended_access = ExternalIntegrationAccess::Auto; - let unsafe_error = evaluate_external_integration_policy( - &ExternalIntegrationPolicyDocument::default(), - None, - &unsafe_recommendation, - ) - .expect_err("registry defaults cannot exceed their safety ceiling"); - assert!(unsafe_error - .to_string() - .contains("exceeds the safety ceiling")); -} - -#[test] -fn public_snapshot_never_exposes_executable_prompt_templates() { - let snapshot = ExternalSourceCatalogSnapshot { - generation: 1, - discovery_pending: false, - sources: Vec::new(), - commands: vec![PromptCommandCatalogEntry { - definition: command("opencode", "project-commands", 1), - }], - command_conflicts: Vec::new(), - tools: Vec::new(), - tool_approval_requests: Vec::new(), - tool_conflicts: Vec::new(), - mcp_generation: 0, - mcp_servers: Vec::new(), - mcp_approval_requests: Vec::new(), - mcp_conflicts: Vec::new(), - subagent_generation: 0, - preference_revision: 0, - subagents: Vec::new(), - subagent_model_binding_groups: vec![ExternalSubagentModelBindingGroup { - binding_key: "external_subagent_model_binding:review".to_string(), - request: ExternalSubagentModelRequest::Reference { - provider_hint: Some("anthropic".to_string()), - model_name: "claude-sonnet-4".to_string(), - }, - profile_request: None, - scope: ExternalSourceScope::Project, - method: ExternalSubagentModelBindingMethod::BindingRequired, - selected_target: None, - effective_model_label: None, - affected_candidate_ids: vec!["opencode-review".to_string()], - }], - subagent_model_binding_options: vec![ExternalSubagentModelBindingOption { - target: ExternalSubagentModelBindingTarget::Fast, - effective_model_label: "GLM-4.5-Air".to_string(), - configured_reasoning_effort: None, - }], - subagent_conflicts: Vec::new(), - pending_subagent_approvals: Vec::new(), - integration_policy: Default::default(), - diagnostics: Vec::new(), - }; - - let public = ExternalSourcePublicSnapshot::from(snapshot); - let encoded = serde_json::to_value(public).expect("serialize public projection"); - - assert_eq!(encoded["commands"][0]["definition"]["name"], "review"); - assert!(encoded["commands"][0]["definition"] - .get("template") - .is_none()); - assert_eq!( - encoded["subagentModelBindingGroups"][0]["bindingKey"], - "external_subagent_model_binding:review" - ); - assert_eq!( - encoded["subagentModelBindingOptions"][0]["effectiveModelLabel"], - "GLM-4.5-Air" - ); -} - -#[test] -fn control_projection_keeps_lifecycle_facts_orthogonal() { - let catalog = ExternalSourceCatalogSnapshot { - generation: 7, - discovery_pending: false, - sources: vec![ExternalSourceCatalogEntry { - stable_key: "opencode.commands:project".to_string(), - presentation_group_id: None, - record: source("opencode.commands", "opencode", "project"), - lifecycle: ExternalSourceLifecycleState::UsingLastValidVersion, - }], - commands: vec![PromptCommandCatalogEntry { - definition: command("opencode.commands", "project", 1), - }], - command_conflicts: Vec::new(), - tools: Vec::new(), - tool_approval_requests: Vec::new(), - tool_conflicts: Vec::new(), - mcp_generation: 2, - mcp_servers: Vec::new(), - mcp_approval_requests: Vec::new(), - mcp_conflicts: Vec::new(), - subagent_generation: 3, - preference_revision: 11, - subagents: Vec::new(), - subagent_model_binding_groups: Vec::new(), - subagent_model_binding_options: Vec::new(), - subagent_conflicts: Vec::new(), - pending_subagent_approvals: Vec::new(), - integration_policy: Default::default(), - diagnostics: Vec::new(), - }; - - let control = ExternalSourceControlSnapshotV1::from_catalog( - &catalog, - ExecutionDomainId::new("local-user").unwrap(), - false, - ExternalSourceHostCapabilities::read_write(), - ); - - assert_eq!(control.schema_version, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1); - assert_eq!(control.refresh_generation, 7); - assert_eq!(control.preference_revision, 11); - assert_eq!(control.sources.len(), 1); - assert_eq!( - control.sources[0].discovery, - ExternalSourceDiscoveryState::LastKnownGood - ); - assert_eq!( - control.sources[0].desired, - ExternalSourceDesiredState::Enabled - ); - assert_eq!( - control.sources[0].review, - ExternalSourceReviewState::NotRequired - ); - assert_eq!(control.capabilities.len(), 4); -} - -#[test] -fn control_projection_does_not_infer_review_facts_from_runtime_activation() { - let record = source("opencode.mcp", "opencode", "project-config"); - let definition = ExternalMcpServerDefinition { - id: SourceQualifiedMcpServerId::new(record.key.clone(), "docs").unwrap(), - provenance: vec![record.key.clone()], - name: "docs".to_string(), - transport: ExternalMcpTransportKind::StreamableHttp, - command_preview: None, - argument_count: 0, - working_directory: None, - environment_keys: Vec::new(), - environment_reference_names: Vec::new(), - remote_url_preview: Some("https://mcp.example.com".to_string()), - header_names: Vec::new(), - timeouts: ExternalMcpTimeouts::default(), - source_enabled: true, - behavior_version: "behavior-v1".to_string(), - static_status: ExternalMcpStaticStatus::Ready, - }; - let catalog = ExternalSourceCatalogSnapshot { - generation: 1, - discovery_pending: false, - sources: vec![ExternalSourceCatalogEntry { - stable_key: "opencode.mcp:project-config".to_string(), - presentation_group_id: None, - record: record.clone(), - lifecycle: ExternalSourceLifecycleState::Available, - }], - commands: Vec::new(), - command_conflicts: Vec::new(), - tools: Vec::new(), - tool_approval_requests: Vec::new(), - tool_conflicts: Vec::new(), - mcp_generation: 1, - mcp_servers: vec![ExternalMcpCatalogEntry { - candidate_id: "external_mcp:docs".to_string(), - definition, - approval_key: "approval-v1".to_string(), - decision_key: "decision-v1".to_string(), - runtime_id: None, - activation_state: ExternalMcpActivationState::Declined, - }], - mcp_approval_requests: Vec::new(), - mcp_conflicts: Vec::new(), - subagent_generation: 1, - preference_revision: 1, - subagents: Vec::new(), - subagent_model_binding_groups: Vec::new(), - subagent_model_binding_options: Vec::new(), - subagent_conflicts: Vec::new(), - pending_subagent_approvals: Vec::new(), - integration_policy: Default::default(), - diagnostics: Vec::new(), - }; - - let control = ExternalSourceControlSnapshotV1::from_catalog( - &catalog, - ExecutionDomainId::new("local-user").unwrap(), - false, - ExternalSourceHostCapabilities::read_write(), - ); - - assert_eq!( - control.sources[0].review, - ExternalSourceReviewState::NotRequired - ); -} - -#[test] -fn desktop_local_host_capability_is_additive_on_the_wire() { - let portable = serde_json::to_value(ExternalSourceHostCapabilities::read_write()).unwrap(); - let read_only = - serde_json::to_value(ExternalSourceHostCapabilities::read_only_projection()).unwrap(); - let desktop = serde_json::to_value(ExternalSourceHostCapabilities::local_desktop()).unwrap(); - - assert!(portable.get("canRevealSourceLocation").is_none()); - assert!(read_only.get("canRevealSourceLocation").is_none()); - assert_eq!(desktop["canRevealSourceLocation"], true); - - let legacy: ExternalSourceHostCapabilities = serde_json::from_value(serde_json::json!({ - "canRefresh": true, - "canMutatePolicy": true, - "canManageSources": true, - "canApproveRuntime": true, - "canExecuteExternalAssets": true, - "canSetSafeMode": true - })) - .unwrap(); - assert!(!legacy.can_reveal_source_location); -} - -#[test] -fn operation_error_round_trip_preserves_typed_recovery_without_message_parsing() { - let error = ExternalSourceOperationError::new( - ExternalSourceOperationErrorCode::StaleRevision, - "refresh required", - true, - ) - .with_stage(ExternalSourceOperationStage::ApplyPreference) - .with_causation_id("refresh-generation-7") - .with_recovery_action(ExternalSourceRecoveryActionV1::Refresh); - - let encoded = error.encode(); - assert_eq!(ExternalSourceOperationError::decode(&encoded), Some(error)); - assert!(!encoded.contains("metadata")); -} - -#[test] -fn control_action_uses_one_camel_case_dto_across_product_surfaces() { - let request = ExternalSourceControlRequestV1 { - schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, - operation_id: "surface-operation-1".to_string(), - expected_preference_revision: Some(8), - action: ExternalSourceControlActionV1::SetSourceEnabled { - source_key: "opencode.commands:project".to_string(), - enabled: false, - }, - }; - - let encoded = serde_json::to_value(&request).expect("serialize control request"); - assert_eq!(encoded["schemaVersion"], 1); - assert_eq!(encoded["operationId"], "surface-operation-1"); - assert_eq!(encoded["expectedPreferenceRevision"], 8); - assert_eq!(encoded["action"]["type"], "set_source_enabled"); - assert_eq!(encoded["action"]["sourceKey"], "opencode.commands:project"); - assert!(encoded["action"].get("source_key").is_none()); - assert_eq!( - serde_json::from_value::(encoded) - .expect("deserialize the shared control request"), - request - ); -} - -#[test] -fn legacy_operation_errors_decode_with_empty_extension_fields() { - let decoded = ExternalSourceOperationError::decode( - r#"{"code":"unavailable","detail":"retry","retryable":true}"#, - ) - .expect("legacy operation error remains readable"); - - assert_eq!(decoded.code, ExternalSourceOperationErrorCode::Unavailable); - assert!(decoded.stage.is_none()); - assert!(decoded.causation_id.is_none()); - assert!(decoded.recovery_actions.is_empty()); -} - -#[test] -fn decoded_operation_errors_bound_untrusted_extension_fields() { - let oversized = "x".repeat(5000); - let encoded = serde_json::json!({ - "code": "stale_revision", - "detail": oversized, - "retryable": true, - "correlationId": "forged\nreference", - "recoveryActions": [ - { "type": "refresh" }, - { "type": "refresh" }, - { "type": "retry" } - ] - }) - .to_string(); - - let decoded = ExternalSourceOperationError::decode(&encoded).unwrap(); - assert_eq!(decoded.detail.chars().count(), 4096); - assert!(decoded.correlation_id.is_none()); - assert_eq!( - decoded.recovery_actions, - vec![ - ExternalSourceRecoveryActionV1::Refresh, - ExternalSourceRecoveryActionV1::Retry, - ] - ); -} +#![cfg(feature = "external-sources")] + +#[path = "external_source_contracts/external_hook_catalog_contracts.rs"] +mod external_hook_catalog_contracts; +#[path = "external_source_contracts/external_hook_contribution_contracts.rs"] +mod external_hook_contribution_contracts; +#[path = "external_source_contracts/external_source_contracts.rs"] +mod external_source_contracts; +#[path = "external_source_contracts/workspace_reference_contracts.rs"] +mod workspace_reference_contracts; diff --git a/src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_catalog_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/external_hook_catalog_contracts.rs rename to src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_catalog_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/external_hook_contribution_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_contribution_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/external_hook_contribution_contracts.rs rename to src/crates/contracts/product-domains/tests/external_source_contracts/external_hook_contribution_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs new file mode 100644 index 000000000..e9f9cc074 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/external_source_contracts/external_source_contracts.rs @@ -0,0 +1,2074 @@ +use bitfun_product_domains::external_integration_policy::{ + evaluate_external_integration_policy, external_integration_policy_snapshot, + ExternalEcosystemPolicy, ExternalEcosystemPolicyOverride, ExternalIntegrationAccess, + ExternalIntegrationCapabilityDescriptor, ExternalIntegrationEcosystemDescriptor, + ExternalIntegrationMode, ExternalIntegrationPolicyDocument, ExternalIntegrationPolicyOverride, + ExternalIntegrationPolicyStatus, +}; +use bitfun_product_domains::external_source_control::{ + ExternalSourceControlActionV1, ExternalSourceControlRequestV1, ExternalSourceControlSnapshotV1, + ExternalSourceDesiredState, ExternalSourceDiscoveryState, ExternalSourceOperationStage, + ExternalSourceRecoveryActionV1, ExternalSourceReviewState, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, +}; +use bitfun_product_domains::external_sources::{ + external_mcp_approval_key, external_mcp_conflict_key, external_tool_approval_key, + external_tool_conflict_key, prompt_command_conflict_key, EcosystemId, ExecutionDomainId, + ExpandedPromptCommand, ExternalIntegrationCapabilityId, ExternalMcpActivationState, + ExternalMcpApprovalRequest, ExternalMcpCatalogEntry, ExternalMcpConflict, + ExternalMcpConflictCandidate, ExternalMcpDiscoveryInput, ExternalMcpImportApplyRequestV1, + ExternalMcpImportSelectionV1, ExternalMcpProviderIdentity, ExternalMcpProviderSnapshot, + ExternalMcpRevisionKey, ExternalMcpServerDefinition, ExternalMcpStaticStatus, + ExternalMcpTimeouts, ExternalMcpTransportKind, ExternalSourceAssetKind, + ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceHostCapabilities, + ExternalSourceLifecycleState, ExternalSourceOperationError, ExternalSourceOperationErrorCode, + ExternalSourceProviderError, ExternalSourcePublicSnapshot, ExternalSourceRecord, + ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, ExternalToolRuntimeKind, + ExternalToolStaticStatus, ExternalWatchRoot, NativePromptCommandDescriptor, + PreparedExternalMcpImportServer, PreparedExternalMcpImportTransport, PreparedExternalMcpServer, + PreparedExternalMcpTransport, PromptCommandAvailability, PromptCommandCatalogEntry, + PromptCommandDefinition, PromptCommandExpansion, PromptCommandProviderIdentity, + PromptCommandProviderSnapshot, PromptCommandSourceProvider, SecretValue, SourceKey, + SourceQualifiedCommandId, SourceQualifiedMcpServerId, SourceQualifiedToolId, + SourceQualifiedToolTargetId, +}; +use bitfun_product_domains::external_subagents::{ + external_subagent_approval_key, external_subagent_candidate_id, external_subagent_conflict_key, + external_subagent_model_binding_key, ExternalSubagentBehaviorVersion, + ExternalSubagentCandidateId, ExternalSubagentCompatibilityState, + ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentDefinition, + ExternalSubagentDiscoveryInput, ExternalSubagentLocalId, ExternalSubagentMode, + ExternalSubagentModelBindingGroup, ExternalSubagentModelBindingMethod, + ExternalSubagentModelBindingOption, ExternalSubagentModelBindingTarget, + ExternalSubagentModelProfileRequest, ExternalSubagentModelRequest, + ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, + ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, + SecretText, +}; +use bitfun_product_domains::tool_permissions::{ + PermissionConstraintLayer, PermissionEffect, PermissionRule, +}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; + +#[test] +fn native_prompt_command_descriptors_reject_external_candidate_namespaces() { + let descriptor = NativePromptCommandDescriptor { + command_name: "review".to_string(), + candidate_id: "opencode.commands:project:review".to_string(), + behavior_version: "v1".to_string(), + }; + + assert!(descriptor.validate().is_err()); +} + +#[test] +fn external_mcp_import_contract_keeps_private_values_out_of_debug_and_requests() { + let source = SourceKey::new("opencode.mcp", "user-config").unwrap(); + let prepared = PreparedExternalMcpImportServer { + id: SourceQualifiedMcpServerId::new(source, "docs").unwrap(), + behavior_version: "sha256:behavior-v1".to_string(), + transport: PreparedExternalMcpImportTransport::Local { + command: "secret-command".to_string(), + args: vec!["secret-argument".to_string()], + }, + }; + let debug = format!("{prepared:?}"); + assert!(!debug.contains("secret-command")); + assert!(!debug.contains("secret-argument")); + prepared.validate().unwrap(); + + let request = ExternalMcpImportApplyRequestV1 { + schema_version: 1, + plan_fingerprint: "sha256:plan-v1".to_string(), + selections: vec![ExternalMcpImportSelectionV1 { + candidate_id: "opencode:mcp:docs".to_string(), + requested_native_id: None, + }], + }; + request.validate().unwrap(); + let encoded = serde_json::to_string(&request).unwrap(); + assert!(!encoded.contains("command")); + assert!(!encoded.contains("argument")); +} + +#[test] +fn external_mcp_import_contract_rejects_urls_that_cannot_be_copied_losslessly() { + let prepared = |url: &str| PreparedExternalMcpImportServer { + id: SourceQualifiedMcpServerId::new( + SourceKey::new("codex.mcp", "user-config").unwrap(), + "docs", + ) + .unwrap(), + behavior_version: "sha256:behavior-v1".to_string(), + transport: PreparedExternalMcpImportTransport::Remote { + url: url.to_string(), + }, + }; + + prepared("https://docs.example.test/mcp") + .validate() + .unwrap(); + for url in [ + "http://docs.example.test/mcp", + "https://user@docs.example.test/mcp", + "https://user:secret@docs.example.test/mcp", + "https://docs.example.test/mcp?token=secret", + "https://docs.example.test/mcp#private", + ] { + assert!( + prepared(url).validate().is_err(), + "unexpectedly safe: {url}" + ); + } +} + +fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { + ExternalSourceRecord { + key: SourceKey::new(provider_id, source_id).expect("valid source key"), + ecosystem_id: EcosystemId::new(ecosystem_id).expect("valid ecosystem id"), + display_name: format!("{provider_id} commands"), + source_kind: "prompt_commands".to_string(), + scope: ExternalSourceScope::Project, + location: format!("/workspace/{provider_id}"), + execution_domain_id: ExecutionDomainId::new("local-user").expect("valid domain"), + health: ExternalSourceHealth::Available, + content_version: format!("{provider_id}-v1"), + diagnostics: Vec::new(), + } +} + +fn command(provider_id: &str, source_id: &str, precedence: i32) -> PromptCommandDefinition { + PromptCommandDefinition { + id: SourceQualifiedCommandId::new( + SourceKey::new(provider_id, source_id).unwrap(), + "review", + ) + .unwrap(), + name: "review".to_string(), + description: format!("Review from {provider_id}"), + template: format!("{provider_id}: $ARGUMENTS"), + shell_preference: None, + execution_target: Default::default(), + availability: PromptCommandAvailability::Available, + content_version: format!("command-v{precedence}"), + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +#[test] +fn opaque_ids_are_validated_without_closing_the_ecosystem_set() { + assert_eq!( + EcosystemId::new("future.product/v2") + .expect("future ecosystem ids remain open") + .as_str(), + "future.product/v2" + ); + assert!(EcosystemId::new(" ").is_err()); + assert!(ExecutionDomainId::new("domain\nwith-control").is_err()); +} + +#[test] +fn source_and_command_identity_remain_provider_qualified() { + let left = SourceQualifiedCommandId::new( + SourceKey::new("adapter-a", "project-commands").unwrap(), + "review", + ) + .unwrap(); + let right = SourceQualifiedCommandId::new( + SourceKey::new("adapter-b", "project-commands").unwrap(), + "review", + ) + .unwrap(); + + assert_ne!(left, right); + assert_ne!(left.stable_key(), right.stable_key()); +} + +#[test] +fn presentation_group_id_is_optional_and_uses_the_camel_case_wire_name() { + let mut entry = ExternalSourceCatalogEntry { + stable_key: "opencode.commands:project".to_string(), + presentation_group_id: None, + record: source("opencode.commands", "opencode", "project"), + lifecycle: ExternalSourceLifecycleState::Available, + }; + + let legacy_value = serde_json::to_value(&entry).unwrap(); + assert!(legacy_value.get("presentationGroupId").is_none()); + let legacy_entry: ExternalSourceCatalogEntry = serde_json::from_value(legacy_value).unwrap(); + assert!(legacy_entry.presentation_group_id.is_none()); + + entry.presentation_group_id = Some("external-source:[\"source\"]".to_string()); + let current_value = serde_json::to_value(&entry).unwrap(); + assert_eq!( + current_value["presentationGroupId"], + "external-source:[\"source\"]" + ); +} + +#[test] +fn conflict_fingerprint_is_order_independent_and_changes_with_content() { + let first = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v2")]); + let reordered = prompt_command_conflict_key("local-user", "REVIEW", [("b", "v2"), ("a", "v1")]); + let updated = prompt_command_conflict_key("local-user", "review", [("a", "v1"), ("b", "v3")]); + let remote = prompt_command_conflict_key("remote-user", "review", [("a", "v1"), ("b", "v2")]); + + assert_eq!(first, reordered); + assert_ne!(first, updated); + assert_ne!(first, remote); +} + +#[test] +fn prompt_commands_use_a_typed_contract_instead_of_an_arbitrary_asset_payload() { + let command = PromptCommandDefinition { + id: SourceQualifiedCommandId::new( + SourceKey::new("example-provider", "project-commands").unwrap(), + "review", + ) + .unwrap(), + name: "review".to_string(), + description: "Review the current change".to_string(), + template: "Review $ARGUMENTS".to_string(), + shell_preference: None, + execution_target: Default::default(), + availability: PromptCommandAvailability::Restricted { + reason: "Shell expansion is not supported yet".to_string(), + required_capabilities: vec!["command.shell".to_string()], + }, + content_version: "sha256:command-v1".to_string(), + }; + + let encoded = serde_json::to_value(&command).expect("serialize command contract"); + assert_eq!(encoded["name"], "review"); + assert_eq!(encoded["availability"]["state"], "restricted"); + assert!(encoded.get("payload").is_none()); +} + +struct FakeProvider { + identity: PromptCommandProviderIdentity, + snapshot: PromptCommandProviderSnapshot, +} + +impl FakeProvider { + fn new(provider_id: &str, ecosystem_id: &str, source_id: &str, precedence: i32) -> Self { + let identity = PromptCommandProviderIdentity::new( + provider_id, + ecosystem_id, + format!("{provider_id} display"), + ) + .unwrap(); + Self { + identity: identity.clone(), + snapshot: PromptCommandProviderSnapshot { + provider: identity, + sources: vec![source(provider_id, ecosystem_id, source_id)], + commands: vec![command(provider_id, source_id, precedence)], + unavailable_command_ids: Vec::new(), + diagnostics: Vec::new(), + }, + } + } +} + +impl PromptCommandSourceProvider for FakeProvider { + fn identity(&self) -> PromptCommandProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _context: &ExternalSourceContext, + ) -> Result { + Ok(self.snapshot.clone()) + } + + fn expand( + &self, + _context: &ExternalSourceContext, + command: &PromptCommandDefinition, + arguments: &str, + ) -> Result { + Ok(PromptCommandExpansion { + content: command.template.replace("$ARGUMENTS", arguments), + workspace_file_references: vec!["src/lib.rs".to_string()], + shell: None, + }) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + vec![ExternalWatchRoot { + path: context.workspace_root.clone().unwrap(), + recursive: true, + }] + } +} + +#[test] +fn capability_provider_contract_does_not_require_core_or_an_ecosystem_enum() { + let provider: Box = Box::new(FakeProvider::new( + "fake-provider", + "fake.ecosystem", + "project-commands", + 1, + )); + + let snapshot = provider.discover(&context()).expect("discover fake source"); + assert_eq!(snapshot.provider.ecosystem_id.as_str(), "fake.ecosystem"); + assert_eq!(provider.watch_roots(&context()).len(), 1); + let expansion = provider + .expand(&context(), &snapshot.commands[0], "change") + .expect("prepare fake command expansion"); + assert_eq!(expansion.content, "fake-provider: change"); + assert_eq!(expansion.workspace_file_references, ["src/lib.rs"]); + + let final_result = ExpandedPromptCommand { + content: expansion.content, + }; + assert_eq!( + serde_json::to_value(final_result).unwrap(), + serde_json::json!({"content": "fake-provider: change"}) + ); +} + +#[test] +fn persisted_source_preference_keys_round_trip_without_path_guessing() { + let record = source( + "provider.with.dots", + "fake.ecosystem", + "project/source:agents", + ); + assert_eq!( + ExternalSourceRecord::source_key_from_preference_key(&record.preference_key()), + Some(record.key) + ); + assert!(ExternalSourceRecord::source_key_from_preference_key("malformed").is_none()); +} + +#[test] +fn external_subagent_identity_preserves_ordered_provenance_and_separate_revisions() { + let provider = + ExternalSubagentProviderIdentity::new("fake.agents", "fake.ecosystem", "Fake Agents") + .unwrap(); + let first = ExternalSubagentContributionId::new( + SourceKey::new("fake.agents", "global-config").unwrap(), + ExternalSubagentLocalId::new("review").unwrap(), + ); + let second = ExternalSubagentContributionId::new( + SourceKey::new("fake.agents", "project-config").unwrap(), + ExternalSubagentLocalId::new("review").unwrap(), + ); + let provenance = vec![ + ExternalSubagentProvenanceRef { + contribution_id: first, + role: ExternalSubagentContributionRole::Base, + }, + ExternalSubagentProvenanceRef { + contribution_id: second, + role: ExternalSubagentContributionRole::Overlay, + }, + ]; + let candidate_id = external_subagent_candidate_id(&provider.provider_id, "review", &provenance); + let reversed = external_subagent_candidate_id( + &provider.provider_id, + "review", + &provenance.iter().cloned().rev().collect::>(), + ); + assert_ne!( + candidate_id, reversed, + "provenance order changes behavior identity" + ); + + let definition = ExternalSubagentDefinition { + candidate_id, + logical_id: "review".to_string(), + provenance, + display_name: "Review".to_string(), + description: "Reviews a change".to_string(), + prompt: SecretText::new("Review carefully"), + mode: ExternalSubagentMode::Subagent, + disabled: false, + hidden: false, + requested_model: ExternalSubagentModelRequest::Default, + requested_model_profile: None, + requested_tools: ExternalSubagentToolRequest { + selectors: vec![ExternalSubagentToolSelector { + source_name: "read".to_string(), + canonical_host_name: Some("Read".to_string()), + allowed: true, + }], + uses_conservative_default: false, + }, + permission_constraints: PermissionConstraintLayer::new(vec![PermissionRule::new( + "read", + "C:/sensitive/private/*", + PermissionEffect::Deny, + )]), + compatibility: ExternalSubagentCompatibilityState::Ready, + diagnostic_codes: Vec::new(), + behavior_version: ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(), + }; + assert_eq!(definition.prompt.expose(), "Review carefully"); + assert!(!format!("{definition:?}").contains("Review carefully")); + assert!(!format!("{definition:?}").contains("C:/sensitive/private")); + + let mut invalid_model = definition.clone(); + invalid_model.requested_model = ExternalSubagentModelRequest::Reference { + provider_hint: Some("fake\nprovider".to_string()), + model_name: "model".to_string(), + }; + assert!(invalid_model.validate().is_err()); + + let mut invalid_tool = definition.clone(); + invalid_tool.requested_tools.selectors[0].source_name = "read\nsecret".to_string(); + assert!(invalid_tool.validate().is_err()); + + let mut invalid_permission = definition.clone(); + invalid_permission.permission_constraints = + PermissionConstraintLayer::new(vec![PermissionRule::new( + "read\nsecret", + "*", + PermissionEffect::Deny, + )]); + assert!(invalid_permission.validate().is_err()); + + let mut invalid_diagnostic = definition.clone(); + invalid_diagnostic.diagnostic_codes = vec!["provider.invalid:raw-source-key".to_string()]; + assert!(invalid_diagnostic.validate().is_err()); + + let mut excessive_tools = definition.clone(); + excessive_tools.requested_tools.selectors = (0..257) + .map(|index| ExternalSubagentToolSelector { + source_name: format!("tool-{index}"), + canonical_host_name: None, + allowed: true, + }) + .collect(); + assert!(excessive_tools.validate().is_err()); + + let snapshot = ExternalSubagentProviderSnapshot { + provider, + sources: vec![ + source("fake.agents", "fake.ecosystem", "global-config"), + source("fake.agents", "fake.ecosystem", "project-config"), + ], + definitions: vec![definition], + diagnostics: Vec::new(), + }; + snapshot + .validate() + .expect("valid external subagent provider snapshot"); + + let source_key = snapshot.sources[0].key.clone(); + let mut valid_diagnostic = snapshot.clone(); + valid_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.degraded", + "An optional field is not supported", + Some(source_key), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + valid_diagnostic + .validate() + .expect("bounded provider diagnostics with a known source are valid"); + + let mut valid_source_diagnostic = snapshot.clone(); + let valid_source_key = valid_source_diagnostic.sources[0].key.clone(); + valid_source_diagnostic.sources[0].diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.source_degraded", + "This source has a recoverable warning", + Some(valid_source_key), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + valid_source_diagnostic + .validate() + .expect("source-owned diagnostics use the same provider contract"); + + let mut invalid_provider_diagnostic = snapshot.clone(); + invalid_provider_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent:raw-source", + "Invalid diagnostic code", + Some(SourceKey::new("other.agents", "project").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Command), + ); + assert!(invalid_provider_diagnostic.validate().is_err()); + + let mut wrong_provider_diagnostic = snapshot.clone(); + wrong_provider_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.invalid_source", + "Unknown provider source", + Some(SourceKey::new("other.agents", "project").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(wrong_provider_diagnostic.validate().is_err()); + + let mut unknown_source_diagnostic = snapshot.clone(); + unknown_source_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.unknown_source", + "Unknown source", + Some(SourceKey::new("fake.agents", "missing").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(unknown_source_diagnostic.validate().is_err()); + + let mut invalid_diagnostic_message = snapshot.clone(); + invalid_diagnostic_message.diagnostics.push( + ExternalSourceDiagnostic::warning("fake.agent.invalid_message", "invalid\nmessage", None) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(invalid_diagnostic_message.validate().is_err()); + + let mut wrong_asset_kind = snapshot.clone(); + wrong_asset_kind.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.wrong_kind", + "Diagnostic belongs to another asset kind", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Tool), + ); + assert!(wrong_asset_kind.validate().is_err()); + + let mut excessive_sources = snapshot.clone(); + excessive_sources.sources = vec![snapshot.sources[0].clone(); 1025]; + assert!(excessive_sources.validate().is_err()); + + let mut excessive_definitions = snapshot.clone(); + excessive_definitions.definitions = vec![snapshot.definitions[0].clone(); 1025]; + assert!(excessive_definitions.validate().is_err()); + + let mut excessive_diagnostics = snapshot.clone(); + excessive_diagnostics.diagnostics = vec![ + ExternalSourceDiagnostic::warning( + "fake.agent.degraded", + "An optional field is not supported", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent); + 1025 + ]; + assert!(excessive_diagnostics.validate().is_err()); + + let mut excessive_provenance = snapshot.clone(); + excessive_provenance.definitions[0].provenance = + vec![snapshot.definitions[0].provenance[0].clone(); 257]; + assert!(excessive_provenance.validate().is_err()); + + let input = ExternalSubagentDiscoveryInput { + context: context(), + suppressed_sources: [SourceKey::new("fake.agents", "suppressed").unwrap()] + .into_iter() + .collect(), + }; + assert_eq!(input.suppressed_sources.len(), 1); +} + +#[test] +fn external_subagent_model_contract_preserves_control_and_opaque_reference_semantics() { + let requests = [ + ExternalSubagentModelRequest::Default, + ExternalSubagentModelRequest::Inherit, + ExternalSubagentModelRequest::Reference { + provider_hint: Some("openrouter".to_string()), + model_name: "anthropic/claude-sonnet-4".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "gpt-5.6-codex".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "glm-5".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "deepseek-v4".to_string(), + }, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "future-model-that-does-not-exist-yet".to_string(), + }, + ]; + + for request in requests { + let encoded = serde_json::to_value(&request).unwrap(); + if let ExternalSubagentModelRequest::Reference { + provider_hint, + model_name, + } = &request + { + assert_eq!(encoded["modelName"], model_name.as_str()); + assert!(encoded.get("model_name").is_none()); + if let Some(provider_hint) = provider_hint { + assert_eq!(encoded["providerHint"], provider_hint.as_str()); + assert!(encoded.get("provider_hint").is_none()); + } + } + let decoded: ExternalSubagentModelRequest = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, request); + } + + assert_ne!( + ExternalSubagentModelRequest::Inherit, + ExternalSubagentModelRequest::Reference { + provider_hint: None, + model_name: "inherit".to_string(), + } + ); +} + +#[test] +fn external_subagent_model_profile_contract_keeps_variant_and_effort_semantics_distinct() { + let profiles = [ + ExternalSubagentModelProfileRequest::NamedVariant { + name: "high".to_string(), + }, + ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "high".to_string(), + }, + ]; + + let encoded = profiles + .iter() + .map(|profile| serde_json::to_value(profile).unwrap()) + .collect::>(); + assert_eq!( + encoded[0], + serde_json::json!({ "kind": "named_variant", "name": "high" }) + ); + assert_eq!( + encoded[1], + serde_json::json!({ "kind": "reasoning_effort", "value": "high" }) + ); + assert_ne!(profiles[0], profiles[1]); + + for (profile, encoded) in profiles.into_iter().zip(encoded) { + let decoded: ExternalSubagentModelProfileRequest = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, profile); + } + + assert!(ExternalSubagentModelProfileRequest::NamedVariant { + name: "x".repeat(4097), + } + .validate() + .is_err()); + assert!(ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "bad\u{0001}".to_string(), + } + .validate() + .is_err()); +} + +#[test] +fn external_subagent_model_binding_contract_groups_only_matching_scope_identity() { + let ecosystem = EcosystemId::new("opencode").unwrap(); + let request = ExternalSubagentModelRequest::Reference { + provider_hint: Some("openrouter".to_string()), + model_name: "vendor/model".to_string(), + }; + let global_a = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::UserGlobal, + "D:/workspace/a", + ) + .unwrap(); + assert_eq!( + global_a, + "external_subagent_model_binding:408ebedb7c2644acda3b4c0c5a78e8eb83fb2ece8b3a1671a866ed0d6cc08f56", + "profile-free bindings must retain their pre-profile persisted identity" + ); + let global_b = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::UserGlobal, + "D:/workspace/b", + ) + .unwrap(); + assert_eq!( + global_a, global_b, + "user bindings belong to the execution domain" + ); + + let project_a = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap(); + let project_b = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/b", + ) + .unwrap(); + assert_ne!( + project_a, project_b, + "project bindings stay workspace-scoped" + ); + assert_ne!( + global_a, project_a, + "global and project bindings never alias" + ); + let remote_global = external_subagent_model_binding_key( + &ecosystem, + &request, + None, + "remote:user@example", + ExternalSourceScope::RemoteUser, + "D:/workspace/a", + ) + .unwrap(); + assert_ne!( + global_a, remote_global, + "remote and local execution domains never share bindings" + ); + + let option = ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Primary, + effective_model_label: "Provider / Model".to_string(), + configured_reasoning_effort: Some("high".to_string()), + }; + let group = ExternalSubagentModelBindingGroup { + binding_key: project_a, + request, + profile_request: Some(ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "high".to_string(), + }), + scope: ExternalSourceScope::Project, + method: ExternalSubagentModelBindingMethod::Explicit, + selected_target: Some(option.target.clone()), + effective_model_label: Some(option.effective_model_label.clone()), + affected_candidate_ids: vec!["candidate-a".to_string(), "candidate-b".to_string()], + }; + let encoded = serde_json::to_value((&option, &group)).unwrap(); + let decoded: ( + ExternalSubagentModelBindingOption, + ExternalSubagentModelBindingGroup, + ) = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, (option, group)); +} + +#[test] +fn external_subagent_profile_binding_identity_extends_existing_model_binding_scope() { + let ecosystem = EcosystemId::new("opencode").unwrap(); + let default_request = ExternalSubagentModelRequest::Default; + assert!(external_subagent_model_binding_key( + &ecosystem, + &default_request, + None, + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .is_none()); + + let variant = ExternalSubagentModelProfileRequest::NamedVariant { + name: "high".to_string(), + }; + let effort = ExternalSubagentModelProfileRequest::ReasoningEffort { + value: "high".to_string(), + }; + let variant_key = external_subagent_model_binding_key( + &ecosystem, + &default_request, + Some(&variant), + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap(); + let effort_key = external_subagent_model_binding_key( + &ecosystem, + &default_request, + Some(&effort), + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap(); + assert_ne!(variant_key, effort_key); + let delimited_provider = ExternalSubagentModelRequest::Reference { + provider_hint: Some("a:b".to_string()), + model_name: "c".to_string(), + }; + let delimited_model = ExternalSubagentModelRequest::Reference { + provider_hint: Some("a".to_string()), + model_name: "b:c".to_string(), + }; + let key_for = |request| { + external_subagent_model_binding_key( + &ecosystem, + request, + Some(&effort), + "local-user", + ExternalSourceScope::Project, + "D:/workspace/a", + ) + .unwrap() + }; + assert_ne!(key_for(&delimited_provider), key_for(&delimited_model)); +} + +#[test] +fn external_subagent_decision_keys_bind_behavior_but_not_catalog_copy() { + let candidate = ExternalSubagentCandidateId::new("candidate-v1").unwrap(); + let behavior = ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(); + let approval = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); + let same = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); + let changed = external_subagent_approval_key( + &candidate, + &ExternalSubagentBehaviorVersion::new("behavior-v2").unwrap(), + "envelope-v1", + ); + assert_eq!(approval, same); + assert_ne!(approval, changed); + + let first = external_subagent_conflict_key( + "local-user", + "/workspace", + "review", + [("local", "v1"), (candidate.as_str(), behavior.as_str())], + ); + let reordered = external_subagent_conflict_key( + "local-user", + "/workspace", + "REVIEW", + [(candidate.as_str(), behavior.as_str()), ("local", "v1")], + ); + assert_eq!(first, reordered); +} + +#[test] +fn diagnostics_remain_source_qualified() { + let diagnostic = ExternalSourceDiagnostic::warning( + "fake.warning", + "A non-blocking fake diagnostic", + Some(SourceKey::new("fake", "source").unwrap()), + ); + assert_eq!(diagnostic.source.unwrap().provider_id.as_str(), "fake"); +} + +#[test] +fn provider_snapshot_rejects_duplicate_sources_and_commands() { + let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); + let mut duplicate_source = provider.snapshot.clone(); + duplicate_source + .sources + .push(duplicate_source.sources[0].clone()); + assert!(duplicate_source.validate().is_err()); + + let mut duplicate_command = provider.snapshot; + duplicate_command + .commands + .push(duplicate_command.commands[0].clone()); + assert!(duplicate_command.validate().is_err()); +} + +#[test] +fn unavailable_command_must_be_unique_absent_and_source_qualified() { + let provider = FakeProvider::new("fake", "fake.ecosystem", "project", 1); + let mut invalid = provider.snapshot; + invalid + .unavailable_command_ids + .push(invalid.commands[0].id.clone()); + assert!(invalid.validate().is_err()); +} + +#[test] +fn standalone_tool_contract_separates_static_preview_from_executable_source() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("opencode.tools", "project-tools").unwrap(), + "weather.js", + ) + .unwrap(); + let tool = ExternalToolDefinition { + id: SourceQualifiedToolId::new(target, "default").unwrap(), + name: "weather".to_string(), + description_preview: "Get the weather for a location".to_string(), + module_path: "/workspace/.opencode/tools/weather.js".to_string(), + working_directory: "/workspace".to_string(), + runtime_kind: ExternalToolRuntimeKind::JavaScript, + capabilities: vec![ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ExternalToolCapability::Process, + ], + content_version: "sha256:v1".to_string(), + static_status: ExternalToolStaticStatus::Ready, + }; + + let encoded = serde_json::to_value(&tool).expect("serialize tool preview"); + assert_eq!(encoded["name"], "weather"); + assert_eq!(encoded["runtimeKind"], "java_script"); + assert!(encoded.get("moduleSource").is_none()); + assert!(encoded.get("payload").is_none()); + tool.validate().expect("valid standalone tool preview"); +} + +#[test] +fn legacy_public_snapshot_downprojects_new_tool_review_variants() { + let snapshot: ExternalSourcePublicSnapshot = serde_json::from_value(serde_json::json!({ + "generation": 1, + "discoveryPending": false, + "sources": [], + "commands": [{ + "candidateId": "17:opencode.commands6:global6:review", + "definition": { + "id": { + "source": { "providerId": "opencode.commands", "sourceId": "global" }, + "localId": "review" + }, + "name": "review", + "description": "Review changes", + "availability": { "state": "available" }, + "contentVersion": "v1" + } + }], + "tools": [{ + "definition": { + "id": { + "target": { + "source": { "providerId": "opencode.tools", "sourceId": "project" }, + "localId": "weather.js" + }, + "exportId": "default" + }, + "name": "weather", + "descriptionPreview": "Get weather", + "modulePath": "/.opencode/tools/weather.js", + "workingDirectory": "", + "runtimeKind": "java_script", + "capabilities": [], + "contentVersion": "sha256:v1", + "staticStatus": { "state": "ready" } + }, + "approvalKey": "approval-v1", + "decisionKey": "decision-v1", + "activation": { "state": "declined" } + }], + "subagents": [{ + "candidateId": "external-review", + "logicalId": "review", + "displayName": "External Review", + "description": "Review changes", + "providerLabel": "OpenCode", + "scope": "project", + "sourceKeys": [], + "sourceLocationLabels": [], + "sourceCount": 1, + "requestedModel": { + "kind": "reference", + "providerHint": "anthropic", + "modelName": "claude-sonnet-4" + }, + "requestedModelProfile": { + "kind": "reasoning_effort", + "value": "high" + }, + "modelBindingMethod": "binding_required", + "modelBindingKey": "external_subagent_model_binding:review", + "effectiveToolLabels": ["Read"], + "unavailableToolLabels": ["Shell"], + "supportsFollowUp": false, + "compatibilityState": "blocked", + "diagnostics": [{ + "code": "external_subagent.tool_unavailable", + "blocksActivation": true + }], + "activationState": { "state": "blocked" }, + "decisionKey": "agent-decision-v1" + }], + "subagentModelBindingGroups": [{ + "bindingKey": "external_subagent_model_binding:review", + "request": { "kind": "reference", "modelName": "claude-sonnet-4" }, + "profileRequest": { "kind": "reasoning_effort", "value": "high" }, + "scope": "project", + "method": "binding_required", + "affectedCandidateIds": ["external-review"] + }], + "subagentModelBindingOptions": [{ + "target": { "kind": "fast" }, + "effectiveModelLabel": "Fast", + "configuredReasoningEffort": "high" + }] + })) + .expect("new public snapshot"); + + let legacy = + serde_json::to_value(snapshot.into_legacy_v0_compatible()).expect("legacy public snapshot"); + assert!(legacy["commands"][0].get("candidateId").is_none()); + assert_eq!(legacy["tools"][0]["activation"]["state"], "disabled"); + assert!(legacy["subagents"][0] + .get("unavailableToolLabels") + .is_none()); + assert!(legacy["subagents"][0].get("requestedModel").is_none()); + assert!(legacy["subagents"][0] + .get("requestedModelProfile") + .is_none()); + assert!(legacy["subagents"][0].get("modelBindingMethod").is_none()); + assert!(legacy["subagents"][0].get("modelBindingKey").is_none()); + assert!(legacy.get("subagentModelBindingGroups").is_none()); + assert!(legacy.get("subagentModelBindingOptions").is_none()); +} + +#[test] +fn standalone_tool_contract_rejects_names_that_are_not_model_callable() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("fake.tools", "project-tools").unwrap(), + "unsafe.js", + ) + .unwrap(); + let mut tool = ExternalToolDefinition { + id: SourceQualifiedToolId::new(target, "default").unwrap(), + name: "unsafe tool".to_string(), + description_preview: String::new(), + module_path: "/workspace/unsafe.js".to_string(), + working_directory: "/workspace".to_string(), + runtime_kind: ExternalToolRuntimeKind::JavaScript, + capabilities: vec![ExternalToolCapability::FileSystem], + content_version: "sha256:v1".to_string(), + static_status: ExternalToolStaticStatus::Ready, + }; + + assert!(tool.validate().is_err()); + tool.name = "safe_tool-1".to_string(); + tool.validate() + .expect("portable tool name should be accepted"); +} + +#[test] +fn tool_approval_is_stable_for_safe_updates_but_changes_with_capabilities_or_domain() { + let target = SourceQualifiedToolTargetId::new( + SourceKey::new("opencode.tools", "project-tools").unwrap(), + "weather.js", + ) + .unwrap(); + let first = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ], + ); + let reordered = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::Network, + ExternalToolCapability::FileSystem, + ], + ); + let expanded = external_tool_approval_key( + "local-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ExternalToolCapability::Process, + ], + ); + let remote = external_tool_approval_key( + "remote-user", + &target, + ExternalToolRuntimeKind::JavaScript, + [ + ExternalToolCapability::FileSystem, + ExternalToolCapability::Network, + ], + ); + + assert_eq!(first, reordered); + assert_ne!(first, expanded); + assert_ne!(first, remote); +} + +#[test] +fn tool_conflict_choice_is_invalidated_when_name_or_candidate_changes() { + let first = external_tool_conflict_key( + "local-user", + "weather", + [ + ("builtin:weather", "builtin-v1"), + ("opencode:weather", "tool-v1"), + ], + ); + let reordered = external_tool_conflict_key( + "local-user", + "WEATHER", + [ + ("opencode:weather", "tool-v1"), + ("builtin:weather", "builtin-v1"), + ], + ); + let updated = external_tool_conflict_key( + "local-user", + "weather", + [ + ("builtin:weather", "builtin-v1"), + ("opencode:weather", "tool-v2"), + ], + ); + + assert_ne!(first, reordered); + assert_ne!(first, updated); +} + +#[test] +fn external_mcp_contract_keeps_runtime_secrets_out_of_static_snapshots() { + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key.clone()], + name: "github".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://mcp.example.com/mcp".to_string()), + header_names: vec!["Authorization".to_string()], + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let provider = + ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP servers") + .unwrap(); + let snapshot = ExternalMcpProviderSnapshot { + provider, + sources: vec![source], + servers: vec![definition.clone()], + diagnostics: Vec::new(), + }; + + snapshot.validate().expect("valid MCP provider snapshot"); + let encoded = serde_json::to_string(&snapshot).expect("serialize MCP snapshot"); + assert!(encoded.contains("Authorization")); + assert!(!encoded.contains("Bearer secret")); + assert!(encoded.contains("mcp.example.com")); + + let prepared = PreparedExternalMcpServer { + id: definition.id, + behavior_version: definition.behavior_version, + timeouts: ExternalMcpTimeouts::default(), + transport: PreparedExternalMcpTransport::Remote { + url: "https://mcp.example.com/mcp?token=url-secret".to_string(), + headers: [( + "Authorization".to_string(), + SecretValue::new("Bearer secret"), + )] + .into_iter() + .collect(), + oauth_enabled: true, + }, + }; + assert_eq!( + prepared.transport.remote_headers().unwrap()["Authorization"].expose(), + "Bearer secret" + ); + assert!(!format!("{prepared:?}").contains("Bearer secret")); + assert!(!format!("{prepared:?}").contains("url-secret")); +} + +#[test] +fn external_mcp_timeouts_are_positive_optional_millisecond_facts() { + let timeouts = ExternalMcpTimeouts { + startup_ms: Some(2_000), + catalog_ms: None, + execution_ms: Some(30_000), + }; + + timeouts.validate().expect("positive timeouts are valid"); + assert_eq!( + serde_json::to_value(&timeouts).unwrap(), + serde_json::json!({ + "startupMs": 2_000, + "executionMs": 30_000, + }) + ); + assert!(ExternalMcpTimeouts { + startup_ms: Some(0), + ..Default::default() + } + .validate() + .is_err()); + assert!(ExternalMcpTimeouts { + execution_ms: Some(9_007_199_254_740_991), + ..Default::default() + } + .validate() + .is_ok()); + assert!(ExternalMcpTimeouts { + execution_ms: Some(9_007_199_254_740_992), + ..Default::default() + } + .validate() + .is_err()); + assert!(ExternalMcpTimeouts::default().is_empty()); +} + +#[test] +fn external_mcp_revision_key_never_exposes_material_through_debug_output() { + let key = ExternalMcpRevisionKey::new([0x5a; 32]); + assert_eq!(format!("{key:?}"), "ExternalMcpRevisionKey([REDACTED])"); + assert!(!format!("{key:?}").contains("5a")); +} + +#[test] +fn external_mcp_revision_is_stable_secret_sensitive_and_not_an_unkeyed_oracle() { + let key = ExternalMcpRevisionKey::new([7; 32]); + let first = key.opaque_revision( + "test.mcp.behavior.v1", + [b"server".as_slice(), b"PIN=0007".as_slice()], + ); + let repeated = key.opaque_revision( + "test.mcp.behavior.v1", + [b"server".as_slice(), b"PIN=0007".as_slice()], + ); + let changed = key.opaque_revision( + "test.mcp.behavior.v1", + [b"server".as_slice(), b"PIN=0008".as_slice()], + ); + let raw_candidate = format!( + "sha256:{}", + hex::encode(Sha256::digest(b"server\0PIN=0007")) + ); + + assert_eq!(first, repeated); + assert_ne!(first, changed); + assert_ne!(first, raw_candidate); + assert!(first.starts_with("hmac-sha256:")); +} + +#[test] +fn external_mcp_snapshot_rejects_cross_provider_and_duplicate_servers() { + let provider = + ExternalMcpProviderIdentity::new("opencode.mcp", "opencode", "OpenCode MCP").unwrap(); + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key.clone()], + name: "github".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("npx".to_string()), + argument_count: 2, + working_directory: Some("/workspace".to_string()), + environment_keys: vec!["GITHUB_TOKEN".to_string()], + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let snapshot = ExternalMcpProviderSnapshot { + provider, + sources: vec![source], + servers: vec![definition.clone(), definition], + diagnostics: Vec::new(), + }; + + assert!(snapshot.validate().is_err()); + + let input = ExternalMcpDiscoveryInput { + context: context(), + suppressed_sources: [SourceKey::new("opencode.mcp", "suppressed").unwrap()] + .into_iter() + .collect(), + revision_key: ExternalMcpRevisionKey::new([7; 32]), + }; + assert_eq!(input.suppressed_sources.len(), 1); +} + +#[test] +fn external_mcp_decisions_change_only_with_behavior_domain_or_conflict_participants() { + let id = SourceQualifiedMcpServerId::new( + SourceKey::new("opencode.mcp", "project-config").unwrap(), + "github", + ) + .unwrap(); + let first = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); + let same = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v1"); + let updated = external_mcp_approval_key("local-user", "/workspace-a", &id, "behavior-v2"); + let other_workspace = + external_mcp_approval_key("local-user", "/workspace-b", &id, "behavior-v1"); + let remote = external_mcp_approval_key("remote-user", "/workspace-a", &id, "behavior-v1"); + assert_eq!(first, same); + assert_ne!(first, updated); + assert_ne!(first, other_workspace); + assert_ne!(first, remote); + + let stable_id = id.stable_key(); + let conflict = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v1"), + ], + ); + let reordered = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "GITHUB", + [ + (stable_id.as_str(), "behavior-v1"), + ("bitfun:github", "native-v1"), + ], + ); + let participant_updated = external_mcp_conflict_key( + "local-user", + "/workspace-a", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v2"), + ], + ); + assert_eq!(conflict, reordered); + assert_ne!(conflict, participant_updated); + assert_ne!( + conflict, + external_mcp_conflict_key( + "local-user", + "/workspace-b", + "github", + [ + ("bitfun:github", "native-v1"), + (stable_id.as_str(), "behavior-v1"), + ], + ) + ); +} + +#[test] +fn external_mcp_product_view_is_version_guarded_and_contains_only_disclosed_fields() { + let source = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(source.key.clone(), "github").unwrap(), + provenance: vec![source.key], + name: "github".to_string(), + transport: ExternalMcpTransportKind::LocalStdio, + command_preview: Some("npx".to_string()), + argument_count: 2, + working_directory: Some("".to_string()), + environment_keys: vec!["GITHUB_TOKEN".to_string()], + environment_reference_names: Vec::new(), + remote_url_preview: None, + header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "sha256:behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let entry = ExternalMcpCatalogEntry { + candidate_id: definition.candidate_id(), + definition: definition.clone(), + approval_key: "external_mcp_approval:local-user:v1".to_string(), + decision_key: "external_mcp_approval:local-user:v1".to_string(), + runtime_id: None, + activation_state: ExternalMcpActivationState::ApprovalRequired, + }; + let request = ExternalMcpApprovalRequest { + candidate_id: entry.candidate_id.clone(), + approval_key: entry.approval_key.clone(), + decision_key: entry.decision_key.clone(), + definition, + }; + let conflict = ExternalMcpConflict { + conflict_key: "external_mcp:local-user:github:v1".to_string(), + server_name: "github".to_string(), + candidates: vec![ + ExternalMcpConflictCandidate { + candidate_id: "native_mcp:github".to_string(), + display_name: "BitFun: github".to_string(), + external: false, + source: None, + behavior_version: "native-v1".to_string(), + available: true, + unavailable_reason: None, + }, + ExternalMcpConflictCandidate { + candidate_id: entry.candidate_id.clone(), + display_name: "OpenCode: github".to_string(), + external: true, + source: Some(entry.definition.id.source.clone()), + behavior_version: entry.definition.behavior_version.clone(), + available: true, + unavailable_reason: None, + }, + ], + selected_candidate_id: None, + }; + + let encoded = serde_json::to_string(&(entry, request, conflict)).unwrap(); + assert!(encoded.contains("GITHUB_TOKEN")); + assert!(!encoded.contains("Bearer secret")); + assert!(encoded.contains("approval_required")); +} + +fn external_capability(value: &str) -> ExternalIntegrationCapabilityId { + ExternalIntegrationCapabilityId::new(value).expect("valid external capability id") +} + +const TEST_ECOSYSTEM_ID: &str = "test-ecosystem"; +const EXTERNAL_CAPABILITY_COMMAND: &str = "command"; +const EXTERNAL_CAPABILITY_TOOL: &str = "tool"; +const EXTERNAL_CAPABILITY_SUBAGENT: &str = "subagent"; +const EXTERNAL_CAPABILITY_MCP: &str = "mcp"; + +fn test_external_integration_ecosystems() -> Vec { + let capability = + |id, recommended_access, safety_ceiling| ExternalIntegrationCapabilityDescriptor { + capability_id: external_capability(id), + recommended_access, + safety_ceiling, + }; + vec![ExternalIntegrationEcosystemDescriptor { + ecosystem_id: EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(), + display_name: "Test ecosystem".to_string(), + adapter_revision: "1".to_string(), + capabilities: vec![ + capability( + EXTERNAL_CAPABILITY_COMMAND, + ExternalIntegrationAccess::Auto, + ExternalIntegrationAccess::Auto, + ), + capability( + EXTERNAL_CAPABILITY_TOOL, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + capability( + EXTERNAL_CAPABILITY_SUBAGENT, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + capability( + EXTERNAL_CAPABILITY_MCP, + ExternalIntegrationAccess::AskBeforeUse, + ExternalIntegrationAccess::AskBeforeUse, + ), + ], + }] +} + +#[test] +fn external_integration_policy_is_disabled_by_default() { + let effective = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .expect("default policy evaluates"); + let opencode = effective + .ecosystems + .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) + .expect("test ecosystem is registered"); + + assert!(!effective.enabled); + assert_eq!(opencode.mode, ExternalIntegrationMode::Disabled); + for capability in [ + EXTERNAL_CAPABILITY_COMMAND, + EXTERNAL_CAPABILITY_TOOL, + EXTERNAL_CAPABILITY_SUBAGENT, + EXTERNAL_CAPABILITY_MCP, + ] { + assert_eq!( + opencode.capabilities[&external_capability(capability)], + ExternalIntegrationAccess::Disabled + ); + } +} + +#[test] +fn explicitly_enabled_recommended_policy_keeps_registered_access_defaults() { + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.enabled = true; + + let effective = evaluate_external_integration_policy( + &document, + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .expect("enabled recommended policy evaluates"); + let opencode = effective + .ecosystems + .get(&EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap()) + .expect("test ecosystem is registered"); + + assert!(effective.enabled); + assert_eq!(opencode.mode, ExternalIntegrationMode::Recommended); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], + ExternalIntegrationAccess::Auto + ); + for capability in [ + EXTERNAL_CAPABILITY_TOOL, + EXTERNAL_CAPABILITY_SUBAGENT, + EXTERNAL_CAPABILITY_MCP, + ] { + assert_eq!( + opencode.capabilities[&external_capability(capability)], + ExternalIntegrationAccess::AskBeforeUse + ); + } +} + +#[test] +fn workspace_policy_overrides_only_the_fields_the_user_changed() { + let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.enabled = true; + document.user_defaults.ecosystems.insert( + ecosystem.clone(), + ExternalEcosystemPolicy { + mode: ExternalIntegrationMode::DiscoverOnly, + ..ExternalEcosystemPolicy::default() + }, + ); + document.workspace_overrides.insert( + "workspace-a".to_string(), + ExternalIntegrationPolicyOverride { + ecosystems: [( + ecosystem.clone(), + ExternalEcosystemPolicyOverride { + mode: Some(ExternalIntegrationMode::Custom), + capability_overrides: [( + external_capability(EXTERNAL_CAPABILITY_COMMAND), + ExternalIntegrationAccess::Auto, + )] + .into_iter() + .collect(), + ..ExternalEcosystemPolicyOverride::default() + }, + )] + .into_iter() + .collect(), + ..ExternalIntegrationPolicyOverride::default() + }, + ); + + let effective = evaluate_external_integration_policy( + &document, + Some("workspace-a"), + &test_external_integration_ecosystems(), + ) + .unwrap(); + let opencode = &effective.ecosystems[&ecosystem]; + assert_eq!(opencode.mode, ExternalIntegrationMode::Custom); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_COMMAND)], + ExternalIntegrationAccess::Auto + ); + assert_eq!( + opencode.capabilities[&external_capability(EXTERNAL_CAPABILITY_MCP)], + ExternalIntegrationAccess::DiscoverOnly + ); + + let inherited = evaluate_external_integration_policy( + &document, + Some("workspace-b"), + &test_external_integration_ecosystems(), + ) + .unwrap(); + assert_eq!( + inherited.ecosystems[&ecosystem].mode, + ExternalIntegrationMode::DiscoverOnly + ); +} + +#[test] +fn high_risk_auto_access_is_limited_by_the_capability_owner() { + let ecosystem = EcosystemId::new(TEST_ECOSYSTEM_ID).unwrap(); + let mcp = external_capability(EXTERNAL_CAPABILITY_MCP); + let mut document = ExternalIntegrationPolicyDocument::default(); + document.user_defaults.enabled = true; + document.user_defaults.ecosystems.insert( + ecosystem.clone(), + ExternalEcosystemPolicy { + mode: ExternalIntegrationMode::Custom, + capability_overrides: [(mcp.clone(), ExternalIntegrationAccess::Auto)] + .into_iter() + .collect(), + ..ExternalEcosystemPolicy::default() + }, + ); + + let effective = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .unwrap(); + let opencode = &effective.ecosystems[&ecosystem]; + assert_eq!( + opencode.capabilities[&mcp], + ExternalIntegrationAccess::AskBeforeUse + ); + assert!(opencode.policy_limited_capabilities.contains(&mcp)); +} + +#[test] +fn future_policy_values_and_minor_fields_survive_read_modify_write() { + let raw = serde_json::json!({ + "schemaMajor": 1, + "userDefaults": { + "enabled": true, + "ecosystems": { + "opencode": { + "mode": "future_mode", + "capabilityOverrides": { + "future-capability": "future_access" + }, + "futureEcosystemField": { "enabled": true } + } + }, + "futureSettingsField": "preserve-me" + }, + "workspaceOverrides": {}, + "futureDocumentField": [1, 2, 3] + }); + let mut document: ExternalIntegrationPolicyDocument = + serde_json::from_value(raw.clone()).expect("future minor data remains readable"); + document.user_defaults.enabled = false; + let encoded = serde_json::to_value(&document).expect("policy remains serializable"); + + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["mode"], + "future_mode" + ); + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["capabilityOverrides"] + ["future-capability"], + "future_access" + ); + assert_eq!( + encoded["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"], + raw["userDefaults"]["ecosystems"]["opencode"]["futureEcosystemField"] + ); + assert_eq!( + encoded["userDefaults"]["futureSettingsField"], + "preserve-me" + ); + assert_eq!(encoded["futureDocumentField"], raw["futureDocumentField"]); + + let effective = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .unwrap(); + assert!(!effective.enabled); +} + +#[test] +fn incompatible_policy_schema_major_is_rejected_without_downgrade() { + let document = ExternalIntegrationPolicyDocument { + schema_major: 2, + ..ExternalIntegrationPolicyDocument::default() + }; + let error = evaluate_external_integration_policy( + &document, + None, + &test_external_integration_ecosystems(), + ) + .expect_err("future major schemas must fail closed"); + assert!(error.to_string().contains("schema major: 2")); +} + +#[test] +fn incompatible_policy_schema_has_a_safe_read_only_public_snapshot() { + let raw = serde_json::json!({ + "schemaMajor": 2, + "userDefaults": { + "enabled": true, + "futureSecretHostField": "persistence-only" + }, + "futureDocumentField": { "keep": true } + }); + let document: ExternalIntegrationPolicyDocument = serde_json::from_value(raw).unwrap(); + let snapshot = external_integration_policy_snapshot( + &document, + Some("workspace-a"), + test_external_integration_ecosystems(), + ) + .expect("incompatible schemas remain inspectable through a safe snapshot"); + + assert_eq!( + snapshot.status, + ExternalIntegrationPolicyStatus::IncompatibleSchema + ); + assert!(!snapshot.global_effective.enabled); + assert!(!snapshot.effective.enabled); + assert!(snapshot + .effective + .ecosystems + .values() + .all(|ecosystem| ecosystem + .capabilities + .values() + .all(|access| { matches!(access, ExternalIntegrationAccess::Disabled) }))); + + let public = serde_json::to_string(&snapshot).unwrap(); + assert!(!public.contains("futureSecretHostField")); + assert!(!public.contains("futureDocumentField")); + + let persisted = serde_json::to_string(&document).unwrap(); + assert!(persisted.contains("futureSecretHostField")); + assert!(persisted.contains("futureDocumentField")); +} + +#[test] +fn integration_registry_rejects_ambiguous_or_unsafe_descriptors() { + let mut duplicate_ecosystem = test_external_integration_ecosystems(); + duplicate_ecosystem.push(duplicate_ecosystem[0].clone()); + let duplicate_error = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + None, + &duplicate_ecosystem, + ) + .expect_err("duplicate ecosystem registrations must fail closed"); + assert!(duplicate_error.to_string().contains("duplicate ecosystem")); + + let mut unsafe_recommendation = test_external_integration_ecosystems(); + unsafe_recommendation[0].capabilities[1].recommended_access = ExternalIntegrationAccess::Auto; + let unsafe_error = evaluate_external_integration_policy( + &ExternalIntegrationPolicyDocument::default(), + None, + &unsafe_recommendation, + ) + .expect_err("registry defaults cannot exceed their safety ceiling"); + assert!(unsafe_error + .to_string() + .contains("exceeds the safety ceiling")); +} + +#[test] +fn public_snapshot_never_exposes_executable_prompt_templates() { + let snapshot = ExternalSourceCatalogSnapshot { + generation: 1, + discovery_pending: false, + sources: Vec::new(), + commands: vec![PromptCommandCatalogEntry { + definition: command("opencode", "project-commands", 1), + }], + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 0, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 0, + preference_revision: 0, + subagents: Vec::new(), + subagent_model_binding_groups: vec![ExternalSubagentModelBindingGroup { + binding_key: "external_subagent_model_binding:review".to_string(), + request: ExternalSubagentModelRequest::Reference { + provider_hint: Some("anthropic".to_string()), + model_name: "claude-sonnet-4".to_string(), + }, + profile_request: None, + scope: ExternalSourceScope::Project, + method: ExternalSubagentModelBindingMethod::BindingRequired, + selected_target: None, + effective_model_label: None, + affected_candidate_ids: vec!["opencode-review".to_string()], + }], + subagent_model_binding_options: vec![ExternalSubagentModelBindingOption { + target: ExternalSubagentModelBindingTarget::Fast, + effective_model_label: "GLM-4.5-Air".to_string(), + configured_reasoning_effort: None, + }], + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let public = ExternalSourcePublicSnapshot::from(snapshot); + let encoded = serde_json::to_value(public).expect("serialize public projection"); + + assert_eq!(encoded["commands"][0]["definition"]["name"], "review"); + assert!(encoded["commands"][0]["definition"] + .get("template") + .is_none()); + assert_eq!( + encoded["subagentModelBindingGroups"][0]["bindingKey"], + "external_subagent_model_binding:review" + ); + assert_eq!( + encoded["subagentModelBindingOptions"][0]["effectiveModelLabel"], + "GLM-4.5-Air" + ); +} + +#[test] +fn control_projection_keeps_lifecycle_facts_orthogonal() { + let catalog = ExternalSourceCatalogSnapshot { + generation: 7, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: "opencode.commands:project".to_string(), + presentation_group_id: None, + record: source("opencode.commands", "opencode", "project"), + lifecycle: ExternalSourceLifecycleState::UsingLastValidVersion, + }], + commands: vec![PromptCommandCatalogEntry { + definition: command("opencode.commands", "project", 1), + }], + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 2, + mcp_servers: Vec::new(), + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 3, + preference_revision: 11, + subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let control = ExternalSourceControlSnapshotV1::from_catalog( + &catalog, + ExecutionDomainId::new("local-user").unwrap(), + false, + ExternalSourceHostCapabilities::read_write(), + ); + + assert_eq!(control.schema_version, EXTERNAL_SOURCE_CONTROL_SCHEMA_V1); + assert_eq!(control.refresh_generation, 7); + assert_eq!(control.preference_revision, 11); + assert_eq!(control.sources.len(), 1); + assert_eq!( + control.sources[0].discovery, + ExternalSourceDiscoveryState::LastKnownGood + ); + assert_eq!( + control.sources[0].desired, + ExternalSourceDesiredState::Enabled + ); + assert_eq!( + control.sources[0].review, + ExternalSourceReviewState::NotRequired + ); + assert_eq!(control.capabilities.len(), 4); +} + +#[test] +fn control_projection_does_not_infer_review_facts_from_runtime_activation() { + let record = source("opencode.mcp", "opencode", "project-config"); + let definition = ExternalMcpServerDefinition { + id: SourceQualifiedMcpServerId::new(record.key.clone(), "docs").unwrap(), + provenance: vec![record.key.clone()], + name: "docs".to_string(), + transport: ExternalMcpTransportKind::StreamableHttp, + command_preview: None, + argument_count: 0, + working_directory: None, + environment_keys: Vec::new(), + environment_reference_names: Vec::new(), + remote_url_preview: Some("https://mcp.example.com".to_string()), + header_names: Vec::new(), + timeouts: ExternalMcpTimeouts::default(), + source_enabled: true, + behavior_version: "behavior-v1".to_string(), + static_status: ExternalMcpStaticStatus::Ready, + }; + let catalog = ExternalSourceCatalogSnapshot { + generation: 1, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: "opencode.mcp:project-config".to_string(), + presentation_group_id: None, + record: record.clone(), + lifecycle: ExternalSourceLifecycleState::Available, + }], + commands: Vec::new(), + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + mcp_generation: 1, + mcp_servers: vec![ExternalMcpCatalogEntry { + candidate_id: "external_mcp:docs".to_string(), + definition, + approval_key: "approval-v1".to_string(), + decision_key: "decision-v1".to_string(), + runtime_id: None, + activation_state: ExternalMcpActivationState::Declined, + }], + mcp_approval_requests: Vec::new(), + mcp_conflicts: Vec::new(), + subagent_generation: 1, + preference_revision: 1, + subagents: Vec::new(), + subagent_model_binding_groups: Vec::new(), + subagent_model_binding_options: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + integration_policy: Default::default(), + diagnostics: Vec::new(), + }; + + let control = ExternalSourceControlSnapshotV1::from_catalog( + &catalog, + ExecutionDomainId::new("local-user").unwrap(), + false, + ExternalSourceHostCapabilities::read_write(), + ); + + assert_eq!( + control.sources[0].review, + ExternalSourceReviewState::NotRequired + ); +} + +#[test] +fn desktop_local_host_capability_is_additive_on_the_wire() { + let portable = serde_json::to_value(ExternalSourceHostCapabilities::read_write()).unwrap(); + let read_only = + serde_json::to_value(ExternalSourceHostCapabilities::read_only_projection()).unwrap(); + let desktop = serde_json::to_value(ExternalSourceHostCapabilities::local_desktop()).unwrap(); + + assert!(portable.get("canRevealSourceLocation").is_none()); + assert!(read_only.get("canRevealSourceLocation").is_none()); + assert_eq!(desktop["canRevealSourceLocation"], true); + + let legacy: ExternalSourceHostCapabilities = serde_json::from_value(serde_json::json!({ + "canRefresh": true, + "canMutatePolicy": true, + "canManageSources": true, + "canApproveRuntime": true, + "canExecuteExternalAssets": true, + "canSetSafeMode": true + })) + .unwrap(); + assert!(!legacy.can_reveal_source_location); +} + +#[test] +fn operation_error_round_trip_preserves_typed_recovery_without_message_parsing() { + let error = ExternalSourceOperationError::new( + ExternalSourceOperationErrorCode::StaleRevision, + "refresh required", + true, + ) + .with_stage(ExternalSourceOperationStage::ApplyPreference) + .with_causation_id("refresh-generation-7") + .with_recovery_action(ExternalSourceRecoveryActionV1::Refresh); + + let encoded = error.encode(); + assert_eq!(ExternalSourceOperationError::decode(&encoded), Some(error)); + assert!(!encoded.contains("metadata")); +} + +#[test] +fn control_action_uses_one_camel_case_dto_across_product_surfaces() { + let request = ExternalSourceControlRequestV1 { + schema_version: EXTERNAL_SOURCE_CONTROL_SCHEMA_V1, + operation_id: "surface-operation-1".to_string(), + expected_preference_revision: Some(8), + action: ExternalSourceControlActionV1::SetSourceEnabled { + source_key: "opencode.commands:project".to_string(), + enabled: false, + }, + }; + + let encoded = serde_json::to_value(&request).expect("serialize control request"); + assert_eq!(encoded["schemaVersion"], 1); + assert_eq!(encoded["operationId"], "surface-operation-1"); + assert_eq!(encoded["expectedPreferenceRevision"], 8); + assert_eq!(encoded["action"]["type"], "set_source_enabled"); + assert_eq!(encoded["action"]["sourceKey"], "opencode.commands:project"); + assert!(encoded["action"].get("source_key").is_none()); + assert_eq!( + serde_json::from_value::(encoded) + .expect("deserialize the shared control request"), + request + ); +} + +#[test] +fn legacy_operation_errors_decode_with_empty_extension_fields() { + let decoded = ExternalSourceOperationError::decode( + r#"{"code":"unavailable","detail":"retry","retryable":true}"#, + ) + .expect("legacy operation error remains readable"); + + assert_eq!(decoded.code, ExternalSourceOperationErrorCode::Unavailable); + assert!(decoded.stage.is_none()); + assert!(decoded.causation_id.is_none()); + assert!(decoded.recovery_actions.is_empty()); +} + +#[test] +fn decoded_operation_errors_bound_untrusted_extension_fields() { + let oversized = "x".repeat(5000); + let encoded = serde_json::json!({ + "code": "stale_revision", + "detail": oversized, + "retryable": true, + "correlationId": "forged\nreference", + "recoveryActions": [ + { "type": "refresh" }, + { "type": "refresh" }, + { "type": "retry" } + ] + }) + .to_string(); + + let decoded = ExternalSourceOperationError::decode(&encoded).unwrap(); + assert_eq!(decoded.detail.chars().count(), 4096); + assert!(decoded.correlation_id.is_none()); + assert_eq!( + decoded.recovery_actions, + vec![ + ExternalSourceRecoveryActionV1::Refresh, + ExternalSourceRecoveryActionV1::Retry, + ] + ); +} diff --git a/src/crates/contracts/product-domains/tests/workspace_reference_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts/workspace_reference_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/workspace_reference_contracts.rs rename to src/crates/contracts/product-domains/tests/external_source_contracts/workspace_reference_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs b/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs index 6788520f0..75fa47a87 100644 --- a/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/plugin_source_contracts.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "plugin-source")] + use bitfun_product_domains::plugin_source::{ PluginPackageInput, PluginPackageManifest, PluginPackageSourceIdentity, PluginPackageTrustLevel, PluginTrustDecision, PluginTrustStore, @@ -428,7 +430,8 @@ fn activation_lifecycle_is_exact_independent_and_idempotent() { assert_eq!((store.epoch(), store.activation_epoch()), (trust_epoch, 9)); assert!(store .clear_activation_record(PROJECT, WORKSPACE, &package.package_id, None) - .expect("repeat deactivation").is_none()); + .expect("repeat deactivation") + .is_none()); assert_eq!((store.epoch(), store.activation_epoch()), (trust_epoch, 9)); } @@ -496,7 +499,8 @@ fn stale_residual_cleanup_cannot_clear_a_newer_activation() { assert!(store .clear_activation_record(PROJECT, WORKSPACE, &package.package_id, Some(stale_epoch),) - .expect("stale cleanup is a no-op").is_none()); + .expect("stale cleanup is a no-op") + .is_none()); assert!(store.is_activated(PROJECT, WORKSPACE, &package)); assert_eq!(store.activation_epoch(), current_epoch); } diff --git a/src/crates/contracts/product-domains/tests/product_domain_contracts.rs b/src/crates/contracts/product-domains/tests/product_domain_contracts.rs new file mode 100644 index 000000000..476faa886 --- /dev/null +++ b/src/crates/contracts/product-domains/tests/product_domain_contracts.rs @@ -0,0 +1,4 @@ +#[path = "product_domain_contracts/canvas_contracts.rs"] +mod canvas_contracts; +#[path = "product_domain_contracts/tool_permission_contracts.rs"] +mod tool_permission_contracts; diff --git a/src/crates/contracts/product-domains/tests/canvas_contracts.rs b/src/crates/contracts/product-domains/tests/product_domain_contracts/canvas_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/canvas_contracts.rs rename to src/crates/contracts/product-domains/tests/product_domain_contracts/canvas_contracts.rs diff --git a/src/crates/contracts/product-domains/tests/tool_permission_contracts.rs b/src/crates/contracts/product-domains/tests/product_domain_contracts/tool_permission_contracts.rs similarity index 100% rename from src/crates/contracts/product-domains/tests/tool_permission_contracts.rs rename to src/crates/contracts/product-domains/tests/product_domain_contracts/tool_permission_contracts.rs diff --git a/src/crates/contracts/runtime-ports/Cargo.toml b/src/crates/contracts/runtime-ports/Cargo.toml index b142db5da..7cc930223 100644 --- a/src/crates/contracts/runtime-ports/Cargo.toml +++ b/src/crates/contracts/runtime-ports/Cargo.toml @@ -4,11 +4,16 @@ version.workspace = true authors.workspace = true edition.workspace = true description = "Thin runtime ports for BitFun core decomposition" +autotests = false [lib] name = "bitfun_runtime_ports" crate-type = ["rlib"] +[[test]] +name = "runtime_port_contracts" +path = "tests/runtime_port_contracts.rs" + [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs new file mode 100644 index 000000000..cc711cc08 --- /dev/null +++ b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts.rs @@ -0,0 +1,10 @@ +#[path = "runtime_port_contracts/git_port_contracts.rs"] +mod git_port_contracts; +#[path = "runtime_port_contracts/plugin_runtime_contracts.rs"] +mod plugin_runtime_contracts; +#[path = "runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs"] +mod plugin_runtime_diagnostics_contracts; +#[path = "runtime_port_contracts/script_tool_port_contracts.rs"] +mod script_tool_port_contracts; +#[path = "runtime_port_contracts/session_store_contracts.rs"] +mod session_store_contracts; diff --git a/src/crates/contracts/runtime-ports/tests/git_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/git_port_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/git_port_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/git_port_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/plugin_runtime_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/plugin_runtime_diagnostics_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/plugin_runtime_diagnostics_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/script_tool_port_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/script_tool_port_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/script_tool_port_contracts.rs diff --git a/src/crates/contracts/runtime-ports/tests/session_store_contracts.rs b/src/crates/contracts/runtime-ports/tests/runtime_port_contracts/session_store_contracts.rs similarity index 100% rename from src/crates/contracts/runtime-ports/tests/session_store_contracts.rs rename to src/crates/contracts/runtime-ports/tests/runtime_port_contracts/session_store_contracts.rs diff --git a/src/crates/services/miniapp-market-service/Cargo.toml b/src/crates/services/miniapp-market-service/Cargo.toml index 6c2163a96..c15336869 100644 --- a/src/crates/services/miniapp-market-service/Cargo.toml +++ b/src/crates/services/miniapp-market-service/Cargo.toml @@ -29,7 +29,6 @@ tokio = { workspace = true, features = ["fs", "rt", "sync", "time"] } tower-http = { version = "0.6.11", features = ["fs", "set-header", "trace"] } tracing = { workspace = true } url = { workspace = true } -urlencoding = { workspace = true } uuid = { workspace = true } zip = { workspace = true } diff --git a/src/crates/services/page-function-runtime/Cargo.toml b/src/crates/services/page-function-runtime/Cargo.toml index f399d1e4a..ab78c3f3b 100644 --- a/src/crates/services/page-function-runtime/Cargo.toml +++ b/src/crates/services/page-function-runtime/Cargo.toml @@ -15,6 +15,3 @@ rquickjs = { version = "0.9", default-features = false, features = ["classes", " serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" thiserror = "2" - -[dev-dependencies] -tokio = { version = "1.52", features = ["macros", "rt"] } From 9d3f12f3cd1fb3b467936c8b30783fab5697ba4c Mon Sep 17 00:00:00 2001 From: t Date: Tue, 11 Aug 2026 19:26:06 +0800 Subject: [PATCH 16/39] =?UTF-8?q?chore:=20=E5=9B=BA=E5=8C=96=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=20CI=20=E5=85=A8=E9=87=8F=E5=A4=8D=E5=88=BB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=20local-replica.ps1=EF=BC=88=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E9=A2=84=E6=BC=94=E5=AF=B9=E9=BD=90=E8=BF=9C=E7=A8=8B=20CI?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/ci/local-replica.ps1 | 394 +++++++++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 scripts/ci/local-replica.ps1 diff --git a/scripts/ci/local-replica.ps1 b/scripts/ci/local-replica.ps1 new file mode 100644 index 000000000..90396ec78 --- /dev/null +++ b/scripts/ci/local-replica.ps1 @@ -0,0 +1,394 @@ +<# +.SYNOPSIS + 本地 CI 全量复刻脚本:按 .github/workflows/ci.yml 逐 job 逐 step 在本地 Windows 上完整预演。 + +.DESCRIPTION + 固化 5 个 job / 28 步(shell-scripts / cli-test / cargo-deny / rust-build-check / frontend-build), + 与远程 CI(ubuntu-latest 主线)对齐。已知 Windows 平台差异项显式标注、不判整体失败, + 其余步骤严格判失败(核心失败 → 退出码非 0)。 + + 环境预处理(关键): + - PATH 前置 Git Bash:系统 bash.exe 可能是 WSL stub(无发行版),会让所有 bash 脚本/契约测试 + 误报失败。本脚本探测 %ProgramFiles%\Git\bin 等常见安装位置,找不到则报错退出。 + - NODE_OPTIONS=--max-old-space-size=6144(对齐 CI frontend-build env)。 + - cargo-deny:已安装则直接使用,未安装则提示安装命令(不自动装)。 + +.PARAMETER SkipFrontend + 跳过 frontend-build job(构建耗时较长,可选)。 + +.EXAMPLE + .\scripts\ci\local-replica.ps1 # 全量 28 步 + .\scripts\ci\local-replica.ps1 -SkipFrontend # 跳过前端 job +#> +[CmdletBinding()] +param( + [switch]$SkipFrontend +) + +$ErrorActionPreference = 'Continue' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +Set-Location $repoRoot + +# ── 结果记录 ────────────────────────────────────────────────────────────── +$results = [System.Collections.Generic.List[object]]::new() +$global:stepFailed = $false + +function Add-Result { + param([string]$Job, [string]$Step, [string]$Command, [int]$Exit, [string]$Status, [string]$Note = '') + $script:results.Add([pscustomobject]@{ + Job = $Job + Step = $Step + Command = $Command + Exit = $Exit + Status = $Status + Note = $Note + }) +} + +function Invoke-CIStep { + param( + [string]$Job, + [string]$Step, + [string]$Command, + [scriptblock]$Body, + [ValidateSet('strict', 'platform-warn', 'skip')] + [string]$Mode = 'strict' + ) + Write-Host "`n[$Job] $Step" -ForegroundColor Cyan + Write-Host " > $Command" -ForegroundColor DarkGray + + if ($Mode -eq 'skip') { + Add-Result $Job $Step $Command -1 'SKIP' 'Windows 平台限制(CI ubuntu 专属)' + Write-Host " [SKIP] 平台限制:该步骤 CI 在 ubuntu 跑,Windows 无法复刻" -ForegroundColor Yellow + return + } + + $ex = 0 + try { + $ret = & $Body + # Body 显式 `return N`(int)优先作为退出码;否则用外部命令的 $LASTEXITCODE。 + # 兼容 Body 内 pipeline 产生对象输出的情况:从 $ret 提取最后一个 int 值。 + $returnedInt = $null + if ($null -ne $ret) { + if ($ret -is [int]) { $returnedInt = $ret } + elseif ($ret -is [array]) { + foreach ($item in $ret) { if ($item -is [int]) { $returnedInt = $item } } + } + } + if ($null -ne $returnedInt) { + $ex = $returnedInt + } else { + $ex = $LASTEXITCODE + if ($null -eq $ex) { $ex = 0 } + } + } catch { + # PowerShell 5.1:外部命令 stderr 经 2>&1 合并时抛 NativeCommandError, + # 这是"输出流"而非真失败——退出码以 $LASTEXITCODE 为准。 + if ($_.Exception -is [System.Management.Automation.NativeCommandExitException]) { + $ex = $LASTEXITCODE + if ($null -eq $ex) { $ex = 1 } + } else { + $ex = 1 + Write-Host " [EXCEPTION] $_" -ForegroundColor Red + } + } + + if ($ex -eq 0) { + Add-Result $Job $Step $Command 0 'PASS' + Write-Host " [PASS] EXIT=$ex" -ForegroundColor Green + } elseif ($Mode -eq 'platform-warn') { + Add-Result $Job $Step $Command $ex 'WARN' 'Windows 已知平台差异(远程 CI 通过,基线复测证实与改动无关)' + Write-Host " [WARN] EXIT=$ex Windows 已知平台差异,不判整体失败" -ForegroundColor Yellow + } else { + Add-Result $Job $Step $Command $ex 'FAIL' + $script:stepFailed = $true + Write-Host " [FAIL] EXIT=$ex" -ForegroundColor Red + } +} + +# ── 0. 环境预处理 ───────────────────────────────────────────────────────── +Write-Host "`n===== 环境预处理 =====" -ForegroundColor Magenta + +# 0a. 探测 Git Bash +$gitBashCandidates = @( + "$env:ProgramFiles\Git\bin\bash.exe", + "${env:ProgramFiles(x86)}\Git\bin\bash.exe", + "$env:LOCALAPPDATA\Programs\Git\bin\bash.exe", + "$env:USERPROFILE\scoop\apps\git\current\bin\bash.exe" +) +$gitBash = $gitBashCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $gitBash) { + Write-Host " [ERROR] 未找到 Git Bash。请安装 Git for Windows(https://git-scm.com/download/win)" -ForegroundColor Red + Write-Host " 或用 bash 所在目录执行:\$env:PATH = 'C:\Program Files\Git\bin;' + \$env:PATH" -ForegroundColor Red + exit 2 +} +$gitBashDir = Split-Path (Split-Path $gitBash -Parent) -Parent # ...\Git +# 把 Git\bin 和 Git\usr\bin 前置到 PATH(usr\bin 提供 grep/sed 等 coreutils) +$env:PATH = "$gitBashDir\bin;$gitBashDir\usr\bin;$env:PATH" +Write-Host " Git Bash: $gitBash" -ForegroundColor Green +$bashVer = & $gitBash --version 2>&1 | Select-Object -First 1 +Write-Host " 版本: $bashVer" -ForegroundColor DarkGray + +# 0b. NODE_OPTIONS 对齐 CI +$env:NODE_OPTIONS = '--max-old-space-size=6144' +Write-Host " NODE_OPTIONS=$env:NODE_OPTIONS" -ForegroundColor Green + +# 0c. cargo-deny 检查 +$cargoDeny = Get-Command cargo-deny -ErrorAction SilentlyContinue +if ($cargoDeny) { + Write-Host " cargo-deny: $(& cargo-deny --version 2>&1 | Select-Object -First 1)" -ForegroundColor Green +} else { + Write-Host " [WARN] 未安装 cargo-deny。cargo-deny job 将跳过。" -ForegroundColor Yellow + Write-Host " 安装:cargo install cargo-deny --locked --version 0.20.2" -ForegroundColor Yellow +} + +# ── 1. shell-scripts ────────────────────────────────────────────────────── +Write-Host "`n===== Job 1: shell-scripts =====" -ForegroundColor Magenta + +Invoke-CIStep 'shell-scripts' 'CRLF 检查(shell/deploy 资产必须 LF)' ` + "git ls-files '*.sh' '*.bash' Dockerfile* Caddyfile docker-compose* 扫 CR" -Mode strict -Body { + $bad = @() + foreach ($pat in @('*.sh', '*.bash', 'Dockerfile', 'Dockerfile.*', '*.Dockerfile', 'Caddyfile', 'docker-compose.yml', 'docker-compose.*.yml')) { + git ls-files $pat | ForEach-Object { + $f = $_; $bytes = [System.IO.File]::ReadAllBytes((Resolve-Path $f)) + if ($bytes -contains 13) { $bad += $f } + } + } + if ($bad.Count -gt 0) { Write-Host "CRLF FOUND:"; $bad; return 1 } + Write-Host "All shell and deploy assets are LF-only." +} + +Invoke-CIStep 'shell-scripts' 'bash -n 全部跟踪的 shell 脚本' ` + "bash -n (Git Bash)" -Mode strict -Body { + $rc = 0 + foreach ($f in (git ls-files '*.sh' '*.bash')) { + & $gitBash -n $f 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { Write-Host "bash syntax error: $f"; $rc = 1 } + } + if ($rc -eq 0) { Write-Host "All shell scripts pass bash -n" } + return $rc +} + +Invoke-CIStep 'shell-scripts' 'release/version 契约测试(node --test)' ` + "node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs" -Mode strict -Body { + node --test scripts/tauri-release-manifest.test.mjs scripts/linux-binaries-manifest.test.mjs scripts/version-generation.test.mjs 2>&1 | Select-String -Pattern 'pass |fail ' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +# minisign fallback:Windows 平台限制(脚本主动拒绝 MINGW64),CI ubuntu 专属 +Invoke-CIStep 'shell-scripts' 'minisign 下载 fallback' ` + "bash scripts/sign-release-assets.sh " -Mode skip -Body { } + +# ── 2. cli-test(Linux 分支 = 主线)─────────────────────────────────────── +Write-Host "`n===== Job 2: cli-test =====" -ForegroundColor Magenta + +Invoke-CIStep 'cli-test' 'CLI + ACP 测试' ` + "cargo test --locked -p bitfun-cli -p bitfun-acp" -Mode platform-warn -Body { + cargo test --locked -p bitfun-cli -p bitfun-acp 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'cli-test' 'agent-runtime 测试' ` + "cargo test --locked -p bitfun-agent-runtime" -Mode strict -Body { + cargo test --locked -p bitfun-agent-runtime 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'cli-test' 'SDK Host 测试' ` + "cargo test --locked -p bitfun-sdk-host -p bitfun-sdk-host-app" -Mode strict -Body { + cargo test --locked -p bitfun-sdk-host -p bitfun-sdk-host-app 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'cli-test' 'SDK Host terminal 清理回归(3 测试)' ` + "cargo test --locked -p terminal-core <3 回归> -- --test-threads=1" -Mode strict -Body { + $names = @( + 'shutdown_returns_only_after_process_exit_is_confirmed', + 'shutdown_evicts_a_process_whose_controller_already_confirmed_exit', + 'background_only_binding_is_owned_by_the_session' + ) + foreach ($n in $names) { + cargo test --locked -p terminal-core $n -- --test-threads=1 2>&1 | Select-String -Pattern 'test result' | ForEach-Object { Write-Host " $_" } + if ($LASTEXITCODE -ne 0) { return $LASTEXITCODE } + } +} + +# ── 3. cargo-deny ───────────────────────────────────────────────────────── +Write-Host "`n===== Job 3: cargo-deny =====" -ForegroundColor Magenta + +if ($cargoDeny) { + Invoke-CIStep 'cargo-deny' 'advisories' 'cargo deny check advisories' -Mode strict -Body { + cargo deny check advisories 2>&1 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + Invoke-CIStep 'cargo-deny' 'licenses' 'cargo deny check licenses' -Mode strict -Body { + cargo deny check licenses 2>&1 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + Invoke-CIStep 'cargo-deny' 'sources' 'cargo deny check sources' -Mode strict -Body { + cargo deny check sources 2>&1 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } +} else { + Write-Host " [SKIP] cargo-deny 未安装,跳过 3 步(不判失败)" -ForegroundColor Yellow + Add-Result 'cargo-deny' 'advisories' 'cargo deny check advisories' -1 'SKIP' 'cargo-deny 未安装' + Add-Result 'cargo-deny' 'licenses' 'cargo deny check licenses' -1 'SKIP' 'cargo-deny 未安装' + Add-Result 'cargo-deny' 'sources' 'cargo deny check sources' -1 'SKIP' 'cargo-deny 未安装' +} + +# ── 4. rust-build-check ─────────────────────────────────────────────────── +Write-Host "`n===== Job 4: rust-build-check =====" -ForegroundColor Magenta + +Invoke-CIStep 'rust-build-check' 'workspace 编译检查' ` + "cargo check --locked --workspace" -Mode strict -Body { + cargo check --locked --workspace 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'installer 编译检查(Windows 专属步骤)' ` + "cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml" -Mode strict -Body { + cargo check --manifest-path BitFun-Installer/src-tauri/Cargo.toml 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'core + desktop 库测试' ` + "cargo test --locked -p bitfun-core -p bitfun-desktop --lib" -Mode strict -Body { + cargo test --locked -p bitfun-core -p bitfun-desktop --lib 2>&1 | Select-String -Pattern 'test result: FAILED|test result: ok\.' | Select-Object -Last 4 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'page-function-runtime 测试' ` + "cargo test --locked -p bitfun-page-function-runtime" -Mode strict -Body { + cargo test --locked -p bitfun-page-function-runtime 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'relay-service 测试' ` + "cargo test --locked -p bitfun-relay-service" -Mode strict -Body { + cargo test --locked -p bitfun-relay-service 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'subscription-auth 测试' ` + "cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth" -Mode strict -Body { + cargo test --locked -p bitfun-ai-adapters --features subscription-auth --lib subscription_auth 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'file-watch 契约测试(非 macOS)' ` + "cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts" -Mode strict -Body { + cargo test --locked -p bitfun-services-integrations --no-default-features --features file-watch --test file_watch_contracts 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +Invoke-CIStep 'rust-build-check' 'search 工具测试' ` + "cargo test --locked -p tool-runtime --lib search::" -Mode strict -Body { + cargo test --locked -p tool-runtime --lib search:: 2>&1 | Select-String -Pattern 'test result' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE +} + +# ── 5. frontend-build ───────────────────────────────────────────────────── +if (-not $SkipFrontend) { + Write-Host "`n===== Job 5: frontend-build =====" -ForegroundColor Magenta + + Invoke-CIStep 'frontend-build' 'repo 卫生检查' 'pnpm run check:repo-hygiene' -Mode strict -Body { + pnpm run check:repo-hygiene 2>&1 | Select-String -Pattern 'passed|valid|error' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'core 边界检查' 'node --test scripts/check-core-boundaries.test.mjs' -Mode strict -Body { + node --test scripts/check-core-boundaries.test.mjs 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + # PPT Live 契约:Windows 已知平台差异(fixture 字节 hash 依赖 WebKit 渲染确定性) + Invoke-CIStep 'frontend-build' 'PPT Live 生成文件契约' ` + "pnpm run test:ppt-live" -Mode platform-warn -Body { + pnpm run test:ppt-live 2>&1 | Select-String -Pattern 'pass |fail |✖' | Select-Object -Last 6 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'GitHub 配置校验' 'pnpm run check:github-config' -Mode strict -Body { + pnpm run check:github-config 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'i18n 契约(CI profile)' 'pnpm run i18n:contract:test:ci' -Mode strict -Body { + pnpm run i18n:contract:test:ci 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'i18n 资源审计' 'pnpm run i18n:audit' -Mode strict -Body { + pnpm run i18n:audit 2>&1 | Select-String -Pattern 'Passed|warning' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'theme 色彩审计契约' 'pnpm run theme:color-audit:test' -Mode strict -Body { + pnpm run theme:color-audit:test 2>&1 | Select-String -Pattern 'pass |fail ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'theme 色彩治理审计' 'pnpm run theme:color-audit:all' -Mode strict -Body { + pnpm run theme:color-audit:all 2>&1 | Select-String -Pattern 'error|FAIL' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'theme 视觉治理契约' 'pnpm run theme:visual-contract' -Mode strict -Body { + pnpm run theme:visual-contract 2>&1 | Select-String -Pattern 'covered|error' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'web-ui lint' 'pnpm run lint:web' -Mode strict -Body { + pnpm run lint:web 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'web-ui 测试(vitest)' 'pnpm --dir src/web-ui run test:run' -Mode strict -Body { + pnpm --dir src/web-ui run test:run 2>&1 | Select-String -Pattern 'Test Files|Tests ' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'web-ui 构建' 'pnpm run build:web' -Mode strict -Body { + pnpm run build:web 2>&1 | Select-String -Pattern 'built in|verified|error' | Select-Object -Last 3 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'mobile-web type-check' 'pnpm --dir src/mobile-web run type-check' -Mode strict -Body { + pnpm --dir src/mobile-web run type-check 2>&1 | Select-Object -Last 2 + return $LASTEXITCODE + } + + Invoke-CIStep 'frontend-build' 'mobile-web 构建' 'pnpm run build:mobile-web' -Mode strict -Body { + pnpm run build:mobile-web 2>&1 | Select-String -Pattern 'built in|error' | Select-Object -Last 2 | ForEach-Object { Write-Host " $_" } + return $LASTEXITCODE + } +} else { + Write-Host "`n[SKIP] frontend-build job(-SkipFrontend)" -ForegroundColor Yellow +} + +# ── 汇总矩阵 ───────────────────────────────────────────────────────────── +Write-Host "`n===== 汇总矩阵 =====" -ForegroundColor Magenta +Write-Host ("{0,-14} {1,-38} {2,5} {3,-6} {4}" -f 'JOB', 'STEP', 'EXIT', 'STATUS', 'NOTE') +Write-Host ('-' * 110) +$passCount = 0; $failCount = 0; $warnCount = 0; $skipCount = 0 +foreach ($r in $results) { + Write-Host ("{0,-14} {1,-38} {2,5} {3,-6} {4}" -f $r.Job, $r.Step, $r.Exit, $r.Status, $r.Note) + switch ($r.Status) { + 'PASS' { $passCount++ } + 'FAIL' { $failCount++ } + 'WARN' { $warnCount++ } + 'SKIP' { $skipCount++ } + } +} +Write-Host ('-' * 110) +Write-Host "PASS=$passCount FAIL=$failCount WARN=$warnCount SKIP=$skipCount TOTAL=$($results.Count)" +if ($skipCount -gt 0) { Write-Host "SKIP 项:Windows 平台限制(minisign)或未安装(cargo-deny),远程 CI ubuntu 上通过" -ForegroundColor Yellow } +if ($warnCount -gt 0) { Write-Host "WARN 项:Windows 已知平台差异(cli plugin trust store / ppt-live fixture hash),远程 CI 通过,基线复测证实与改动无关" -ForegroundColor Yellow } + +# ── 退出码 ─────────────────────────────────────────────────────────────── +if ($script:stepFailed) { + Write-Host "`n[RESULT] 核心步骤存在失败(FAIL),本地预演未通过" -ForegroundColor Red + exit 1 +} +Write-Host "`n[RESULT] 本地预演通过(PASS + WARN + SKIP,无核心失败)" -ForegroundColor Green +exit 0 From e00f4d06e50a1654eb5299893ef25a164f20ac5d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 11 Aug 2026 21:08:32 +0800 Subject: [PATCH 17/39] =?UTF-8?q?fix(clippy):=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E9=93=BE=E5=8D=87=E7=BA=A7=201.97.1=20=E7=A1=AC=20error=20?= =?UTF-8?q?=E5=85=A8=E9=87=8F=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - await-holding-lock ×11:测试专用环境锁 9 处加 #[allow] + 注释(与库内既有先例一致),生产代码 1 处(tui_client worktree_repository_status)改作用域先取值后 await,runtime.rs 测试 1 处允许跨断言持有;逻辑等价无死锁 - result_large_err ×1:workspace_search/service.rs cross_validate_empty_result Err 侧改 Box - manual_unwrap_or_default ×1:session_control_tool.rs list_session_metadata match 改 .unwrap_or_default() - E0432 ×2 + unused import ×1:relay_deploy.rs 删死 import(sync_source_bash/verified_checksum_exports)与未用 import(stage_scripts_command) - suspicious_open_options ×3:config.rs/prompt_stash.rs 锁文件 create(true) 补 truncate(true) - E0716 ×1:desktop/logging.rs 测试 format_args 临时值改为 let 绑定 验证(1.97.1):clippy --all-targets 0 error 0 warning、check --workspace 0 error、CI 路径测试全绿(tool-runtime 1 + cli 3 个 Windows 既有失败已双链归因);rustup default 已切 1.97.1 --- src/apps/cli/src/agent/tui_client.rs | 18 ++++++++++++------ src/apps/cli/src/config.rs | 1 + src/apps/cli/src/prompt_stash.rs | 2 ++ src/apps/desktop/src/logging.rs | 3 ++- .../execution/conditional_instructions.rs | 1 + .../src/agentic/execution/execution_engine.rs | 4 ++++ .../implementations/session_control_tool.rs | 7 ++----- .../core/src/service/instruction_context.rs | 2 ++ .../assembly/core/tests/rbac_master_switch.rs | 2 ++ .../execution/agent-runtime/src/runtime.rs | 1 + .../src/remote_ssh/relay_deploy.rs | 3 +-- .../src/workspace_search/service.rs | 8 ++++---- 12 files changed, 34 insertions(+), 18 deletions(-) diff --git a/src/apps/cli/src/agent/tui_client.rs b/src/apps/cli/src/agent/tui_client.rs index eedd9822e..672fd3d5e 100644 --- a/src/apps/cli/src/agent/tui_client.rs +++ b/src/apps/cli/src/agent/tui_client.rs @@ -817,15 +817,21 @@ impl TuiAgentClient { &self, workspace_path: String, ) -> Result { - let paths = self - .workspace_paths - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); + let (remote_connection_id, remote_ssh_host) = { + let paths = self + .workspace_paths + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + paths.remote_connection_id.clone(), + paths.remote_ssh_host.clone(), + ) + }; self.backend .worktree_repository_status(WorktreeRepositoryStatusRequest { workspace_path, - remote_connection_id: paths.remote_connection_id.clone(), - remote_ssh_host: paths.remote_ssh_host.clone(), + remote_connection_id, + remote_ssh_host, }) .await .map_err(Into::into) diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 4925b2a72..7562d312c 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -274,6 +274,7 @@ impl CliConfig { let lock_path = config_path.with_extension("toml.lock"); let lock_file = OpenOptions::new() .create(true) + .truncate(true) .read(true) .write(true) .open(lock_path)?; diff --git a/src/apps/cli/src/prompt_stash.rs b/src/apps/cli/src/prompt_stash.rs index b4a6b457c..4ee61b2bc 100644 --- a/src/apps/cli/src/prompt_stash.rs +++ b/src/apps/cli/src/prompt_stash.rs @@ -177,6 +177,7 @@ impl PromptStashStore { let lock_path = self.path.with_extension("jsonl.lock"); let lock = OpenOptions::new() .create(true) + .truncate(true) .read(true) .write(true) .open(lock_path)?; @@ -398,6 +399,7 @@ mod tests { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); let lock = std::fs::OpenOptions::new() .create(true) + .truncate(true) .read(true) .write(true) .open(path.with_extension("jsonl.lock")) diff --git a/src/apps/desktop/src/logging.rs b/src/apps/desktop/src/logging.rs index 008cfeadf..6e9e2f07f 100644 --- a/src/apps/desktop/src/logging.rs +++ b/src/apps/desktop/src/logging.rs @@ -780,10 +780,11 @@ mod tests { let temp_dir = tempfile::tempdir().expect("create temp dir"); let path = temp_dir.path().join(EARLY_STARTUP_LOG_FILE_NAME); let logger = EarlyFileLogger::new(path.clone()); + let record_args = format_args!("Startup failed: code={}", 7); let record = log::Record::builder() .level(log::Level::Error) .target("bitfun_desktop::startup") - .args(format_args!("Startup failed: code={}", 7)) + .args(record_args) .build(); logger.write_record(&record); diff --git a/src/crates/assembly/core/src/agentic/execution/conditional_instructions.rs b/src/crates/assembly/core/src/agentic/execution/conditional_instructions.rs index fc957251f..6a89583cc 100644 --- a/src/crates/assembly/core/src/agentic/execution/conditional_instructions.rs +++ b/src/crates/assembly/core/src/agentic/execution/conditional_instructions.rs @@ -322,6 +322,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn an_unmatched_read_does_not_freeze_rule_content_before_activation() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 846c297a0..0146ed9b8 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -5873,6 +5873,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn user_context_cache_identity_includes_external_sources_switch_state() { // P2-1 (KV cache design audit 20260810): the external_instruction_sources // master switch changes the rendered User Context content (external user @@ -5907,6 +5908,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn user_context_cache_identity_layers_remote_and_switch_state() { // remote: and extsrc: are orthogonal scope suffixes: // a remote overlay reconnect and a switch toggle must both invalidate @@ -5926,6 +5928,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn session_user_context_cache_misses_after_external_sources_switch_toggle() { // P2-1 end-to-end guard: the scope key drives the session-level user // context cache. With the switch ON we remember content under the @@ -6110,6 +6113,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn conditional_rules_persist_once_and_reload_after_compaction() { let _environment = lock_environment(); let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs index c55cedbe5..d083524b3 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/session_control_tool.rs @@ -2164,19 +2164,16 @@ Arguments: .iter() .map(|session| session.session_id.as_str()) .collect(); - let metadata_list = match coordinator + let metadata_list = coordinator .session_manager .persistence_manager() .list_session_metadata_including_internal( &std::path::PathBuf::from(&workspace.project_workspace), ) .await - { - Ok(metadata_list) => metadata_list, // 批量读取失败时按“无任何 shortName”处理(与原先逐条 // .ok().flatten() 的最佳努力语义一致,不中断 list 输出)。 - Err(_) => Vec::new(), - }; + .unwrap_or_default(); for metadata in metadata_list { // 仅保留已过滤会话(daemon/warden 已在上方剔除)的 // shortName,保持输出契约不变。 diff --git a/src/crates/assembly/core/src/service/instruction_context.rs b/src/crates/assembly/core/src/service/instruction_context.rs index b62ca7515..c5b19941c 100644 --- a/src/crates/assembly/core/src/service/instruction_context.rs +++ b/src/crates/assembly/core/src/service/instruction_context.rs @@ -338,6 +338,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn conditional_instructions_keep_user_then_workspace_precedence() { let _environment = lock_environment(); // Enable both instruction master switches for this test; the @@ -387,6 +388,7 @@ mod tests { #[cfg(feature = "external-sources")] #[tokio::test] + #[allow(clippy::await_holding_lock)] // environment lock is intentionally held for the whole test body async fn invalid_user_rule_does_not_hide_project_conditional_instructions() { let _environment = lock_environment(); // Enable both instruction master switches for this test; the diff --git a/src/crates/assembly/core/tests/rbac_master_switch.rs b/src/crates/assembly/core/tests/rbac_master_switch.rs index 2199070f7..1321f30c9 100644 --- a/src/crates/assembly/core/tests/rbac_master_switch.rs +++ b/src/crates/assembly/core/tests/rbac_master_switch.rs @@ -153,6 +153,7 @@ fn enforce_tool_runtime_restrictions_active_when_switch_on() { // ============================================================================ #[tokio::test] +#[allow(clippy::await_holding_lock)] // switch guard is intentionally held for the whole test body async fn warden_runtime_off_disables_turn_and_tool_tracking() { let _guard = switch_guard(); let previous = rbac_enabled(); @@ -189,6 +190,7 @@ async fn warden_runtime_off_disables_turn_and_tool_tracking() { } #[tokio::test] +#[allow(clippy::await_holding_lock)] // switch guard is intentionally held for the whole test body async fn warden_runtime_on_keeps_turn_and_tool_tracking() { let _guard = switch_guard(); let previous = rbac_enabled(); diff --git a/src/crates/execution/agent-runtime/src/runtime.rs b/src/crates/execution/agent-runtime/src/runtime.rs index b30052a09..e5a5ca228 100644 --- a/src/crates/execution/agent-runtime/src/runtime.rs +++ b/src/crates/execution/agent-runtime/src/runtime.rs @@ -3008,6 +3008,7 @@ mod tests { } #[tokio::test] + #[allow(clippy::await_holding_lock)] // cancelled_turns guard is intentionally held between two assertions async fn lineage_cancellation_delegates_scope_and_execution_to_one_owner() { let ports = Arc::new(FakeAgentRuntimePorts::default()); let runtime = AgentRuntimeBuilder::new() diff --git a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs index b9dce6fb1..4e003e603 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/relay_deploy.rs @@ -1551,8 +1551,7 @@ mod tests { classify_docker_access, decide_task_status, deploy_body_script_with_image, install_docker_body_script, interactive_driver_script, parse_preflight, prepare_helpers_bash, release_binary_deploy_bash, release_tag_for_version, - split_poll_stdout, stage_scripts_command, sync_source_bash, to_unix_script, - validate_relay_image_descriptor, verified_checksum_exports, verify_minisign, + split_poll_stdout, to_unix_script, validate_relay_image_descriptor, verify_minisign, DockerAccessMode, RelayImageDescriptor, RelayTaskStatus, RELAY_IMAGE_REPOSITORY, RELAY_MIRROR_SH, RELAY_RELEASE_DOWNLOAD_SH, RELEASE_PUBKEY, }; diff --git a/src/crates/services/services-integrations/src/workspace_search/service.rs b/src/crates/services/services-integrations/src/workspace_search/service.rs index f4ece0e85..953a0ba70 100644 --- a/src/crates/services/services-integrations/src/workspace_search/service.rs +++ b/src/crates/services/services-integrations/src/workspace_search/service.rs @@ -368,7 +368,7 @@ impl WorkspaceSearchService { repo_root.display(), pattern_for_log, ); - original + *original } }; @@ -1105,7 +1105,7 @@ fn unknown_repo_status( fn cross_validate_empty_result( validation_request: &super::rg_fallback::RgValidationRequest, result: ContentSearchResult, -) -> Result { +) -> Result> { if !super::rg_fallback::search_result_is_empty(&result) { return Ok(result); } @@ -1116,8 +1116,8 @@ fn cross_validate_empty_result( Ok(Some(outcome)) => outcome, // 预算内无法判定(scope 文件数超预算且前段无命中)或校验不可用: // 保守保留原结果,交给工具层既有判据兜底,不在 service 层放大不确定性。 - Ok(None) => return Err(result), - Err(_) => return Err(result), + Ok(None) => return Err(Box::new(result)), + Err(_) => return Err(Box::new(result)), }; if outcome.total_matches() == 0 { // rg 也确认无命中 = 真实空结果。 From c9bd9812c9dd65ed3437c3347e37515081342d08 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 11 Aug 2026 22:10:34 +0800 Subject: [PATCH 18/39] =?UTF-8?q?fix(deps):=20=E5=8D=87=E7=BA=A7=20ratatui?= =?UTF-8?q?=200.30=20=E4=BF=AE=E5=A4=8D=20Cargo=20Deny=20RUSTSEC-2026-0253?= =?UTF-8?q?=EF=BC=88lru=200.12.5=20unsound=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lru 0.12.5 存在 RUSTSEC-2026-0253(LruCache::pop 非 panic-safe, eviction 触发 UAF/双 free),advisory 要求 lru >=0.18.2。 ratatui 0.29.0 锁定 lru ^0.12 无法满足,连带升级: - ratatui 0.29 -> 0.30(ratatui-core 0.1.2 声明 lru ^0.18,lru 升至 0.18.2) - bitflags 精确锁定 =2.11.1 解除为 ^2(ratatui-core 0.1.2 需 ^2.12) - deny.toml 删除失效的 RUSTSEC-2026-0002 ignore(lru 已升级) - startup.rs: Backend 泛型补 B::Error: Send+Sync+'static 约束 (ratatui 0.30 Backend trait 新增关联 Error 类型) 验证(1.97.1):cargo check --workspace 0 error;cli 677 单测通过 (3 个 plugin_source_cli 失败为 Windows 平台 pre-existing,基线同失败); cargo deny advisories/licenses/sources 全过;clippy warning 数零新增。 --- Cargo.lock | 743 +++++++++++++++++++++++++++------ Cargo.toml | 4 +- deny.toml | 1 - src/apps/cli/src/ui/startup.rs | 5 +- 4 files changed, 621 insertions(+), 132 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c53dd915..b020c4be3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -257,6 +257,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -494,6 +503,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -721,15 +739,30 @@ dependencies = [ "which 4.4.2", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -750,9 +783,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -991,7 +1024,7 @@ dependencies = [ "bitfun-services-core", "chrono", "clap", - "crossterm", + "crossterm 0.28.1", "dirs 6.0.0", "dunce", "flate2", @@ -1020,7 +1053,7 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", "tracing", "tracing-subscriber", - "unicode-width 0.2.0", + "unicode-width", "url", "uuid", "windows 0.61.3", @@ -1134,7 +1167,7 @@ dependencies = [ "atspi", "axum", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "bitfun-acp", "bitfun-agent-runtime", "bitfun-agent-tools", @@ -1846,6 +1879,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "bytemuck" version = "1.25.2" @@ -1908,7 +1947,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cairo-sys-rs", "glib", "libc", @@ -1987,12 +2026,6 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - [[package]] name = "castaway" version = "0.2.4" @@ -2264,20 +2297,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "compact_str" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "rustversion", - "ryu", - "static_assertions", -] - [[package]] name = "compact_str" version = "0.9.1" @@ -2393,7 +2412,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -2417,7 +2436,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -2470,6 +2489,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "cron" version = "0.15.0" @@ -2530,7 +2555,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -2540,6 +2565,24 @@ dependencies = [ "winapi", ] +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.1", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix 1.1.4", + "signal-hook", + "signal-hook-mio", + "winapi", +] + [[package]] name = "crossterm_winapi" version = "0.9.1" @@ -2578,6 +2621,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf 0.11.3", +] + [[package]] name = "cssparser" version = "0.36.0" @@ -2858,6 +2911,12 @@ dependencies = [ "thiserror 2.0.19", ] +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -3024,7 +3083,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3093,13 +3152,22 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.36.0", "foldhash 0.2.0", "html5ever 0.36.1", @@ -3114,7 +3182,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser 0.37.0", "foldhash 0.2.0", "html5ever 0.39.0", @@ -3484,6 +3552,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set 0.5.3", + "regex", +] + [[package]] name = "fast-float2" version = "0.2.3" @@ -3573,6 +3651,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -3776,7 +3866,7 @@ version = "7.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "futures-core", "futures-lite", "pin-project", @@ -4005,7 +4095,7 @@ version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -4126,7 +4216,7 @@ version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddddbf932745a6be37109b6112d3ee09696106f848449069d3a57bba937ab82e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "libgit2-sys", "log", @@ -4141,7 +4231,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "futures-channel", "futures-core", "futures-executor", @@ -4401,6 +4491,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -4952,7 +5044,7 @@ version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "inotify-sys", "libc", ] @@ -5061,9 +5153,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] @@ -5323,6 +5415,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror 2.0.19", +] + [[package]] name = "keepawake" version = "0.6.0" @@ -5344,7 +5447,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "serde", "unicode-segmentation", ] @@ -5374,7 +5477,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", ] @@ -5390,6 +5493,12 @@ dependencies = [ "smallvec", ] +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -5586,6 +5695,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -5604,6 +5722,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "local-ip-address" version = "0.6.13" @@ -5637,7 +5761,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67513274c50a2b51e5f75d9e682fcf4ab064a8a9c9ae2c3c59309084882bb24d" dependencies = [ "aes", - "bitflags 2.11.1", + "bitflags 2.13.1", "cbc", "chrono", "ecb", @@ -5663,11 +5787,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.17.1", ] [[package]] @@ -5835,6 +5959,12 @@ dependencies = [ "libc", ] +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + [[package]] name = "memoffset" version = "0.6.5" @@ -5958,7 +6088,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -5988,7 +6118,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "derive_builder", "getset", @@ -6050,7 +6180,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -6062,7 +6192,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if", "cfg_aliases 0.2.2", "libc", @@ -6100,7 +6230,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -6132,7 +6262,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -6201,6 +6331,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -6332,7 +6473,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -6363,7 +6504,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation", ] @@ -6386,7 +6527,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -6396,7 +6537,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation", ] @@ -6407,7 +6548,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "libc", @@ -6420,7 +6561,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -6453,7 +6594,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-audio", @@ -6478,7 +6619,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -6490,7 +6631,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -6518,7 +6659,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -6542,7 +6683,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "libc", @@ -6556,7 +6697,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -6577,7 +6718,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-app-kit", "objc2-foundation", @@ -6589,7 +6730,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation", @@ -6601,7 +6742,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -6612,7 +6753,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -6662,7 +6803,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit", @@ -6690,7 +6831,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "once_cell", "onig_sys", @@ -6763,6 +6904,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "ordered-multimap" version = "0.4.3" @@ -6854,7 +7004,7 @@ dependencies = [ "textwrap", "thiserror 2.0.19", "unicode-segmentation", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -6886,7 +7036,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22bf885a47f8e0562ae73e0487ec8f83358bbbca8aad99a8293a9919d6d9e7fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "oxc_allocator", "oxc_ast_macros", "oxc_data_structures", @@ -6928,7 +7078,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b9feb869721e14ab8484c697372c468a3df3cb96ca8c02bfe34981fc8d614e9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cow-utils", "dragonbox_ecma", "itoa", @@ -7015,7 +7165,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd79d7f27f20413eaeecada4d850aeb0220e70c821d4863d9fb0df115eae62a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cow-utils", "memchr", "num-bigint", @@ -7039,7 +7189,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95bee883f864fb7c75d92ccae3b7db98afc4dce5d0b8b5165e681d93cb010399" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "oxc_allocator", "oxc_ast_macros", "oxc_diagnostics", @@ -7092,7 +7242,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8faf48e243cdacc96018515701bea560664a4cdef545c1fc50be139c0637db13" dependencies = [ - "compact_str 0.9.1", + "compact_str", "oxc-miette", "oxc_allocator", "oxc_ast_macros", @@ -7106,7 +7256,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7beac249fbb9815b974f1b1c2d22cb59be0c2a4a2ad42f1ee948e2600f4ff04c" dependencies = [ - "compact_str 0.9.1", + "compact_str", "hashbrown 0.17.1", "oxc_allocator", "oxc_estree", @@ -7118,7 +7268,7 @@ version = "0.138.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbe873bf4a3e494a56ec34f2710cb44309a071fd2c8360b893a12347d584c34" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cow-utils", "dragonbox_ecma", "nonmax", @@ -7139,7 +7289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a71b406724d9e1b3bdf8c700207524268a232af7e13302aeea53f98168e05d" dependencies = [ "base64 0.22.1", - "compact_str 0.9.1", + "compact_str", "hmac-sha1-compact", "indexmap 2.14.0", "itoa", @@ -7242,6 +7392,39 @@ dependencies = [ "sha2", ] +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + [[package]] name = "pango" version = "0.18.3" @@ -7391,6 +7574,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.12.1" @@ -7422,6 +7657,16 @@ dependencies = [ "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" @@ -7432,6 +7677,16 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -7452,6 +7707,19 @@ dependencies = [ "phf_shared 0.14.0", ] +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "phf_macros" version = "0.13.1" @@ -7478,6 +7746,15 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -7630,7 +7907,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -7859,7 +8136,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "679341d22c78c6c649893cbd6c3278dcbe9fc4faa62fea3a9296ae2b50c14625" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -8124,23 +8401,89 @@ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "ratatui" -version = "0.29.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" dependencies = [ - "bitflags 2.11.1", - "cassowary", - "compact_str 0.8.2", - "crossterm", - "indoc", "instability", - "itertools 0.13.0", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.1", + "compact_str", + "critical-section", + "hashbrown 0.17.1", + "itertools 0.14.0", + "kasuari", "lru", - "paste", - "strum 0.26.3", + "palette", + "serde", + "strum 0.28.0", + "thiserror 2.0.19", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm 0.29.0", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum 0.27.2", + "time", + "unicode-segmentation", + "unicode-width", ] [[package]] @@ -8149,7 +8492,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -8201,7 +8544,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -8549,7 +8892,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink 0.9.1", @@ -8566,7 +8909,7 @@ dependencies = [ "aes", "aes-gcm", "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.1", "byteorder", "cbc", "chacha20 0.9.1", @@ -8667,7 +9010,7 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed8949eca4163c18a8f59ff96d32cf61e9c13b9735e21ef32b3907f4aafa1a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "chrono", "dashmap", @@ -8718,7 +9061,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -8731,7 +9074,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -9000,7 +9343,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9023,13 +9366,13 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cssparser 0.36.0", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.3", "servo_arc", @@ -9042,13 +9385,13 @@ version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cssparser 0.37.0", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash 2.1.3", "servo_arc", @@ -9299,7 +9642,7 @@ dependencies = [ "ioctl-rs", "libc", "serial-core", - "termios", + "termios 0.2.2", ] [[package]] @@ -9871,11 +10214,11 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.26.4", + "strum_macros 0.27.2", ] [[package]] @@ -9889,14 +10232,13 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "rustversion", "syn 2.0.119", ] @@ -10016,7 +10358,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -10050,7 +10392,7 @@ version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9fa4618f999c4249db1681cba0a19b890718f274de7fa93c445d46bd3a8a999" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -10415,7 +10757,7 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "log", "serde", "serde_json", @@ -10602,6 +10944,18 @@ dependencies = [ "win32job", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom 7.1.3", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + [[package]] name = "termios" version = "0.2.2" @@ -10611,6 +10965,57 @@ dependencies = [ "libc", ] +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bitflags 2.13.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset 0.4.2", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix 0.29.0", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf 0.11.3", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios 0.3.3", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + [[package]] name = "tesseract-plumbing" version = "0.8.0" @@ -10642,7 +11047,7 @@ checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" dependencies = [ "smawk", "unicode-linebreak", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -11055,7 +11460,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", @@ -11266,6 +11671,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.2.1" @@ -11348,21 +11759,15 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-truncate" -version = "1.1.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools 0.13.0", + "itertools 0.14.0", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.0" @@ -11490,6 +11895,7 @@ version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ + "atomic", "getrandom 0.4.3", "js-sys", "serde_core", @@ -11553,12 +11959,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags 2.11.1", + "bitflags 2.13.1", "cursor-icon", "log", "memchr", ] +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -11762,7 +12177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" dependencies = [ "phf 0.13.1", - "phf_codegen", + "phf_codegen 0.13.1", "string_cache", "string_cache_codegen", ] @@ -11880,6 +12295,78 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "which" version = "4.4.2" diff --git a/Cargo.toml b/Cargo.toml index e0b0012a8..5d48c7347 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,7 +120,7 @@ alloc-stdlib = "=0.2.2" regex = "1" base64 = "0.22" # Keep macOS Tauri's dispatch2/bitflags expansion on the known-good bitflags release. -bitflags = "=2.11.1" +bitflags = "2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } md5 = "0.7" dashmap = "6" @@ -170,7 +170,7 @@ portable-pty = "0.8" vte = "0.15.0" clap = { version = "4.6.1", features = ["derive"] } crossterm = "0.28" -ratatui = "0.29" +ratatui = "0.30" unicode-width = "0.2" pulldown-cmark = "0.11" syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "regex-onig"] } diff --git a/deny.toml b/deny.toml index d4f9d0a8b..a09017fd9 100644 --- a/deny.toml +++ b/deny.toml @@ -34,7 +34,6 @@ ignore = [ { id = "RUSTSEC-2026-0195", reason = "quick-xml NsReader OOM advisory; same quick-xml version constraint as RUSTSEC-2026-0194" }, { id = "RUSTSEC-2026-0186", reason = "memmap2 0.7/0.8 pinned by screenshots/enigo on Linux desktop; fix 0.9.11 is a breaking jump" }, { id = "RUSTSEC-2026-0187", reason = "lopdf 0.41 pinned by anydoc 0.1.6 (document conversion); fix 0.42 is a breaking jump" }, - { id = "RUSTSEC-2026-0002", reason = "lru 0.12 pinned by ratatui 0.29 (CLI TUI); fix 0.16.3 is a breaking jump" }, ] [bans] diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 3c9f34dde..ed4a661dd 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -361,7 +361,10 @@ impl StartupPage { || self.login_form.is_visible() } - pub(crate) fn run(&mut self, terminal: &mut Terminal) -> Result { + pub(crate) fn run(&mut self, terminal: &mut Terminal) -> Result + where + B::Error: Send + Sync + 'static, + { terminal.clear()?; let mut event_reader = crate::ui::input::EventReader::default(); From e60d21b731c3424f83a124533a8da1062c3a24a7 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 11 Aug 2026 22:23:57 +0800 Subject: [PATCH 19/39] =?UTF-8?q?fix:=20Windows=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E6=AC=A0=E8=B4=A6=E4=BF=AE=E5=A4=8D=E6=90=AC?= =?UTF-8?q?=E7=A7=BB=20taiji?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../execution/tool-execution/src/context.rs | 4 +- .../src/plugin_source.rs | 87 +++++++++++++------ 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/crates/execution/tool-execution/src/context.rs b/src/crates/execution/tool-execution/src/context.rs index 84d4f420e..50b92dd1b 100644 --- a/src/crates/execution/tool-execution/src/context.rs +++ b/src/crates/execution/tool-execution/src/context.rs @@ -174,7 +174,9 @@ mod tests { extension_custom_data: Some(&extension_custom_data), }); - assert_eq!(custom_data["delegation_allow_subagent_spawn"], json!(false)); + // DelegationPolicy::spawn_child() permits nesting while the child depth + // stays below MAX_FISSION_DEPTH=10, so the depth-1 child may still spawn. + assert_eq!(custom_data["delegation_allow_subagent_spawn"], json!(true)); assert_eq!(custom_data["delegation_nesting_depth"], json!(1)); assert_eq!(custom_data["turn_index"], json!(7)); assert_eq!(custom_data["acp_transport"], json!(true)); diff --git a/src/crates/services/services-integrations/src/plugin_source.rs b/src/crates/services/services-integrations/src/plugin_source.rs index 4d02701c6..6c085053d 100644 --- a/src/crates/services/services-integrations/src/plugin_source.rs +++ b/src/crates/services/services-integrations/src/plugin_source.rs @@ -2326,22 +2326,43 @@ fn replace_file_atomically(temp_path: &Path, target_path: &Path) -> io::Result<( MoveFileExW, ReplaceFileW, MOVEFILE_WRITE_THROUGH, REPLACEFILE_WRITE_THROUGH, }; - let temp = temp_path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let target = target_path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); + // The trust store can sit below the 260-char Win32 path limit for long + // workspace slugs (long-slug project runtime roots under a deep temp dir). + // The raw Win32 calls below do not apply the extended-length `\\?\` + // prefix the way std file APIs do, so normalize each path through its + // (existing) parent directory first (`dunce::canonicalize` also resolves + // mixed absolute/relative segments produced by test fixtures such as + // `tempdir().join("user/runtime/plugin-trust.json")`; the target file may + // not exist yet on first persist, hence the parent fallback) and then + // force the `\\?\` prefix on all three paths so ReplaceFileW/MoveFileExW + // never see a mix of prefixed and unprefixed forms, which fails with + // ERROR_PATH_NOT_FOUND. + fn extended(path: &Path) -> Vec { + let normalized = match dunce::canonicalize(path) { + Ok(path) => path, + Err(_) => path + .parent() + .and_then(|parent| dunce::canonicalize(parent).ok()) + .map(|parent| parent.join(path.file_name().unwrap_or_default())) + .unwrap_or_else(|| path.to_path_buf()), + }; + let os = if normalized.to_string_lossy().starts_with(r"\\?\") { + normalized.into_os_string() + } else { + let mut os = std::ffi::OsString::from(r"\\?\"); + os.push(&normalized); + os + }; + os.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>() + } + + let temp = extended(temp_path); + let target = extended(target_path); let backup_path = temp_path.with_extension("backup"); - let backup = backup_path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); + let backup = extended(&backup_path); // SAFETY: `target`, `temp` and `backup` are NUL-terminated wide strings // allocated above; the Win32 calls only read them for the duration of the // call and require no Rust-side aliasing. @@ -2389,16 +2410,32 @@ fn restore_windows_backup_after_replace_failure( }; if backup_path.exists() { - let backup = backup_path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); - let target = target_path - .as_os_str() - .encode_wide() - .chain(std::iter::once(0)) - .collect::>(); + // Match `replace_file_atomically`: canonicalize through the parent + // and force the `\\?\` prefix so the raw Win32 call gets a consistent + // extended-length form even when the backup file does not exist yet. + fn extended(path: &Path) -> Vec { + let normalized = match dunce::canonicalize(path) { + Ok(path) => path, + Err(_) => path + .parent() + .and_then(|parent| dunce::canonicalize(parent).ok()) + .map(|parent| parent.join(path.file_name().unwrap_or_default())) + .unwrap_or_else(|| path.to_path_buf()), + }; + let os = if normalized.to_string_lossy().starts_with(r"\\?\") { + normalized.into_os_string() + } else { + let mut os = std::ffi::OsString::from(r"\\?\"); + os.push(&normalized); + os + }; + os.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>() + } + let backup = extended(backup_path); + let target = extended(target_path); // SAFETY: `backup` and `target` are NUL-terminated wide strings // allocated above; both remain valid for the duration of the call. let restore = unsafe { From 763680a974a0ec9c500fa03e0fc84f165a15f262 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 07:04:24 -0700 Subject: [PATCH 20/39] fix(computer-use): restore text-only observation and unblock the Enter path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single Feishu "send one message" run took 73 tool round-trips. Tracing the log back, most of them came from four defects that compound. **`describe_screen` was blind on macOS.** `macos_foreground_application` built its AppleScript with a `try … end try` *block* in expression position, which AppleScript rejects at compile time (-2741). The command always exited non-zero, so the function always returned `None` — and `describe_screen` derives its target app from that value. Every call reported `foreground_application: null` and `ax_tree_text: null`. The agent reasonably concluded its own tool output was being truncated, stopped trusting results, and fell back to `screencapture` + image-analysis as a substitute for eyes: 10 extra model round-trips that returned prose instead of coordinates and misread the screen repeatedly. Replaced with an in-process `NSWorkspace.frontmostApplication` read, and did the same for `macos_ax_ui::frontmost_pid`, which shelled out to `osascript` on every call — three spawns per `describe_screen`, each ~120ms, each able to block on a System Events AppleEvent timeout. **Enter was permanently refused in text-only mode.** The stale-capture guard is cleared only by a successful capture, but a text-only `screenshot` short-circuits before capturing. So the guard latched: its own error said "call `screenshot` first", calling it changed nothing, and every `click`/Enter stayed blocked. The observed escape was the agent bypassing the tool with raw `osascript … keystroke return`, which skips every check the guard exists to enforce. `describe_screen` (the text-only equivalent of looking) and the text-only `screenshot` stub now waive it. `paste` also clears it unconditionally instead of only when `submit:true` — paste-then-Enter is the common shape and the pointer never moved. **Results were mostly duplicate.** Every `app_state` carried both `tree_text` and `app_state_nodes`, the same nodes re-serialised as verbose JSON. `render_tree_text` already emits every addressable field, nothing consumed the array, and it was 82 KB of a 107 KB result. Dropped, `node_count` kept. **`get_app_state` returned mostly closed menus.** Observing a windowless app produced 188 nodes, 180 of them menu items at zero-size off-screen frames — unclickable until the menu opens. Closed `AXMenu` subtrees are no longer walked; the container stays visible and a note points at `get_app_shortcuts`. Also: `open_app` reported `success: true` for an app running with no window, which is how this run lost ~15 calls rediscovering that `activate` does not reopen an Electron window. It now resolves the bundle id (the launch name, executable name and bundle id are routinely three different strings), polls for a window, retries via `open -b`, and reports `window_count` / `windowless`. Tests: AppleScript templates are now compile-checked with `osacompile`, which catches exactly the class of bug above without executing anything. Drive-by: `embedded_relay_host` tests reserved an ephemeral port, dropped the listener, then assumed it was still free. Harmless on an idle machine and ~80% failing once the suite spawns subprocesses. Port acquisition and the release assertions now retry. --- .../src/computer_use/desktop_host/mod.rs | 357 +++++++++++++++--- .../desktop/src/computer_use/macos_ax_dump.rs | 76 +++- .../desktop/src/computer_use/macos_ax_ui.rs | 64 +++- .../src/computer_use/macos_bg_input.rs | 126 ++++++- src/apps/desktop/src/embedded_relay_host.rs | 102 +++-- .../src/agentic/tools/computer_use_host.rs | 13 + .../implementations/computer_use_actions.rs | 34 +- .../implementations/computer_use_tool.rs | 285 +++++++++++++- .../execution/agent-runtime/src/prompt.rs | 9 + .../tool-contracts/src/computer_use.rs | 22 ++ 10 files changed, 961 insertions(+), 127 deletions(-) diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index 929303c55..defde3460 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -36,6 +36,37 @@ const STALE_CAPTURE_TOOL_MESSAGE: &str = "Computer use refused: call **`screensh static SCREENSHOT_ID_COUNTER: AtomicU64 = AtomicU64::new(1); +/// How long `open_app` waits for a freshly activated app to show up in +/// LaunchServices before giving up on resolving its pid. +#[cfg(target_os = "macos")] +const OPEN_APP_SETTLE_MS: u64 = 3_000; +/// How long `open_app` waits for the app to put a window on screen. Cold +/// Electron launches routinely need several seconds; reporting `window_count: +/// 0` too early would send the agent down a false "app is broken" path. +#[cfg(target_os = "macos")] +const OPEN_APP_WINDOW_WAIT_MS: u64 = 8_000; +#[cfg(target_os = "macos")] +const OPEN_APP_POLL_INTERVAL_MS: u64 = 150; + +/// Quote a string as an AppleScript literal. +/// +/// App names reach us from the model and can contain quotes or backslashes; +/// interpolating them raw would let a name break out of the string and change +/// what the script does. +#[cfg(target_os = "macos")] +fn applescript_quote(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for ch in s.chars() { + if ch == '"' || ch == '\\' { + out.push('\\'); + } + out.push(ch); + } + out.push('"'); + out +} + #[cfg(test)] mod visual_grid_tests { use super::*; @@ -109,6 +140,96 @@ mod visual_grid_tests { } } +#[cfg(all(test, target_os = "macos"))] +mod macos_applescript_tests { + use super::*; + + /// Compile an AppleScript source **without running it**. `osacompile` + /// reports the same syntax errors `osascript` would, so this checks that a + /// template is valid AppleScript with no side effects. + fn compiles(script: &str) -> Result<(), String> { + let out = std::process::Command::new("/usr/bin/osacompile") + .args(["-o", "/dev/null", "-e", script]) + .output() + .map_err(|e| format!("spawn osacompile: {e}"))?; + if out.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) + } + } + + /// The bug that motivated this test: the frontmost-app lookup embedded a + /// `try … end try` **block** in expression position. AppleScript rejects + /// that at compile time, so the command always exited non-zero and the + /// caller silently saw `None` — for every call, forever. Nothing in the + /// build or the test suite noticed, because an AppleScript template is just + /// a string until something runs it. + /// + /// Every AppleScript this module generates now has to compile. + #[test] + fn every_generated_applescript_compiles() { + let templates = [ + format!("id of application {}", applescript_quote("Safari")), + format!( + "tell application {} to activate", + applescript_quote("Safari") + ), + ]; + for t in templates { + assert!(compiles(&t).is_ok(), "template failed to compile: {t}"); + } + } + + #[test] + fn applescript_compile_check_actually_rejects_bad_syntax() { + // Guards the guard: if `compiles` ever silently passed everything, the + // test above would be worthless. This is the exact broken spelling. + let broken = r#"tell application "System Events" + return (try (bundle identifier of p as text) on error "" end try) +end tell"#; + assert!(compiles(broken).is_err()); + } + + #[test] + fn applescript_quote_escapes_quotes_and_backslashes() { + // App names come from the model, so a name containing a quote must not + // be able to terminate the literal and change what the script does. + assert_eq!(applescript_quote("Safari"), "\"Safari\""); + assert_eq!(applescript_quote("a\"b"), "\"a\\\"b\""); + assert_eq!(applescript_quote("a\\b"), "\"a\\\\b\""); + assert_eq!(applescript_quote("飞书"), "\"飞书\""); + } + + #[test] + fn quoted_app_names_stay_inside_the_literal() { + // `" to activate` + a payload would otherwise become script code. + let hostile = "X\" to activate\ntell application \"Calculator"; + let script = format!( + "tell application {} to activate", + applescript_quote(hostile) + ); + assert!( + !script.contains("tell application \"Calculator\""), + "injected tell survived quoting: {script}" + ); + } + + /// The foreground lookup must return a real app in a GUI session. Ignored + /// by default because it needs a logged-in window server. + #[test] + #[ignore] + fn frontmost_application_resolves_in_a_gui_session() { + let app = DesktopComputerUseHost::macos_foreground_application() + .expect("a GUI session always has a frontmost application"); + assert!(app.process_id.unwrap_or(0) > 0); + assert!( + app.name.is_some() || app.bundle_id.is_some(), + "frontmost app must be identifiable: {app:?}" + ); + } +} + #[cfg(all(test, target_os = "windows"))] mod windows_foreground_tests { use super::*; @@ -128,8 +249,11 @@ mod windows_foreground_tests { #[test] fn foreground_app_falls_back_to_title_only_when_process_lookup_fails() { - let app = - DesktopComputerUseHost::windows_foreground_application("Search".to_string(), 4242, None); + let app = DesktopComputerUseHost::windows_foreground_application( + "Search".to_string(), + 4242, + None, + ); assert_eq!(app.name.as_deref(), Some("Search")); assert_eq!(app.process_name, None); @@ -411,33 +535,169 @@ impl DesktopComputerUseHost { } } + /// Launch (or re-front) a macOS app and report enough identity for the + /// agent to keep working with it. + /// + /// Three things the previous implementation got wrong, each of which cost + /// the agent a long recovery detour: + /// + /// 1. It reported only a pid. The name the caller launches by, the + /// executable name and the bundle id are frequently three different + /// strings (`Lark` / `Feishu` / `com.electron.lark`), so every follow-up + /// `tell process "…"` or `open -a …` guessed wrong. + /// 2. It slept a flat `delay 1` and declared success, whether or not a + /// window ever appeared. + /// 3. `activate` does not reopen a window for an app that is already + /// running with none — the usual state for an Electron client the user + /// closed earlier. The result was `success: true` with an empty screen. + /// + /// So: resolve the bundle id via LaunchServices, activate, poll for a + /// window, and re-open the bundle when the poll comes up empty. #[cfg(target_os = "macos")] - fn macos_foreground_application() -> Option { - let out = std::process::Command::new("/usr/bin/osascript") - .args(["-e", r#"tell application "System Events" - set p to first process whose frontmost is true - return (unix id of p as text) & "|" & (name of p) & "|" & (try (bundle identifier of p as text) on error "" end try) -end tell"#]) + fn open_app_macos( + name: String, + ) -> BitFunResult { + use crate::computer_use::macos_bg_input::running_app_identity_macos; + use bitfun_core::agentic::tools::computer_use_host::OpenAppResult; + + let failure = |err: String| OpenAppResult { + app_name: name.clone(), + success: false, + process_id: None, + error_message: Some(err), + bundle_id: None, + process_name: None, + window_count: None, + launch_path: None, + }; + + // `id of application "X"` asks LaunchServices to resolve the name the + // same way `tell application "X"` will, so the bundle id we report is + // guaranteed to describe the app we are about to activate. + let bundle_id = std::process::Command::new("/usr/bin/osascript") + .args([ + "-e", + &format!("id of application {}", applescript_quote(&name)), + ]) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .filter(|s| !s.is_empty()); + + let activate = std::process::Command::new("/usr/bin/osascript") + .args([ + "-e", + &format!("tell application {} to activate", applescript_quote(&name)), + ]) .output() - .ok()?; - if !out.status.success() { - return None; - } - let s = String::from_utf8_lossy(&out.stdout); - let parts: Vec<&str> = s.trim().splitn(3, '|').collect(); - if parts.len() < 2 { - return None; - } - let pid = parts[0].trim().parse::().ok()?; - let name = parts[1].trim(); - let bundle = parts.get(2).map(|x| x.trim()).filter(|x| !x.is_empty()); + .map_err(|e| BitFunError::tool(format!("open_app osascript: {}", e)))?; + if !activate.status.success() { + return Ok(failure( + String::from_utf8_lossy(&activate.stderr).trim().to_string(), + )); + } + + let mut launch_path = "activate"; + // Resolving by bundle id beats "whoever is frontmost right now" — + // activation is asynchronous, so the frontmost app during the first + // poll ticks is often still the previous one. + let mut pid = Self::poll_for_app_pid(bundle_id.as_deref(), OPEN_APP_SETTLE_MS); + let mut window_count = Self::poll_for_window(pid, OPEN_APP_WINDOW_WAIT_MS); + + // Alive but windowless: `open -b` asks the app to reopen its main + // window (AppKit `applicationShouldHandleReopen:`), which `activate` + // alone never triggers. + if window_count == Some(0) { + if let Some(bid) = bundle_id.as_deref() { + let reopened = std::process::Command::new("/usr/bin/open") + .args(["-b", bid]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if reopened { + launch_path = "reopen_bundle"; + pid = Self::poll_for_app_pid(Some(bid), OPEN_APP_WINDOW_WAIT_MS).or(pid); + window_count = Self::poll_for_window(pid, OPEN_APP_WINDOW_WAIT_MS); + } + } + } + + let (localized_name, resolved_bundle) = pid + .and_then(running_app_identity_macos) + .unwrap_or((None, None)); + + Ok(OpenAppResult { + app_name: name, + success: true, + process_id: pid, + error_message: None, + bundle_id: resolved_bundle.or(bundle_id), + process_name: localized_name, + window_count, + launch_path: Some(launch_path.to_string()), + }) + } + + /// Poll until the app owning `bundle_id` is running (or the frontmost app + /// settles, when no bundle id could be resolved). Returns its pid. + #[cfg(target_os = "macos")] + fn poll_for_app_pid(bundle_id: Option<&str>, budget_ms: u64) -> Option { + use crate::computer_use::macos_bg_input::{frontmost_pid_macos, pid_for_bundle_id_macos}; + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(budget_ms); + loop { + let found = match bundle_id { + Some(bid) => pid_for_bundle_id_macos(bid), + None => frontmost_pid_macos(), + }; + if found.is_some() { + return found; + } + if std::time::Instant::now() >= deadline { + return None; + } + std::thread::sleep(std::time::Duration::from_millis(OPEN_APP_POLL_INTERVAL_MS)); + } + } + + /// Poll until the app owns at least one window, or the budget expires. + /// Returns the final observed count so callers can report `Some(0)` — a + /// windowless-but-alive app is a real state, not a failure to measure. + #[cfg(target_os = "macos")] + fn poll_for_window(pid: Option, budget_ms: u64) -> Option { + use crate::computer_use::macos_ax_ui::window_count_for_pid; + let pid = pid?; + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(budget_ms); + let mut last = window_count_for_pid(pid); + while last.unwrap_or(0) == 0 && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(OPEN_APP_POLL_INTERVAL_MS)); + last = window_count_for_pid(pid); + } + last + } + + /// Identity of the frontmost macOS application, read from `NSWorkspace`. + /// + /// This used to shell out to `osascript`. That spelling embedded a + /// `try … end try` **block** in expression position, which AppleScript + /// rejects at compile time (`-2741`), so the command always exited + /// non-zero and this function always returned `None`. Because + /// `describe_screen` derives its target app from this value, the text-only + /// observation path was permanently blind: it reported + /// `foreground_application: null` and `ax_tree_text: null` on every call, + /// and the agent could only conclude its own output was being truncated. + #[cfg(target_os = "macos")] + fn macos_foreground_application() -> Option { + let app = crate::computer_use::macos_bg_input::frontmost_app_identity_macos()?; Some(ComputerUseForegroundApplication { - name: Some(name.to_string()), - // `name of p` from System Events is already the process name, not a - // window title, so it doubles as the process identity here. - process_name: Some(name.to_string()), - bundle_id: bundle.map(|b| b.to_string()), - process_id: Some(pid), + name: app.name.clone(), + // `localizedName` is the app's user-visible name ("飞书"), which on + // localised or re-branded bundles differs from both the executable + // name ("Feishu") and the bundle name ("Lark"). Callers that need + // to address the process by name should prefer `bundle_id`. + process_name: app.name, + bundle_id: app.bundle_id, + process_id: Some(app.pid), }) } @@ -477,7 +737,11 @@ end tell"#]) } else { crate::computer_use::windows_list_apps::exe_basename_for_pid(pid) }; - Some(Self::windows_foreground_application(title, pid, exe_basename)) + Some(Self::windows_foreground_application( + title, + pid, + exe_basename, + )) }; ComputerUseSessionSnapshot { @@ -1176,37 +1440,7 @@ impl ComputerUseHost for DesktopComputerUseHost { #[cfg(target_os = "macos")] { let result = tokio::task::spawn_blocking(move || -> BitFunResult { - let output = std::process::Command::new("/usr/bin/osascript") - .args([ - "-e", - &format!( - r#"tell application "{}" to activate -delay 1 -tell application "System Events" to get unix id of first process whose frontmost is true"#, - name - ), - ]) - .output() - .map_err(|e| BitFunError::tool(format!("open_app osascript: {}", e)))?; - - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let pid = stdout.trim().parse::().ok(); - Ok(OpenAppResult { - app_name: name, - success: true, - process_id: pid, - error_message: None, - }) - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - Ok(OpenAppResult { - app_name: name, - success: false, - process_id: None, - error_message: Some(stderr.trim().to_string()), - }) - } + Self::open_app_macos(name) }) .await .map_err(|e| BitFunError::tool(e.to_string()))??; @@ -1398,6 +1632,13 @@ tell application "System Events" to get unix id of first process whose frontmost } } + fn computer_use_waive_fresh_capture_guard(&self) { + if let Ok(mut s) = self.state.lock() { + s.click_needs_fresh_screenshot = false; + s.pending_verify_screenshot = false; + } + } + fn computer_use_guard_click_allowed(&self) -> BitFunResult<()> { let s = self .state diff --git a/src/apps/desktop/src/computer_use/macos_ax_dump.rs b/src/apps/desktop/src/computer_use/macos_ax_dump.rs index 7298eeffb..8d8461ed7 100644 --- a/src/apps/desktop/src/computer_use/macos_ax_dump.rs +++ b/src/apps/desktop/src/computer_use/macos_ax_dump.rs @@ -431,6 +431,15 @@ pub(super) struct DumpOpts { pub max_depth: u32, pub max_nodes: usize, pub focus_window_only: bool, + /// Walk into menus that are currently closed. Off by default. + /// + /// A closed `AXMenu` still reports its whole item hierarchy, but every item + /// comes back collapsed at a zero-size off-screen frame — unclickable until + /// the menu is opened, and useless for addressing. They dominate the dump + /// anyway: observing a windowless app produced 188 nodes, 180 of them + /// closed menu items. `get_app_shortcuts` is the supported way to read menu + /// structure (and walks menus itself), so `get_app_state` stops at the menu. + pub include_closed_menus: bool, } impl Default for DumpOpts { @@ -439,10 +448,28 @@ impl Default for DumpOpts { max_depth: 32, max_nodes: 4_000, focus_window_only: false, + include_closed_menus: false, } } } +/// Whether a node is a menu container that is not currently open, and whose +/// children are therefore off-screen and unclickable. +/// +/// macOS gives an open menu's items real on-screen frames; a closed one leaves +/// them at a zero-size origin. Size is the reliable signal here — `AXExpanded` +/// is not exposed consistently by `AXMenu` across apps. +fn is_closed_menu_container(role: &str, frame: Option<(f64, f64, f64, f64)>) -> bool { + if role != "AXMenu" { + return false; + } + match frame { + // No frame at all: treat as closed. + None => true, + Some((_, _, w, h)) => w < 1.0 || h < 1.0, + } +} + pub(super) fn dump_app_ax(pid: i32, opts: DumpOpts) -> BitFunResult { let app = unsafe { AXUIElementCreateApplication(pid) }; if app.is_null() { @@ -501,6 +528,7 @@ pub(super) fn dump_app_ax(pid: i32, opts: DumpOpts) -> BitFunResult opts.max_depth || visited >= opts.max_nodes { @@ -530,10 +558,13 @@ pub(super) fn dump_app_ax(pid: i32, opts: DumpOpts) -> BitFunResult BitFunResult BitFunResult 0 { + tree_text.push_str(&format!( + "\n[note] {} closed menu subtree(s) omitted — their items are off-screen and \ +unclickable until the menu opens. Use `get_app_shortcuts` for menu commands and their key \ +equivalents, or AXPress the menu first.\n", + pruned_menu_subtrees + )); + } let digest = compute_digest(&nodes); let captured_at_ms = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -887,6 +937,28 @@ mod tests { assert!(out.contains("actions=[AXShowMenu]")); } + #[test] + fn closed_menus_are_pruned_but_open_ones_are_kept() { + // A closed menu reports its items at a zero-size off-screen frame. + assert!(is_closed_menu_container( + "AXMenu", + Some((0.0, 982.0, 0.0, 0.0)) + )); + assert!(is_closed_menu_container("AXMenu", None)); + // An open menu has a real frame and must still be walked. + assert!(!is_closed_menu_container( + "AXMenu", + Some((100.0, 40.0, 220.0, 380.0)) + )); + // Only menus are ever pruned — a zero-size button is still a node the + // model may need to reason about. + assert!(!is_closed_menu_container( + "AXButton", + Some((0.0, 0.0, 0.0, 0.0)) + )); + assert!(!is_closed_menu_container("AXMenuItem", None)); + } + #[test] fn quote_clip_truncates_on_char_boundary() { let s = "中文字符测试abcdef"; diff --git a/src/apps/desktop/src/computer_use/macos_ax_ui.rs b/src/apps/desktop/src/computer_use/macos_ax_ui.rs index 721b6a1e4..e2b899455 100644 --- a/src/apps/desktop/src/computer_use/macos_ax_ui.rs +++ b/src/apps/desktop/src/computer_use/macos_ax_ui.rs @@ -48,24 +48,17 @@ unsafe extern "C" { const K_AX_VALUE_CGPOINT: u32 = 1; const K_AX_VALUE_CGSIZE: u32 = 2; +/// Pid of the frontmost application, via `NSWorkspace.frontmostApplication`. +/// +/// Reads in-process in microseconds. The previous implementation shelled out to +/// `osascript` on every call — and this is on the hot path for +/// `describe_screen` (which reaches it up to three times per call), so a single +/// observation used to cost several hundred milliseconds of process spawns plus +/// the risk of a System Events AppleEvent timeout. fn frontmost_pid() -> BitFunResult { - let out = std::process::Command::new("/usr/bin/osascript") - .args([ - "-e", - "tell application \"System Events\" to get unix id of first process whose frontmost is true", - ]) - .output() - .map_err(|e| BitFunError::tool(format!("osascript spawn: {}", e)))?; - if !out.status.success() { - return Err(BitFunError::tool(format!( - "osascript failed: {}", - String::from_utf8_lossy(&out.stderr) - ))); - } - let s = String::from_utf8_lossy(&out.stdout); - s.trim() - .parse::() - .map_err(|_| BitFunError::tool("Could not parse frontmost process id.".to_string())) + crate::computer_use::macos_bg_input::frontmost_pid_macos().ok_or_else(|| { + BitFunError::tool("NSWorkspace reported no frontmost application.".to_string()) + }) } unsafe fn ax_release(v: CFTypeRef) { @@ -525,8 +518,10 @@ unsafe fn is_ax_hidden(elem: AXUIElementRef) -> bool { return false; // No AXHidden attribute = not hidden }; // AXHidden is a CFBoolean - let hidden = - std::ptr::eq(val, core_foundation::boolean::kCFBooleanTrue as *const c_void); + let hidden = std::ptr::eq( + val, + core_foundation::boolean::kCFBooleanTrue as *const c_void, + ); ax_release(val); hidden } @@ -1123,6 +1118,37 @@ pub(super) fn frontmost_window_bounds_global() -> BitFunResult<(i32, i32, u32, u window_bounds_global_for_pid(pid) } +/// Number of windows the app currently owns, per the AX `AXWindows` attribute. +/// +/// A launched-but-windowless app — common for Electron clients whose window was +/// closed while the process kept running — is otherwise indistinguishable from a +/// healthy launch: `open_app` reports `success: true` with a live pid while +/// there is nothing on screen to act on. Returning the count lets `open_app` +/// detect that case and re-open the app instead of leaving the agent to +/// discover it by trial and error. +/// +/// `None` means the AX handle could not be created at all (dead pid, no +/// Accessibility trust); `Some(0)` means the app is alive with no windows. +pub(super) fn window_count_for_pid(pid: i32) -> Option { + // SAFETY: `AXUIElementCreateApplication` accepts any pid and returns null + // rather than an invalid handle, which we check before use. + let app = unsafe { AXUIElementCreateApplication(pid) }; + if app.is_null() { + return None; + } + // SAFETY: `app` is a live non-null AXUIElementRef we own. `ax_copy_attr` + // follows the CF *Copy* rule, so the returned array carries a +1 retain that + // `wrap_under_create_rule` takes over. `app` is released as soon as the + // attribute has been copied out of it, on both the Some and None paths. + unsafe { + let arr_ref = ax_copy_attr(app, "AXWindows"); + ax_release(app as CFTypeRef); + let arr_ref = arr_ref?; + let arr = CFArray::<*const c_void>::wrap_under_create_rule(arr_ref as CFArrayRef); + Some(arr.len() as usize) + } +} + /// Bounds of the selected app's focused or main window in global screen coordinates. pub(super) fn window_bounds_global_for_pid(pid: i32) -> BitFunResult<(i32, i32, u32, u32)> { let app = unsafe { AXUIElementCreateApplication(pid) }; diff --git a/src/apps/desktop/src/computer_use/macos_bg_input.rs b/src/apps/desktop/src/computer_use/macos_bg_input.rs index 092c71606..33a9f5872 100644 --- a/src/apps/desktop/src/computer_use/macos_bg_input.rs +++ b/src/apps/desktop/src/computer_use/macos_bg_input.rs @@ -388,8 +388,39 @@ pub(super) fn bg_click( /// Returns `None` when the AppKit lookup is not available (e.g. headless tests /// or non-main-thread contexts where we don't want to assert). pub(super) fn frontmost_pid_macos() -> Option { + frontmost_app_identity_macos().map(|id| id.pid) +} + +/// Identity of the macOS frontmost application, read straight from +/// `NSWorkspace.frontmostApplication`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct MacFrontmostApp { + pub pid: i32, + /// `NSRunningApplication.localizedName` — what the user sees in the menu + /// bar (e.g. "飞书"), which is **not** always the executable or bundle + /// name (`Feishu` / `Lark.app`). + pub name: Option, + pub bundle_id: Option, +} + +/// Best-effort identity (pid + localized name + bundle id) of the frontmost +/// application. +/// +/// This deliberately avoids `osascript`: the previous AppleScript spelling +/// (`tell application "System Events" to … first process whose frontmost is +/// true`) cost a process spawn on every single tool result, could block on an +/// AppleEvent timeout when System Events was busy, and — because it embedded a +/// `try … end try` block in expression position — never actually compiled, so +/// the caller silently saw `None` forever. `NSWorkspace` answers in-process in +/// microseconds and needs no Automation permission. +pub(super) fn frontmost_app_identity_macos() -> Option { use objc2::msg_send; use objc2::runtime::AnyObject; + // SAFETY: every selector is sent to a class/instance that was just checked + // non-null. `sharedWorkspace`, `frontmostApplication`, `localizedName` and + // `bundleIdentifier` are all +0 (autoreleased/borrowed) returns, so nothing + // here owns a retain to balance. `NSWorkspace.frontmostApplication` is + // documented as safe to read from any thread. unsafe { let cls = objc2::runtime::AnyClass::get(c"NSWorkspace")?; let ws: *mut AnyObject = msg_send![cls, sharedWorkspace]; @@ -402,9 +433,102 @@ pub(super) fn frontmost_pid_macos() -> Option { } let pid: i32 = msg_send![app, processIdentifier]; if pid <= 0 { + return None; + } + let name: *mut AnyObject = msg_send![app, localizedName]; + let bundle: *mut AnyObject = msg_send![app, bundleIdentifier]; + Some(MacFrontmostApp { + pid, + name: ns_string_to_rust(name), + bundle_id: ns_string_to_rust(bundle), + }) + } +} + +/// Pid of a running application with the given bundle identifier, preferring +/// the most recently activated instance. `None` when nothing with that bundle +/// id is running. +pub(super) fn pid_for_bundle_id_macos(bundle_id: &str) -> Option { + use objc2::msg_send; + use objc2::runtime::AnyObject; + use objc2_foundation::NSString; + // SAFETY: `runningApplicationsWithBundleIdentifier:` returns a +0 NSArray; + // indices stay in `0..count` and every element is null-checked before use. + unsafe { + let cls = objc2::runtime::AnyClass::get(c"NSRunningApplication")?; + let ns_bundle = NSString::from_str(bundle_id); + let arr: *mut AnyObject = + msg_send![cls, runningApplicationsWithBundleIdentifier: &*ns_bundle]; + if arr.is_null() { + return None; + } + let count: usize = msg_send![arr, count]; + // Prefer an instance that already owns windows; fall back to the first. + let mut fallback: Option = None; + for i in 0..count { + let app: *mut AnyObject = msg_send![arr, objectAtIndex: i]; + if app.is_null() { + continue; + } + let pid: i32 = msg_send![app, processIdentifier]; + if pid <= 0 { + continue; + } + if fallback.is_none() { + fallback = Some(pid); + } + if crate::computer_use::macos_ax_ui::window_count_for_pid(pid).unwrap_or(0) > 0 { + return Some(pid); + } + } + fallback + } +} + +/// Localized name and bundle id of a running application, by pid. +pub(super) fn running_app_identity_macos(pid: i32) -> Option<(Option, Option)> { + use objc2::msg_send; + use objc2::runtime::AnyObject; + // SAFETY: `runningApplicationWithProcessIdentifier:` returns nil for an + // unknown pid, which is checked; the two property reads are +0 returns. + unsafe { + let cls = objc2::runtime::AnyClass::get(c"NSRunningApplication")?; + let app: *mut AnyObject = msg_send![cls, runningApplicationWithProcessIdentifier: pid]; + if app.is_null() { + return None; + } + let name: *mut AnyObject = msg_send![app, localizedName]; + let bundle: *mut AnyObject = msg_send![app, bundleIdentifier]; + Some((ns_string_to_rust(name), ns_string_to_rust(bundle))) + } +} + +/// Copy an `NSString *` into an owned Rust `String`. Returns `None` for a null +/// pointer or a string whose UTF-8 buffer is unavailable. +/// +/// # Safety +/// `s` must be null or a valid `NSString` pointer. +pub(super) unsafe fn ns_string_to_rust(s: *mut objc2::runtime::AnyObject) -> Option { + use objc2::msg_send; + if s.is_null() { + return None; + } + // SAFETY: `s` is a valid NSString per this function's contract, checked + // non-null above. `UTF8String` hands back a NUL-terminated buffer owned by + // the autorelease pool; `CStr::to_string_lossy().into_owned()` copies out of + // it before returning, so nothing borrows the pool past this block. + unsafe { + let utf8: *const std::os::raw::c_char = msg_send![s, UTF8String]; + if utf8.is_null() { + return None; + } + let out = std::ffi::CStr::from_ptr(utf8) + .to_string_lossy() + .into_owned(); + if out.is_empty() { None } else { - Some(pid) + Some(out) } } } diff --git a/src/apps/desktop/src/embedded_relay_host.rs b/src/apps/desktop/src/embedded_relay_host.rs index 1c1a6f243..72fd79376 100644 --- a/src/apps/desktop/src/embedded_relay_host.rs +++ b/src/apps/desktop/src/embedded_relay_host.rs @@ -206,6 +206,56 @@ mod tests { .port() } + /// Assert `port` became bindable again, i.e. the host really did drop its + /// listener. + /// + /// A single bind attempt conflates two different things: our host leaking + /// the listener, and some other socket on the machine transiently holding + /// the port — it came from the ephemeral range, so a busy test run reissues + /// it constantly. Retrying separates them: a leaked listener is held until + /// the process exits and never frees up, while a transient steal clears in + /// milliseconds. + async fn assert_port_released(port: u16, what: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + let mut last_err = None; + loop { + match tokio::net::TcpListener::bind(("0.0.0.0", port)).await { + Ok(l) => { + drop(l); + return; + } + Err(e) => last_err = Some(e), + } + if std::time::Instant::now() >= deadline { + panic!("{what}: port {port} never became bindable again: {last_err:?}"); + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + + /// Start `host` on a port nothing else has taken, returning that port. + /// + /// `unused_port` can only report a port that was free a moment ago: it + /// binds an ephemeral port, reads the number and drops the listener, so + /// anything else on the machine may claim it before the caller binds. A + /// single attempt is a race that stays invisible on a quiet machine and + /// fails most of the time when the rest of the suite is busy enough to + /// churn through ephemeral ports. Retry rather than assume. + async fn start_on_free_port( + host: &DesktopEmbeddedRelayHost, + static_dir: Option, + ) -> u16 { + let mut last_err = String::new(); + for _ in 0..16 { + let port = unused_port().await; + match host.start(port, static_dir.clone()).await { + Ok(()) => return port, + Err(e) => last_err = e.to_string(), + } + } + panic!("could not find a free port for the embedded relay: {last_err}"); + } + #[tokio::test] async fn bind_failure_does_not_create_an_active_runtime() { let occupied = tokio::net::TcpListener::bind("0.0.0.0:0") @@ -241,11 +291,8 @@ mod tests { std::fs::write(static_dir.join("assets").join("app.js"), "test asset") .expect("test asset should be written"); - let port = unused_port().await; let host = DesktopEmbeddedRelayHost::default(); - host.start(port, Some(static_dir.to_string_lossy().into_owned())) - .await - .expect("embedded relay should start"); + let port = start_on_free_port(&host, Some(static_dir.to_string_lossy().into_owned())).await; let client = reqwest::Client::new(); let index = client @@ -290,35 +337,44 @@ mod tests { .expect("embedded relay should restart immediately on the same port"); host.stop().await; - let released = tokio::net::TcpListener::bind(("0.0.0.0", port)) - .await - .expect("stop must release the listener before returning"); - drop(released); + assert_port_released(port, "stop must release the listener before returning").await; std::fs::remove_dir_all(&static_dir).expect("test static directory should be removed"); } #[tokio::test] async fn cancelled_start_releases_listener_without_committing_runtime() { - let port = unused_port().await; + // Same port race as `start_on_free_port`, but this test aborts `start` + // mid-flight and so cannot use its success as the signal: a port stolen + // between reservation and bind shows up here as readiness never firing. + // Retry until we get a port `start` could actually take. let host = Arc::new(DesktopEmbeddedRelayHost::default()); - let start_task = tokio::spawn({ - let host = host.clone(); - async move { host.start(port, None).await } - }); + let mut acquired: Option<(u16, tokio::task::JoinHandle<_>)> = None; + for _ in 0..16 { + let port = unused_port().await; + let start_task = tokio::spawn({ + let host = host.clone(); + async move { host.start(port, None).await } + }); + if tokio::time::timeout( + std::time::Duration::from_secs(1), + host.start_candidate_ready.notified(), + ) + .await + .is_ok() + { + acquired = Some((port, start_task)); + break; + } + start_task.abort(); + let _ = start_task.await; + } + let (port, start_task) = + acquired.expect("start should create the candidate runtime before readiness completes"); - tokio::time::timeout( - std::time::Duration::from_secs(1), - host.start_candidate_ready.notified(), - ) - .await - .expect("start should create the candidate runtime before readiness completes"); start_task.abort(); let _ = start_task.await; assert!(host.runtime.lock().await.is_none()); - let released = tokio::net::TcpListener::bind(("0.0.0.0", port)) - .await - .expect("cancelling start must release the listener"); - drop(released); + assert_port_released(port, "cancelling start must release the listener").await; } } diff --git a/src/crates/assembly/core/src/agentic/tools/computer_use_host.rs b/src/crates/assembly/core/src/agentic/tools/computer_use_host.rs index 88b02c649..8bf08aa44 100644 --- a/src/crates/assembly/core/src/agentic/tools/computer_use_host.rs +++ b/src/crates/assembly/core/src/agentic/tools/computer_use_host.rs @@ -210,6 +210,19 @@ pub trait ComputerUseHost: Send + Sync + std::fmt::Debug { /// is not blocked solely because of a prior click / scroll. fn computer_use_trust_pointer_after_text_input(&self) {} + /// Clear the stale-capture guard because it **cannot be satisfied** on this + /// run, not because the pointer became trustworthy. + /// + /// The guard exists to force a fresh look before a committing action. That + /// only means something for a model that can look. When the primary model + /// is text-only, `screenshot` returns no image and never reaches + /// `transition_after_screenshot`, so the guard latches on forever: the + /// error says "call `screenshot` first", the model calls `screenshot`, + /// nothing changes, and every `click` / Enter `key_chord` is refused for the + /// rest of the session. Text-only observation (`describe_screen`) is the + /// real equivalent of a capture there, so it waives the guard instead. + fn computer_use_waive_fresh_capture_guard(&self) {} + /// Refuse `mouse_click` if the pointer moved (or a click happened) since the last screenshot, /// or if the latest capture is not a valid “fine” basis (desktop: ~500×500 point crop **or** /// quadrant navigation region with longest side < [`COMPUTER_USE_QUADRANT_CLICK_READY_MAX_LONG_EDGE`]). diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs index 93df273d7..2c4b161ca 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_actions.rs @@ -427,8 +427,15 @@ impl ComputerUseActions { host.key_chord(select_all).await?; } host.key_chord(paste_chord).await?; + // A paste lands in whatever already had focus and never moves + // the pointer, so it is not a reason to demand a fresh capture. + // This must run for *every* paste, not just `submit: true`: + // pasting and then sending a separate Enter `key_chord` is the + // common shape, and leaving the guard armed refuses that Enter + // with advice ("call `screenshot` first") that a text-only model + // cannot act on. + host.computer_use_trust_pointer_after_text_input(); if submit { - host.computer_use_trust_pointer_after_text_input(); host.key_chord(submit_keys.clone()).await?; } @@ -542,9 +549,9 @@ impl ComputerUseActions { // ── Desktop AX-first dispatch (Codex parity) ────────────────────── // Routes the seven new app-targeted actions through the typed // `ComputerUseHost` API. Every successful response carries a - // unified envelope: `target_app`, `background_input`, - // `before_digest` and (for state queries) `app_state` / - // `app_state_nodes` so the model can reason about the AX tree + // unified envelope: `target_app`, `background_input`, `before_digest` + // and (for state queries) `app_state`, whose `tree_text` is the single + // rendering of the AX tree — so the model can reason about state // before/after each action without re-querying. async fn handle_desktop_ax( &self, @@ -755,6 +762,16 @@ impl ComputerUseActions { // the heavy `screenshot` payload (it is attached out-of-band as a // multimodal image, not as base64 inside the JSON tree, to keep token // budgets under control and let the provider deliver it as `image_url`). + // + // `tree_text` is the **only** rendering of the AX tree we send. Results + // used to carry a sibling `app_state_nodes` array holding the same + // nodes as verbose JSON — one object per node, ~15 lines each. It was + // strictly redundant (`render_tree_text` already emits idx, role, + // title, value, identifier, description, help, url, frame and the + // enabled/focused/selected/expanded flags, with parentage implied by + // indentation) and nothing consumed it, yet it accounted for ~78% of a + // `get_app_state` result: a single observation of a windowless app + // measured 107 KB, of which 82 KB was that duplicate. fn snap_state_json( snap: &crate::agentic::tools::computer_use_host::AppStateSnapshot, ) -> serde_json::Value { @@ -764,6 +781,7 @@ impl ComputerUseActions { "digest": snap.digest, "captured_at_ms": snap.captured_at_ms, "tree_text": snap.tree_text, + "node_count": snap.nodes.len(), "has_screenshot": snap.screenshot.is_some(), }); if let Some(shot) = snap.screenshot.as_ref() { @@ -904,7 +922,6 @@ impl ComputerUseActions { let mut v = json!({ "target_app": app, "app_state": snap_state_json(&res.snapshot), - "app_state_nodes": res.snapshot.nodes, "loop_warning": res.snapshot.loop_warning, "execution_note": res.execution_note, "interactive_view": res.view.as_ref().map(build_interactive_view_json), @@ -925,7 +942,6 @@ impl ComputerUseActions { let mut v = json!({ "target_app": app, "app_state": snap_state_json(&res.snapshot), - "app_state_nodes": res.snapshot.nodes, "loop_warning": res.snapshot.loop_warning, "execution_note": res.execution_note, "visual_mark_view": res.view.as_ref().map(build_visual_mark_view_json), @@ -1069,7 +1085,6 @@ impl ComputerUseActions { "background_input": bg, "ax_tree": ax, "app_state": snap_state_json(&snap), - "app_state_nodes": snap.nodes, "before_digest": snap.digest, "loop_warning": snap.loop_warning, }); @@ -1164,7 +1179,6 @@ impl ComputerUseActions { "background_input": bg, "before_digest": before, "app_state": snap_state_json(&after), - "app_state_nodes": after.nodes, "loop_warning": after.loop_warning, }); Ok(vec![snap_result(data, Some("clicked".to_string()), &after)]) @@ -1212,7 +1226,6 @@ impl ComputerUseActions { "focus": focus, "before_digest": before, "app_state": snap_state_json(&after), - "app_state_nodes": after.nodes, "loop_warning": after.loop_warning, }); Ok(vec![snap_result( @@ -1237,7 +1250,6 @@ impl ComputerUseActions { "dy": dy, "focus": focus, "app_state": snap_state_json(&after), - "app_state_nodes": after.nodes, "loop_warning": after.loop_warning, }); Ok(vec![snap_result( @@ -1268,7 +1280,6 @@ impl ComputerUseActions { "keys": keys, "focus_idx": focus_idx, "app_state": snap_state_json(&after), - "app_state_nodes": after.nodes, "loop_warning": after.loop_warning, }); Ok(vec![snap_result( @@ -1302,7 +1313,6 @@ impl ComputerUseActions { "background_input": bg, "predicate": predicate, "app_state": snap_state_json(&after), - "app_state_nodes": after.nodes, "loop_warning": after.loop_warning, }); Ok(vec![snap_result( diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index cf9ce31d5..23efc80d8 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -446,7 +446,14 @@ The **primary model cannot consume images** in tool results — **do not** use * async fn describe_screen( host: &dyn ComputerUseHost, _input: &Value, + text_only: bool, ) -> BitFunResult> { + // For a text-only model this *is* the observation step, so it clears + // the same guard a `screenshot` would. Without this the guard can only + // ever be cleared by a capture the model cannot consume. + if text_only { + host.computer_use_waive_fresh_capture_guard(); + } let session_snap = host.computer_use_session_snapshot().await; let interaction = host.computer_use_interaction_state(); let pointer = session_snap.pointer_global.clone(); @@ -469,8 +476,13 @@ The **primary model cannot consume images** in tool results — **do not** use * let mut ax_nodes_count: Option = None; let mut ax_digest: Option = None; let mut window_title: Option = None; - if let Some(app) = selector.as_ref() { - match host.get_app_state(app.clone(), 8, true).await { + // Why `ax_tree_text` is empty, when it is. A bare `null` here reads as + // truncated tool output, and an agent that believes its own results are + // being cut off will keep re-issuing the same call instead of switching + // tactic — which is exactly what a null `ax_tree_text` used to cause. + let ax_tree_status: &str = match selector.as_ref() { + None => "no_foreground_app", + Some(app) => match host.get_app_state(app.clone(), 8, true).await { Ok(snap) => { // Deliberately drop `snap.screenshot` (JPEG) — describe_screen // never returns image bytes so text-only models are safe. @@ -478,15 +490,45 @@ The **primary model cannot consume images** in tool results — **do not** use * ax_nodes_count = Some(snap.nodes.len()); ax_digest = Some(snap.digest.clone()); ax_tree_text = Some(snap.tree_text).filter(|t| !t.trim().is_empty()); + if ax_tree_text.is_some() { + "ok" + } else { + "empty_tree" + } } Err(e) => { debug!("describe_screen: get_app_state failed: {}", e); + "query_failed" } - } - } + }, + }; let ui_tree_text = host.enumerate_ui_tree_text().await; + // Turn each non-`ok` status into the tactic that actually works there, + // so a sparse tree costs one redirect instead of a search. + let ax_tree_note = match ax_tree_status { + "ok" => None, + "no_foreground_app" => Some( + "No application is frontmost, so there is no AX tree to read. Use `list_apps` to \ +find the target, then `open_app` (or `app_click` with an explicit `app` selector) to bring it forward." + .to_string(), + ), + "empty_tree" => Some( + "The frontmost app exposes an empty accessibility tree — usual for Electron / \ +WebView apps that have not enabled their web-content AX tree, and for an app running with no \ +window. This is NOT truncated output: re-calling `describe_screen` returns the same thing. \ +Check `window_count` via `open_app`, or target visible text with `move_to_text` / `click_target`." + .to_string(), + ), + "query_failed" => Some( + "The AX query failed (commonly missing Accessibility trust, or the app exited). \ +Grant Accessibility permission, or fall back to `move_to_text` / `click_target` on visible text." + .to_string(), + ), + _ => None, + }; + let mut body = json!({ "success": true, "action": "describe_screen", @@ -496,9 +538,12 @@ The **primary model cannot consume images** in tool results — **do not** use * "displays": displays, "window_title": window_title, "ax_tree_text": ax_tree_text, + "ax_tree_status": ax_tree_status, + "ax_tree_note": ax_tree_note, "ax_nodes_count": ax_nodes_count, "ax_state_digest": ax_digest, "ui_tree_text": ui_tree_text, + "output_is_complete": true, }); let input_coords = json!({ @@ -510,8 +555,18 @@ The **primary model cannot consume images** in tool results — **do not** use * // pick `node_idx` from `ax_tree_text` for `app_click`/`click_element`, or // match visible text via `move_to_text`, and compare `ax_state_digest` // before/after an action to verify a mutation. - let hint = "describe_screen: text snapshot returned (no image). Use `ax_tree_text` node indices for `app_click`/`click_element`, match visible text with `move_to_text`, and compare `ax_state_digest` across actions to verify state changes."; - Ok(vec![ToolResult::ok(body, Some(hint.to_string()))]) + let hint = format!( + "describe_screen: complete text snapshot returned (no image, ax_tree_status={}). \ +Use `ax_tree_text` node indices for `app_click`/`click_element`, match visible text with `move_to_text`, \ +and compare `ax_state_digest` across actions to verify state changes.{}", + ax_tree_status, + if ax_tree_status == "ok" { + "" + } else { + " No AX tree available — read `ax_tree_note` and switch tactic rather than repeating this call." + } + ); + Ok(vec![ToolResult::ok(body, Some(hint))]) } /// Screenshot tool results attach JPEGs via `tool_image_attachments`; only providers whose @@ -1216,7 +1271,8 @@ impl Tool for ComputerUseTool { // + pointer + displays) with NO image bytes. This is the observe and // verify step that closes the cowork loop for text-only models. "describe_screen" => { - return Self::describe_screen(host_ref, input).await; + let text_only = !context.primary_model_supports_image_understanding(); + return Self::describe_screen(host_ref, input, text_only).await; } // Unified target resolver: AX first, OCR second, explicit screen @@ -1715,12 +1771,19 @@ impl Tool for ComputerUseTool { // at the text-only observe action. The model keeps its turn and // switches to `describe_screen` / AX / OCR / keyboard tactics. if !context.primary_model_supports_image_understanding() { + // A text-only `screenshot` never captures anything, so it + // can never clear the stale-capture guard the usual way. + // Waive it here: otherwise the guard's own recovery advice + // ("call `screenshot` first") is an instruction the model + // can follow forever without ever being allowed to click. + host_ref.computer_use_waive_fresh_capture_guard(); let body = json!({ "success": true, "action": "screenshot", "screenshot_unavailable": true, "reason": "primary_model_is_text_only", - "instruction": "The primary model cannot consume image bytes, so `screenshot` produced nothing. Use `describe_screen` to observe the desktop as text (frontmost app + AX tree + UI tree text + pointer), then act with `click_target`/`click_element`/`move_to_text`/`key_chord`/`paste`. Never retry `screenshot`." + "stale_capture_guard": "waived", + "instruction": "The primary model cannot consume image bytes, so `screenshot` produced nothing. Use `describe_screen` to observe the desktop as text (frontmost app + AX tree + UI tree text + pointer), then act with `click_target`/`click_element`/`move_to_text`/`key_chord`/`paste`. Never retry `screenshot`. The fresh-capture guard has been waived, so `click` and Enter `key_chord` are unblocked." }); let input_coords = json!({ "kind": "screenshot", "text_only": true }); let body = @@ -1937,6 +2000,25 @@ impl Tool for ComputerUseTool { BitFunError::tool("open_app requires `app_name` parameter.".to_string()) })?; let result = host_ref.open_app(app_name).await?; + // A live process with zero windows is the one launch outcome + // that looks like success but leaves nothing to act on. Name it + // explicitly and say what to do, rather than letting the agent + // rediscover it through a chain of failing AX queries. + let windowless = result.success && result.window_count == Some(0); + let next_step = if windowless { + Some(format!( + "'{}' is running (PID {}) but owns no window, so there is nothing on screen to click. \ +The host already retried via `open -b`. Re-run `open_app`, or ask the user to open the app's main window (e.g. from its Dock icon). \ +Do not fall back to screen-coordinate clicks — there is no window to hit.", + result.app_name, + result + .process_id + .map(|p| p.to_string()) + .unwrap_or_else(|| "?".to_string()), + )) + } else { + None + }; let body = computer_use_augment_result_json( host_ref, json!({ @@ -1945,13 +2027,28 @@ impl Tool for ComputerUseTool { "app_name": result.app_name, "process_id": result.process_id, "error_message": result.error_message, + // Address the app by `bundle_id` from here on: the name + // used to launch it, its executable name and its bundle + // id are often three different strings. + "bundle_id": result.bundle_id, + "process_name": result.process_name, + "window_count": result.window_count, + "launch_path": result.launch_path, + "windowless": windowless, + "next_step": next_step, }), None, ) .await; - let summary = if result.success { + let summary = if !result.success { format!( - "Opened app '{}'{}.", + "Failed to open '{}': {}", + result.app_name, + result.error_message.as_deref().unwrap_or("unknown error") + ) + } else if windowless { + format!( + "Opened '{}'{} but it has NO window — nothing is on screen to act on.", result.app_name, result .process_id @@ -1960,9 +2057,16 @@ impl Tool for ComputerUseTool { ) } else { format!( - "Failed to open '{}': {}", + "Opened app '{}'{}{}.", result.app_name, - result.error_message.as_deref().unwrap_or("unknown error") + result + .process_id + .map(|p| format!(" (PID {})", p)) + .unwrap_or_default(), + result + .window_count + .map(|n| format!(", {} window(s)", n)) + .unwrap_or_default() ) }; Ok(vec![ToolResult::ok(body, Some(summary))]) @@ -2360,6 +2464,163 @@ mod tests { } } + /// Host that records whether the stale-capture guard was waived, and + /// reports no frontmost app so `describe_screen` exercises its + /// nothing-to-observe branch. + #[derive(Debug, Default)] + struct GuardRecordingHost { + waived: std::sync::atomic::AtomicBool, + } + + #[async_trait::async_trait] + impl ComputerUseHost for GuardRecordingHost { + async fn permission_snapshot(&self) -> BitFunResult { + not_expected() + } + async fn request_accessibility_permission(&self) -> BitFunResult<()> { + not_expected() + } + async fn request_screen_capture_permission(&self) -> BitFunResult<()> { + not_expected() + } + async fn screenshot_display( + &self, + _params: ComputerUseScreenshotParams, + ) -> BitFunResult { + not_expected() + } + fn map_image_coords_to_pointer(&self, _x: i32, _y: i32) -> BitFunResult<(i32, i32)> { + not_expected() + } + fn map_normalized_coords_to_pointer(&self, _x: i32, _y: i32) -> BitFunResult<(i32, i32)> { + not_expected() + } + async fn mouse_move(&self, _x: i32, _y: i32) -> BitFunResult<()> { + not_expected() + } + async fn pointer_move_relative(&self, _dx: i32, _dy: i32) -> BitFunResult<()> { + not_expected() + } + async fn mouse_click(&self, _button: &str) -> BitFunResult<()> { + not_expected() + } + async fn scroll(&self, _delta_x: i32, _delta_y: i32) -> BitFunResult<()> { + not_expected() + } + async fn key_chord(&self, _keys: Vec) -> BitFunResult<()> { + not_expected() + } + async fn type_text(&self, _text: &str) -> BitFunResult<()> { + not_expected() + } + async fn wait_ms(&self, _ms: u64) -> BitFunResult<()> { + not_expected() + } + async fn computer_use_session_snapshot(&self) -> ComputerUseSessionSnapshot { + ComputerUseSessionSnapshot::default() + } + fn computer_use_waive_fresh_capture_guard(&self) { + self.waived.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + fn text_only_context( + host: std::sync::Arc, + ) -> (ToolUseContext, std::sync::Arc) { + let mut context = ToolUseContext::for_tool_listing(None, None); + context.primary_model_facts = + tool_runtime::context::PrimaryModelFacts::new("m", "m", "anthropic", false); + context.computer_use_host = Some(host.clone()); + (context, host) + } + + /// A text-only `screenshot` captures nothing, so it can never clear the + /// stale-capture guard through the normal path — yet the guard's own error + /// tells the model to "call `screenshot` first". Left as it was, that is a + /// closed loop: every `click` and Enter `key_chord` stays refused for the + /// rest of the session, and the only way out is to bypass the tool entirely + /// (the observed failure was an agent falling back to raw + /// `osascript … keystroke return`, which skips every safety check the guard + /// exists to enforce). + #[tokio::test] + async fn text_only_screenshot_waives_the_unsatisfiable_capture_guard() { + let (context, host) = text_only_context(std::sync::Arc::new(GuardRecordingHost::default())); + let results = ComputerUseTool::new() + .call_impl(&json!({ "action": "screenshot" }), &context) + .await + .expect("text-only screenshot returns a soft envelope"); + assert!( + host.waived.load(std::sync::atomic::Ordering::SeqCst), + "text-only screenshot must waive the guard it can never satisfy" + ); + let body = results[0].content(); + assert_eq!( + body.get("stale_capture_guard").and_then(Value::as_str), + Some("waived"), + "the waiver must be visible to the model: {body}" + ); + // The guard's own error text says "call `screenshot` first"; the + // instruction here has to say that path is now open, or the model has + // no reason to believe retrying the click will work. + let instruction = body + .get("instruction") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + instruction.contains("waived"), + "instruction must tell the model the guard is cleared: {instruction}" + ); + } + + /// `describe_screen` is the text-only equivalent of taking a look, so it + /// clears the same guard a capture would. + #[tokio::test] + async fn text_only_describe_screen_waives_the_capture_guard() { + let (context, host) = text_only_context(std::sync::Arc::new(GuardRecordingHost::default())); + let _ = ComputerUseTool::new() + .call_impl(&json!({ "action": "describe_screen" }), &context) + .await + .expect("describe_screen should succeed"); + assert!( + host.waived.load(std::sync::atomic::Ordering::SeqCst), + "describe_screen is the text-only observation step and must waive the guard" + ); + } + + /// An empty snapshot must say *why* it is empty. A bare `ax_tree_text: + /// null` reads as truncated tool output, and an agent that believes its + /// results are being cut off re-issues the same call instead of changing + /// tactic. + #[tokio::test] + async fn describe_screen_explains_an_empty_ax_tree_instead_of_returning_bare_nulls() { + let (context, _host) = + text_only_context(std::sync::Arc::new(GuardRecordingHost::default())); + let results = ComputerUseTool::new() + .call_impl(&json!({ "action": "describe_screen" }), &context) + .await + .expect("describe_screen should succeed"); + let body = results[0].content(); + let data = body.get("data").unwrap_or(&body); + assert_eq!( + data.get("ax_tree_status").and_then(Value::as_str), + Some("no_foreground_app"), + "status must name the reason the tree is empty: {body}" + ); + assert_eq!( + data.get("output_is_complete").and_then(Value::as_bool), + Some(true), + "result must assert it is not truncated: {body}" + ); + let note = data + .get("ax_tree_note") + .and_then(Value::as_str) + .unwrap_or_default(); + assert!( + note.contains("list_apps") || note.contains("open_app"), + "note must offer a concrete next action: {note}" + ); + } + /// The browser-boundary guard must be reachable from `call_impl`: a /// physical input action while a Chromium-family browser is frontmost is /// rejected with the ControlHub browser-domain redirect instead of diff --git a/src/crates/execution/agent-runtime/src/prompt.rs b/src/crates/execution/agent-runtime/src/prompt.rs index a576206bc..d2f4a6939 100644 --- a/src/crates/execution/agent-runtime/src/prompt.rs +++ b/src/crates/execution/agent-runtime/src/prompt.rs @@ -493,7 +493,16 @@ fn computer_use_text_only_model_guidance() -> Vec { vec![ "- The configured primary model does not accept image inputs.".to_string(), "- When using `ComputerUse` or `ControlHub` with `domain: \"browser\"`, do not use `screenshot` and avoid `domain:\"browser\" action:\"screenshot\"`; image bytes will be unreadable.".to_string(), + // Banning `screenshot` without naming the replacement is what pushes a + // text-only agent into improvising `screencapture` + `analyze_image` as + // a substitute for eyes — a loop that costs an extra model call per + // look, returns prose instead of coordinates, and misreads the screen + // often enough to send the run down false paths. + "- `describe_screen` is your eyes: it returns the frontmost app, the AX tree (`ax_tree_text`, with `node_idx`s you can click by), `ui_tree_text` and the pointer, as text with no image. Call it when UI state is unknown, and again after an action to confirm `ax_state_digest` changed.".to_string(), + "- Do NOT shell out to `screencapture` and feed the file to an image-analysis tool as a substitute for looking. It costs an extra model round-trip per glance and returns descriptions, not clickable coordinates. Use `describe_screen`, `get_app_state`, `locate` and `move_to_text` — they return exact targets.".to_string(), + "- If `ax_tree_text` is empty, read `ax_tree_status` / `ax_tree_note` in the same result: they say why (no frontmost app, an app with no window, a WebView that exposes no tree, or a permission failure) and which tactic to switch to. The result is never truncated — re-calling returns the same thing.".to_string(), "- Action priority: 1) Terminal/CLI/system commands (`ExecCommand`, or `ComputerUse` `run_script`; use `WriteStdin`/`ExecControl` for running ExecCommand sessions) 2) Keyboard shortcuts (`key_chord`, `type_text`) 3) UI control: `click_element` (AX) -> `locate` -> `move_to_text` (use `move_to_text_match_index` when multiple OCR hits are listed) -> `mouse_move` (`use_screen_coordinates: true` with coordinates from tool JSON) -> `click`. For browser work, prefer `snapshot` then click by `@e*` ref over screenshots.".to_string(), + "- To type and send in one step use `paste` with `submit: true` (and `submit_keys` when the app sends on a chord, e.g. `[\"command\",\"return\"]`). `paste` is also the reliable path for CJK and emoji.".to_string(), "- Never guess coordinates. Always use precise methods: AX, OCR, system coordinates from tool results, or browser snapshot refs.".to_string(), ] } diff --git a/src/crates/execution/tool-contracts/src/computer_use.rs b/src/crates/execution/tool-contracts/src/computer_use.rs index fbfbc617e..e2d345c52 100644 --- a/src/crates/execution/tool-contracts/src/computer_use.rs +++ b/src/crates/execution/tool-contracts/src/computer_use.rs @@ -1131,6 +1131,28 @@ pub struct OpenAppResult { pub process_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub error_message: Option, + /// Bundle identifier of the launched app (macOS). The agent needs this to + /// address the app afterwards: the name it launched by (`Lark`), the + /// executable name (`Feishu`) and the bundle id (`com.electron.lark`) are + /// routinely three different strings, and only the bundle id works with + /// every follow-up path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundle_id: Option, + /// Process name as the OS reports it — the identity AppleScript's + /// `tell process "…"` and `ps` expect, which is often **not** `app_name`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub process_name: Option, + /// Windows the app owns once the launch settled. `Some(0)` means the + /// process is alive but has nothing on screen — a real state for Electron + /// apps whose window was closed while the process stayed resident, and one + /// the agent otherwise has no way to distinguish from a healthy launch. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub window_count: Option, + /// How the app ended up in front. Diagnostic: tells the agent whether a + /// plain activate sufficed or the host had to re-open the bundle to force + /// a window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub launch_path: Option, } /// Whether the latest screenshot JPEG was the full display, a point crop, or a quadrant-drill region. From 7fee50ad51d7d60bd585356b53d39c463a7c34ca Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 07:08:48 -0700 Subject: [PATCH 21/39] fix(computer-use): fill new OpenAppResult fields on Windows and Linux The macOS branch gained bundle_id / process_name / window_count / launch_path; the other two construct the same struct and would not compile without them. Both leave the identity fields None rather than guessing: neither `start` nor `xdg-open` reports what it launched, so there is no pid to resolve identity or a window count from. `window_count: Some(0)` would tell the model the app is definitely windowless when it was simply never measured. --- .../src/computer_use/desktop_host/mod.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index defde3460..453317898 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -1463,6 +1463,16 @@ impl ComputerUseHost for DesktopComputerUseHost { } else { Some(String::from_utf8_lossy(&output.stderr).trim().to_string()) }, + // `start` hands off to the shell and returns immediately + // without telling us what it launched, so there is no pid + // to resolve identity or window count from. Left as `None` + // (the "not measured" value) rather than faked — the model + // reads `window_count: Some(0)` as a definite windowless + // app and would act on it. + bundle_id: None, + process_name: None, + window_count: None, + launch_path: Some("shell_start".to_string()), }) }) .await @@ -1487,6 +1497,14 @@ impl ComputerUseHost for DesktopComputerUseHost { } else { Some(String::from_utf8_lossy(&output.stderr).trim().to_string()) }, + // Linux is the legacy tier: no AX layer, so there is no pid + // to resolve identity or window count from. `None` means + // "not measured" — do not substitute `Some(0)`, which the + // model reads as a definite windowless app. + bundle_id: None, + process_name: None, + window_count: None, + launch_path: Some("xdg_open".to_string()), }) }) .await From 60cd458efcaa86f5e22472a0dc35380b4f36058c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 07:12:07 -0700 Subject: [PATCH 22/39] test(computer-use): compile-check AppleScript templates with escaped names Only "Safari" was covered, which never exercises applescript_quote. Compiling the escaped forms of a quote, a backslash and CJK is what proves the escaping matches AppleScript's actual string-literal syntax rather than a plausible guess about it. --- .../src/computer_use/desktop_host/mod.rs | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index 453317898..d2a3fa13e 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -169,15 +169,17 @@ mod macos_applescript_tests { /// Every AppleScript this module generates now has to compile. #[test] fn every_generated_applescript_compiles() { - let templates = [ - format!("id of application {}", applescript_quote("Safari")), - format!( - "tell application {} to activate", - applescript_quote("Safari") - ), - ]; - for t in templates { - assert!(compiles(&t).is_ok(), "template failed to compile: {t}"); + // Includes the names that exercise `applescript_quote`: a quote, a + // backslash and CJK. Asserting the *escaped* form compiles is what + // proves the escaping is genuine AppleScript rather than a plausible + // guess about its string-literal syntax. + for name in ["Safari", "a\"b", "a\\b", "飞书", "Visual Studio Code"] { + for t in [ + format!("id of application {}", applescript_quote(name)), + format!("tell application {} to activate", applescript_quote(name)), + ] { + assert!(compiles(&t).is_ok(), "template failed to compile: {t}"); + } } } From fcde2fca9e84affdc00301f0450c01917b736908 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 07:14:57 -0700 Subject: [PATCH 23/39] docs(computer-use): tell the subagent an empty AX tree is an answer The two lines this prompt was missing are the ones that would have ended the observed failure early. When describe_screen returned nulls the agent concluded its own output was truncated and improvised screencapture plus image analysis as a substitute for eyes. State plainly that an empty ax_tree_text is a result with a reason attached (ax_tree_status / ax_tree_note), that re-calling returns the same thing, and that building eyes out of screencapture costs a model round-trip per glance and returns prose instead of coordinates. --- .../assembly/agent-content/prompts/agents/computer_use_mode.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md b/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md index f66a2f04a..8ccde914e 100644 --- a/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md +++ b/src/crates/assembly/agent-content/prompts/agents/computer_use_mode.md @@ -60,6 +60,8 @@ If the same GUI tactic fails twice, switch strategy: use keyboard navigation, ap When Runtime Context indicates the primary model does not support image understanding, the vision-only actions — `screenshot`, `build_interactive_view`, `interactive_click`, `build_visual_mark_view`, `visual_click` — are unavailable: they are absent from your tool schema, `screenshot` returns no image (`screenshot_unavailable: true`), and the other four return NOT_AVAILABLE. Do NOT retry them and do NOT call them to verify — they cannot help you see. Instead: - **Observe with `describe_screen`** — it returns a text snapshot (frontmost app, `ax_tree_text` with `node_idx`s, `ui_tree_text`, pointer, displays) with no image. This is your eyes. Call it before acting when state is unknown, and after an action to verify `ax_state_digest` changed. +- **An empty `ax_tree_text` is an answer, not a truncated result.** The same result carries `ax_tree_status` and `ax_tree_note` explaining why it is empty — no frontmost app, an app running with no window, a WebView that exposes no tree, or a missing Accessibility permission — and which tactic to switch to. Calling `describe_screen` again returns exactly the same thing. Never conclude your tool output is being cut off; act on the note instead. +- **Do not build your own eyes out of `screencapture`.** Shelling out to capture the screen and passing the file to an image-analysis tool costs an extra model round-trip per glance, returns prose rather than clickable coordinates, and misreads the screen often enough to send you down false paths. `describe_screen`, `get_app_state`, `locate` and `move_to_text` return exact targets — use them. - **Target with AX / OCR, never guessed coordinates** — `click_element`/`app_click` with `node_idx`/`text_contains`/`title_contains`/`role_substring`; `move_to_text`/`click_target` with `target_text` (+ `move_to_text_match_index` when several OCR hits are returned as text candidates). - **Prefer keyboard** — `key_chord` shortcuts (command+F search, Tab/Shift+Tab focus, Return confirm, Escape cancel) and `paste` (clipboard) for CJK / long text before `type_text`. - **Drive hard-to-reach apps directly** — `run_apple_script` (macOS) for messaging/desktop apps whose AX tree is sparse. From 6f2bc2b3d65c275d5f2357b2e0f08abce6b953ca Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 07:24:10 -0700 Subject: [PATCH 24/39] fix(computer-use): let describe_screen see deep enough to act on Electron apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing `foreground_application` gave `describe_screen` an app to look at. It was still walking only 8 levels into the focused window, which is fine for a native Cocoa app and far too shallow for the Electron / WebView clients agents are most often asked to drive. Measured against a real Electron window (focused window only): depth 8: 17 nodes, 7 actionable, 1 KB depth 12: 25 nodes, 15 actionable, 2 KB depth 16: 50 nodes, 40 actionable, 5 KB depth 20: 207 nodes, 197 actionable, 27 KB depth 24: 233 nodes, 223 actionable, 31 KB depth 32: 1289 nodes, 1279 actionable, 206 KB Seven actionable elements is not enough to find a search field or a send button, so the tree read as "this app has no AX tree" and pushed the agent onto OCR and screenshot guessing. The actionable layer appears around 20; past it the payload grows far faster than the number of things worth clicking. Depth is a poor proxy for size, though — a document or a long list can multiply that 27 KB — so the returned tree is also capped at 60 KB, cut on a line boundary. The clip announces itself and says a control that is missing from the view may still exist: an agent that reads a truncated tree as the whole UI concludes the control is not there and gives up. The `#[ignore]`d dump test now prints this depth profile, so the constant can be retuned against evidence rather than intuition. --- .../desktop/src/computer_use/macos_ax_dump.rs | 77 +++++++++++++++ .../implementations/computer_use_tool.rs | 99 ++++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/apps/desktop/src/computer_use/macos_ax_dump.rs b/src/apps/desktop/src/computer_use/macos_ax_dump.rs index 8d8461ed7..86c24eefe 100644 --- a/src/apps/desktop/src/computer_use/macos_ax_dump.rs +++ b/src/apps/desktop/src/computer_use/macos_ax_dump.rs @@ -975,6 +975,83 @@ mod tests { assert_ne!(d1, d2); } + /// Measure the closed-menu pruning against a real running app rather than + /// trusting the unit test's synthetic frames. + /// + /// Dumps the frontmost application twice — once with menus walked, once + /// with the default pruning — and reports both node counts. Requires + /// Accessibility permission and a GUI session, so it is `#[ignore]`d. + #[test] + #[ignore] + fn closed_menu_pruning_shrinks_a_real_app_dump() { + let pid = crate::computer_use::macos_bg_input::frontmost_pid_macos() + .expect("a GUI session has a frontmost app"); + + let with_menus = dump_app_ax( + pid, + DumpOpts { + include_closed_menus: true, + ..Default::default() + }, + ) + .expect("dump with menus"); + let pruned = dump_app_ax(pid, DumpOpts::default()).expect("pruned dump"); + + // What `describe_screen` actually asks for: depth 8, focused window + // only. Reported alongside so the cost of the observe path is visible + // next to the cost of a full `get_app_state`. + let observe = dump_app_ax( + pid, + DumpOpts { + max_depth: 8, + focus_window_only: true, + ..Default::default() + }, + ) + .expect("describe_screen-shaped dump"); + + eprintln!( + "pid={pid}\n full+menus: {:>5} nodes, {:>7} bytes\n full pruned: {:>5} nodes, {:>7} bytes\n observe: {:>5} nodes, {:>7} bytes", + with_menus.nodes.len(), + with_menus.tree_text.len(), + pruned.nodes.len(), + pruned.tree_text.len(), + observe.nodes.len(), + observe.tree_text.len(), + ); + // Depth profile of the focused window. Run this when retuning + // `DESCRIBE_SCREEN_AX_DEPTH`: "actionable" (has AX actions and a real + // frame) is what the agent can actually click, and it is the column + // that matters — node count and bytes grow long after it plateaus. + for d in [8u32, 12, 16, 20, 24, 32] { + let s = dump_app_ax( + pid, + DumpOpts { + max_depth: d, + focus_window_only: true, + ..Default::default() + }, + ) + .expect("depth dump"); + let actionable = s + .nodes + .iter() + .filter(|n| !n.actions.is_empty() && n.frame_global.is_some()) + .count(); + eprintln!( + " depth {:>2}: {:>5} nodes, {:>4} actionable, {:>7} bytes", + d, + s.nodes.len(), + actionable, + s.tree_text.len() + ); + } + assert!( + pruned.nodes.len() <= with_menus.nodes.len(), + "pruning must never grow the tree" + ); + } + /// Smoke test: dump the AX tree of *this* test process. The test process /// usually has no AX windows of its own, so we only assert the call /// returns *something* (possibly an empty tree) without panicking and diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index 23efc80d8..0a8c39644 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -119,6 +119,63 @@ const COMPUTER_USE_DEBUG_SCREENSHOTS_ENV: &str = "BITFUN_COMPUTER_USE_DEBUG_SCRE /// Newest debug screenshots retained in [`COMPUTER_USE_DEBUG_SUBDIR`]; older files are deleted. const COMPUTER_USE_DEBUG_MAX_FILES: usize = 20; +/// AX depth `describe_screen` walks into the focused window. +/// +/// This was 8, which is fine for a native Cocoa app but far too shallow for +/// Electron / WebView clients — the ones agents are most often asked to drive. +/// Measured against a real Electron window (focused window only): +/// +/// | depth | nodes | actionable | tree_text | +/// |------:|------:|-----------:|----------:| +/// | 8 | 17 | 7 | 1 KB | +/// | 12 | 25 | 15 | 2 KB | +/// | 16 | 50 | 40 | 5 KB | +/// | 20 | 207 | 197 | 27 KB | +/// | 24 | 233 | 223 | 31 KB | +/// | 32 | 1289 | 1279 | 206 KB | +/// +/// At 8 the agent could see seven actionable elements in an entire app — not +/// enough to find a search field or a send button, which reads as "this app has +/// no AX tree" and pushes it onto OCR or screenshot guessing. The actionable +/// layer appears around 20; past that the payload grows far faster than the +/// number of things worth clicking. +const DESCRIBE_SCREEN_AX_DEPTH: u32 = 20; + +/// Byte ceiling on the AX tree `describe_screen` returns. +/// +/// The depth above is tuned against a typical rich window (~27 KB), but depth +/// is a poor proxy for size: a document, a long list or a deeply nested canvas +/// can multiply that. `describe_screen` is the action an agent calls most, so +/// it needs a bound that does not depend on the app behaving reasonably. +const DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES: usize = 60_000; + +/// Trim an AX tree to [`DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES`] on a line +/// boundary, appending a note that says what was dropped and how to get it. +/// +/// Silent truncation would be worse than the problem it solves: the agent would +/// read a partial tree as the whole UI and conclude a control does not exist. +fn clip_tree_text(text: String) -> String { + if text.len() <= DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES { + return text; + } + let cut = text[..DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES] + .rfind('\n') + .unwrap_or(DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES); + let kept_lines = text[..cut].lines().count(); + let total_lines = text.lines().count(); + format!( + "{}\n[truncated] showing the first {} of {} AX nodes ({} of {} bytes). \ +This is a size limit, not the end of the UI — a control you cannot find here may still exist. \ +Narrow the view with `get_app_state` (`focus_window_only`, a smaller `max_depth`) or target it \ +directly with `locate` / `move_to_text`.\n", + &text[..cut], + kept_lines, + total_lines, + cut, + text.len(), + ) +} + pub struct ComputerUseTool; impl Default for ComputerUseTool { @@ -482,14 +539,18 @@ The **primary model cannot consume images** in tool results — **do not** use * // tactic — which is exactly what a null `ax_tree_text` used to cause. let ax_tree_status: &str = match selector.as_ref() { None => "no_foreground_app", - Some(app) => match host.get_app_state(app.clone(), 8, true).await { + Some(app) => match host + .get_app_state(app.clone(), DESCRIBE_SCREEN_AX_DEPTH, true) + .await + { Ok(snap) => { // Deliberately drop `snap.screenshot` (JPEG) — describe_screen // never returns image bytes so text-only models are safe. window_title = snap.window_title.clone(); ax_nodes_count = Some(snap.nodes.len()); ax_digest = Some(snap.digest.clone()); - ax_tree_text = Some(snap.tree_text).filter(|t| !t.trim().is_empty()); + ax_tree_text = + Some(clip_tree_text(snap.tree_text)).filter(|t| !t.trim().is_empty()); if ax_tree_text.is_some() { "ok" } else { @@ -2178,7 +2239,7 @@ fn req_i32(input: &Value, key: &str) -> BitFunResult { #[cfg(test)] mod tests { - use super::ComputerUseTool; + use super::{clip_tree_text, ComputerUseTool, DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES}; use crate::agentic::tools::computer_use_host::{ ComputerScreenshot, ComputerUseForegroundApplication, ComputerUseHost, ComputerUsePermissionSnapshot, ComputerUseScreenshotParams, ComputerUseSessionSnapshot, @@ -2587,6 +2648,38 @@ mod tests { ); } + #[test] + fn tree_text_under_the_cap_is_returned_verbatim() { + let small = "[0] AXApplication\n [1] AXWindow\n".to_string(); + assert_eq!(clip_tree_text(small.clone()), small); + } + + /// Truncation must announce itself. An agent that reads a clipped tree as + /// the whole UI concludes a control does not exist and gives up on it. + #[test] + fn oversized_tree_text_is_clipped_on_a_line_boundary_and_says_so() { + let line = "[0] AXButton title=\"x\" frame=(0,0,10x10)\n"; + let big = line.repeat(DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES / line.len() + 500); + let out = clip_tree_text(big.clone()); + + assert!(out.len() < big.len(), "must actually shrink"); + assert!( + out.contains("[truncated]"), + "must announce the clip: {out:.200}" + ); + assert!( + out.contains("not the end of the UI"), + "must warn that a missing control may still exist" + ); + // Cutting mid-line would hand the model a malformed node. + let body = out.split("\n[truncated]").next().unwrap(); + assert!( + body.lines() + .all(|l| l.is_empty() || l.starts_with("[0] AXButton")), + "clip must land on a line boundary" + ); + } + /// An empty snapshot must say *why* it is empty. A bare `ax_tree_text: /// null` reads as truncated tool output, and an agent that believes its /// results are being cut off re-issues the same call instead of changing From ffe2aab71022d1890f9db765fcefc7d09e9098a1 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 07:31:12 -0700 Subject: [PATCH 25/39] fix(computer-use): clip the AX tree on a char boundary, not a raw byte index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 60 KB cap I added in the previous commit sliced the tree at a byte offset. `&str[..n]` panics when `n` falls inside a multi-byte character, so a CJK app tree — the kind most likely to be large enough to hit the cap in the first place — would panic the whole tool call roughly two times in three. Walk back to a char boundary before slicing. The first tests I wrote for this passed against the broken version: repeating a fixed line, and repeating a bare 3-byte character, both happen to land exactly on 60_000. Shifting the content by one and two bytes is what exposes it, so the test now covers all three alignments and was confirmed to fail without the fix. --- .../implementations/computer_use_tool.rs | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs index 0a8c39644..0650323d7 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/computer_use_tool.rs @@ -158,9 +158,16 @@ fn clip_tree_text(text: String) -> String { if text.len() <= DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES { return text; } - let cut = text[..DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES] - .rfind('\n') - .unwrap_or(DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES); + // Walk back to a char boundary before slicing. The cap is a byte count, and + // slicing a `str` at a byte index inside a multi-byte character panics — + // which CJK app trees (the ones most likely to be large) would hit + // constantly. + let mut end = DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + // A newline is single-byte, so its index is always a valid boundary too. + let cut = text[..end].rfind('\n').unwrap_or(end); let kept_lines = text[..cut].lines().count(); let total_lines = text.lines().count(); format!( @@ -2680,6 +2687,49 @@ mod tests { ); } + /// The cap is a byte count but the tree is a `str`, so the clip has to land + /// on a char boundary. A CJK app — exactly the kind whose tree gets large — + /// would otherwise panic the whole tool call on a mid-character slice. + #[test] + fn oversized_cjk_tree_text_clips_without_panicking() { + for label in ["范明裕", "飞书 · 消息", "🙂 emoji", "混合 mixed 内容"] { + let line = format!("[0] AXStaticText title=\"{label}\"\n"); + let big = line.repeat(DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES / line.len() + 500); + assert!(big.len() > DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES); + + let out = clip_tree_text(big.clone()); + assert!(out.contains("[truncated]"), "must announce the clip"); + assert!(out.len() < big.len(), "must actually shrink"); + } + } + + /// The cut offset must be safe for *every* alignment, not the one a given + /// repeated line happens to produce. + /// + /// Shifting the content by one and two bytes is what makes this bite: a + /// 3-byte character misaligns against the byte cap at two of every three + /// offsets, and only those two panic. An unshifted string of `范` lands + /// exactly on 60_000 and sails through a completely broken implementation — + /// which is how the first version of this test passed without the fix. + #[test] + fn clip_lands_on_a_char_boundary_at_every_alignment() { + for pad in 0..3 { + // No newline anywhere, so the cut falls back to the boundary walk + // rather than being rescued by `rfind('\n')`. + let mut s = "a".repeat(pad); + s.push_str(&"范".repeat(DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES / 3 + 10)); + assert!(s.len() > DESCRIBE_SCREEN_TREE_TEXT_MAX_BYTES); + + let out = clip_tree_text(s.clone()); + assert!(out.contains("[truncated]"), "pad={pad}"); + let body = out.split("\n[truncated]").next().unwrap(); + assert!( + body.chars().all(|c| c == 'a' || c == '范'), + "clip split a character at pad={pad}" + ); + } + } + /// An empty snapshot must say *why* it is empty. A bare `ax_tree_text: /// null` reads as truncated tool output, and an agent that believes its /// results are being cut off re-issues the same call instead of changing From 56cfb349fa68d12e314da738d1983ce1e98d591d Mon Sep 17 00:00:00 2001 From: t Date: Tue, 11 Aug 2026 23:51:51 +0800 Subject: [PATCH 26/39] =?UTF-8?q?fix:=20CLI=20TUI=20=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E9=80=82=E9=85=8D=20ratatui=200.30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ratatui 0.30(ratatui-core 0.1.2)的 Terminal::clear() 为保留光标位置 新增后端光标查询(crossterm Unix 发 DSR ESC[6n 并等待应答),在无人 应答的 PTY 测试环境中超时失败,导致 CI(ubuntu/macos)CLI Tests 的 5 个 TUI 测试进程启动即退出(exit 1),panic 于 terminal_process_contracts.rs:696。 - startup.rs: Terminal::clear() -> backend_mut().clear()(Backend::clear() 仅发 ESC[2J,语义与 0.29 的 Terminal::clear() Fullscreen 分支一致) - mod.rs: with_restored 恢复路径同改,并补 Backend trait import 验证(1.97.1):cargo test -p bitfun-cli 726 passed 0 failed; cargo test -p bitfun-core --lib 2467 passed 0 failed; cargo clippy -p bitfun-cli --all-targets 0 error 无新增 warning; cargo check --workspace 0 error。 --- src/apps/cli/src/ui/mod.rs | 7 +++++-- src/apps/cli/src/ui/startup.rs | 6 +++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 0d63333b4..2be1febaa 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -48,7 +48,7 @@ use crossterm::{ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{ - backend::CrosstermBackend, + backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, text::{Line, Span}, @@ -96,7 +96,10 @@ impl TerminalGuard { let operation_result = operation(); let mut resumed = init_terminal()?; - if let Err(error) = resumed.clear() { + // Same as startup.rs: `Terminal::clear()` in ratatui 0.30 queries the cursor + // position (DSR `ESC[6n`), which can time out in PTY test environments; clear the + // backend directly instead. + if let Err(error) = resumed.backend_mut().clear() { drop(resumed); return Err(error.into()); } diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index ed4a661dd..0d96d6fa1 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -365,7 +365,11 @@ impl StartupPage { where B::Error: Send + Sync + 'static, { - terminal.clear()?; + // ratatui 0.30 的 `Terminal::clear()` 会先查询光标位置(crossterm 发 DSR + // `ESC[6n` 等待应答),在无人应答的 PTY 测试环境中会超时失败。 + // 直接清后端(`clear_region(All)`,语义与 0.29 的 `Terminal::clear()` 一致) + // 不查询光标位置;随后的首个 `terminal.draw` 即全量重绘,无需 back-buffer reset。 + terminal.backend_mut().clear()?; let mut event_reader = crate::ui::input::EventReader::default(); loop { From 2aa7bff5b83ccbd5230d83d6e11f7c4ea92f618c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Tue, 11 Aug 2026 08:41:46 -0700 Subject: [PATCH 27/39] fix(computer-use): bound AX payloads, report true image dimensions, cap osascript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four follow-ups left open by #2224, each a case where a result told the agent something that was either far too large or quietly untrue. **`get_app_state` was unbounded.** `describe_screen` got a 60 KB cap; the explicit query did not. Measured unbounded output on real Electron apps was 220-390 KB from a single call — roughly 100k tokens spent on one look at one app. Capped at 120 KB (higher, because asking for an app's tree is an explicit request, but still a ceiling), reusing the same clip that lands on a char boundary and announces itself. Applied in `snap_state_json`, so `app_click` / `app_type_text` / `app_scroll` / `app_key_chord` / `app_wait_for` are covered too — they all carry the same post-action tree and shared the same risk. **`analyze_image` reported the resized dimensions as the file's.** Large screenshots get downscaled to fit the provider (repeated 0.75x passes, floor 64px), and `ProcessedImage` kept only the final size — the source dimensions were computed and dropped. So a caller mapping anything the vision model said back to the screen was off by an unknown factor, with nothing in the result hinting at it. `ProcessedImage` now carries `original_width` / `original_height` with `scale()` and `was_resized()`; `analyze_image` and `view_image` report both frames. `analyze_image` also states plainly what its numbers are: any position in the prose is a vision model's estimate in the resized frame, not a measurement and not a click target — use `locate` / `move_to_text` / `describe_screen`, which return real coordinates. Building a coordinate contract on estimated numbers would be worse than saying they are estimates. **`open_app` could block for two minutes.** `activate` sends an AppleEvent and waits for the app to answer; a hung app does not, and macOS's default AppleEvent timeout is 120s, held on a blocking thread with the agent unaware. `Command::output()` has no timeout, so osascript now runs under a polled 10s deadline and is killed past it. A timeout reports as a failed launch the agent can act on, not an opaque io error. **`interaction_state.displays` rode on every result.** On a single-screen machine it repeats what `active_display_id` already says. Sent only when more than one display is attached; `list_displays` and `describe_screen` still report the full list on demand. --- .../src/computer_use/desktop_host/mod.rs | 133 +++++++++++++++--- .../image_analysis/image_processing.rs | 112 +++++++++++++++ .../implementations/analyze_image_tool.rs | 34 ++++- .../implementations/computer_use_actions.rs | 10 +- .../implementations/computer_use_tool.rs | 72 ++++++++-- .../tools/implementations/view_image_tool.rs | 8 ++ .../tool-contracts/src/computer_use.rs | 5 + 7 files changed, 342 insertions(+), 32 deletions(-) diff --git a/src/apps/desktop/src/computer_use/desktop_host/mod.rs b/src/apps/desktop/src/computer_use/desktop_host/mod.rs index d2a3fa13e..c3fcd69a6 100644 --- a/src/apps/desktop/src/computer_use/desktop_host/mod.rs +++ b/src/apps/desktop/src/computer_use/desktop_host/mod.rs @@ -48,6 +48,55 @@ const OPEN_APP_WINDOW_WAIT_MS: u64 = 8_000; #[cfg(target_os = "macos")] const OPEN_APP_POLL_INTERVAL_MS: u64 = 150; +/// How long an `open_app` AppleScript may run before it is killed. +/// +/// `activate` sends an AppleEvent to the target app and waits for it to answer. +/// A hung or busy app simply does not answer, and macOS's default AppleEvent +/// timeout is **120 seconds** — during which `open_app` occupies a blocking +/// thread and the agent has no idea anything is wrong. An app that has not +/// acknowledged activation in a few seconds is not going to. +#[cfg(target_os = "macos")] +const OSASCRIPT_TIMEOUT_MS: u64 = 10_000; + +/// Run `osascript -e