From 0fdebf967905407d00e6d2a6490ee044b06609f2 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 24 May 2026 10:14:35 +0800 Subject: [PATCH 1/4] feat(goal) --- src/apps/desktop/src/api/agentic_api.rs | 81 +++ src/apps/desktop/src/lib.rs | 1 + .../src/agentic/coordination/coordinator.rs | 155 +++++- .../src/agentic/coordination/scheduler.rs | 59 +++ src/crates/core/src/agentic/goal_mode/mod.rs | 487 ++++++++++++++++++ .../core/src/agentic/goal_mode/types.rs | 62 +++ src/crates/core/src/agentic/mod.rs | 4 + .../src/flow_chat/components/ChatInput.tsx | 110 +++- .../services/goalCommandParser.test.ts | 22 + .../flow_chat/services/goalCommandParser.ts | 15 + .../src/flow_chat/services/goalService.ts | 77 +++ .../api/service-api/AgentAPI.ts | 20 + src/web-ui/src/locales/en-US/flow-chat.json | 6 + src/web-ui/src/locales/zh-CN/flow-chat.json | 6 + src/web-ui/src/locales/zh-TW/flow-chat.json | 6 + 15 files changed, 1105 insertions(+), 6 deletions(-) create mode 100644 src/crates/core/src/agentic/goal_mode/mod.rs create mode 100644 src/crates/core/src/agentic/goal_mode/types.rs create mode 100644 src/web-ui/src/flow_chat/services/goalCommandParser.test.ts create mode 100644 src/web-ui/src/flow_chat/services/goalCommandParser.ts create mode 100644 src/web-ui/src/flow_chat/services/goalService.ts diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index d1a91df12..ad98e1d73 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -123,6 +123,29 @@ pub struct CompactSessionRequest { pub remote_ssh_host: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivateSessionGoalRequest { + pub session_id: String, + #[serde(default)] + pub user_hint: Option, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ActivateSessionGoalResponse { + pub success: bool, + pub goal_text: String, + pub success_criteria: Vec, + pub kickoff_message: String, + pub display_message: String, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EnsureCoordinatorSessionRequest { @@ -801,6 +824,64 @@ pub async fn compact_session( }) } +#[tauri::command] +pub async fn activate_session_goal( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: ActivateSessionGoalRequest, +) -> Result { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + + if coordinator + .get_session_manager() + .get_session(session_id) + .is_none() + { + let workspace_path = request + .workspace_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + "workspace_path is required when the session is not loaded".to_string() + })?; + let effective = desktop_effective_session_storage_path( + &app_state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + coordinator + .restore_session(&effective, session_id) + .await + .map_err(|e| format!("Failed to restore session before activating goal mode: {e}"))?; + } + + let user_hint = request + .user_hint + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + + let activation = coordinator + .activate_session_goal(session_id.to_string(), user_hint) + .await + .map_err(|e| format!("Failed to activate session goal mode: {e}"))?; + + Ok(ActivateSessionGoalResponse { + success: true, + goal_text: activation.goal_text, + success_criteria: activation.success_criteria, + kickoff_message: activation.kickoff_message, + display_message: activation.display_message, + }) +} + #[tauri::command] pub async fn ensure_assistant_bootstrap( coordinator: State<'_, Arc>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index f9632bf9f..e72381dfd 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -586,6 +586,7 @@ pub async fn run() { api::agentic_api::ensure_coordinator_session, api::agentic_api::start_dialog_turn, api::agentic_api::compact_session, + api::agentic_api::activate_session_goal, api::agentic_api::ensure_assistant_bootstrap, api::agentic_api::cancel_dialog_turn, api::agentic_api::steer_dialog_turn, diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 949487fb1..d3d0c8479 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -16,6 +16,13 @@ use crate::agentic::execution::{ContextCompactionOutcome, ExecutionContext, Exec use crate::agentic::fork_agent::{ ForkAgentContextSnapshot, ForkAgentExecutionRequest, ForkAgentExecutionResult, }; +use crate::agentic::goal_mode::{ + build_goal_kickoff_messages, build_goal_continuation_plan, build_recent_context_summary, + clear_goal_mode_patch, generate_goal_from_context, goal_mode_from_custom_metadata, + goal_mode_patch, should_skip_goal_verification_for_turn, verify_goal_achievement, + wrap_user_input_with_goal_reminder, GoalActivationResult, GoalContinuationPlan, + GoalModeState, MAX_GOAL_CONTINUATIONS, now_ms, +}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::{DialogRoundInjectionSource, DialogRoundPreemptSource}; use crate::agentic::session::SessionManager; @@ -1400,6 +1407,142 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + async fn load_active_goal_mode(&self, session_id: &str) -> BitFunResult> { + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + let workspace_path = session.config.workspace_path.as_deref().ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {session_id}" + )) + })?; + let metadata = self + .session_manager + .load_session_metadata(Path::new(workspace_path), session_id) + .await?; + Ok( + goal_mode_from_custom_metadata(metadata.as_ref().and_then(|value| value.custom_metadata.as_ref())) + .filter(GoalModeState::is_active), + ) + } + + /// Activate `/goal` mode for a session by synthesizing a goal from context. + pub async fn activate_session_goal( + &self, + session_id: String, + user_hint: Option, + ) -> BitFunResult { + let session = self + .session_manager + .get_session(&session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + + if matches!(session.kind, SessionKind::Subagent | SessionKind::EphemeralChild) { + return Err(BitFunError::Validation( + "Goal mode is only available for main sessions".to_string(), + )); + } + + let context_messages = self + .session_manager + .get_context_messages(&session_id) + .await?; + let context_summary = build_recent_context_summary(&context_messages, 12_000); + let trimmed_hint = user_hint + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()); + + let generation = generate_goal_from_context(&context_summary, trimmed_hint, None).await?; + let activation = build_goal_kickoff_messages(&generation, trimmed_hint); + + let state = GoalModeState { + active: true, + goal_text: activation.goal_text.clone(), + success_criteria: activation.success_criteria.clone(), + user_hint: trimmed_hint.map(str::to_string), + activated_at_ms: now_ms(), + continuation_count: 0, + }; + + self.session_manager + .merge_session_custom_metadata(&session_id, goal_mode_patch(&state)) + .await?; + + info!( + "Session goal mode activated: session_id={}, goal={}", + session_id, activation.goal_text + ); + + Ok(activation) + } + + /// Verify the active session goal after a dialog turn completes. + pub async fn prepare_goal_continuation_after_turn( + &self, + session_id: &str, + user_input: &str, + user_message_metadata: Option<&serde_json::Value>, + final_response: &str, + ) -> BitFunResult> { + if should_skip_goal_verification_for_turn(user_input, user_message_metadata) { + return Ok(None); + } + + let session = self + .session_manager + .get_session(session_id) + .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {session_id}")))?; + if matches!(session.kind, SessionKind::Subagent | SessionKind::EphemeralChild) { + return Ok(None); + } + + let Some(mut goal_state) = self.load_active_goal_mode(session_id).await? else { + return Ok(None); + }; + + if goal_state.continuation_count >= MAX_GOAL_CONTINUATIONS { + warn!( + "Session goal continuation limit reached; stopping auto-continue: session_id={}, goal={}", + session_id, goal_state.goal_text + ); + self.session_manager + .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) + .await?; + return Ok(None); + } + + let context_messages = self + .session_manager + .get_context_messages(session_id) + .await?; + let context_summary = build_recent_context_summary(&context_messages, 12_000); + let verification = verify_goal_achievement(&goal_state, &context_summary, final_response) + .await?; + + if verification.achieved { + info!( + "Session goal achieved: session_id={}, goal={}", + session_id, goal_state.goal_text + ); + self.session_manager + .merge_session_custom_metadata(session_id, clear_goal_mode_patch()) + .await?; + return Ok(None); + } + + goal_state.continuation_count = goal_state.continuation_count.saturating_add(1); + self.session_manager + .merge_session_custom_metadata(session_id, goal_mode_patch(&goal_state)) + .await?; + + Ok(Some(build_goal_continuation_plan( + &goal_state, + &verification, + ))) + } + /// Compact the active session context as a persisted maintenance turn. pub async fn compact_session_manually(&self, session_id: String) -> BitFunResult<()> { let session = self @@ -1846,7 +1989,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } ); - let wrapped_user_input = self + let mut wrapped_user_input = self .wrap_user_input( &effective_agent_type, previous_agent_type @@ -1858,6 +2001,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; + if let Ok(Some(goal_state)) = self.load_active_goal_mode(&session_id).await { + if !should_skip_goal_verification_for_turn( + &original_user_input, + user_message_metadata.as_ref(), + ) { + wrapped_user_input = + wrap_user_input_with_goal_reminder(wrapped_user_input, &goal_state); + } + } + if original_user_input != wrapped_user_input { let mut metadata = Self::ensure_user_message_metadata_object(user_message_metadata.take()); diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 657efce94..b34cfe1dc 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -100,6 +100,9 @@ pub struct AgentSessionReplyRoute { struct ActiveTurn { turn_id: String, workspace_path: Option, + agent_type: String, + user_input: String, + user_message_metadata: Option, policy: DialogSubmissionPolicy, reply_route: Option, } @@ -109,6 +112,12 @@ impl ActiveTurn { Self { turn_id, workspace_path: turn.workspace_path.clone(), + agent_type: turn.agent_type.clone(), + user_input: turn + .original_user_input + .clone() + .unwrap_or_else(|| turn.user_input.clone()), + user_message_metadata: turn.user_message_metadata.clone(), policy: turn.policy, reply_route: turn.reply_route.clone(), } @@ -789,6 +798,53 @@ Status: {status}" } } + if let (Some(active_turn), TurnOutcome::Completed { final_response, .. }) = + (active_turn.as_ref(), &outcome) + { + match self + .coordinator + .prepare_goal_continuation_after_turn( + &session_id, + &active_turn.user_input, + active_turn.user_message_metadata.as_ref(), + final_response, + ) + .await + { + Ok(Some(plan)) => { + if let Err(error) = self + .submit( + session_id.clone(), + plan.wrapped_message, + Some(plan.display_message), + None, + active_turn.agent_type.clone(), + active_turn.workspace_path.clone(), + DialogSubmissionPolicy::for_source( + DialogTriggerSource::AgentSession, + ), + None, + Some(plan.user_message_metadata), + None, + ) + .await + { + warn!( + "Failed to submit goal continuation turn: session_id={}, error={}", + session_id, error + ); + } + } + Ok(None) => {} + Err(error) => { + warn!( + "Goal verification failed after turn completion: session_id={}, error={}", + session_id, error + ); + } + } + } + let status = outcome.status(); match outcome.queue_action() { TurnOutcomeQueueAction::DispatchNext => { @@ -835,6 +891,9 @@ mod tests { ActiveTurn { turn_id: "turn_1".to_string(), workspace_path: Some("/workspace".to_string()), + agent_type: "agentic".to_string(), + user_input: "hello".to_string(), + user_message_metadata: None, policy: DialogSubmissionPolicy::for_source(DialogTriggerSource::AgentSession), reply_route: Some(AgentSessionReplyRoute { source_session_id: source_session_id.to_string(), diff --git a/src/crates/core/src/agentic/goal_mode/mod.rs b/src/crates/core/src/agentic/goal_mode/mod.rs new file mode 100644 index 000000000..bcf6ed048 --- /dev/null +++ b/src/crates/core/src/agentic/goal_mode/mod.rs @@ -0,0 +1,487 @@ +//! Session goal mode: `/goal` command support with AI goal synthesis and +//! post-turn achievement verification. + +mod types; + +pub use types::*; + +use crate::agentic::core::{Message, MessageContent, MessageRole, PromptEnvelope}; +use crate::service::config::{get_app_language_code, short_model_user_language_instruction}; +use crate::util::errors::{BitFunError, BitFunResult}; +use crate::util::extract_json_from_ai_response; +use crate::util::sanitize_plain_model_output; +use crate::util::types::Message as AIMessage; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn goal_mode_from_custom_metadata( + custom_metadata: Option<&serde_json::Value>, +) -> Option { + let value = custom_metadata?.get(GOAL_MODE_METADATA_KEY)?; + serde_json::from_value(value.clone()).ok() +} + +pub fn goal_mode_patch(state: &GoalModeState) -> serde_json::Value { + serde_json::json!({ + GOAL_MODE_METADATA_KEY: state, + }) +} + +pub fn clear_goal_mode_patch() -> serde_json::Value { + serde_json::json!({ + GOAL_MODE_METADATA_KEY: serde_json::Value::Null, + }) +} + +pub fn message_text(message: &Message) -> Option { + match &message.content { + MessageContent::Text(text) => Some(text.clone()), + MessageContent::Multimodal { text, .. } => Some(text.clone()), + MessageContent::Mixed { text, .. } if !text.trim().is_empty() => Some(text.clone()), + _ => None, + } +} + +pub fn build_recent_context_summary(messages: &[Message], max_chars: usize) -> String { + let mut lines: Vec = Vec::new(); + for message in messages.iter().rev() { + let role = match message.role { + MessageRole::User => "User", + MessageRole::Assistant => "Assistant", + _ => continue, + }; + let Some(text) = message_text(message) else { + continue; + }; + let trimmed = text.trim(); + if trimmed.is_empty() { + continue; + } + let snippet = if trimmed.chars().count() > 800 { + format!( + "{}...", + trimmed.chars().take(800).collect::() + ) + } else { + trimmed.to_string() + }; + lines.push(format!("{role}: {snippet}")); + if lines.iter().map(|line| line.len()).sum::() >= max_chars { + break; + } + } + lines.reverse(); + let mut summary = lines.join("\n\n"); + if summary.chars().count() > max_chars { + summary = summary.chars().take(max_chars).collect(); + summary.push_str("..."); + } + summary +} + +pub fn build_goal_system_reminder(state: &GoalModeState) -> String { + let criteria = if state.success_criteria.is_empty() { + "- Use your best judgment to decide when the goal is fully complete.".to_string() + } else { + state + .success_criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + }; + + format!( + "Active session goal mode is ON.\n\ +Goal: {}\n\ +Success criteria:\n{}\n\ +Keep working toward this goal. Do not declare the task finished until every criterion is truly satisfied.", + state.goal_text.trim(), + criteria + ) +} + +pub fn wrap_user_input_with_goal_reminder(user_input: String, state: &GoalModeState) -> String { + if has_prompt_markup(&user_input) { + return user_input; + } + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(build_goal_system_reminder(state)); + envelope.push_user_query(user_input); + envelope.render() +} + +fn has_prompt_markup(text: &str) -> bool { + crate::agentic::core::has_prompt_markup(text) +} + +pub fn build_goal_kickoff_messages( + generation: &GoalGenerationResult, + user_hint: Option<&str>, +) -> GoalActivationResult { + let goal_text = generation.goal_text.trim().to_string(); + let criteria = generation + .success_criteria + .iter() + .map(|item| item.trim()) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect::>(); + + let criteria_block = if criteria.is_empty() { + String::new() + } else { + format!( + "\nSuccess criteria:\n{}", + criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + ) + }; + + let hint_line = user_hint + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("\nUser-provided focus: {value}")) + .unwrap_or_default(); + + let display_message = format!("/goal {goal_text}"); + let kickoff_message = format!( + "Work toward this session goal until it is fully achieved.{hint_line}\n\nGoal: {goal_text}{criteria_block}\n\nStart executing now. Verify your work before stopping." + ); + + GoalActivationResult { + goal_text: goal_text.clone(), + success_criteria: criteria, + kickoff_message, + display_message, + } +} + +pub fn build_goal_continuation_plan( + state: &GoalModeState, + verification: &GoalVerificationResult, +) -> GoalContinuationPlan { + let gaps = if verification.gaps.is_empty() { + "- The goal is not fully complete yet.".to_string() + } else { + verification + .gaps + .iter() + .map(|gap| format!("- {gap}")) + .collect::>() + .join("\n") + }; + + let guidance = verification.guidance.trim(); + let guidance_block = if guidance.is_empty() { + "Continue working on the remaining gaps before stopping.".to_string() + } else { + guidance.to_string() + }; + + let display_message = format!( + "Goal not yet achieved — continuing work on: {}", + state.goal_text + ); + + let wrapped_message = { + let mut envelope = PromptEnvelope::new(); + envelope.push_system_reminder(format!( + "Goal verification found the active session goal is NOT yet achieved.\n\ +Goal: {}\n\ +Remaining gaps:\n{gaps}\n\ +Next steps:\n{guidance_block}\n\ +Continue working until the goal is fully satisfied. Do not stop early.", + state.goal_text.trim() + )); + envelope.push_user_query(format!( + "Continue working toward the session goal. Address the remaining gaps and complete the goal before stopping.\n\nGoal: {}", + state.goal_text.trim() + )); + envelope.render() + }; + + GoalContinuationPlan { + wrapped_message, + display_message, + user_message_metadata: serde_json::json!({ + "goalModeContinuation": true, + "goalText": state.goal_text, + }), + } +} + +pub fn should_skip_goal_verification_for_turn( + user_input: &str, + user_message_metadata: Option<&serde_json::Value>, +) -> bool { + let trimmed = user_input.trim(); + if trimmed.eq_ignore_ascii_case("/compact") + || trimmed.starts_with("/usage") + || trimmed.starts_with("/btw") + { + return true; + } + if user_message_metadata + .and_then(|metadata| metadata.get("maintenanceTurn")) + .and_then(|value| value.as_bool()) + .unwrap_or(false) + { + return true; + } + false +} + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +async fn call_goal_func_agent(system_prompt: String, user_prompt: String) -> BitFunResult { + let messages = vec![ + AIMessage { + role: "system".to_string(), + content: Some(system_prompt), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }, + AIMessage { + role: "user".to_string(), + content: Some(user_prompt), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }, + ]; + + let ai_client_factory = crate::infrastructure::ai::get_global_ai_client_factory() + .await + .map_err(|error| BitFunError::AIClient(format!("Failed to get AI client factory: {error}")))?; + + let ai_client = ai_client_factory + .get_client_by_func_agent(GOAL_MODE_FUNC_AGENT) + .await + .map_err(|error| BitFunError::AIClient(format!("Failed to get goal func agent client: {error}")))?; + + let response = ai_client + .send_message(messages, None) + .await + .map_err(|error| BitFunError::ai(format!("Goal func agent call failed: {error}")))?; + + Ok(sanitize_plain_model_output(&response.text)) +} + +pub async fn generate_goal_from_context( + context_summary: &str, + user_hint: Option<&str>, + final_response: Option<&str>, +) -> BitFunResult { + let lang_code = get_app_language_code().await; + let language_instruction = short_model_user_language_instruction(lang_code.as_str()); + + let hint_block = user_hint + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("\nUser-provided goal focus: {value}")) + .unwrap_or_default(); + + let response_block = final_response + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| format!("\nLatest assistant response:\n{value}")) + .unwrap_or_default(); + + let system_prompt = format!( + "You synthesize a single actionable session goal from conversation context.\n\ +Return ONLY valid JSON with this shape:\n\ +{{\"goalText\":\"...\",\"successCriteria\":[\"...\",\"...\"]}}\n\ +Requirements:\n\ +- {language_instruction}\n\ +- goalText must be concrete and verifiable\n\ +- successCriteria must list 2-5 objective completion checks\n\ +- Do not include markdown or commentary" + ); + + let user_prompt = format!( + "Conversation context:\n{context_summary}{hint_block}{response_block}\n\n\ +Synthesize the session goal JSON:" + ); + + let raw = call_goal_func_agent(system_prompt, user_prompt).await?; + parse_goal_generation(&raw) +} + +pub async fn verify_goal_achievement( + state: &GoalModeState, + context_summary: &str, + final_response: &str, +) -> BitFunResult { + let criteria = if state.success_criteria.is_empty() { + "- Use the goal text itself as the completion standard.".to_string() + } else { + state + .success_criteria + .iter() + .map(|item| format!("- {item}")) + .collect::>() + .join("\n") + }; + + let system_prompt = "You verify whether a coding-agent session goal has truly been achieved.\n\ +Return ONLY valid JSON with this shape:\n\ +{\"achieved\":true|false,\"confidence\":0.0,\"gaps\":[\"...\"],\"guidance\":\"...\"}\n\ +Rules:\n\ +- achieved=true ONLY when every success criterion is objectively satisfied in the actual work done\n\ +- Be strict: partial progress, plans, or explanations without completed work means achieved=false\n\ +- gaps must list concrete missing items when achieved=false\n\ +- guidance must be actionable next steps for the agent\n\ +- Do not include markdown or commentary" + .to_string(); + + let user_prompt = format!( + "Goal: {}\n\ +Success criteria:\n{criteria}\n\ +Conversation context:\n{context_summary}\n\ +Latest assistant response:\n{final_response}\n\n\ +Verify goal completion JSON:", + state.goal_text.trim(), + criteria = criteria, + context_summary = context_summary, + final_response = final_response, + ); + + let raw = call_goal_func_agent(system_prompt, user_prompt).await?; + parse_goal_verification(&raw) +} + +fn parse_goal_generation(raw: &str) -> BitFunResult { + let json = extract_json_from_ai_response(raw).ok_or_else(|| { + BitFunError::Validation(format!("Goal generation returned non-JSON output: {raw}")) + })?; + let mut parsed: GoalGenerationResult = serde_json::from_str(&json).map_err(|error| { + BitFunError::Validation(format!("Failed to parse goal generation JSON: {error}")) + })?; + parsed.goal_text = parsed.goal_text.trim().to_string(); + parsed.success_criteria = parsed + .success_criteria + .into_iter() + .map(|item| item.trim().to_string()) + .filter(|item| !item.is_empty()) + .collect(); + if parsed.goal_text.is_empty() { + return Err(BitFunError::Validation( + "Goal generation returned an empty goal".to_string(), + )); + } + Ok(parsed) +} + +fn parse_goal_verification(raw: &str) -> BitFunResult { + let json = extract_json_from_ai_response(raw).ok_or_else(|| { + BitFunError::Validation(format!("Goal verification returned non-JSON output: {raw}")) + })?; + let mut parsed: GoalVerificationResult = serde_json::from_str(&json).map_err(|error| { + BitFunError::Validation(format!("Failed to parse goal verification JSON: {error}")) + })?; + parsed.guidance = parsed.guidance.trim().to_string(); + parsed.gaps = parsed + .gaps + .into_iter() + .map(|gap| gap.trim().to_string()) + .filter(|gap| !gap.is_empty()) + .collect(); + Ok(parsed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::core::Message; + + #[test] + fn goal_mode_patch_round_trips() { + let state = GoalModeState { + active: true, + goal_text: "Fix login".to_string(), + success_criteria: vec!["Tests pass".to_string()], + user_hint: None, + activated_at_ms: 1, + continuation_count: 0, + }; + let patch = goal_mode_patch(&state); + let parsed = goal_mode_from_custom_metadata(Some(&patch)).expect("goal mode"); + assert_eq!(parsed, state); + } + + #[test] + fn build_recent_context_summary_keeps_user_and_assistant_messages() { + let messages = vec![ + Message::user("Implement /goal".to_string()), + Message::assistant("Working on it".to_string()), + ]; + let summary = build_recent_context_summary(&messages, 1000); + assert!(summary.contains("Implement /goal")); + assert!(summary.contains("Working on it")); + } + + #[test] + fn skip_verification_for_maintenance_commands() { + assert!(should_skip_goal_verification_for_turn("/compact", None)); + assert!(should_skip_goal_verification_for_turn("/usage", None)); + assert!(!should_skip_goal_verification_for_turn("fix bug", None)); + } + + #[test] + fn continuation_plan_includes_goal_text() { + let state = GoalModeState { + active: true, + goal_text: "Ship feature".to_string(), + success_criteria: vec![], + user_hint: None, + activated_at_ms: 0, + continuation_count: 1, + }; + let verification = GoalVerificationResult { + achieved: false, + confidence: 0.2, + gaps: vec!["Missing tests".to_string()], + guidance: "Add tests".to_string(), + }; + let plan = build_goal_continuation_plan(&state, &verification); + assert!(plan.wrapped_message.contains("Ship feature")); + assert!(plan.display_message.contains("Ship feature")); + } + + #[test] + fn parse_goal_generation_accepts_json() { + let parsed = parse_goal_generation( + r#"{"goalText":"Fix bug","successCriteria":["Tests pass"]}"#, + ) + .expect("parsed"); + assert_eq!(parsed.goal_text, "Fix bug"); + assert_eq!(parsed.success_criteria, vec!["Tests pass".to_string()]); + } + + #[test] + fn parse_goal_verification_accepts_json() { + let parsed = parse_goal_verification( + r#"{"achieved":false,"confidence":0.4,"gaps":["Need tests"],"guidance":"Add tests"}"#, + ) + .expect("parsed"); + assert!(!parsed.achieved); + assert_eq!(parsed.guidance, "Add tests"); + } +} diff --git a/src/crates/core/src/agentic/goal_mode/types.rs b/src/crates/core/src/agentic/goal_mode/types.rs new file mode 100644 index 000000000..b33dddbff --- /dev/null +++ b/src/crates/core/src/agentic/goal_mode/types.rs @@ -0,0 +1,62 @@ +use serde::{Deserialize, Serialize}; + +pub const GOAL_MODE_METADATA_KEY: &str = "goal_mode"; +pub const GOAL_MODE_FUNC_AGENT: &str = "session-title-func-agent"; +pub const MAX_GOAL_CONTINUATIONS: u32 = 100; +pub const MAX_CONTEXT_SUMMARY_CHARS: usize = 12_000; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GoalModeState { + pub active: bool, + pub goal_text: String, + #[serde(default)] + pub success_criteria: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub user_hint: Option, + #[serde(default)] + pub activated_at_ms: u64, + #[serde(default)] + pub continuation_count: u32, +} + +impl GoalModeState { + pub fn is_active(&self) -> bool { + self.active && !self.goal_text.trim().is_empty() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GoalGenerationResult { + pub goal_text: String, + #[serde(default)] + pub success_criteria: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GoalVerificationResult { + pub achieved: bool, + #[serde(default)] + pub confidence: f32, + #[serde(default)] + pub gaps: Vec, + #[serde(default)] + pub guidance: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoalActivationResult { + pub goal_text: String, + pub success_criteria: Vec, + pub kickoff_message: String, + pub display_message: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoalContinuationPlan { + pub wrapped_message: String, + pub display_message: String, + pub user_message_metadata: serde_json::Value, +} diff --git a/src/crates/core/src/agentic/mod.rs b/src/crates/core/src/agentic/mod.rs index 689919241..9704a1c23 100644 --- a/src/crates/core/src/agentic/mod.rs +++ b/src/crates/core/src/agentic/mod.rs @@ -35,6 +35,9 @@ pub mod image_analysis; // Ephemeral side-question module (used by desktop /btw overlay) pub mod side_question; + +// Session goal mode (/goal command) +pub mod goal_mode; pub mod system; // Agents module @@ -53,6 +56,7 @@ pub use core::*; pub use events::{queue, router, types as event_types}; pub use execution::*; pub use fork_agent::*; +pub use goal_mode::*; pub use image_analysis::{ImageAnalyzer, MessageEnhancer}; pub use persistence::PersistenceManager; pub use round_preempt::{ diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 5f8a707d5..219496ca2 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -35,6 +35,7 @@ import { useChatInputState } from '../store/chatInputStateStore'; import { useInputHistoryStore } from '../store/inputHistoryStore'; import { startBtwThread } from '../services/BtwThreadService'; import { runUsageReportCommand } from '../services/usageReportService'; +import { isGoalSlashCommand, parseGoalCommand, runGoalCommandSafely } from '../services/goalService'; import { FlowChatManager } from '@/flow_chat'; import { DEEP_REVIEW_SLASH_COMMAND, @@ -1096,6 +1097,12 @@ export const ChatInput: React.FC = ({ command: '/btw', label: t('btw.title', { defaultValue: 'Side question' }), }]), + { + kind: 'action', + id: 'goal', + command: '/goal', + label: t('chatInput.goalAction', { defaultValue: 'Session goal' }), + }, { kind: 'action', id: 'usage', @@ -1210,12 +1217,13 @@ export const ChatInput: React.FC = ({ const trimmedLower = text.trim().toLowerCase(); const isBtwCommand = trimmedLower.startsWith('/btw'); const isCompactCommand = trimmedLower.startsWith('/compact'); + const isGoalCommand = isGoalSlashCommand(text); const isUsageCommand = trimmedLower.startsWith('/usage'); const isDeepReviewCommand = isDeepReviewSlashCommand(text); const isProcessing = !!derivedState?.isProcessing; - // Don't queue /btw while the main session is processing; /btw runs independently. - if (derivedState?.isProcessing && !isBtwCommand && !isCompactCommand && !isUsageCommand && !isDeepReviewCommand) { + // Don't queue /btw or /goal while the main session is processing; they have dedicated flows. + if (derivedState?.isProcessing && !isBtwCommand && !isGoalCommand && !isCompactCommand && !isUsageCommand && !isDeepReviewCommand) { setQueuedInput(text); } @@ -1230,7 +1238,7 @@ export const ChatInput: React.FC = ({ // Only show the picker for "/..." patterns that are plausibly a command (/ or /b... /d...). // Once the user types a space (starts composing the real question), stop showing the picker // so Enter can submit "/btw ..." or "/DeepReview ..." instead of selecting from the picker. - if (!hasWhitespace && (query === '' || query.startsWith('b') || query.startsWith('d') || query.startsWith('u'))) { + if (!hasWhitespace && (query === '' || query.startsWith('b') || query.startsWith('d') || query.startsWith('g') || query.startsWith('u'))) { setSlashCommandState({ isActive: true, kind: 'actions', @@ -1244,7 +1252,7 @@ export const ChatInput: React.FC = ({ } // When idle, keep the picker for mode switching, but don't interfere with executable slash commands. - if (!isBtwCommand && !isCompactCommand && !isUsageCommand && !isDeepReviewCommand && !matchedMcpPrompt) { + if (!isBtwCommand && !isGoalCommand && !isCompactCommand && !isUsageCommand && !isDeepReviewCommand && !matchedMcpPrompt) { setSlashCommandState({ isActive: true, kind: 'all', @@ -1541,6 +1549,65 @@ export const ChatInput: React.FC = ({ t, ]); + const submitGoalFromInput = useCallback(async () => { + if (!effectiveTargetSessionId || !effectiveTargetSession) { + notificationService.error( + t('chatInput.goalNoSession', { defaultValue: 'No active session for /goal' }) + ); + return; + } + + if (isBtwSession) { + notificationService.warning( + t('chatInput.goalNestedDisabled', { + defaultValue: 'Goal mode can only be started from the main session.', + }) + ); + return; + } + + const message = inputState.value.trim(); + const parsed = parseGoalCommand(message); + if (!parsed) { + notificationService.warning( + t('chatInput.goalUsage', { + defaultValue: 'Use /goal with optional focus text, for example /goal fix the login bug.', + }) + ); + return; + } + + const originalMessage = message; + dispatchInput({ type: 'CLEAR_VALUE' }); + setQueuedInput(null); + setSlashCommandState({ isActive: false, kind: 'modes', query: '', selectedIndex: 0 }); + + const result = await runGoalCommandSafely({ + session: effectiveTargetSession, + userHint: parsed.userHint, + failedTitle: t('chatInput.goalFailed', { defaultValue: 'Goal mode activation failed' }), + unknownErrorMessage: t('error.unknown'), + activatedTitle: t('chatInput.goalActivated', { defaultValue: 'Session goal activated' }), + }); + + if (!result) { + dispatchInput({ type: 'ACTIVATE' }); + dispatchInput({ type: 'SET_VALUE', payload: originalMessage }); + return; + } + + onSendMessage?.(result.goalText); + dispatchInput({ type: 'DEACTIVATE' }); + }, [ + effectiveTargetSession, + effectiveTargetSessionId, + inputState.value, + isBtwSession, + onSendMessage, + setQueuedInput, + t, + ]); + const submitDeepreviewFromInput = useCallback(async () => { if (!effectiveTargetSessionId || !effectiveTargetSession) { notificationService.error( @@ -1780,6 +1847,11 @@ export const ChatInput: React.FC = ({ return; } + if (isGoalSlashCommand(message)) { + await submitGoalFromInput(); + return; + } + if (/^\/compact\s*$/i.test(message)) { await submitCompactFromInput(); return; @@ -1825,6 +1897,15 @@ export const ChatInput: React.FC = ({ ); return; } + + if (message.toLowerCase().startsWith('/goal') && !isGoalSlashCommand(message)) { + notificationService.warning( + t('chatInput.goalUsage', { + defaultValue: 'Use /goal with optional focus text, for example /goal fix the login bug.', + }) + ); + return; + } // Add to history before clearing (session-scoped) if (effectiveTargetSessionId) { @@ -1881,6 +1962,7 @@ export const ChatInput: React.FC = ({ expandComposerSpecialTokens, setQueuedInput, submitBtwFromInput, + submitGoalFromInput, submitCompactFromInput, submitUsageFromInput, submitInitFromInput, @@ -1976,6 +2058,19 @@ export const ChatInput: React.FC = ({ } } else if (actionId === 'compact') { next = '/compact'; + } else if (actionId === 'goal') { + if (!lower.startsWith('/goal')) { + next = '/goal '; + } else { + const m = raw.match(/^(\s*)\/goal\b/i); + if (m) { + const leadingWs = m[1] || ''; + const rest = raw.slice(m[0].length); + next = `${leadingWs}/goal ${rest.trimStart()}`; + } else { + next = '/goal '; + } + } } else if (actionId === 'usage') { next = '/usage'; } else if (actionId === 'init') { @@ -2271,6 +2366,11 @@ export const ChatInput: React.FC = ({ return; } + if (isGoalSlashCommand(inputState.value.trim())) { + void submitGoalFromInput(); + return; + } + if (derivedState?.isProcessing) { if (!inputState.value.trim()) return; void handleSendOrCancel(); @@ -2284,7 +2384,7 @@ export const ChatInput: React.FC = ({ e.preventDefault(); void handleCancelCurrentTask(); } - }, [handleSendOrCancel, submitBtwFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, t]); + }, [handleSendOrCancel, submitBtwFromInput, submitGoalFromInput, derivedState, handleCancelCurrentTask, slashCommandState, getFilteredIncrementalModes, getFilteredActions, getSlashPickerItems, selectSlashCommandMode, selectSlashCommandAction, selectSlashPromptCommand, canSwitchModes, historyIndex, inputHistory, savedDraft, inputState.value, currentSessionId, isBtwSession, showTargetSwitcher, setInputTarget, t]); const handleImeCompositionStart = useCallback(() => { isImeComposingRef.current = true; diff --git a/src/web-ui/src/flow_chat/services/goalCommandParser.test.ts b/src/web-ui/src/flow_chat/services/goalCommandParser.test.ts new file mode 100644 index 000000000..8d9b651ef --- /dev/null +++ b/src/web-ui/src/flow_chat/services/goalCommandParser.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { isGoalSlashCommand, parseGoalCommand } from './goalCommandParser'; + +describe('goalCommandParser', () => { + it('parses /goal without a hint', () => { + expect(parseGoalCommand('/goal')).toEqual({ userHint: undefined }); + expect(parseGoalCommand('/goal ')).toEqual({ userHint: undefined }); + }); + + it('parses /goal with a hint', () => { + expect(parseGoalCommand('/goal fix login bug')).toEqual({ + userHint: 'fix login bug', + }); + }); + + it('detects valid goal commands only', () => { + expect(isGoalSlashCommand('/goal')).toBe(true); + expect(isGoalSlashCommand('/goal ship feature')).toBe(true); + expect(isGoalSlashCommand('/goalie')).toBe(false); + expect(isGoalSlashCommand('/goals')).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/goalCommandParser.ts b/src/web-ui/src/flow_chat/services/goalCommandParser.ts new file mode 100644 index 000000000..49126038a --- /dev/null +++ b/src/web-ui/src/flow_chat/services/goalCommandParser.ts @@ -0,0 +1,15 @@ +const GOAL_COMMAND_PATTERN = /^\/goal(?:\s+(.*))?$/i; + +export function parseGoalCommand(message: string): { userHint?: string } | null { + const trimmed = message.trim(); + const match = trimmed.match(GOAL_COMMAND_PATTERN); + if (!match) { + return null; + } + const userHint = match[1]?.trim(); + return { userHint: userHint || undefined }; +} + +export function isGoalSlashCommand(message: string): boolean { + return GOAL_COMMAND_PATTERN.test(message.trim()); +} diff --git a/src/web-ui/src/flow_chat/services/goalService.ts b/src/web-ui/src/flow_chat/services/goalService.ts new file mode 100644 index 000000000..81f0cc61b --- /dev/null +++ b/src/web-ui/src/flow_chat/services/goalService.ts @@ -0,0 +1,77 @@ +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { notificationService } from '@/shared/notification-system'; +import type { Session } from '../types/flow-chat'; +import { FlowChatManager } from './FlowChatManager'; + +export { isGoalSlashCommand, parseGoalCommand } from './goalCommandParser'; + +export interface GoalCommandParams { + session: Session; + userHint?: string; + failedTitle: string; + unknownErrorMessage: string; + activatedTitle: string; +} + +export interface GoalCommandResult { + goalText: string; + successCriteria: string[]; +} + +export async function runGoalCommand(params: GoalCommandParams): Promise { + if (!params.session.workspacePath) { + throw new Error('A workspace is required to activate goal mode.'); + } + + const activation = await agentAPI.activateSessionGoal({ + sessionId: params.session.sessionId, + userHint: params.userHint, + workspacePath: params.session.workspacePath, + remoteConnectionId: params.session.remoteConnectionId, + remoteSshHost: params.session.remoteSshHost, + }); + + const flowChatManager = FlowChatManager.getInstance(); + await flowChatManager.sendMessage( + activation.kickoffMessage, + params.session.sessionId, + activation.displayMessage, + undefined, + undefined, + { + userMessageMetadata: { + goalModeKickoff: true, + goalModeCommand: params.userHint ? `/goal ${params.userHint}` : '/goal', + goalText: activation.goalText, + successCriteria: activation.successCriteria, + }, + } + ); + + notificationService.success(activation.goalText, { + title: params.activatedTitle, + duration: 6000, + }); + + return { + goalText: activation.goalText, + successCriteria: activation.successCriteria, + }; +} + +export async function runGoalCommandSafely( + params: GoalCommandParams +): Promise { + try { + return await runGoalCommand(params); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : params.unknownErrorMessage, + { + title: params.failedTitle, + duration: 5000, + } + ); + return null; + } +} diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index a1c24cae2..d948ead10 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -318,6 +318,26 @@ export class AgentAPI { } } + async activateSessionGoal(request: { + sessionId: string; + userHint?: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + }): Promise<{ + success: boolean; + goalText: string; + successCriteria: string[]; + kickoffMessage: string; + displayMessage: string; + }> { + try { + return await api.invoke('activate_session_goal', { request }); + } catch (error) { + throw createTauriCommandError('activate_session_goal', error, request); + } + } + async ensureAssistantBootstrap( request: EnsureAssistantBootstrapRequest ): Promise { diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index f2c423e6c..8df75e3cc 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -492,6 +492,12 @@ "usageBusy": "Wait until the session is idle before using /usage.", "usageNoWorkspace": "A workspace is required to build a usage report.", "usageFailed": "Usage report failed", + "goalAction": "Session goal", + "goalNoSession": "No active session for /goal", + "goalUsage": "Use /goal with optional focus text, for example /goal fix the login bug.", + "goalFailed": "Goal mode activation failed", + "goalActivated": "Session goal activated", + "goalNestedDisabled": "Goal mode can only be started from the main session.", "initAction": "Generate AGENTS.md", "initNoSession": "No active session for /init", "initBusy": "Wait until the session is idle before using /init.", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 741935ac9..7b9022c38 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -486,6 +486,12 @@ "compactBusy": "请等待当前会话空闲后再使用 /compact。", "compactUsage": "请直接使用 /compact,不要附加额外参数。", "compactFailed": "会话压缩失败", + "goalAction": "会话目标", + "goalNoSession": "当前没有可用于 /goal 的会话", + "goalUsage": "使用 /goal 并可选择附加目标描述,例如 /goal 修复登录问题。", + "goalFailed": "目标模式激活失败", + "goalActivated": "会话目标已激活", + "goalNestedDisabled": "目标模式只能从主会话启动。", "initAction": "生成 AGENTS.md", "initNoSession": "当前没有可用于 /init 的会话", "initBusy": "请等待当前会话空闲后再使用 /init。", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 9cac7a796..b90272a41 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -486,6 +486,12 @@ "compactBusy": "請等待當前會話空閒後再使用 /compact。", "compactUsage": "請直接使用 /compact,不要附加額外參數。", "compactFailed": "會話壓縮失敗", + "goalAction": "會話目標", + "goalNoSession": "當前沒有可用於 /goal 的會話", + "goalUsage": "使用 /goal 並可選擇附加目標描述,例如 /goal 修復登入問題。", + "goalFailed": "目標模式啟用失敗", + "goalActivated": "會話目標已啟用", + "goalNestedDisabled": "目標模式只能從主會話啟動。", "initAction": "生成 AGENTS.md", "initNoSession": "當前沒有可用於 /init 的會話", "initBusy": "請等待當前會話空閒後再使用 /init。", From 2af9e90c8279b8dcabd6656a3cef687fec9d08c0 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 24 May 2026 10:45:37 +0800 Subject: [PATCH 2/4] fix(core) --- .../desktop/src/api/clipboard_file_api.rs | 163 +++++++--- .../agentic/agents/prompts/agentic_mode.md | 5 +- .../src/agentic/execution/execution_engine.rs | 63 +++- .../agentic/session/compression/compressor.rs | 28 ++ .../src/agentic/session/file_read_state.rs | 67 ++++ src/crates/core/src/agentic/session/mod.rs | 2 + .../src/agentic/session/session_manager.rs | 31 +- .../agentic/tools/file_read_state_runtime.rs | 275 ++++++++++++++++ .../tools/implementations/file_edit_tool.rs | 176 +++++++++-- .../tools/implementations/file_read_tool.rs | 22 +- .../tools/implementations/file_write_tool.rs | 6 + src/crates/core/src/agentic/tools/mod.rs | 1 + .../src/agentic/tools/tool_result_storage.rs | 42 ++- src/crates/tool-runtime/src/fs/edit_file.rs | 277 +++++++++++++++- src/crates/tool-runtime/src/util/mod.rs | 1 + .../tool-runtime/src/util/read_line_prefix.rs | 89 ++++++ .../src/app/components/panels/FilesPanel.tsx | 244 +++++++-------- .../utils/agentCompanionActivity.test.ts | 29 ++ .../flow_chat/utils/agentCompanionActivity.ts | 11 +- .../components/ui/ContextMenu.tsx | 4 +- .../core/ContextResolver.ts | 7 +- .../providers/FileExplorerMenuProvider.ts | 7 +- .../controller/ExplorerController.ts | 74 ++++- .../file-explorer/model/ExplorerModel.test.ts | 23 ++ .../file-explorer/model/ExplorerModel.ts | 90 +++++- .../file-system/components/FileExplorer.tsx | 79 ++++- .../file-system/components/FileTreeItem.tsx | 37 ++- .../tools/file-system/hooks/useFileSystem.ts | 6 + .../file-system/hooks/useWorkspaceFileDrop.ts | 192 ++++++++++++ .../services/workspaceFileTransfer.test.ts | 62 ++++ .../services/workspaceFileTransfer.ts | 295 ++++++++++++++---- 31 files changed, 2089 insertions(+), 319 deletions(-) create mode 100644 src/crates/core/src/agentic/session/file_read_state.rs create mode 100644 src/crates/core/src/agentic/tools/file_read_state_runtime.rs create mode 100644 src/crates/tool-runtime/src/util/read_line_prefix.rs create mode 100644 src/web-ui/src/tools/file-explorer/model/ExplorerModel.test.ts create mode 100644 src/web-ui/src/tools/file-system/hooks/useWorkspaceFileDrop.ts create mode 100644 src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index c60dce746..6fe0df80a 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -30,6 +30,53 @@ pub struct FailedFile { pub error: String, } +fn decode_file_uri(uri: &str) -> Option { + let trimmed = uri.trim(); + if !trimmed.starts_with("file://") { + return None; + } + + let rest = trimmed.strip_prefix("file://")?; + let path_part = if rest.starts_with('/') { + rest.to_string() + } else if let Some(slash_idx) = rest.find('/') { + let host = &rest[..slash_idx]; + if host.eq_ignore_ascii_case("localhost") { + rest[slash_idx..].to_string() + } else { + return None; + } + } else { + return None; + }; + + Some( + urlencoding::decode(&path_part) + .map(|value| value.into_owned()) + .unwrap_or(path_part), + ) +} + +#[allow(dead_code)] +fn parse_uri_list(content: &str) -> Vec { + content + .lines() + .map(str::trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(decode_file_uri) + .collect() +} + +fn parse_clipboard_path_segments(content: &str) -> Vec { + content + .split(|c| c == '\n' || c == '\r') + .flat_map(|segment| segment.split(',')) + .map(str::trim) + .filter(|segment| !segment.is_empty()) + .map(|segment| decode_file_uri(segment).unwrap_or_else(|| segment.to_string())) + .collect() +} + #[cfg(target_os = "windows")] mod windows_clipboard { use std::ffi::OsString; @@ -98,9 +145,7 @@ mod windows_clipboard { if actual_len > 0 { let path = OsString::from_wide(&buffer[..actual_len as usize]); - if let Some(path_str) = path.to_str() { - files.push(path_str.to_string()); - } + files.push(path.to_string_lossy().into_owned()); } } @@ -111,28 +156,31 @@ mod windows_clipboard { #[cfg(target_os = "macos")] mod macos_clipboard { - pub fn get_clipboard_files() -> Result, String> { - use std::process::Command; + use super::parse_clipboard_path_segments; + use std::process::Command; + pub fn get_clipboard_files() -> Result, String> { let output = Command::new("osascript") .args(&[ "-e", r#" set theFiles to {} + set linefeed to ASCII character 10 + set output to "" try set theClip to the clipboard as «class furl» - set end of theFiles to POSIX path of theClip + set output to (POSIX path of theClip) & linefeed on error try set theClip to the clipboard as list repeat with aFile in theClip try - set end of theFiles to POSIX path of (aFile as alias) + set output to output & (POSIX path of (aFile as alias)) & linefeed end try end repeat end try end try - return theFiles as text + return output "#, ]) .output() @@ -140,12 +188,7 @@ mod macos_clipboard { if output.status.success() { let paths_str = String::from_utf8_lossy(&output.stdout); - let files: Vec = paths_str - .lines() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()) - .collect(); - Ok(files) + Ok(parse_clipboard_path_segments(&paths_str)) } else { Ok(Vec::new()) } @@ -154,31 +197,42 @@ mod macos_clipboard { #[cfg(target_os = "linux")] mod linux_clipboard { - pub fn get_clipboard_files() -> Result, String> { - use std::process::Command; + use super::parse_uri_list; + use std::process::Command; + fn read_xclip_uri_list() -> Option { let output = Command::new("xclip") - .args(&["-selection", "clipboard", "-t", "text/uri-list", "-o"]) - .output(); - - match output { - Ok(output) if output.status.success() => { - let content = String::from_utf8_lossy(&output.stdout); - let files: Vec = content - .lines() - .filter(|line| line.starts_with("file://")) - .map(|line| { - let path = line.trim_start_matches("file://"); - urlencoding::decode(path) - .map(|s| s.into_owned()) - .unwrap_or_else(|_| path.to_string()) - }) - .collect(); - Ok(files) - } - _ => Ok(Vec::new()), + .args(["-selection", "clipboard", "-t", "text/uri-list", "-o"]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + None } } + + fn read_wl_paste_uri_list() -> Option { + let output = Command::new("wl-paste") + .args(["-t", "text/uri-list"]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).into_owned()) + } else { + None + } + } + + pub fn get_clipboard_files() -> Result, String> { + let content = read_xclip_uri_list() + .or_else(read_wl_paste_uri_list) + .unwrap_or_default(); + + Ok(parse_uri_list(&content)) + } } fn get_clipboard_files_internal() -> Result, String> { @@ -313,7 +367,7 @@ fn generate_unique_path(path: &Path) -> std::path::PathBuf { let mut counter = 1; loop { let new_name = if let Some(ext) = extension { - format!("{} ({}). {}", stem, counter, ext) + format!("{} ({}).{}", stem, counter, ext) } else { format!("{} ({})", stem, counter) }; @@ -346,3 +400,40 @@ fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(), String> Ok(()) } + +#[cfg(test)] +mod tests { + use super::{decode_file_uri, parse_uri_list}; + + #[test] + fn decode_unix_file_uri() { + assert_eq!( + decode_file_uri("file:///tmp/example.txt").as_deref(), + Some("/tmp/example.txt") + ); + } + + #[test] + fn decode_localhost_file_uri() { + assert_eq!( + decode_file_uri("file://localhost/home/user/example.txt").as_deref(), + Some("/home/user/example.txt") + ); + } + + #[test] + fn decode_windows_file_uri() { + assert_eq!( + decode_file_uri("file:///C:/Users/dev/example.txt").as_deref(), + Some("/C:/Users/dev/example.txt") + ); + } + + #[test] + fn parse_uri_list_ignores_comments_and_blank_lines() { + let files = parse_uri_list( + "# comment\n\nfile:///tmp/a.txt\r\nfile://localhost/tmp/b.txt\n", + ); + assert_eq!(files, vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()]); + } +} diff --git a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md index 21586729b..5120965a0 100644 --- a/src/crates/core/src/agentic/agents/prompts/agentic_mode.md +++ b/src/crates/core/src/agentic/agents/prompts/agentic_mode.md @@ -61,12 +61,13 @@ The user will primarily request you perform software engineering tasks. This inc - Use specialized tools for file reads, edits, searches, and deletions because they preserve workspace context and permissions. Use Bash for commands that genuinely need a shell. Do not use shell commands only to communicate with the user. - For security-sensitive tasks, support defensive analysis and remediation only. Refuse malicious code, exploit workflows, credential harvesting, or instructions that would facilitate abuse. - Edit reliability discipline: + - The Edit tool is rejected until the target file has been read in the current session with a non-partial view, and the on-disk content still matches that read (or was refreshed by a prior Edit/Write in this session). - Base `old_string` on the latest Read result for that file or exact content produced by a successful prior tool call. + - Read output uses cat -n format: spaces, line number, tab, then file content. Copy only the text after the tab into `old_string`. - Treat Read output as stale after a successful edit to the same file; avoid parallel Edit calls against the same file unless the edits are independent and based on non-overlapping current content. - - Copy current file content exactly, excluding Read line-number prefixes. - Add stable surrounding context from the same block when a snippet may appear multiple times. - Use `replace_all` only when every occurrence should change. - - If an edit fails because the text was not found or matched multiple locations, read the target area again before retrying rather than adjusting the failed string from memory. + - If an edit fails because the text was not found or matched multiple locations, read the target area again before retrying rather than adjusting the failed string from memory. Use the nearby file snippets in the error when provided. - Subagent delegation: use Explore, FileFinder, or other Task subagents when their specialized focus, separate context, or autonomy is likely to improve coverage. For simple known-path, single-symbol, or one-file questions, direct tools are usually enough. user: Give me a high-level map of how authentication flows through this monorepo diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index b5152a506..029a6e860 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -263,6 +263,31 @@ impl ExecutionEngine { ) } + /// Estimate how full the mutable conversation portion is for compression decisions. + /// + /// System prompt and tool definitions are fixed per dialog turn and should not + /// count against the auto-compression threshold the same way tool results do. + fn estimate_auto_compression_pressure( + messages: &[Message], + tools: Option<&[ToolDefinition]>, + context_window: usize, + ) -> (usize, usize, f32) { + let total_tokens = Self::estimate_request_tokens_internal(messages, tools); + let system_tokens = messages + .first() + .filter(|message| message.role == MessageRole::System) + .map(|message| message.estimate_tokens_with_reasoning(false)) + .unwrap_or(0); + let tool_tokens = tools + .map(TokenCounter::estimate_tool_definitions_tokens) + .unwrap_or(0); + let reserved_overhead = system_tokens.saturating_add(tool_tokens); + let conversation_tokens = total_tokens.saturating_sub(reserved_overhead); + let conversation_budget = context_window.saturating_sub(reserved_overhead).max(1); + let usage_ratio = conversation_tokens as f32 / conversation_budget as f32; + (total_tokens, conversation_tokens, usage_ratio) + } + fn tool_signature_args_summary(args_str: &str) -> String { if args_str.len() <= 128 { return args_str.to_string(); @@ -1725,17 +1750,22 @@ impl ExecutionEngine { // - L1: AI-summary based full compression (preserves semantics). // - L2: Emergency truncation (only if tokens still exceed the // provider context window after L1). - let current_tokens = - Self::estimate_request_tokens_internal(&messages, tool_definitions.as_deref()); + let (current_tokens, conversation_tokens, token_usage_ratio) = + Self::estimate_auto_compression_pressure( + &messages, + tool_definitions.as_deref(), + context_window, + ); debug!( - "Round {} token usage before send: {} / {} tokens ({:.1}%)", + "Round {} token usage before send: total={} / {}, conversation={} / {}, usage={:.1}%", round_index, current_tokens, context_window, - (current_tokens as f32 / context_window as f32) * 100.0 + conversation_tokens, + context_window, + token_usage_ratio * 100.0 ); - let token_usage_ratio = current_tokens as f32 / context_window as f32; let should_compress = enable_context_compression && token_usage_ratio >= compression_threshold; @@ -2529,9 +2559,10 @@ impl ExecutionEngine { #[cfg(test)] mod tests { use super::{ContextHealthSnapshot, ExecutionEngine}; - use crate::agentic::core::{Message, ToolCall, ToolResult}; + use crate::agentic::core::{Message, MessageRole, ToolCall, ToolResult}; use crate::service::config::types::AIConfig; use crate::service::config::types::AIModelConfig; + use crate::util::types::ToolDefinition; use serde_json::json; use sha2::{Digest, Sha256}; @@ -2578,6 +2609,26 @@ mod tests { ); } + #[test] + fn auto_compression_pressure_excludes_system_and_tool_overhead() { + let messages = vec![ + Message::system("system prompt".repeat(10_000)), + Message::user("hello".to_string()), + ]; + let tools = vec![ToolDefinition { + name: "Read".to_string(), + description: "Read files".repeat(5_000), + parameters: json!({"type": "object"}), + }]; + + let (total_tokens, conversation_tokens, usage_ratio) = + ExecutionEngine::estimate_auto_compression_pressure(&messages, Some(&tools), 128_000); + + assert!(total_tokens > conversation_tokens); + assert!(usage_ratio < total_tokens as f32 / 128_000_f32); + assert_eq!(messages[1].role, MessageRole::User); + } + #[test] fn tool_signature_args_summary_truncates_on_utf8_boundary() { let args = format!("{}{}", "a".repeat(62), "案".repeat(30)); diff --git a/src/crates/core/src/agentic/session/compression/compressor.rs b/src/crates/core/src/agentic/session/compression/compressor.rs index 1c706261f..64e83f846 100644 --- a/src/crates/core/src/agentic/session/compression/compressor.rs +++ b/src/crates/core/src/agentic/session/compression/compressor.rs @@ -162,6 +162,16 @@ impl ContextCompressor { let turns_count = turns.len(); let turns_tokens: Vec = turns.iter().map(|turn| turn.tokens).collect(); + // Auto-compression should not collapse the only active dialog turn mid-flight. + // Within-turn pressure is handled by tool-result budgeting and emergency truncation. + if turns_count == 1 { + debug!( + "Single-turn session skipped for auto compression: session_id={}", + session_id + ); + return Ok((0, turns)); + } + let token_limit_keep_turns = (context_window as f32 * self.config.keep_turns_ratio) as usize; let mut turn_index_to_keep = @@ -974,6 +984,24 @@ mod tests { assert_eq!(normalized, None); } + #[tokio::test] + async fn preprocess_turns_skips_single_active_turn() { + let compressor = ContextCompressor::new(Default::default()); + let messages = vec![ + Message::system("system".to_string()), + Message::user("First request".to_string()), + Message::assistant("First reply".to_string()), + ]; + + let (turn_index, turns) = compressor + .preprocess_turns("session", 8_000, messages) + .await + .expect("preprocessing succeeds"); + + assert_eq!(turn_index, 0); + assert_eq!(turns.len(), 1); + } + #[tokio::test] async fn manual_compaction_turn_collection_includes_all_non_system_turns() { let compressor = ContextCompressor::new(Default::default()); diff --git a/src/crates/core/src/agentic/session/file_read_state.rs b/src/crates/core/src/agentic/session/file_read_state.rs new file mode 100644 index 000000000..d800547bc --- /dev/null +++ b/src/crates/core/src/agentic/session/file_read_state.rs @@ -0,0 +1,67 @@ +//! Session-scoped cache of files the agent has read, used to gate Edit/Write reliability. + +use dashmap::DashMap; +use log::debug; +use std::sync::Arc; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileReadState { + /// Raw file content without Read-tool line-number prefixes (LF-normalized view). + pub content: String, + /// File mtime in milliseconds since UNIX epoch when recorded, if known. + pub timestamp_ms: u64, + pub start_line: usize, + pub end_line: usize, + pub total_lines: usize, + pub is_partial_view: bool, +} + +impl FileReadState { + pub fn is_full_file_read(&self) -> bool { + !self.is_partial_view && self.start_line == 1 && self.end_line >= self.total_lines + } +} + +#[derive(Default)] +pub struct FileReadStateStore { + session_states: Arc>>, +} + +impl FileReadStateStore { + pub fn new() -> Self { + Self::default() + } + + pub fn create_session(&self, session_id: &str) { + self.session_states + .entry(session_id.to_string()) + .or_insert_with(DashMap::new); + debug!("Created file read state cache: session_id={}", session_id); + } + + pub fn delete_session(&self, session_id: &str) { + self.session_states.remove(session_id); + debug!("Deleted file read state cache: session_id={}", session_id); + } + + pub fn clear_session(&self, session_id: &str) { + if let Some(states) = self.session_states.get(session_id) { + states.clear(); + debug!("Cleared file read state cache: session_id={}", session_id); + } + } + + pub fn set(&self, session_id: &str, logical_path: &str, state: FileReadState) { + let session_states = self + .session_states + .entry(session_id.to_string()) + .or_insert_with(DashMap::new); + session_states.insert(logical_path.to_string(), state); + } + + pub fn get(&self, session_id: &str, logical_path: &str) -> Option { + self.session_states + .get(session_id) + .and_then(|states| states.get(logical_path).map(|entry| entry.clone())) + } +} diff --git a/src/crates/core/src/agentic/session/mod.rs b/src/crates/core/src/agentic/session/mod.rs index 54578fb87..8a39dc7d0 100644 --- a/src/crates/core/src/agentic/session/mod.rs +++ b/src/crates/core/src/agentic/session/mod.rs @@ -3,11 +3,13 @@ //! Provides session lifecycle management and context management. pub mod compression; +pub mod file_read_state; pub mod context_store; pub mod evidence_ledger; pub mod session_manager; pub use compression::*; +pub use file_read_state::*; pub use context_store::*; pub use evidence_ledger::*; pub use session_manager::*; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 4320597dd..5a5bcfa02 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -10,7 +10,8 @@ use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::{ EvidenceLedgerCheckpoint, EvidenceLedgerEvent, EvidenceLedgerEventStatus, - EvidenceLedgerSummary, EvidenceLedgerTargetKind, SessionContextStore, SessionEvidenceLedger, + EvidenceLedgerSummary, EvidenceLedgerTargetKind, FileReadState, FileReadStateStore, + SessionContextStore, SessionEvidenceLedger, }; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::{ @@ -90,6 +91,7 @@ pub struct SessionManager { /// Sub-components context_store: Arc, + file_read_state_store: Arc, evidence_ledger: Arc, persistence_manager: Arc, @@ -686,6 +688,7 @@ impl SessionManager { sessions: Arc::new(DashMap::new()), session_workspace_index: Arc::new(DashMap::new()), context_store, + file_read_state_store: Arc::new(FileReadStateStore::new()), evidence_ledger: Arc::new(SessionEvidenceLedger::new()), persistence_manager, config, @@ -871,6 +874,7 @@ impl SessionManager { let sessions = self.sessions.clone(); let session_workspace_index = self.session_workspace_index.clone(); let context_store = self.context_store.clone(); + let file_read_state_store = self.file_read_state_store.clone(); let evidence_ledger = self.evidence_ledger.clone(); let persistence_manager = self.persistence_manager.clone(); let manager_config = self.config.clone(); @@ -890,6 +894,7 @@ impl SessionManager { sessions, session_workspace_index, context_store, + file_read_state_store, evidence_ledger, persistence_manager, config: manager_config, @@ -1028,6 +1033,7 @@ impl SessionManager { // 2. Initialize the in-memory context cache. self.context_store.create_session(&session_id); + self.file_read_state_store.create_session(&session_id); // 3. Persist to local path (handles remote workspaces correctly) // Use the local `session` directly -- no need to re-fetch from DashMap, @@ -1428,6 +1434,7 @@ impl SessionManager { session_id ); self.context_store.delete_session(session_id); + self.file_read_state_store.delete_session(session_id); debug!( "Session deletion stage completed: session_id={}, stage=context_store_delete, duration_ms={}", session_id, @@ -1874,6 +1881,7 @@ impl SessionManager { // If session already exists, delete old one first then create (ensure clean state) if session_already_in_memory { self.context_store.delete_session(session_id); + self.file_read_state_store.delete_session(session_id); } let context_replace_started_at = Instant::now(); @@ -3216,10 +3224,29 @@ impl SessionManager { /// snapshot. This is primarily used after compression rewrites the model-visible context. pub async fn replace_context_messages(&self, session_id: &str, messages: Vec) { self.context_store.replace_context(session_id, messages); + self.file_read_state_store.clear_session(session_id); self.persist_current_turn_context_snapshot_best_effort(session_id, "context_replaced") .await; } + pub fn set_file_read_state( + &self, + session_id: &str, + logical_path: &str, + state: FileReadState, + ) { + self.file_read_state_store + .set(session_id, logical_path, state); + } + + pub fn get_file_read_state( + &self, + session_id: &str, + logical_path: &str, + ) -> Option { + self.file_read_state_store.get(session_id, logical_path) + } + /// Get dialog turn count pub fn get_turn_count(&self, session_id: &str) -> usize { self.sessions @@ -3455,6 +3482,7 @@ impl SessionManager { let persistence = self.persistence_manager.clone(); let enable_persistence = self.config.enable_persistence; let context_store = self.context_store.clone(); + let file_read_state_store = self.file_read_state_store.clone(); tokio::spawn(async move { let mut ticker = time::interval(Duration::from_secs(60)); @@ -3511,6 +3539,7 @@ impl SessionManager { .is_some() { context_store.delete_session(&candidate.session_id); + file_read_state_store.delete_session(&candidate.session_id); } } } diff --git a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs new file mode 100644 index 000000000..0dc4ad9f4 --- /dev/null +++ b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs @@ -0,0 +1,275 @@ +//! Runtime helpers for session-scoped file read state used by Read/Edit/Write tools. + +use crate::agentic::coordination::get_global_coordinator; +use crate::agentic::session::FileReadState; +use crate::agentic::tools::framework::{ToolPathResolution, ToolUseContext}; +use crate::util::errors::BitFunResult; +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; +use tool_runtime::fs::read_file::ReadFileResult; +use tool_runtime::util::read_line_prefix::read_tool_output_to_file_content; +use tool_runtime::util::string::normalize_string; + +pub fn record_file_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, + read_result: &ReadFileResult, + requested_start_line: usize, + requested_limit: usize, + timestamp_ms: u64, +) { + let Some(session_id) = context.session_id.as_deref() else { + return; + }; + let Some(coordinator) = get_global_coordinator() else { + return; + }; + + let is_partial_view = requested_start_line != 1 + || read_result.end_line < read_result.total_lines + || read_result.hit_total_char_limit + || requested_limit < read_result.total_lines; + + let state = FileReadState { + content: read_tool_output_to_file_content(&read_result.content), + timestamp_ms, + start_line: read_result.start_line, + end_line: read_result.end_line, + total_lines: read_result.total_lines, + is_partial_view, + }; + + coordinator.get_session_manager().set_file_read_state( + session_id, + &resolved.logical_path, + state, + ); +} + +pub async fn validate_edit_against_read_state( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + let read_state = coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path)?; + + if read_state.is_partial_view { + return Some(format!( + "File {} was only partially read (lines {}-{} of {}). Read the full target area again before editing.", + resolved.logical_path, + read_state.start_line, + read_state.end_line, + read_state.total_lines + )); + } + + let current_content = read_current_file_content(context, resolved).await.ok()?; + let current_mtime_ms = file_modification_time_ms(context, resolved).await; + + if let Some(current_mtime_ms) = current_mtime_ms { + if current_mtime_ms > read_state.timestamp_ms { + if read_state.is_full_file_read() + && normalize_string(¤t_content) == normalize_string(&read_state.content) + { + return None; + } + + return Some(format!( + "File {} has been modified since it was last read (by the user, another tool, or a linter). Read it again before editing.", + resolved.logical_path + )); + } + } else if normalize_string(¤t_content) != normalize_string(&read_state.content) { + return Some(format!( + "File {} no longer matches the last Read result. Read it again before editing.", + resolved.logical_path + )); + } + + None +} + +pub fn validate_edit_has_prior_read( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + let session_id = context.session_id.as_deref()?; + let coordinator = get_global_coordinator()?; + let has_read = coordinator + .get_session_manager() + .get_file_read_state(session_id, &resolved.logical_path) + .is_some(); + + if has_read { + return None; + } + + Some(format!( + "File {} has not been read yet in this session. Use the Read tool on it before editing.", + resolved.logical_path + )) +} + +pub fn update_file_read_state_after_mutation( + context: &ToolUseContext, + resolved: &ToolPathResolution, + content: &str, + timestamp_ms: u64, +) { + let Some(session_id) = context.session_id.as_deref() else { + return; + }; + let Some(coordinator) = get_global_coordinator() else { + return; + }; + + let line_count = content.lines().count().max(1); + let state = FileReadState { + content: content.to_string(), + timestamp_ms, + start_line: 1, + end_line: line_count, + total_lines: line_count, + is_partial_view: false, + }; + + coordinator.get_session_manager().set_file_read_state( + session_id, + &resolved.logical_path, + state, + ); +} + +async fn read_current_file_content( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> BitFunResult { + if resolved.uses_remote_workspace_backend() { + let ws_fs = context.ws_fs().ok_or_else(|| { + crate::util::errors::BitFunError::tool( + "Remote workspace file system is unavailable".to_string(), + ) + })?; + ws_fs + .read_file_text(&resolved.resolved_path) + .await + .map_err(|error| { + crate::util::errors::BitFunError::tool(format!("Failed to read file: {}", error)) + }) + } else { + std::fs::read_to_string(&resolved.resolved_path).map_err(|error| { + crate::util::errors::BitFunError::tool(format!( + "Failed to read file {}: {}", + resolved.logical_path, error + )) + }) + } +} + +async fn file_modification_time_ms( + _context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> Option { + if resolved.uses_remote_workspace_backend() { + return None; + } + + let metadata = std::fs::metadata(&resolved.resolved_path).ok()?; + let modified = metadata.modified().ok()?; + modified + .duration_since(UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as u64) +} + +pub async fn file_mutation_timestamp_ms( + context: &ToolUseContext, + resolved: &ToolPathResolution, +) -> u64 { + if let Some(timestamp_ms) = file_modification_time_ms(context, resolved).await { + return timestamp_ms; + } + + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +pub fn local_file_modification_time_ms(path: &Path) -> u64 { + std::fs::metadata(path) + .ok() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis() as u64) + .unwrap_or_else(|| { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agentic::tools::framework::{ToolPathBackend, ToolUseContext}; + use crate::agentic::WorkspaceBinding; + use std::collections::HashMap; + use std::path::PathBuf; + + fn test_context(session_id: Option<&str>, root: PathBuf) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: None, + session_id: session_id.map(str::to_string), + dialog_turn_id: Some("turn-1".to_string()), + workspace: Some(WorkspaceBinding::new(None, root)), + unlocked_collapsed_tools: Vec::new(), + custom_data: HashMap::new(), + computer_use_host: None, + cancellation_token: None, + runtime_tool_restrictions: Default::default(), + workspace_services: None, + } + } + + #[test] + fn validate_edit_has_prior_read_skips_without_session_id() { + let context = test_context(None, PathBuf::from("/tmp")); + + assert!(validate_edit_has_prior_read( + &context, + &ToolPathResolution { + logical_path: "src/main.rs".to_string(), + resolved_path: "src/main.rs".to_string(), + requested_path: "src/main.rs".to_string(), + backend: ToolPathBackend::Local, + runtime_root: None, + runtime_scope: None, + } + ) + .is_none()); + } + + #[test] + fn validate_edit_has_prior_read_skips_without_coordinator() { + let context = test_context(Some("session-1"), PathBuf::from("/tmp")); + + assert!(validate_edit_has_prior_read( + &context, + &ToolPathResolution { + logical_path: "src/main.rs".to_string(), + resolved_path: "src/main.rs".to_string(), + requested_path: "src/main.rs".to_string(), + backend: ToolPathBackend::Local, + runtime_root: None, + runtime_scope: None, + } + ) + .is_none()); + } +} diff --git a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs index b6a1732b0..1fc6ef670 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_edit_tool.rs @@ -1,15 +1,30 @@ +use crate::agentic::tools::file_read_state_runtime::{ + file_mutation_timestamp_ms, update_file_read_state_after_mutation, + validate_edit_against_read_state, validate_edit_has_prior_read, +}; use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; use crate::agentic::tools::ToolPathOperation; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; -use tool_runtime::fs::edit_file::{apply_edit_to_content, edit_file}; +use tool_runtime::fs::edit_file::apply_edit_to_content; pub struct FileEditTool; const LARGE_EDIT_SOFT_LINE_LIMIT: usize = 200; const LARGE_EDIT_SOFT_BYTE_LIMIT: usize = 20 * 1024; -const EDIT_RETRY_GUIDANCE: &str = "Common causes: stale Read output after another edit, copied line-number prefixes, changed whitespace, or an old_string that is too broad. Recovery: read the current target area again, copy the exact current text after any line-number prefix, and retry with a uniquely matching old_string. If several edits target the same file, apply them sequentially from fresh content or replace one stable enclosing block. If the text appears more than once, include more surrounding context or set replace_all only when every occurrence should change."; +const EDIT_TOOL_PROMPT: &str = r#"Performs exact string replacements in files. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- The `file_path` parameter must be a workspace-relative path, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. +- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance."#; + +const EDIT_RETRY_GUIDANCE: &str = "Common causes: stale Read output after another edit, copied Read line-number prefixes, changed whitespace, truncated Read lines, or an old_string that is too broad. Recovery: read the current target area again, copy the exact current text after the tab on each Read line, and retry with a uniquely matching old_string. If several edits target the same file, apply them sequentially from fresh content or replace one stable enclosing block. If the text appears more than once, include more surrounding context or set replace_all only when every occurrence should change."; impl Default for FileEditTool { fn default() -> Self { @@ -42,25 +57,11 @@ impl Tool for FileEditTool { } async fn description(&self) -> BitFunResult { - Ok(r#"Performs exact string replacements in files. - -Usage: -- Use the Read tool before editing so `old_string` is based on current file content. -- Treat Read output as stale after any successful edit to the same file. For multiple edits in one file, either apply them sequentially from fresh content or replace a stable enclosing block once. -- The file_path parameter must be workspace-relative, an absolute path inside the current workspace, or an exact `bitfun://runtime/...` URI returned by another tool. -- Build `old_string` from current file contents rather than from memory, an intended final version, or a guessed retry. -- When editing text from Read output, copy only the text after the line-number prefix and preserve indentation exactly. -- Prefer editing existing files in the codebase; create new files only when the task genuinely calls for a new artifact. -- Avoid adding emojis to files unless the user asks. -- The edit requires `old_string` to be unique unless `replace_all` is true. Add surrounding context from the same stable block when a snippet may appear more than once, or use `replace_all` when every occurrence should change. -- If an edit fails because `old_string` was not found or matched multiple places, read the current target area again before retrying. Do not retry by slightly modifying the failed `old_string` from memory. -- Keep edits focused. Large replacements are allowed when necessary, but staged section/function/component edits are usually more reliable than one huge replacement. -- Use `replace_all` for intentional file-wide replacements, such as renaming a variable."# - .to_string()) + Ok(EDIT_TOOL_PROMPT.to_string()) } fn short_description(&self) -> String { - "Apply exact string replacements to an existing file.".to_string() + "A tool for editing files".to_string() } fn input_schema(&self) -> Value { @@ -69,22 +70,20 @@ Usage: "properties": { "file_path": { "type": "string", - "description": "The file to modify. Use a workspace-relative path, an absolute path inside the current workspace, or an exact bitfun://runtime URI returned by another tool." + "description": "The path to the file to modify" }, "old_string": { "type": "string", - "minLength": 1, - "default": "", - "description": "The non-empty exact current text to replace. It must match the current file contents exactly, including whitespace and indentation, and must be unique unless replace_all is true. Copy it from a fresh Read result, excluding the line-number prefix. If this file was edited earlier in the turn, read the target area again before building old_string. Include stable surrounding context when a short snippet may appear multiple times." + "description": "The text to replace" }, "new_string": { "type": "string", - "description": "The replacement text. It must be different from old_string. Keep edits targeted. Large replacements are allowed when necessary; focused edits by section, function, or component are usually more reliable." + "description": "The text to replace it with (must be different from old_string)" }, "replace_all": { "type": "boolean", "default": false, - "description": "Replace all occurrences of old_string (default false). Use only when every occurrence should change." + "description": "Replace all occurrences of old_string (default false)" } }, "required": ["file_path", "old_string", "new_string"], @@ -160,6 +159,24 @@ Usage: meta: None, }; } + + if let Some(message) = validate_edit_has_prior_read(ctx, &resolved) { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } + + if let Some(message) = validate_edit_against_read_state(ctx, &resolved).await { + return ValidationResult { + result: false, + message: Some(message), + error_code: Some(400), + meta: None, + }; + } } let old_string = input @@ -263,6 +280,14 @@ Usage: .await .map_err(|e| BitFunError::tool(format!("Failed to write file: {}", e)))?; + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation( + context, + &resolved, + &edit_result.new_content, + timestamp_ms, + ); + let result = ToolResult::Result { data: json!({ "file_path": resolved.logical_path, @@ -283,19 +308,43 @@ Usage: return Ok(vec![result]); } - // Local: direct local edit via tool-runtime - let edit_result = edit_file(&resolved.resolved_path, old_string, new_string, replace_all) + // Local: read → edit in memory → write back so failures can include current file context. + let content = std::fs::read_to_string(&resolved.resolved_path).map_err(|e| { + BitFunError::tool(format!( + "Failed to read file {}: {}", + resolved.logical_path, e + )) + })?; + let edit_result = apply_edit_to_content(&content, old_string, new_string, replace_all) .map_err(|e| BitFunError::tool(Self::enhance_edit_error(file_path, e)))?; + std::fs::write(&resolved.resolved_path, edit_result.new_content.as_bytes()).map_err( + |e| { + BitFunError::tool(format!( + "Failed to write file {}: {}", + resolved.logical_path, e + )) + }, + )?; + + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation( + context, + &resolved, + &edit_result.new_content, + timestamp_ms, + ); + let result = ToolResult::Result { data: json!({ "file_path": resolved.logical_path, "old_string": old_string, "new_string": new_string, "success": true, - "start_line": edit_result.start_line, - "old_end_line": edit_result.old_end_line, - "new_end_line": edit_result.new_end_line, + "match_count": edit_result.match_count, + "start_line": edit_result.edit_result.start_line, + "old_end_line": edit_result.edit_result.old_end_line, + "new_end_line": edit_result.edit_result.new_end_line, }), result_for_assistant: Some(format!("Successfully edited {}", resolved.logical_path)), image_attachments: None, @@ -307,19 +356,84 @@ Usage: #[cfg(test)] mod tests { - use super::FileEditTool; + use super::{FileEditTool, EDIT_TOOL_PROMPT}; + use crate::agentic::tools::framework::Tool; + use serde_json::Value; + + #[tokio::test] + async fn edit_tool_prompt_matches_claude_style() { + let description = FileEditTool::new().description().await.expect("description"); + + assert_eq!(description, EDIT_TOOL_PROMPT); + assert!(description.contains("You must use your `Read` tool")); + assert!(description.contains("spaces + line number + tab")); + assert!(description.contains("NEVER write new files unless explicitly required")); + assert!(!description.contains("Large Edit payload")); + assert!(!description.contains("auto-strip")); + } + + #[test] + fn edit_tool_schema_uses_minimal_parameter_descriptions() { + let schema = FileEditTool::new().input_schema(); + let properties = schema + .get("properties") + .and_then(Value::as_object) + .expect("properties"); + + assert_eq!( + properties + .get("file_path") + .and_then(|value| value.get("description")) + .and_then(Value::as_str), + Some("The path to the file to modify") + ); + assert_eq!( + properties + .get("old_string") + .and_then(|value| value.get("description")) + .and_then(Value::as_str), + Some("The text to replace") + ); + assert_eq!( + properties + .get("new_string") + .and_then(|value| value.get("description")) + .and_then(Value::as_str), + Some("The text to replace it with (must be different from old_string)") + ); + assert_eq!( + properties + .get("replace_all") + .and_then(|value| value.get("description")) + .and_then(Value::as_str), + Some("Replace all occurrences of old_string (default false)") + ); + assert!(properties + .get("old_string") + .and_then(|value| value.get("minLength")) + .is_none()); + } + + #[test] + fn edit_tool_short_description_matches_claude_summary() { + assert_eq!( + FileEditTool::new().short_description(), + "A tool for editing files" + ); + } #[test] fn edit_not_found_error_includes_retry_guidance() { let message = FileEditTool::enhance_edit_error( "src/lib.rs", - "old_string not found in file.".to_string(), + "old_string not found in file.\n[nearby content around line 2]\nfn main() {}".to_string(), ); assert!(message.contains("Edit failed for src/lib.rs")); assert!(message.contains("Common causes")); assert!(message.contains("stale Read output")); assert!(message.contains("read the current target area again")); + assert!(message.contains("[nearby content around line 2]")); } #[test] diff --git a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs index fa2d7d4c4..c24d40daf 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_read_tool.rs @@ -1,3 +1,6 @@ +use crate::agentic::tools::file_read_state_runtime::{ + local_file_modification_time_ms, record_file_read_state, +}; use crate::agentic::tools::framework::{ Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -28,7 +31,7 @@ impl FileReadTool { Self { default_max_lines_to_read: 2000, max_line_chars: 2000, - max_total_chars: 50_000, + max_total_chars: 16_000, } } @@ -409,6 +412,23 @@ Usage: .map_err(BitFunError::tool)? }; + let timestamp_ms = if resolved.uses_remote_workspace_backend() { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) + } else { + local_file_modification_time_ms(Path::new(&resolved.resolved_path)) + }; + record_file_read_state( + context, + &resolved, + &read_file_result, + start_line, + limit, + timestamp_ms, + ); + let mut result_for_assistant = format!( "Read lines {}-{} from {} ({} total lines)\n\n{}\n", read_file_result.start_line, diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index fbd7f7963..b6b5fdb65 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -1,3 +1,6 @@ +use crate::agentic::tools::file_read_state_runtime::{ + file_mutation_timestamp_ms, update_file_read_state_after_mutation, +}; use crate::agentic::tools::framework::{ Tool, ToolPathResolution, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, }; @@ -222,6 +225,9 @@ Usage: })?; } + let timestamp_ms = file_mutation_timestamp_ms(context, &resolved).await; + update_file_read_state_after_mutation(context, &resolved, content, timestamp_ms); + let result = ToolResult::Result { data: json!({ "file_path": resolved.logical_path, diff --git a/src/crates/core/src/agentic/tools/mod.rs b/src/crates/core/src/agentic/tools/mod.rs index 77c1af267..1f68da180 100644 --- a/src/crates/core/src/agentic/tools/mod.rs +++ b/src/crates/core/src/agentic/tools/mod.rs @@ -1,5 +1,6 @@ //! Tool system - includes Tool interface, tool registry and tool executor +pub mod file_read_state_runtime; pub mod browser_control; pub mod computer_use_capability; pub mod computer_use_host; diff --git a/src/crates/core/src/agentic/tools/tool_result_storage.rs b/src/crates/core/src/agentic/tools/tool_result_storage.rs index 90490d4d8..16e0c3a9d 100644 --- a/src/crates/core/src/agentic/tools/tool_result_storage.rs +++ b/src/crates/core/src/agentic/tools/tool_result_storage.rs @@ -13,6 +13,7 @@ use std::collections::HashSet; use std::path::Path; pub(crate) const DEFAULT_MAX_TOOL_RESULT_CHARS: usize = 50_000; +pub(crate) const READ_MAX_TOOL_RESULT_CHARS: usize = 16_000; pub(crate) const MAX_TOOL_RESULTS_PER_ROUND_CHARS: usize = 200_000; pub(crate) const TOOL_RESULT_PREVIEW_CHARS: usize = 2_000; pub(crate) const PERSISTED_OUTPUT_TAG: &str = ""; @@ -145,8 +146,7 @@ pub(crate) async fn apply_round_tool_result_budget( } fn should_skip_tool_result(result: &ToolResult) -> bool { - result.tool_name == READ_TOOL_NAME - || result.tool_name == GET_TOOL_SPEC_TOOL_NAME + result.tool_name == GET_TOOL_SPEC_TOOL_NAME || result .image_attachments .as_ref() @@ -297,6 +297,7 @@ fn serialize_tool_result_content(result: &ToolResult) -> BitFunResult<(String, b fn effective_per_tool_limit(tool_name: &str, policy: ToolResultStoragePolicy) -> usize { match tool_name { + READ_TOOL_NAME => READ_MAX_TOOL_RESULT_CHARS, BASH_TOOL_NAME => SHELL_MAX_TOOL_RESULT_CHARS, _ => policy.per_tool_limit_chars, } @@ -529,10 +530,30 @@ mod tests { } #[tokio::test] - async fn read_result_is_not_persisted_even_when_large() { + async fn read_result_is_persisted_when_over_read_limit() { let root = temp_workspace("read"); let context = test_context(root.clone()); - let text = "x".repeat(DEFAULT_MAX_TOOL_RESULT_CHARS + 1); + let text = "x".repeat(READ_MAX_TOOL_RESULT_CHARS + 1); + let result = tool_result("read_1", "Read", text); + + let processed = maybe_persist_large_tool_result(result, &context).await; + let assistant = processed.result_for_assistant.unwrap_or_default(); + + assert!(assistant.starts_with(PERSISTED_OUTPUT_TAG)); + assert!(assistant.contains("Full output saved to:")); + let session_dir = context + .current_workspace_session_tool_results_dir("session_1") + .expect("session tool-results dir"); + assert!(session_dir.join("read_1.txt").exists()); + + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn read_result_stays_inline_when_under_read_limit() { + let root = temp_workspace("read-inline"); + let context = test_context(root.clone()); + let text = "x".repeat(READ_MAX_TOOL_RESULT_CHARS); let result = tool_result("read_1", "Read", text.clone()); let processed = maybe_persist_large_tool_result(result, &context).await; @@ -601,14 +622,15 @@ mod tests { } #[tokio::test] - async fn round_budget_persists_largest_non_read_results() { + async fn round_budget_persists_largest_results_including_read() { let root = temp_workspace("round"); let context = test_context(root.clone()); - let largest = tool_result("large_1", "Bash", "a".repeat(170_000)); + let read = tool_result("read_1", "Read", "a".repeat(170_000)); let medium = tool_result("medium_1", "WebFetch", "b".repeat(60_000)); - let read = tool_result("read_1", "Read", "c".repeat(170_000)); + let bash = tool_result("bash_1", "Bash", "c".repeat(30_000)); - let processed = apply_round_tool_result_budget(vec![largest, medium, read], &context).await; + let processed = + apply_round_tool_result_budget(vec![read, medium, bash], &context).await; assert!(processed[0] .result_for_assistant @@ -629,9 +651,9 @@ mod tests { let session_dir = context .current_workspace_session_tool_results_dir("session_1") .expect("session tool-results dir"); - assert!(session_dir.join("large_1.txt").exists()); + assert!(session_dir.join("read_1.txt").exists()); assert!(!session_dir.join("medium_1.txt").exists()); - assert!(!session_dir.join("read_1.txt").exists()); + assert!(!session_dir.join("bash_1.txt").exists()); let _ = std::fs::remove_dir_all(root); } diff --git a/src/crates/tool-runtime/src/fs/edit_file.rs b/src/crates/tool-runtime/src/fs/edit_file.rs index e6731c058..edc39c7db 100644 --- a/src/crates/tool-runtime/src/fs/edit_file.rs +++ b/src/crates/tool-runtime/src/fs/edit_file.rs @@ -1,9 +1,14 @@ +use crate::util::read_line_prefix::{ + read_tool_output_to_file_content, strip_read_line_number_prefix, +}; use crate::util::string::normalize_string; use std::fs; const MAX_MATCH_CONTEXTS: usize = 5; const CONTEXT_LINES_BEFORE: usize = 2; const CONTEXT_LINES_AFTER: usize = 2; +const NOT_FOUND_DIAGNOSTIC_SNIPPETS: usize = 3; +const NOT_FOUND_MIN_SUBSTRING_LEN: usize = 8; /// Edit result, contains line number range information #[derive(Debug, Clone, PartialEq, Eq)] @@ -68,7 +73,186 @@ fn match_contexts(content: &str, old_string: &str, matches: &[(usize, &str)]) -> ) } -pub fn apply_edit_to_content( +/// Remove Read-tool cat -n prefixes line-by-line when present. +pub fn sanitize_read_tool_copied_text(text: &str) -> Option { + let sanitized = read_tool_output_to_file_content(text); + (sanitized != text).then_some(sanitized) +} + +fn normalize_quote_char(ch: char) -> char { + match ch { + '\u{2018}' | '\u{2019}' => '\'', + '\u{201C}' | '\u{201D}' => '"', + other => other, + } +} + +fn find_actual_string(file_content: &str, search_string: &str) -> Option { + if file_content.contains(search_string) { + return Some(search_string.to_string()); + } + + let file_chars: Vec = file_content.chars().collect(); + let search_chars: Vec = search_string.chars().collect(); + if search_chars.is_empty() || file_chars.len() < search_chars.len() { + return None; + } + + let normalized_search: Vec = search_chars + .iter() + .copied() + .map(normalize_quote_char) + .collect(); + + for start in 0..=file_chars.len() - search_chars.len() { + let window_matches = file_chars[start..start + search_chars.len()] + .iter() + .copied() + .map(normalize_quote_char) + .eq(normalized_search.iter().copied()); + if window_matches { + return Some(file_chars[start..start + search_chars.len()].iter().collect()); + } + } + + None +} + +fn edit_string_candidates(content: &str, old_string: &str, new_string: &str) -> Vec<(String, String)> { + let mut candidates = Vec::new(); + let mut push_candidate = |old: String, new: String| { + if !candidates.iter().any(|(existing_old, existing_new)| { + existing_old == &old && existing_new == &new + }) { + candidates.push((old, new)); + } + }; + + push_candidate(old_string.to_string(), new_string.to_string()); + + if let Some(sanitized_old) = sanitize_read_tool_copied_text(old_string) { + let sanitized_new = sanitize_read_tool_copied_text(new_string) + .unwrap_or_else(|| new_string.to_string()); + push_candidate(sanitized_old, sanitized_new); + } + + if let Some(actual_old) = find_actual_string(content, old_string) { + push_candidate(actual_old, new_string.to_string()); + } + + if !old_string.ends_with('\n') { + let with_newline = format!("{old_string}\n"); + if content.contains(&with_newline) { + push_candidate(with_newline, format!("{new_string}\n")); + } + } + + candidates +} + +fn contains_read_tool_line_prefixes(text: &str) -> bool { + text.lines() + .any(|line| strip_read_line_number_prefix(line) != line) +} + +fn contains_read_truncation_marker(text: &str) -> bool { + text.contains(" [truncated]") +} + +fn longest_shared_prefix_len(left: &str, right: &str) -> usize { + left.chars() + .zip(right.chars()) + .take_while(|(a, b)| a == b) + .count() +} + +fn longest_shared_suffix_len(left: &str, right: &str) -> usize { + longest_shared_prefix_len( + &left.chars().rev().collect::(), + &right.chars().rev().collect::(), + ) +} + +fn snippet_context(lines: &[&str], line_idx: usize) -> String { + let start = line_idx.saturating_sub(CONTEXT_LINES_BEFORE); + let end = (line_idx + CONTEXT_LINES_AFTER + 1).min(lines.len()); + lines[start..end].join("\n") +} + +fn build_not_found_diagnostics(content: &str, old_string: &str) -> String { + let mut hints = Vec::new(); + + if contains_read_tool_line_prefixes(old_string) { + hints.push( + "Detected Read-tool line-number prefixes inside `old_string`. Copy only the text after the tab on each line, or rely on the tool to strip those prefixes automatically on retry.".to_string(), + ); + } + + if contains_read_truncation_marker(old_string) { + hints.push( + "Detected a Read-tool `[truncated]` marker inside `old_string`. Re-read the file with a narrower start_line/limit so the target lines are complete.".to_string(), + ); + } + + let normalized_content = normalize_string(content); + let lines: Vec<&str> = normalized_content.split('\n').collect(); + let anchor_line = old_string + .lines() + .find(|line| !line.trim().is_empty()) + .unwrap_or(old_string) + .trim(); + + if !anchor_line.is_empty() { + let mut candidates = Vec::new(); + for (idx, line) in lines.iter().enumerate() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + let shared_prefix = longest_shared_prefix_len(anchor_line, trimmed); + let shared_suffix = longest_shared_suffix_len(anchor_line, trimmed); + let score = shared_prefix.max(shared_suffix); + + if anchor_line.contains(trimmed) + || trimmed.contains(anchor_line) + || score >= NOT_FOUND_MIN_SUBSTRING_LEN + { + candidates.push((score, idx)); + } + } + + candidates.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1))); + candidates.dedup_by_key(|candidate| candidate.1); + + let snippets: Vec = candidates + .into_iter() + .take(NOT_FOUND_DIAGNOSTIC_SNIPPETS) + .map(|(_, idx)| { + format!( + "[nearby content around line {}]\n{}", + idx + 1, + snippet_context(&lines, idx) + ) + }) + .collect(); + + if !snippets.is_empty() { + hints.push(format!( + "Current file snippets that may be closest to your `old_string`:\n{}", + snippets.join("\n---\n") + )); + } + } + + if hints.is_empty() { + "No close match was found in the current file contents. Re-read the target area and copy the exact current text.".to_string() + } else { + hints.join("\n\n") + } +} + +fn apply_match_and_replace( content: &str, old_string: &str, new_string: &str, @@ -125,6 +309,31 @@ pub fn apply_edit_to_content( }) } +pub fn apply_edit_to_content( + content: &str, + old_string: &str, + new_string: &str, + replace_all: bool, +) -> Result { + let mut last_error = String::from("old_string not found in file."); + + for (candidate_old, candidate_new) in edit_string_candidates(content, old_string, new_string) { + match apply_match_and_replace(content, &candidate_old, &candidate_new, replace_all) { + Ok(result) => return Ok(result), + Err(error) if error == "old_string not found in file." => { + last_error = error; + } + Err(error) => return Err(error), + } + } + + Err(format!( + "{}\n{}", + last_error, + build_not_found_diagnostics(content, old_string) + )) +} + pub fn edit_file( file_path: &str, old_string: &str, @@ -143,7 +352,9 @@ pub fn edit_file( #[cfg(test)] mod tests { - use super::{apply_edit_to_content, edit_file, EditResult}; + use super::{ + apply_edit_to_content, edit_file, sanitize_read_tool_copied_text, EditResult, + }; use std::fs; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; @@ -158,6 +369,31 @@ mod tests { path } + #[test] + fn sanitize_read_tool_copied_text_strips_cat_n_prefixes() { + let sanitized = sanitize_read_tool_copied_text(" 1\talpha\n 2\tbeta") + .expect("read prefixes should be stripped"); + + assert_eq!(sanitized, "alpha\nbeta"); + } + + #[test] + fn sanitize_read_tool_copied_text_allows_mixed_lines() { + let sanitized = sanitize_read_tool_copied_text(" 1\talpha\nplain") + .expect("partial prefixes should still be stripped"); + + assert_eq!(sanitized, "alpha\nplain"); + } + + #[test] + fn apply_edit_to_content_matches_curly_quotes() { + let content = "msg := “hello”\n"; + let result = apply_edit_to_content(content, "msg := \"hello\"", "msg := \"hi\"", false) + .expect("quote-normalized edit should succeed"); + + assert_eq!(result.new_content, "msg := \"hi\"\n"); + } + #[test] fn apply_edit_to_content_matches_multiline_lf_input_against_crlf_file() { let content = "header\r\nalpha\r\nbeta\r\nfooter\r\n"; @@ -176,6 +412,15 @@ mod tests { assert_eq!(result.new_content, "header\r\nalpha\r\nBETA\r\nfooter\r\n"); } + #[test] + fn apply_edit_to_content_accepts_read_tool_line_prefixes() { + let content = "alpha\nbeta\n"; + let result = apply_edit_to_content(content, " 1\talpha\n 2\tbeta", "alpha\nBETA", false) + .expect("edit should succeed with read prefixes"); + + assert_eq!(result.new_content, "alpha\nBETA\n"); + } + #[test] fn apply_edit_to_content_replace_all_reports_match_count() { let result = apply_edit_to_content("one\r\ntwo\r\none\r\n", "one", "ONE", true) @@ -211,6 +456,34 @@ mod tests { assert!(error.contains("second block")); } + #[test] + fn apply_edit_to_content_not_found_includes_nearby_diagnostics() { + let error = apply_edit_to_content( + "fn main() {\n println!(\"hello\");\n}\n", + "println!(\"goodbye\");", + "println!(\"hi\");", + false, + ) + .expect_err("missing text should fail"); + + assert!(error.contains("old_string not found in file.")); + assert!(error.contains("[nearby content around line 2]")); + assert!(error.contains("println!(\"hello\");")); + } + + #[test] + fn apply_edit_to_content_not_found_calls_out_read_prefixes() { + let error = apply_edit_to_content( + "alpha\nbeta\n", + " 1\talpha\n 2\tgamma", + "alpha\nBETA", + false, + ) + .expect_err("missing text should fail"); + + assert!(error.contains("Read-tool line-number prefixes")); + } + #[test] fn edit_file_preserves_crlf_when_editing_with_lf_old_string() { let path = write_temp_file("first\r\nalpha\r\nbeta\r\n"); diff --git a/src/crates/tool-runtime/src/util/mod.rs b/src/crates/tool-runtime/src/util/mod.rs index b9c942743..923ac5a25 100644 --- a/src/crates/tool-runtime/src/util/mod.rs +++ b/src/crates/tool-runtime/src/util/mod.rs @@ -1,2 +1,3 @@ pub mod ansi_cleaner; +pub mod read_line_prefix; pub mod string; diff --git a/src/crates/tool-runtime/src/util/read_line_prefix.rs b/src/crates/tool-runtime/src/util/read_line_prefix.rs new file mode 100644 index 000000000..712fdac38 --- /dev/null +++ b/src/crates/tool-runtime/src/util/read_line_prefix.rs @@ -0,0 +1,89 @@ +/// Strip a Read-tool line prefix (`spaces + line_number + tab|→`) from one line. +pub fn strip_read_line_number_prefix(line: &str) -> String { + let mut chars = line.chars().peekable(); + + while matches!(chars.peek(), Some(' ')) { + chars.next(); + } + + let mut saw_digits = false; + while matches!(chars.peek(), Some(ch) if ch.is_ascii_digit()) { + saw_digits = true; + chars.next(); + } + + if !saw_digits { + return line.to_string(); + } + + match chars.peek().copied() { + Some('\t') => { + chars.next(); + chars.collect() + } + Some('\u{2192}') => { + chars.next(); + chars.collect() + } + _ => line.to_string(), + } +} + +/// Convert Read-tool cat -n output into raw file content (one line at a time). +pub fn read_tool_output_to_file_content(formatted: &str) -> String { + formatted + .split('\n') + .map(strip_read_line_number_prefix) + .collect::>() + .join("\n") +} + +/// True when every non-empty line still carries a Read-tool prefix. +pub fn all_lines_have_read_prefix(text: &str) -> bool { + if text.is_empty() { + return false; + } + + text.lines().all(|line| line_has_read_prefix(line)) +} + +fn line_has_read_prefix(line: &str) -> bool { + strip_read_line_number_prefix(line) != line +} + +#[cfg(test)] +mod tests { + use super::{ + all_lines_have_read_prefix, read_tool_output_to_file_content, + strip_read_line_number_prefix, + }; + + #[test] + fn strip_tab_prefix() { + assert_eq!(strip_read_line_number_prefix(" 1\talpha"), "alpha"); + } + + #[test] + fn strip_arrow_prefix() { + assert_eq!(strip_read_line_number_prefix(" 2→beta"), "beta"); + } + + #[test] + fn leaves_unprefixed_lines_unchanged() { + assert_eq!(strip_read_line_number_prefix("plain"), "plain"); + } + + #[test] + fn read_tool_output_to_file_content_strips_each_line() { + assert_eq!( + read_tool_output_to_file_content(" 1\talpha\n 2\tbeta"), + "alpha\nbeta" + ); + } + + #[test] + fn all_lines_have_read_prefix_requires_every_line() { + assert!(all_lines_have_read_prefix(" 1\ta\n 2\tb")); + assert!(!all_lines_have_read_prefix(" 1\ta\nplain")); + } +} diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index e1f0e3e43..e7fd626ea 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -25,7 +25,6 @@ import { PanelHeader } from './base'; import { createLogger } from '@/shared/utils/logger'; import { basenamePath, - dirnameAbsolutePath, normalizeLocalPathForRename, pathsEquivalentFs, replaceBasename, @@ -39,11 +38,13 @@ import type { } from '@/infrastructure/api/service-api/tauri-commands'; import { downloadWorkspaceFileToDisk, - isDragPositionOverElement, - resolveDropTargetDirectoryFromDragPosition, - uploadLocalPathsToWorkspaceDirectory, + joinWorkspaceTargetPath, + normalizeWorkspaceTargetDirectory, + pasteClipboardFilesToWorkspaceDirectory, + resolvePasteTargetDirectory, type TransferProgressState, } from '@/tools/file-system/services/workspaceFileTransfer'; +import { useWorkspaceFileDrop } from '@/tools/file-system/hooks/useWorkspaceFileDrop'; import '@/tools/file-system/styles/FileExplorer.scss'; import './FilesPanel.scss'; @@ -111,6 +112,7 @@ const FilesPanel: React.FC = ({ const { workspace: currentWorkspace } = useCurrentWorkspace(); const panelRef = useRef(null); + const pasteInFlightRef = useRef(false); const lastFocusRefreshAtRef = useRef(0); const [internalViewMode, setInternalViewMode] = useState<'tree' | 'search'>('tree'); const viewMode = externalViewMode !== undefined ? externalViewMode : internalViewMode; @@ -189,6 +191,7 @@ const FilesPanel: React.FC = ({ expandFolder, expandFolderLazy, expandFolderEnsure, + removePath, } = useFileSystem({ rootPath: workspacePath, autoLoad: true, @@ -255,7 +258,11 @@ const FilesPanel: React.FC = ({ }, []); const handleConfirmNewFile = useCallback(async (fileName: string) => { - const filePath = `${inputDialog.parentPath}${inputDialog.parentPath.endsWith('/') ? '' : '/'}${fileName}`; + const filePath = joinWorkspaceTargetPath( + inputDialog.parentPath, + fileName, + isRemoteWorkspace(currentWorkspace), + ); try { await workspaceAPI.createFile(filePath); @@ -266,7 +273,7 @@ const FilesPanel: React.FC = ({ log.error('Failed to create file', error); notification.error(t('notifications.createFileFailed', { error: String(error) })); } - }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose]); + }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose, currentWorkspace]); const handleNewFolder = useCallback((data: { parentPath: string }) => { setInputDialog({ @@ -277,7 +284,11 @@ const FilesPanel: React.FC = ({ }, []); const handleConfirmNewFolder = useCallback(async (folderName: string) => { - const folderPath = `${inputDialog.parentPath}${inputDialog.parentPath.endsWith('/') ? '' : '/'}${folderName}`; + const folderPath = joinWorkspaceTargetPath( + inputDialog.parentPath, + folderName, + isRemoteWorkspace(currentWorkspace), + ); try { await workspaceAPI.createDirectory(folderPath); @@ -288,7 +299,7 @@ const FilesPanel: React.FC = ({ log.error('Failed to create directory', error); notification.error(t('notifications.createFolderFailed', { error: String(error) })); } - }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose]); + }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose, currentWorkspace]); const handleInputDialogConfirm = useCallback((value: string) => { if (inputDialog.type === 'newFile') { @@ -299,7 +310,7 @@ const FilesPanel: React.FC = ({ }, [inputDialog.type, handleConfirmNewFile, handleConfirmNewFolder]); const handleStartRename = useCallback((data: { path: string; name: string }) => { - setRenamingPath(data.path); + setRenamingPath(normalizeLocalPathForRename(data.path)); }, []); const handleExecuteRename = useCallback(async (oldPath: string, newName: string) => { @@ -317,32 +328,36 @@ const FilesPanel: React.FC = ({ await workspaceAPI.renameFile(normalizedOld, newPath); log.info('File renamed', { oldPath: normalizedOld, newPath }); setRenamingPath(null); - loadFileTree(workspacePath || '', true); + removePath(normalizedOld); + await loadFileTree(workspacePath || '', true); } catch (error) { log.error('Failed to rename file', error); notification.error(t('notifications.renameFailed', { error: String(error) })); setRenamingPath(null); } - }, [workspacePath, loadFileTree, notification, t]); + }, [workspacePath, loadFileTree, removePath, notification, t]); const handleCancelRename = useCallback(() => { setRenamingPath(null); }, []); const handleDelete = useCallback(async (data: { path: string; isDirectory: boolean }) => { + const normalizedPath = normalizeLocalPathForRename(data.path); + try { if (data.isDirectory) { - await workspaceAPI.deleteDirectory(data.path); + await workspaceAPI.deleteDirectory(normalizedPath); } else { - await workspaceAPI.deleteFile(data.path); + await workspaceAPI.deleteFile(normalizedPath); } - log.info('File deleted', { path: data.path, isDirectory: data.isDirectory }); - loadFileTree(workspacePath || '', true); + log.info('File deleted', { path: normalizedPath, isDirectory: data.isDirectory }); + removePath(normalizedPath); + await loadFileTree(workspacePath || '', true); } catch (error) { log.error('Failed to delete file', error); notification.error(t('notifications.deleteFailed', { error: String(error) })); } - }, [workspacePath, loadFileTree, notification, t]); + }, [workspacePath, loadFileTree, removePath, notification, t]); const handleReveal = useCallback(async (data: { path: string }) => { if (isRemoteWorkspace(workspaceManager.getState().currentWorkspace)) { @@ -463,13 +478,9 @@ const FilesPanel: React.FC = ({ })(); }, [workspacePath, expandFolder, expandFolderEnsure, expandedFolders]); - const getParentDirectory = useCallback((filePath: string): string => { - return dirnameAbsolutePath(filePath); - }, []); - const findNode = useCallback((nodes: FileSystemNode[], path: string): FileSystemNode | null => { for (const node of nodes) { - if (node.path === path) return node; + if (pathsEquivalentFs(node.path, path)) return node; if (node.children) { const found = findNode(node.children, path); if (found) return found; @@ -484,53 +495,83 @@ const FilesPanel: React.FC = ({ return; } + if (!currentWorkspace) { + notification.warning(t('notifications.selectWorkspaceFirst')); + return; + } + + if (pasteInFlightRef.current) { + return; + } + + pasteInFlightRef.current = true; + try { - const { files, isCut } = await workspaceAPI.getClipboardFiles(); - - if (files.length === 0) { + let targetDirectory = resolvePasteTargetDirectory({ + workspacePath, + explicitTargetDir: targetDir, + selectedFile, + fileTree, + findNode, + }); + + targetDirectory = normalizeWorkspaceTargetDirectory(targetDirectory, currentWorkspace); + + notification.info( + t('notifications.pastingFiles', { + count: 1, + target: targetDirectory.split(/[/\\]/).pop(), + }) + ); + + const result = await pasteClipboardFilesToWorkspaceDirectory( + targetDirectory, + currentWorkspace, + setTransferProgress + ); + + if (result.successCount === 0 && result.failedFiles.length === 0) { notification.info(t('notifications.pasteNoFiles')); return; } - let targetDirectory = targetDir || workspacePath; - - if (!targetDir && selectedFile) { - const selectedNode = findNode(fileTree, selectedFile); - if (selectedNode) { - if (selectedNode.isDirectory) { - targetDirectory = selectedFile; - } else { - targetDirectory = getParentDirectory(selectedFile); - } - } - } - - notification.info(t('notifications.pastingFiles', { count: files.length, target: targetDirectory.split(/[/\\]/).pop() })); - - const result = await workspaceAPI.pasteFiles(files, targetDirectory, isCut); - if (result.successCount > 0) { notification.success(t('notifications.pasteSuccess', { count: result.successCount })); - loadFileTree(undefined, true); - - if (targetDirectory !== workspacePath) { + await loadFileTree(undefined, true); + + if (!pathsEquivalentFs(targetDirectory, workspacePath)) { expandFolder(targetDirectory, true); } } - + if (result.failedFiles.length > 0) { - const failedNames = result.failedFiles.map(f => { - const name = f.path.split(/[/\\]/).pop() || f.path; - return `${name}: ${f.error}`; + const failedNames = result.failedFiles.map((entry) => { + const name = entry.path.split(/[/\\]/).pop() || entry.path; + return `${name}: ${entry.error}`; }).join('\n'); - notification.error(t('notifications.pasteFailed', { count: result.failedFiles.length }) + `:\n${failedNames}`, { duration: 5000 }); + notification.error( + t('notifications.pasteFailed', { count: result.failedFiles.length }) + `:\n${failedNames}`, + { duration: 5000 } + ); } - } catch (error) { log.error('Failed to paste files', error); + setTransferProgress(null); notification.error(t('notifications.pasteFailed', { count: 1 })); + } finally { + pasteInFlightRef.current = false; } - }, [workspacePath, selectedFile, fileTree, notification, loadFileTree, expandFolder, findNode, getParentDirectory, t]); + }, [ + workspacePath, + currentWorkspace, + selectedFile, + fileTree, + notification, + loadFileTree, + expandFolder, + findNode, + t, + ]); const handlePasteFromContextMenu = useCallback((data: { targetDirectory: string }) => { executePaste(data.targetDirectory); @@ -643,86 +684,33 @@ const FilesPanel: React.FC = ({ }; }, [isRemoteCurrentWorkspace, workspacePath, viewMode, loadFileTree]); - useEffect(() => { - if (typeof window === 'undefined' || !('__TAURI__' in window) || !workspacePath) { - return; - } + const handleFileDropOver = useCallback((overPanel: boolean) => { + setFileDropHighlight(overPanel); + }, []); - let unlisten: (() => void) | undefined; - let cancelled = false; - let lastEnterPaths: string[] = []; + const handleFileDropComplete = useCallback((targetDirectory: string) => { + setFileDropHighlight(false); + void loadFileTree(workspacePath || '', true); + if (workspacePath && !pathsEquivalentFs(targetDirectory, workspacePath)) { + expandFolder(targetDirectory, true); + } + }, [workspacePath, loadFileTree, expandFolder]); - const setup = async () => { - try { - // File-drop IPC is scoped to the webview; Window.onDragDropEvent may not receive events. - const { getCurrentWebview } = await import('@tauri-apps/api/webview'); - const webview = getCurrentWebview(); - unlisten = await webview.onDragDropEvent(async (event) => { - if (cancelled) return; - const payload = event.payload; - if (payload.type === 'leave') { - setFileDropHighlight(false); - lastEnterPaths = []; - return; - } - if (payload.type === 'enter') { - lastEnterPaths = payload.paths; - return; - } - if (payload.type === 'over') { - const factor = await webview.window.scaleFactor(); - const panelEl = panelRef.current; - setFileDropHighlight( - isDragPositionOverElement(payload.position, factor, panelEl) - ); - return; - } - if (payload.type === 'drop') { - setFileDropHighlight(false); - const paths = - payload.paths.length > 0 ? payload.paths : [...lastEnterPaths]; - lastEnterPaths = []; - if (!workspacePath || paths.length === 0) { - return; - } - - const factor = await webview.window.scaleFactor(); - const targetDir = resolveDropTargetDirectoryFromDragPosition( - payload.position, - factor, - workspacePath - ); - - const ws = workspaceManager.getState().currentWorkspace; - try { - await uploadLocalPathsToWorkspaceDirectory( - paths, - targetDir, - ws, - setTransferProgress - ); - loadFileTree(workspacePath, true); - if (targetDir !== workspacePath) { - expandFolder(targetDir, true); - } - } catch (error) { - log.error('Failed to upload dropped files', error); - setTransferProgress(null); - notification.error(t('transfer.failed', { error: String(error) })); - } - } - }); - } catch (e) { - log.warn('File drag-drop listener not available', e); - } - }; + const handleFileDropError = useCallback((error: unknown) => { + setTransferProgress(null); + setFileDropHighlight(false); + notification.error(t('transfer.failed', { error: String(error) })); + }, [notification, t]); - void setup(); - return () => { - cancelled = true; - unlisten?.(); - }; - }, [workspacePath, loadFileTree, expandFolder, notification, t]); + useWorkspaceFileDrop({ + workspacePath, + panelRef, + enabled: Boolean(workspacePath) && viewMode === 'tree', + onProgress: setTransferProgress, + onDragOver: handleFileDropOver, + onComplete: handleFileDropComplete, + onError: handleFileDropError, + }); const handleFileSelect = useCallback((filePath: string, fileName: string) => { selectFile(filePath); diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts index 2f8a1cf57..afd824c0d 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.test.ts @@ -213,4 +213,33 @@ describe('buildAgentCompanionActivity', () => { latestOutput: finalText, }); }); + + it('does not show hidden subagent sessions as companion bubbles', async () => { + const parentSession = createSession('processing'); + const subagentSession: Session = { + ...createSession('completed'), + sessionId: 'subagent-1', + title: 'Explore: Find ppt', + sessionKind: 'subagent', + parentSessionId: 'session-1', + parentToolCallId: 'task-call-1', + subagentType: 'Explore', + hasUnreadCompletion: 'completed', + lastFinishedAt: 2200, + }; + + flowChatStore.setState(() => ({ + sessions: new Map([ + ['session-1', parentSession], + ['subagent-1', subagentSession], + ]), + activeSessionId: 'session-1', + })); + await putStateMachineInStreaming(); + + const activity = buildAgentCompanionActivity(); + + expect(activity.tasks).toHaveLength(1); + expect(activity.tasks[0]?.sessionId).toBe('session-1'); + }); }); diff --git a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts index f55bcc3b8..0db8d69dd 100644 --- a/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts +++ b/src/web-ui/src/flow_chat/utils/agentCompanionActivity.ts @@ -3,6 +3,7 @@ import { stateMachineManager } from '../state-machine/SessionStateMachineManager import { ProcessingPhase, type SessionStateMachine } from '../state-machine/types'; import { deriveChatInputPetMood, type ChatInputPetMood } from './chatInputPetMood'; import type { DialogTurn, FlowTextItem, FlowThinkingItem, Session } from '../types/flow-chat'; +import { resolveSessionRelationship } from './sessionMetadata'; import { toWellFormedText } from '@/shared/utils/wellFormedText'; export type AgentCompanionTaskState = @@ -295,9 +296,17 @@ function aggregateMood(tasks: AgentCompanionTaskStatus[]): ChatInputPetMood { return 'rest'; } +function isIndependentCompanionSession(session: Session): boolean { + if (session.isTransient) { + return false; + } + + return !resolveSessionRelationship(session).isSubagent; +} + export function buildAgentCompanionActivity(): AgentCompanionActivityPayload { const sessions = Array.from(FlowChatStore.getInstance().getState().sessions.values()) - .filter(session => !session.isTransient); + .filter(isIndependentCompanionSession); const tasks: AgentCompanionTaskStatus[] = []; sessions.forEach(session => { diff --git a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx index 11228fd4e..1c624bb3e 100644 --- a/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx +++ b/src/web-ui/src/shared/context-menu-system/components/ui/ContextMenu.tsx @@ -208,7 +208,7 @@ export const ContextMenu: React.FC = ({ }, [activeSubmenuId, visible]); - const handleItemClick = useCallback((item: ContextMenuItem, event: React.MouseEvent) => { + const handleItemClick = useCallback(async (item: ContextMenuItem, event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); @@ -224,7 +224,7 @@ export const ContextMenu: React.FC = ({ if (item.onClick) { try { - item.onClick(context); + await Promise.resolve(item.onClick(context)); } catch (error) { log.error('onClick handler failed', { itemId: item.id, error }); } diff --git a/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts b/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts index b543f9be3..9b44df010 100644 --- a/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts +++ b/src/web-ui/src/shared/context-menu-system/core/ContextResolver.ts @@ -55,10 +55,15 @@ export class ContextResolver { }; + // Inside the file explorer, file-node context must win over stray text selection; + // otherwise delete/rename commands can silently fail or target the wrong context. + const inFileExplorer = this.findAreaName(baseContext.targetElement) === 'file-explorer'; + const context = + (inFileExplorer ? this.resolveFileNode(baseContext) : null) ?? this.resolveSelection(baseContext) ?? this.resolveTerminal(baseContext) ?? - this.resolveFileNode(baseContext) ?? + (!inFileExplorer ? this.resolveFileNode(baseContext) : null) ?? this.resolveEditor(baseContext) ?? this.resolveFlowChat(baseContext) ?? this.resolveTab(baseContext) ?? diff --git a/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts b/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts index e7a9b1344..8cd7d2936 100644 --- a/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts +++ b/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts @@ -9,6 +9,7 @@ import { i18nService } from '../../../infrastructure/i18n'; import { workspaceManager } from '../../../infrastructure/services/business/workspaceManager'; import { isRemoteWorkspace } from '../../../shared/types'; import { addFileMentionToChat } from '@/shared/utils/chatContext'; +import { dirnameAbsolutePath } from '@/shared/utils/pathUtils'; export class FileExplorerMenuProvider implements IMenuProvider { readonly id = 'file-explorer'; @@ -277,10 +278,6 @@ export class FileExplorerMenuProvider implements IMenuProvider { private getParentDirectory(filePath: string): string { - const isWindows = filePath.includes('\\'); - const separator = isWindows ? '\\' : '/'; - const parts = filePath.split(separator); - parts.pop(); - return parts.join(separator); + return dirnameAbsolutePath(filePath); } } diff --git a/src/web-ui/src/tools/file-explorer/controller/ExplorerController.ts b/src/web-ui/src/tools/file-explorer/controller/ExplorerController.ts index 3fa2130a2..5868338cd 100644 --- a/src/web-ui/src/tools/file-explorer/controller/ExplorerController.ts +++ b/src/web-ui/src/tools/file-explorer/controller/ExplorerController.ts @@ -101,6 +101,7 @@ export class ExplorerController { private unwatch?: () => void; private pendingRefreshTimer?: ReturnType; private pendingRefreshPaths = new Set(); + private queuedForceRefreshPaths = new Set(); private generation = 0; private disposed = false; @@ -234,6 +235,12 @@ export class ExplorerController { } } + removePath(path: string): void { + if (this.model.removePath(path)) { + this.emit(); + } + } + dispose(): void { this.disposed = true; this.stopWatchers(); @@ -327,12 +334,16 @@ export class ExplorerController { expectedGeneration = this.generation, expectedRootPath = this.config.rootPath ?? '' ): Promise { - const node = this.model.getNode(path); + const canonicalPath = this.model.resolveNodeKey(path) ?? path; + const node = this.model.getNode(canonicalPath); if (!node || node.kind !== 'directory') { return; } if (node.childrenState === 'refreshing') { + if (forceRefresh) { + this.queueForceRefresh(canonicalPath); + } return; } @@ -346,59 +357,91 @@ export class ExplorerController { return; } - this.model.setDirectoryRefreshing(path, true); + this.model.setDirectoryRefreshing(canonicalPath, true); this.emit(); try { const children = await this.provider.getChildren({ - path, + path: canonicalPath, options: this.config, }); if (!this.isGenerationCurrent(expectedGeneration, expectedRootPath)) { + this.model.setDirectoryRefreshing(canonicalPath, false); return; } this.model.upsertChildren( - path, + canonicalPath, sortNodes(children, this.config.sortBy ?? 'name', this.config.sortOrder ?? 'asc') ); this.emit(); } catch (error) { if (!this.isGenerationCurrent(expectedGeneration, expectedRootPath)) { + this.model.setDirectoryRefreshing(canonicalPath, false); return; } const message = error instanceof Error ? error.message : String(error); - this.model.markDirectoryError(path, message); - if (pathsEquivalentFs(path, this.config.rootPath ?? '')) { + this.model.markDirectoryError(canonicalPath, message); + if (pathsEquivalentFs(canonicalPath, this.config.rootPath ?? '')) { this.model.setError(message); } this.emit(); - log.error('Failed to resolve explorer directory', { path, error }); + log.error('Failed to resolve explorer directory', { path: canonicalPath, error }); throw error; + } finally { + await this.flushQueuedForceRefresh(canonicalPath, expectedGeneration, expectedRootPath); } } + private queueForceRefresh(path: string): void { + const canonicalPath = this.model.resolveNodeKey(path) ?? path; + this.queuedForceRefreshPaths.add(canonicalPath); + } + + private async flushQueuedForceRefresh( + path: string, + expectedGeneration: number, + expectedRootPath: string + ): Promise { + const canonicalPath = this.model.resolveNodeKey(path) ?? path; + if (!this.queuedForceRefreshPaths.has(canonicalPath)) { + return; + } + + this.queuedForceRefreshPaths.delete(canonicalPath); + if (!this.isGenerationCurrent(expectedGeneration, expectedRootPath)) { + return; + } + + await this.resolveDirectory(canonicalPath, true, expectedGeneration, expectedRootPath); + } + private handleFileChange(event: FileSystemChangeEvent): void { const parentPath = dirnameAbsolutePath(event.path); + const resolvedParent = parentPath + ? (this.model.resolveNodeKey(parentPath) ?? parentPath) + : null; const changedDirectory = this.model.getNode(event.path); - if (parentPath) { - this.pendingRefreshPaths.add(parentPath); - this.model.markDirectoryStale(parentPath); + if (resolvedParent) { + this.pendingRefreshPaths.add(resolvedParent); + this.model.markDirectoryStale(resolvedParent); } if (changedDirectory?.kind === 'directory') { - this.pendingRefreshPaths.add(event.path); - this.model.markDirectoryStale(event.path); + const resolvedDirectory = this.model.resolveNodeKey(event.path) ?? event.path; + this.pendingRefreshPaths.add(resolvedDirectory); + this.model.markDirectoryStale(resolvedDirectory); } if (event.oldPath) { const oldParent = dirnameAbsolutePath(event.oldPath); if (oldParent) { - this.pendingRefreshPaths.add(oldParent); - this.model.markDirectoryStale(oldParent); + const resolvedOldParent = this.model.resolveNodeKey(oldParent) ?? oldParent; + this.pendingRefreshPaths.add(resolvedOldParent); + this.model.markDirectoryStale(resolvedOldParent); } } @@ -450,7 +493,8 @@ export class ExplorerController { } for (const directory of Array.from(directoriesToRefresh).sort(comparePathDepth)) { - await this.resolveDirectory(directory, true); + const resolvedDirectory = this.model.resolveNodeKey(directory) ?? directory; + await this.resolveDirectory(resolvedDirectory, true); } } diff --git a/src/web-ui/src/tools/file-explorer/model/ExplorerModel.test.ts b/src/web-ui/src/tools/file-explorer/model/ExplorerModel.test.ts new file mode 100644 index 000000000..b95cf7e8c --- /dev/null +++ b/src/web-ui/src/tools/file-explorer/model/ExplorerModel.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; +import { ExplorerModel } from './ExplorerModel'; + +describe('ExplorerModel path equivalence', () => { + it('resolves node keys across path separators', () => { + const model = new ExplorerModel(); + model.reset('/workspace/project'); + + expect(model.resolveNodeKey('/workspace/project')).toBe('/workspace/project'); + expect(model.resolveNodeKey('\\workspace\\project')).toBe('/workspace/project'); + }); + + it('removes a file using an equivalent path key', () => { + const model = new ExplorerModel(); + model.reset('/workspace/project'); + model.upsertChildren('/workspace/project', [ + { path: '/workspace/project/readme.md', name: 'readme.md', isDirectory: false }, + ]); + + expect(model.removePath('\\workspace\\project\\readme.md')).toBe(true); + expect(model.getSnapshot().fileTree[0]?.children ?? []).toHaveLength(0); + }); +}); diff --git a/src/web-ui/src/tools/file-explorer/model/ExplorerModel.ts b/src/web-ui/src/tools/file-explorer/model/ExplorerModel.ts index e653f9bfb..ab46668c3 100644 --- a/src/web-ui/src/tools/file-explorer/model/ExplorerModel.ts +++ b/src/web-ui/src/tools/file-explorer/model/ExplorerModel.ts @@ -1,6 +1,7 @@ import { expandedFoldersAddEquivalent, expandedFoldersDeleteEquivalent, + pathsEquivalentFs, } from '@/shared/utils/pathUtils'; import type { FileSystemNode, FileSystemOptions } from '@/tools/file-system/types'; import type { ExplorerControllerConfig, ExplorerNodeRecord, ExplorerSnapshot } from '../types/explorer'; @@ -136,27 +137,37 @@ export class ExplorerModel { } setDirectoryRefreshing(path: string, refreshing: boolean): void { - const node = this.nodes.get(path); + const nodeKey = this.resolveNodeKey(path); + if (!nodeKey) { + return; + } + + const node = this.nodes.get(nodeKey); if (!node || node.kind !== 'directory') { return; } if (refreshing) { - this.loadingPaths.add(path); + this.loadingPaths.add(nodeKey); node.childrenState = 'refreshing'; node.stale = false; node.errorMessage = undefined; return; } - this.loadingPaths.delete(path); + this.loadingPaths.delete(nodeKey); if (node.childrenState === 'refreshing') { - node.childrenState = 'resolved'; + node.childrenState = node.stale ? 'unresolved' : 'resolved'; } } markDirectoryStale(path: string): void { - const node = this.nodes.get(path); + const nodeKey = this.resolveNodeKey(path); + if (!nodeKey) { + return; + } + + const node = this.nodes.get(nodeKey); if (!node || node.kind !== 'directory') { return; } @@ -182,7 +193,12 @@ export class ExplorerModel { } upsertChildren(parentPath: string, children: FileSystemNode[]): void { - const parent = this.nodes.get(parentPath); + const parentKey = this.resolveNodeKey(parentPath); + if (!parentKey) { + return; + } + + const parent = this.nodes.get(parentKey); if (!parent || parent.kind !== 'directory') { return; } @@ -192,7 +208,7 @@ export class ExplorerModel { for (const child of children) { const existing = this.nodes.get(child.path); - const nextRecord = createNodeRecord(child, parentPath, false, existing); + const nextRecord = createNodeRecord(child, parentKey, false, existing); this.nodes.set(child.path, nextRecord); nextChildIds.push(child.path); previousChildIds.delete(child.path); @@ -216,7 +232,12 @@ export class ExplorerModel { } markDirectoryError(path: string, message: string): void { - const node = this.nodes.get(path); + const nodeKey = this.resolveNodeKey(path); + if (!nodeKey) { + return; + } + + const node = this.nodes.get(nodeKey); if (!node || node.kind !== 'directory') { return; } @@ -224,11 +245,60 @@ export class ExplorerModel { node.childrenState = 'error'; node.stale = true; node.errorMessage = message; - this.loadingPaths.delete(path); + this.loadingPaths.delete(nodeKey); } getNode(path: string): ExplorerNodeRecord | undefined { - return this.nodes.get(path); + const nodeKey = this.resolveNodeKey(path); + return nodeKey ? this.nodes.get(nodeKey) : undefined; + } + + resolveNodeKey(path: string): string | undefined { + if (this.nodes.has(path)) { + return path; + } + + for (const key of this.nodes.keys()) { + if (pathsEquivalentFs(key, path)) { + return key; + } + } + + return undefined; + } + + removePath(path: string): boolean { + const nodeKey = this.resolveNodeKey(path); + if (!nodeKey) { + return false; + } + + const node = this.nodes.get(nodeKey); + if (!node) { + return false; + } + + if (node.parentId) { + const parent = this.nodes.get(node.parentId); + if (parent) { + parent.childIds = parent.childIds.filter((childId) => childId !== nodeKey); + } + } else if (node.isRoot) { + const rootIndex = this.roots.indexOf(nodeKey); + if (rootIndex >= 0) { + this.roots.splice(rootIndex, 1); + } + } + + this.removeSubtree(nodeKey); + this.replaceExpandedFolders(expandedFoldersDeleteEquivalent(this.expandedFolders, nodeKey)); + this.loadingPaths.delete(nodeKey); + + if (this.selectedFile && pathsEquivalentFs(this.selectedFile, nodeKey)) { + this.selectedFile = undefined; + } + + return true; } getExpandedFolders(): Set { diff --git a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx index 62231a786..fb9ae4778 100644 --- a/src/web-ui/src/tools/file-system/components/FileExplorer.tsx +++ b/src/web-ui/src/tools/file-system/components/FileExplorer.tsx @@ -7,9 +7,43 @@ import { FileExplorerProps, FileSystemNode, FlatFileNode } from '../types'; import { flattenFileTree } from '../utils/treeFlattening'; import { getNewItemParentPath } from '../utils/getNewItemParentPath'; import { i18nService, useI18n } from '@/infrastructure/i18n'; -import { expandedFoldersContains } from '@/shared/utils/pathUtils'; +import { expandedFoldersContains, pathsEquivalentFs } from '@/shared/utils/pathUtils'; import { IconButton } from '@/component-library'; import { filterTreeByPredicate, filterTreeBySearch } from '@/tools/file-explorer'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { commandExecutor } from '@/shared/context-menu-system/commands/CommandExecutor'; +import { ContextType, type FileNodeContext } from '@/shared/context-menu-system/types/context.types'; + +function findNodeByPath(nodes: FileSystemNode[], path: string): FileSystemNode | undefined { + for (const node of nodes) { + if (pathsEquivalentFs(node.path, path)) { + return node; + } + if (node.children) { + const found = findNodeByPath(node.children, path); + if (found) { + return found; + } + } + } + return undefined; +} + +function buildFileNodeContext(node: FileSystemNode, workspacePath?: string): FileNodeContext { + return { + type: node.isDirectory ? ContextType.FOLDER_NODE : ContextType.FILE_NODE, + event: new MouseEvent('contextmenu'), + targetElement: document.body, + position: { x: 0, y: 0 }, + timestamp: Date.now(), + metadata: {}, + filePath: node.path, + fileName: node.name, + isDirectory: node.isDirectory, + isReadOnly: false, + workspacePath, + }; +} const VIRTUAL_SCROLL_THRESHOLD = 100; @@ -289,6 +323,35 @@ export const FileExplorer: React.FC = ({ } }, [onRefresh]); + const handleRenameSelected = useCallback(() => { + if (!selectedFile || renamingPath) { + return; + } + + const node = findNodeByPath(fileTree, selectedFile); + if (!node) { + return; + } + + globalEventBus.emit('file:rename', { + path: node.path, + name: node.name, + }); + }, [selectedFile, renamingPath, fileTree]); + + const handleDeleteSelected = useCallback(async () => { + if (!selectedFile) { + return; + } + + const node = findNodeByPath(fileTree, selectedFile); + if (!node) { + return; + } + + await commandExecutor.execute('file.delete', buildFileNodeContext(node, workspacePath)); + }, [selectedFile, fileTree, workspacePath]); + const handleBreadcrumbNavigate = useCallback((path: string) => { if (externalOnNodeExpand) { externalOnNodeExpand(path, true); @@ -319,6 +382,20 @@ export const FileExplorer: React.FC = ({ handleNewFolder, { enabled: Boolean(onNewFolder), description: 'keyboard.shortcuts.filetree.newFolder' } ); + useShortcut( + 'filetree.rename', + { key: 'F2', scope: 'filetree' }, + handleRenameSelected, + { enabled: Boolean(selectedFile) && !renamingPath, description: 'keyboard.shortcuts.filetree.rename' } + ); + useShortcut( + 'filetree.delete', + { key: 'Delete', scope: 'filetree' }, + () => { + void handleDeleteSelected(); + }, + { enabled: Boolean(selectedFile), description: 'keyboard.shortcuts.filetree.delete' } + ); if (filteredFileTree.length === 0) { return ( diff --git a/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx b/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx index c9dd376da..985b2153c 100644 --- a/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx +++ b/src/web-ui/src/tools/file-system/components/FileTreeItem.tsx @@ -4,6 +4,7 @@ import { Input } from '../../../component-library/components/Input'; import { dragManager } from '../../../shared/services/DragManager'; import { fileTreeDragSource } from '../../../shared/context-system/drag-drop/FileTreeDragSource'; import { useI18n } from '@/infrastructure/i18n'; +import { pathsEquivalentFs } from '@/shared/utils/pathUtils'; import { FileSystemNode } from '../types'; import { getFileIcon, getFileIconClass } from '../utils/fileIcons'; import { getCompressionTooltip } from '../utils/pathCompression'; @@ -16,6 +17,7 @@ interface RenameInputProps { const RenameInput: React.FC = ({ node, onRename, onCancel }) => { const [value, setValue] = useState(node.name); + const submittedRef = React.useRef(false); useEffect(() => { const timer = setTimeout(() => { @@ -36,31 +38,38 @@ const RenameInput: React.FC = ({ node, onRename, onCancel }) = return () => clearTimeout(timer); }, [node.name, node.isDirectory]); + const commitRename = (nextValue: string) => { + if (submittedRef.current) { + return; + } + submittedRef.current = true; + + const newName = nextValue.trim(); + if (newName && newName !== node.name) { + onRename(newName); + } else { + onCancel?.(); + } + }; + const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); - const newName = value.trim(); - if (newName && newName !== node.name) { - onRename(newName); - } else { - onCancel?.(); - } + commitRename(value); return; } if (event.key === 'Escape') { event.preventDefault(); - onCancel?.(); + if (!submittedRef.current) { + submittedRef.current = true; + onCancel?.(); + } } }; const handleBlur = () => { - const newName = value.trim(); - if (newName && newName !== node.name) { - onRename(newName); - } else { - onCancel?.(); - } + commitRename(value); }; return ( @@ -118,7 +127,7 @@ export const FileTreeItem: React.FC = ({ const isCompressed = node.isCompressed; const tooltip = isCompressed ? getCompressionTooltip(node as any) : node.path; - const isRenaming = renamingPath === node.path; + const isRenaming = renamingPath ? pathsEquivalentFs(renamingPath, node.path) : false; const handleClick = (event: React.MouseEvent) => { if (event.button !== 0) { diff --git a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts index 9e97faf6f..12916af44 100644 --- a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts +++ b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts @@ -21,6 +21,7 @@ export interface UseFileSystemReturn { expandFolder: (folderPath: string, expanded?: boolean) => void; expandFolderLazy: (folderPath: string) => Promise; expandFolderEnsure: (folderPath: string) => Promise; + removePath: (path: string) => void; searchFiles: (query: string) => Promise; refreshFileTree: () => Promise; updateOptions: (options: Partial) => void; @@ -78,6 +79,10 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem return controller.expandFolderEnsure(folderPath); }, [controller]); + const removePath = useCallback((path: string) => { + controller.removePath(path); + }, [controller]); + const refreshFileTree = useCallback(() => { return controller.loadFileTree(undefined, false); }, [controller]); @@ -116,6 +121,7 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem expandFolder, expandFolderLazy, expandFolderEnsure, + removePath, searchFiles, refreshFileTree, updateOptions, diff --git a/src/web-ui/src/tools/file-system/hooks/useWorkspaceFileDrop.ts b/src/web-ui/src/tools/file-system/hooks/useWorkspaceFileDrop.ts new file mode 100644 index 000000000..4a527c822 --- /dev/null +++ b/src/web-ui/src/tools/file-system/hooks/useWorkspaceFileDrop.ts @@ -0,0 +1,192 @@ +import { useEffect, useRef, type RefObject } from 'react'; +import { createLogger } from '@/shared/utils/logger'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { + isDragPositionOverElement, + resolveDropTargetDirectoryFromDragPosition, + uploadLocalPathsToWorkspaceDirectory, + type TransferProgressState, +} from '@/tools/file-system/services/workspaceFileTransfer'; + +const log = createLogger('useWorkspaceFileDrop'); + +const DROP_DEDUPE_MS = 500; + +export interface UseWorkspaceFileDropOptions { + workspacePath?: string; + panelRef: RefObject; + enabled?: boolean; + onProgress: (state: TransferProgressState | null) => void; + onDragOver?: (overPanel: boolean) => void; + onComplete: (targetDirectory: string) => void; + onError: (error: unknown) => void; +} + +export function useWorkspaceFileDrop({ + workspacePath, + panelRef, + enabled = true, + onProgress, + onDragOver, + onComplete, + onError, +}: UseWorkspaceFileDropOptions): void { + const { workspace: currentWorkspace } = useCurrentWorkspace(); + const lastEnterPathsRef = useRef([]); + const lastDropTargetRef = useRef(null); + const isDragOverPanelRef = useRef(false); + const dropProcessingRef = useRef(false); + const lastDropSignatureRef = useRef<{ signature: string; at: number } | null>(null); + + useEffect(() => { + if ( + typeof window === 'undefined' + || !('__TAURI__' in window) + || !workspacePath + || !enabled + ) { + return; + } + + let unlisten: (() => void) | undefined; + let cancelled = false; + + const setup = async () => { + try { + const { getCurrentWebview } = await import('@tauri-apps/api/webview'); + const webview = getCurrentWebview(); + + if (cancelled) { + return; + } + + unlisten = await webview.onDragDropEvent(async (event) => { + if (cancelled) { + return; + } + + const payload = event.payload; + + if (payload.type === 'leave') { + isDragOverPanelRef.current = false; + onDragOver?.(false); + return; + } + + if (payload.type === 'enter') { + lastEnterPathsRef.current = [...payload.paths]; + return; + } + + if (payload.type === 'over') { + const factor = await webview.window.scaleFactor(); + const panelEl = panelRef.current; + const overPanel = isDragPositionOverElement(payload.position, factor, panelEl); + isDragOverPanelRef.current = overPanel; + + if (overPanel) { + lastDropTargetRef.current = resolveDropTargetDirectoryFromDragPosition( + payload.position, + factor, + workspacePath, + panelEl + ); + } + onDragOver?.(overPanel); + return; + } + + if (payload.type !== 'drop') { + return; + } + + const factor = await webview.window.scaleFactor(); + const panelEl = panelRef.current; + const overPanel = isDragPositionOverElement(payload.position, factor, panelEl) + || isDragOverPanelRef.current; + + if (!overPanel) { + lastEnterPathsRef.current = []; + lastDropTargetRef.current = null; + isDragOverPanelRef.current = false; + return; + } + + const paths = payload.paths.length > 0 + ? payload.paths + : [...lastEnterPathsRef.current]; + + if (paths.length === 0) { + log.warn('Ignoring file drop with empty paths'); + return; + } + + const signature = `${paths.join('\0')}->${lastDropTargetRef.current ?? workspacePath}`; + const now = Date.now(); + const lastDrop = lastDropSignatureRef.current; + if ( + lastDrop + && lastDrop.signature === signature + && now - lastDrop.at < DROP_DEDUPE_MS + ) { + return; + } + lastDropSignatureRef.current = { signature, at: now }; + + if (dropProcessingRef.current) { + return; + } + + const targetDir = lastDropTargetRef.current + ?? resolveDropTargetDirectoryFromDragPosition( + payload.position, + factor, + workspacePath, + panelEl + ); + + lastEnterPathsRef.current = []; + lastDropTargetRef.current = null; + isDragOverPanelRef.current = false; + + dropProcessingRef.current = true; + try { + await uploadLocalPathsToWorkspaceDirectory( + paths, + targetDir, + currentWorkspace, + onProgress + ); + onComplete(targetDir); + } catch (error) { + log.error('Failed to upload dropped files', error); + onError(error); + } finally { + dropProcessingRef.current = false; + } + }); + } catch (error) { + log.warn('File drag-drop listener not available', error); + } + }; + + void setup(); + + return () => { + cancelled = true; + unlisten?.(); + lastEnterPathsRef.current = []; + lastDropTargetRef.current = null; + isDragOverPanelRef.current = false; + }; + }, [ + workspacePath, + enabled, + currentWorkspace, + panelRef, + onProgress, + onDragOver, + onComplete, + onError, + ]); +} diff --git a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts new file mode 100644 index 000000000..32a1e551d --- /dev/null +++ b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { + joinWorkspaceTargetPath, + normalizeClipboardLocalPaths, + resolvePasteTargetDirectory, +} from './workspaceFileTransfer'; + +describe('workspaceFileTransfer', () => { + it('joins remote workspace paths with POSIX separators', () => { + expect(joinWorkspaceTargetPath('/home/user/project/', 'file.txt', true)) + .toBe('/home/user/project/file.txt'); + }); + + it('joins local workspace paths with native separators', () => { + expect(joinWorkspaceTargetPath('/Users/dev/project', 'file.txt', false)) + .toBe('/Users/dev/project/file.txt'); + expect(joinWorkspaceTargetPath('C:\\dev\\project', 'file.txt', false)) + .toBe('C:\\dev\\project\\file.txt'); + }); + + it('normalizes clipboard file URLs and deduplicates paths', () => { + expect(normalizeClipboardLocalPaths([ + 'file:///tmp/a.txt', + ' /tmp/a.txt ', + '', + ])).toEqual(['/tmp/a.txt']); + + expect(normalizeClipboardLocalPaths([ + 'file:///C:/Users/dev/Documents/report.pdf', + ])).toEqual(['C:/Users/dev/Documents/report.pdf']); + }); + + it('resolves paste target from selected directory node', () => { + const fileTree = [ + { + path: '/tmp/project', + isDirectory: true, + children: [ + { path: '/tmp/project/src', isDirectory: true }, + ], + }, + ]; + + const findNode = (nodes: typeof fileTree, path: string) => { + for (const node of nodes) { + if (node.path === path) return node; + if (node.children) { + const child = node.children.find((entry) => entry.path === path); + if (child) return child; + } + } + return null; + }; + + expect(resolvePasteTargetDirectory({ + workspacePath: '/tmp/project', + selectedFile: '/tmp/project/src', + fileTree, + findNode, + })).toBe('/tmp/project/src'); + }); +}); diff --git a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts index be48a6c92..c90404d3d 100644 --- a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts +++ b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts @@ -7,6 +7,13 @@ import { sshApi } from '@/features/ssh-remote/sshApi'; import { workspaceAPI } from '@/infrastructure/api'; import { i18nService } from '@/infrastructure/i18n'; import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; +import { + dirnameAbsolutePath, + normalizeLocalPathForRename, + normalizePath, + normalizeRemoteWorkspacePath, + pathsEquivalentFs, +} from '@/shared/utils/pathUtils'; export type TransferPhase = 'download' | 'upload'; @@ -19,41 +26,134 @@ export interface TransferProgressState { indeterminate?: boolean; } +export interface WorkspaceTransferResult { + successCount: number; + failedFiles: Array<{ path: string; error: string }>; +} + +export interface UploadToWorkspaceOptions { + isCut?: boolean; +} + +function normalizeClipboardLocalPath(path: string): string { + const trimmed = path.trim(); + if (!trimmed) { + return ''; + } + + if (trimmed.startsWith('file://')) { + let normalized = normalizePath(trimmed); + // file:///absolute/unix/path loses its leading slash in normalizePath. + if ( + /^file:\/\/\/(?!\/)/.test(trimmed) + && !/^[A-Za-z]:/.test(normalized) + && !normalized.startsWith('/') + ) { + normalized = `/${normalized}`; + } + return normalized; + } + + if (trimmed.startsWith('\\\\')) { + return trimmed; + } + + return normalizeLocalPathForRename(trimmed); +} + +export function normalizeClipboardLocalPaths(paths: string[]): string[] { + const normalized: string[] = []; + + for (const path of paths) { + const next = normalizeClipboardLocalPath(path); + if (!next || normalized.some((existing) => pathsEquivalentFs(existing, next))) { + continue; + } + normalized.push(next); + } + + return normalized; +} + function isTauri(): boolean { return typeof window !== 'undefined' && '__TAURI__' in window; } -export function joinWorkspaceTargetPath(dir: string, fileName: string): string { - const sep = dir.includes('\\') ? '\\' : '/'; - const base = dir.replace(/[/\\]+$/, ''); +export function resolvePasteTargetDirectory(options: { + workspacePath: string; + explicitTargetDir?: string; + selectedFile?: string; + fileTree: T[]; + findNode: (nodes: T[], path: string) => T | null; +}): string { + if (options.explicitTargetDir) { + return options.explicitTargetDir; + } + + const targetDirectory = options.workspacePath; + if (!options.selectedFile) { + return targetDirectory; + } + + const selectedNode = options.findNode(options.fileTree, options.selectedFile); + if (!selectedNode) { + return targetDirectory; + } + + if (selectedNode.isDirectory) { + return selectedNode.path; + } + + return dirnameAbsolutePath(selectedNode.path) || targetDirectory; +} + +export function normalizeWorkspaceTargetDirectory( + targetDirectory: string, + workspace: WorkspaceInfo | null +): string { + if (isRemoteWorkspace(workspace)) { + return normalizeRemoteWorkspacePath(targetDirectory); + } + return normalizeLocalPathForRename(targetDirectory); +} + +export function joinWorkspaceTargetPath(dir: string, fileName: string, remote = false): string { + const sep = remote ? '/' : (dir.includes('\\') ? '\\' : '/'); + const base = remote + ? normalizeRemoteWorkspacePath(dir) + : dir.replace(/[/\\]+$/, ''); return `${base}${sep}${fileName}`; } export function getParentPathFromFile(filePath: string): string { - const isWin = filePath.includes('\\'); - const sep = isWin ? '\\' : '/'; - const parts = filePath.split(sep); - parts.pop(); - return parts.join(sep); + return dirnameAbsolutePath(filePath); } export function resolveExplorerDropTargetDirectory( clientX: number, clientY: number, - workspacePath: string + workspacePath: string, + boundary?: HTMLElement | null ): string { const el = document.elementFromPoint(clientX, clientY); if (!el) { return workspacePath; } - const explorer = el.closest('.bitfun-file-explorer'); + + const explorer = boundary ?? el.closest('.bitfun-file-explorer'); if (!explorer) { return workspacePath; } + + if (!explorer.contains(el)) { + return workspacePath; + } + const node = el.closest('[data-file-path]'); - if (!node) { + if (!node || !explorer.contains(node)) { return workspacePath; } + const path = node.getAttribute('data-file-path'); if (!path) { return workspacePath; @@ -62,7 +162,19 @@ export function resolveExplorerDropTargetDirectory( if (isDir) { return path; } - return getParentPathFromFile(path) || workspacePath; + return dirnameAbsolutePath(path) || workspacePath; +} + +function dragPositionToLogicalCandidates( + position: { x: number; y: number }, + scaleFactor: number +): { x: number; y: number }[] { + const logical = new PhysicalPosition(position.x, position.y).toLogical(scaleFactor); + return [ + { x: logical.x, y: logical.y }, + { x: position.x, y: position.y }, + { x: position.x / scaleFactor, y: position.y / scaleFactor }, + ]; } /** @@ -72,24 +184,27 @@ export function resolveExplorerDropTargetDirectory( export function resolveDropTargetDirectoryFromDragPosition( position: { x: number; y: number }, scaleFactor: number, - workspacePath: string + workspacePath: string, + boundary?: HTMLElement | null ): string { - const logical = new PhysicalPosition(position.x, position.y).toLogical(scaleFactor); - const candidates: { x: number; y: number }[] = [ - { x: logical.x, y: logical.y }, - { x: position.x, y: position.y }, - { x: position.x / scaleFactor, y: position.y / scaleFactor }, - ]; - for (const { x, y } of candidates) { + for (const { x, y } of dragPositionToLogicalCandidates(position, scaleFactor)) { if (!Number.isFinite(x) || !Number.isFinite(y)) { continue; } + const hit = document.elementFromPoint(x, y); - if (!hit?.closest('.bitfun-file-explorer')) { + const explorer = boundary ?? hit?.closest('.bitfun-file-explorer'); + if (!explorer) { continue; } - return resolveExplorerDropTargetDirectory(x, y, workspacePath); + + if (hit && !explorer.contains(hit)) { + continue; + } + + return resolveExplorerDropTargetDirectory(x, y, workspacePath, explorer as HTMLElement); } + return workspacePath; } @@ -102,13 +217,7 @@ export function isDragPositionOverElement( return false; } const rect = element.getBoundingClientRect(); - const logical = new PhysicalPosition(position.x, position.y).toLogical(scaleFactor); - const candidates = [ - { x: logical.x, y: logical.y }, - { x: position.x, y: position.y }, - { x: position.x / scaleFactor, y: position.y / scaleFactor }, - ]; - for (const { x, y } of candidates) { + for (const { x, y } of dragPositionToLogicalCandidates(position, scaleFactor)) { if (!Number.isFinite(x) || !Number.isFinite(y)) { continue; } @@ -170,45 +279,125 @@ export async function uploadLocalPathsToWorkspaceDirectory( localPaths: string[], targetDirectory: string, workspace: WorkspaceInfo | null, - onProgress: (state: TransferProgressState | null) => void -): Promise { + onProgress: (state: TransferProgressState | null) => void, + options: UploadToWorkspaceOptions = {} +): Promise { if (!isTauri()) { throw new Error(i18nService.t('common:ssh.remote.transferNeedsDesktop')); } - if (localPaths.length === 0) { - return; + + const normalizedLocalPaths = normalizeClipboardLocalPaths(localPaths); + if (normalizedLocalPaths.length === 0) { + return { successCount: 0, failedFiles: [] }; } - const total = localPaths.length; - for (let i = 0; i < total; i++) { - const lp = localPaths[i]!; - const name = lp.split(/[/\\]/).pop(); - if (!name) { - continue; + + const remote = isRemoteWorkspace(workspace); + const normalizedTargetDirectory = normalizeWorkspaceTargetDirectory(targetDirectory, workspace); + const isCut = options.isCut ?? false; + + if (remote) { + const cid = workspace?.connectionId; + if (!cid) { + throw new Error(i18nService.t('panels/files:transfer.missingConnection')); + } + + const failedFiles: WorkspaceTransferResult['failedFiles'] = []; + let successCount = 0; + const total = normalizedLocalPaths.length; + + for (let i = 0; i < total; i++) { + const localPath = normalizedLocalPaths[i]!; + const name = localPath.split(/[/\\]/).pop(); + if (!name) { + continue; + } + + const destPath = joinWorkspaceTargetPath(normalizedTargetDirectory, name, true); + onProgress({ + phase: 'upload', + current: i, + total, + label: name, + indeterminate: total === 1, + }); + + try { + await sshApi.uploadFromLocalPath(cid, localPath, destPath); + successCount += 1; + } catch (error) { + failedFiles.push({ + path: localPath, + error: error instanceof Error ? error.message : String(error), + }); + } } - const destPath = joinWorkspaceTargetPath(targetDirectory, name); + onProgress({ phase: 'upload', - current: i, + current: total, total, - label: name, - indeterminate: total === 1, + label: '', + indeterminate: false, }); - if (isRemoteWorkspace(workspace)) { - const cid = workspace?.connectionId; - if (!cid) { - throw new Error(i18nService.t('panels/files:transfer.missingConnection')); - } - await sshApi.uploadFromLocalPath(cid, lp, destPath); - } else { - await workspaceAPI.exportLocalFileToPath(lp, destPath); + window.setTimeout(() => onProgress(null), 450); + + if (successCount === 0 && failedFiles.length > 0) { + const details = failedFiles.map((entry) => `${entry.path}: ${entry.error}`).join('; '); + throw new Error(details); } + + return { successCount, failedFiles }; } + + onProgress({ + phase: 'upload', + current: 0, + total: normalizedLocalPaths.length, + label: normalizedLocalPaths.length === 1 + ? (normalizedLocalPaths[0]?.split(/[/\\]/).pop() ?? '') + : '', + indeterminate: normalizedLocalPaths.length === 1, + }); + + const result = await workspaceAPI.pasteFiles( + normalizedLocalPaths, + normalizedTargetDirectory, + isCut + ); + onProgress({ phase: 'upload', - current: total, - total, + current: normalizedLocalPaths.length, + total: normalizedLocalPaths.length, label: '', indeterminate: false, }); window.setTimeout(() => onProgress(null), 450); + + if (result.successCount === 0 && result.failedFiles.length > 0) { + const details = result.failedFiles + .map((entry) => `${entry.path}: ${entry.error}`) + .join('; '); + throw new Error(details); + } + + return { + successCount: result.successCount, + failedFiles: result.failedFiles, + }; +} + +export async function pasteClipboardFilesToWorkspaceDirectory( + targetDirectory: string, + workspace: WorkspaceInfo | null, + onProgress: (state: TransferProgressState | null) => void +): Promise { + const { files, isCut } = await workspaceAPI.getClipboardFiles(); + return uploadLocalPathsToWorkspaceDirectory( + files, + targetDirectory, + workspace, + onProgress, + { isCut } + ); } From 32223a4a1deaeb8057b4791e156bf28d5199d98d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 24 May 2026 11:00:26 +0800 Subject: [PATCH 3/4] fix(model) --- .../tools/implementations/task_tool.rs | 59 ++++++++++++++++++- .../locales/en-US/settings/agentic-tools.json | 2 +- .../locales/zh-CN/settings/agentic-tools.json | 2 +- .../locales/zh-TW/settings/agentic-tools.json | 2 +- 4 files changed, 60 insertions(+), 5 deletions(-) diff --git a/src/crates/core/src/agentic/tools/implementations/task_tool.rs b/src/crates/core/src/agentic/tools/implementations/task_tool.rs index 0e902300e..25914a4d3 100644 --- a/src/crates/core/src/agentic/tools/implementations/task_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/task_tool.rs @@ -22,6 +22,8 @@ use crate::agentic::tools::framework::{ }; use crate::agentic::tools::pipeline::SubagentParentInfo; use crate::agentic::tools::InputValidator; +use crate::service::config::global::GlobalConfigManager; +use crate::service::config::types::AIConfig; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::timing::elapsed_ms_u64; use async_trait::async_trait; @@ -57,6 +59,29 @@ impl TaskTool { ) } + async fn load_configured_tool_execution_timeout() -> Option { + let service = GlobalConfigManager::get_service().await.ok()?; + let ai_config: AIConfig = service.get_config(Some("ai")).await.ok()?; + ai_config + .tool_execution_timeout_secs + .filter(|seconds| *seconds > 0) + } + + fn resolve_subagent_timeout_seconds( + requested_timeout_seconds: Option, + configured_execution_timeout_secs: Option, + ) -> Option { + match ( + requested_timeout_seconds.filter(|seconds| *seconds > 0), + configured_execution_timeout_secs.filter(|seconds| *seconds > 0), + ) { + (Some(requested), Some(configured)) => Some(requested.max(configured)), + (Some(requested), None) => Some(requested), + (None, Some(configured)) => Some(configured), + (None, None) => None, + } + } + fn deep_review_launch_batch_for_task( subagent_type: &str, description: Option<&str>, @@ -418,7 +443,7 @@ Usage notes: - If 'workspace_path' is omitted, the task inherits the current workspace by default. - Provide 'workspace_path' when the selected agent requires an explicit workspace, such as Explore or FileFinder. - Use 'model_id' when a caller needs a specific model or model slot for the subagent. Omit it to use the agent default. -- Use 'timeout_seconds' when you need a hard deadline for the subagent. Omit it or set it to 0 to disable the timeout. +- Use 'timeout_seconds' when you need a hard deadline for the subagent. When omitted, the session execution timeout from settings is used. When provided, the effective timeout is the larger of the requested value and the session execution timeout. Set it to 0 with no configured session execution timeout to disable the timeout. - For DeepReview only, set 'retry' to true when re-dispatching a reviewer after that same reviewer returned partial_timeout or an explicit transient capacity failure in the current turn. Retry calls must include retry_coverage with source_packet_id, source_status, covered_files, and a smaller retry_scope_files list. Do not set 'auto_retry' unless this is a backend-owned automatic retry admitted by Review Team settings; model-issued retry decisions should omit it or set it to false. Example retry_coverage: {{ "source_packet_id": "reviewer-123", "source_status": "partial_timeout", "covered_files": ["src/main.rs"], "retry_scope_files": ["src/parser.rs"] }}. - Launch independent agents concurrently when that improves coverage or latency; send parallel Task calls in a single assistant message. - When the agent is done, it will return a single message back to you. @@ -521,7 +546,7 @@ impl Tool for TaskTool { "timeout_seconds": { "type": "integer", "minimum": 0, - "description": "Optional timeout for this subagent task in seconds. Use 0 or omit it to disable the timeout." + "description": "Optional timeout for this subagent task in seconds. When omitted, the session execution timeout from settings is used. When provided, the effective timeout is the larger of this value and the session execution timeout. Use 0 with no configured session execution timeout to disable the timeout." }, "run_in_background": { "type": "boolean", @@ -1094,6 +1119,12 @@ impl Tool for TaskTool { } } + if deep_review_subagent_role.is_none() { + let configured_timeout = Self::load_configured_tool_execution_timeout().await; + timeout_seconds = + Self::resolve_subagent_timeout_seconds(timeout_seconds, configured_timeout); + } + if let Some(retry_scope_files) = deep_review_retry_scope_files.as_ref() { prompt = Self::prompt_with_deep_review_retry_scope(&prompt, retry_scope_files); } @@ -1644,6 +1675,30 @@ mod tests { assert!(policy.classify_subagent("DeepReview").is_err()); } + #[test] + fn resolve_subagent_timeout_uses_session_execution_timeout_as_floor() { + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(Some(300), Some(1200)), + Some(1200) + ); + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(None, Some(1200)), + Some(1200) + ); + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(Some(1800), Some(1200)), + Some(1800) + ); + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(Some(300), None), + Some(300) + ); + assert_eq!( + TaskTool::resolve_subagent_timeout_seconds(None, None), + None + ); + } + #[test] fn deep_review_policy_caps_reviewer_and_judge_timeouts() { let policy = DeepReviewExecutionPolicy::from_config_value(Some(&json!({ diff --git a/src/web-ui/src/locales/en-US/settings/agentic-tools.json b/src/web-ui/src/locales/en-US/settings/agentic-tools.json index f3fb9aa77..006bff10c 100644 --- a/src/web-ui/src/locales/en-US/settings/agentic-tools.json +++ b/src/web-ui/src/locales/en-US/settings/agentic-tools.json @@ -17,7 +17,7 @@ "confirmTimeout": "Confirm Timeout", "confirmTimeoutDesc": "Maximum time (seconds) to wait for user confirmation of tool calls.", "executionTimeout": "Execution Timeout", - "executionTimeoutDesc": "Maximum time (seconds) for tool execution.", + "executionTimeoutDesc": "Maximum time (seconds) for tool execution, including Task subagent runs.", "seconds": "sec" }, "filters": { diff --git a/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json b/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json index fb3a7c93e..78af8b298 100644 --- a/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json +++ b/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json @@ -17,7 +17,7 @@ "confirmTimeout": "确认超时", "confirmTimeoutDesc": "等待用户确认工具调用的最长时间(秒)。", "executionTimeout": "执行超时", - "executionTimeoutDesc": "工具执行的最长时间(秒)。", + "executionTimeoutDesc": "工具执行的最长时间(秒),包含 Task 子智能体运行时长。", "seconds": "秒" }, "filters": { diff --git a/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json b/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json index bb146f05b..24abaa31a 100644 --- a/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json +++ b/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json @@ -17,7 +17,7 @@ "confirmTimeout": "確認超時", "confirmTimeoutDesc": "等待用戶確認工具調用的最長時間(秒)。", "executionTimeout": "執行超時", - "executionTimeoutDesc": "工具執行的最長時間(秒)。", + "executionTimeoutDesc": "工具執行的最長時間(秒),包含 Task 子智能體執行時長。", "seconds": "秒" }, "filters": { From d855a0516e37b87eff3654edbc1901688ad0f8ed Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 24 May 2026 11:11:29 +0800 Subject: [PATCH 4/4] fix --- src/apps/desktop/src/api/agentic_api.rs | 2 +- .../desktop/src/api/clipboard_file_api.rs | 84 +++++- .../src/agentic/coordination/coordinator.rs | 23 +- src/crates/core/src/agentic/goal_mode/mod.rs | 252 +++++++++++------- .../src/agentic/session/file_read_state.rs | 55 +++- .../agentic/tools/file_read_state_runtime.rs | 125 ++++++++- .../tool-runtime/src/util/read_line_prefix.rs | 10 +- .../src/flow_chat/components/ChatInput.tsx | 3 + .../src/flow_chat/services/goalService.ts | 33 ++- src/web-ui/src/locales/en-US/flow-chat.json | 1 + src/web-ui/src/locales/zh-CN/flow-chat.json | 1 + src/web-ui/src/locales/zh-TW/flow-chat.json | 1 + .../services/workspaceFileTransfer.ts | 4 - 13 files changed, 457 insertions(+), 137 deletions(-) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index ad98e1d73..ba8633ab4 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -871,7 +871,7 @@ pub async fn activate_session_goal( let activation = coordinator .activate_session_goal(session_id.to_string(), user_hint) .await - .map_err(|e| format!("Failed to activate session goal mode: {e}"))?; + .map_err(|error| error.to_string())?; Ok(ActivateSessionGoalResponse { success: true, diff --git a/src/apps/desktop/src/api/clipboard_file_api.rs b/src/apps/desktop/src/api/clipboard_file_api.rs index 6fe0df80a..7208d3973 100644 --- a/src/apps/desktop/src/api/clipboard_file_api.rs +++ b/src/apps/desktop/src/api/clipboard_file_api.rs @@ -30,6 +30,32 @@ pub struct FailedFile { pub error: String, } +fn normalize_decoded_file_path(mut path: String) -> String { + path = path.replace('\\', "/"); + + while path.starts_with("//") { + path = path[1..].to_string(); + } + + if let Some(rest) = path.strip_prefix('/') { + if rest.len() >= 2 { + let bytes = rest.as_bytes(); + if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + path = rest.to_string(); + } + } + } + + if path.len() >= 2 { + let bytes = path.as_bytes(); + if bytes[0].is_ascii_alphabetic() && bytes[1] == b':' { + path = format!("{}{}", bytes[0].to_ascii_uppercase() as char, &path[1..]); + } + } + + path +} + fn decode_file_uri(uri: &str) -> Option { let trimmed = uri.trim(); if !trimmed.starts_with("file://") { @@ -50,11 +76,11 @@ fn decode_file_uri(uri: &str) -> Option { return None; }; - Some( - urlencoding::decode(&path_part) - .map(|value| value.into_owned()) - .unwrap_or(path_part), - ) + let decoded = urlencoding::decode(&path_part) + .map(|value| value.into_owned()) + .unwrap_or(path_part); + + Some(normalize_decoded_file_path(decoded)) } #[allow(dead_code)] @@ -360,7 +386,10 @@ pub async fn paste_files(request: PasteFilesRequest) -> Result std::path::PathBuf { - let parent = path.parent().unwrap_or(Path::new("")); + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let extension = path.extension().and_then(|s| s.to_str()); @@ -403,7 +432,8 @@ fn copy_directory_recursive(source: &Path, target: &Path) -> Result<(), String> #[cfg(test)] mod tests { - use super::{decode_file_uri, parse_uri_list}; + use super::{decode_file_uri, generate_unique_path, parse_clipboard_path_segments, parse_uri_list}; + use std::path::Path; #[test] fn decode_unix_file_uri() { @@ -425,10 +455,48 @@ mod tests { fn decode_windows_file_uri() { assert_eq!( decode_file_uri("file:///C:/Users/dev/example.txt").as_deref(), - Some("/C:/Users/dev/example.txt") + Some("C:/Users/dev/example.txt") + ); + } + + #[test] + fn decode_windows_file_uri_lowercases_drive_letter() { + assert_eq!( + decode_file_uri("file:///c:/Users/dev/example.txt").as_deref(), + Some("C:/Users/dev/example.txt") ); } + #[test] + fn parse_clipboard_path_segments_handles_posix_paths() { + assert_eq!( + parse_clipboard_path_segments("/tmp/a.txt\n/tmp/b.txt"), + vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()] + ); + } + + #[test] + fn parse_clipboard_path_segments_handles_comma_separated_paths() { + assert_eq!( + parse_clipboard_path_segments("/tmp/a.txt,/tmp/b.txt"), + vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()] + ); + } + + #[test] + fn parse_clipboard_path_segments_decodes_file_uris() { + assert_eq!( + parse_clipboard_path_segments("file:///tmp/a.txt\r\nfile:///tmp/b.txt"), + vec!["/tmp/a.txt".to_string(), "/tmp/b.txt".to_string()] + ); + } + + #[test] + fn generate_unique_path_uses_current_dir_when_parent_missing() { + let unique = generate_unique_path(Path::new("example.txt")); + assert_eq!(unique.file_name(), Some(std::ffi::OsStr::new("example (1).txt"))); + } + #[test] fn parse_uri_list_ignores_comments_and_blank_lines() { let files = parse_uri_list( diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index d3d0c8479..1e7fb63ca 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -17,11 +17,11 @@ use crate::agentic::fork_agent::{ ForkAgentContextSnapshot, ForkAgentExecutionRequest, ForkAgentExecutionResult, }; use crate::agentic::goal_mode::{ - build_goal_kickoff_messages, build_goal_continuation_plan, build_recent_context_summary, - clear_goal_mode_patch, generate_goal_from_context, goal_mode_from_custom_metadata, - goal_mode_patch, should_skip_goal_verification_for_turn, verify_goal_achievement, - wrap_user_input_with_goal_reminder, GoalActivationResult, GoalContinuationPlan, - GoalModeState, MAX_GOAL_CONTINUATIONS, now_ms, + build_goal_kickoff_messages, build_goal_continuation_plan, clear_goal_mode_patch, + generate_goal_from_context, goal_mode_from_custom_metadata, goal_mode_patch, + should_skip_goal_verification_for_turn, user_facing_goal_mode_error, + verify_goal_achievement, wrap_user_input_with_goal_reminder, GoalActivationResult, + GoalContinuationPlan, GoalModeState, MAX_GOAL_CONTINUATIONS, now_ms, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::round_preempt::{DialogRoundInjectionSource, DialogRoundPreemptSource}; @@ -1448,13 +1448,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .get_context_messages(&session_id) .await?; - let context_summary = build_recent_context_summary(&context_messages, 12_000); let trimmed_hint = user_hint .as_deref() .map(str::trim) .filter(|value| !value.is_empty()); - let generation = generate_goal_from_context(&context_summary, trimmed_hint, None).await?; + let generation = generate_goal_from_context(&context_messages, trimmed_hint) + .await + .map_err(user_facing_goal_mode_error)?; let activation = build_goal_kickoff_messages(&generation, trimmed_hint); let state = GoalModeState { @@ -1484,7 +1485,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, user_input: &str, user_message_metadata: Option<&serde_json::Value>, - final_response: &str, + _final_response: &str, ) -> BitFunResult> { if should_skip_goal_verification_for_turn(user_input, user_message_metadata) { return Ok(None); @@ -1517,9 +1518,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .get_context_messages(session_id) .await?; - let context_summary = build_recent_context_summary(&context_messages, 12_000); - let verification = verify_goal_achievement(&goal_state, &context_summary, final_response) - .await?; + let verification = verify_goal_achievement(&goal_state, &context_messages) + .await + .map_err(user_facing_goal_mode_error)?; if verification.achieved { info!( diff --git a/src/crates/core/src/agentic/goal_mode/mod.rs b/src/crates/core/src/agentic/goal_mode/mod.rs index bcf6ed048..907e637b0 100644 --- a/src/crates/core/src/agentic/goal_mode/mod.rs +++ b/src/crates/core/src/agentic/goal_mode/mod.rs @@ -5,12 +5,15 @@ mod types; pub use types::*; -use crate::agentic::core::{Message, MessageContent, MessageRole, PromptEnvelope}; +use crate::agentic::core::{ + Message, MessageContent, MessageRole, MessageSemanticKind, PromptEnvelope, +}; use crate::service::config::{get_app_language_code, short_model_user_language_instruction}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::extract_json_from_ai_response; use crate::util::sanitize_plain_model_output; use crate::util::types::Message as AIMessage; +use log::warn; use std::time::{SystemTime, UNIX_EPOCH}; pub fn goal_mode_from_custom_metadata( @@ -41,41 +44,45 @@ pub fn message_text(message: &Message) -> Option { } } -pub fn build_recent_context_summary(messages: &[Message], max_chars: usize) -> String { - let mut lines: Vec = Vec::new(); - for message in messages.iter().rev() { - let role = match message.role { - MessageRole::User => "User", - MessageRole::Assistant => "Assistant", - _ => continue, - }; - let Some(text) = message_text(message) else { - continue; - }; - let trimmed = text.trim(); - if trimmed.is_empty() { - continue; - } - let snippet = if trimmed.chars().count() > 800 { - format!( - "{}...", - trimmed.chars().take(800).collect::() +/// Convert the full in-memory session transcript into provider messages, using +/// the same omission rules as normal model sends for UI-only computer-use frames. +pub fn build_goal_context_ai_messages(messages: &[Message]) -> Vec { + messages + .iter() + .filter(|message| !should_skip_message_for_goal_context(message)) + .map(AIMessage::from) + .collect() +} + +fn should_skip_message_for_goal_context(message: &Message) -> bool { + matches!( + message.metadata.semantic_kind.as_ref(), + Some(MessageSemanticKind::ComputerUseVerificationScreenshot) + | Some(MessageSemanticKind::ComputerUsePostActionSnapshot) + ) +} + +pub fn last_assistant_message_text(messages: &[Message]) -> Option { + messages + .iter() + .rev() + .filter(|message| message.role == MessageRole::Assistant) + .find_map(message_text) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) +} + +pub fn user_facing_goal_mode_error(error: BitFunError) -> BitFunError { + match error { + BitFunError::Validation(_) | BitFunError::NotFound(_) => error, + other => { + warn!("Goal mode AI call failed: {other}"); + BitFunError::Validation( + "Goal mode AI request failed. Check model configuration and try again." + .to_string(), ) - } else { - trimmed.to_string() - }; - lines.push(format!("{role}: {snippet}")); - if lines.iter().map(|line| line.len()).sum::() >= max_chars { - break; } } - lines.reverse(); - let mut summary = lines.join("\n\n"); - if summary.chars().count() > max_chars { - summary = summary.chars().take(max_chars).collect(); - summary.push_str("..."); - } - summary } pub fn build_goal_system_reminder(state: &GoalModeState) -> String { @@ -241,53 +248,68 @@ pub fn now_ms() -> u64 { .unwrap_or(0) } -async fn call_goal_func_agent(system_prompt: String, user_prompt: String) -> BitFunResult { - let messages = vec![ - AIMessage { - role: "system".to_string(), - content: Some(system_prompt), - reasoning_content: None, - thinking_signature: None, - tool_calls: None, - tool_call_id: None, - name: None, - is_error: None, - tool_image_attachments: None, - }, - AIMessage { - role: "user".to_string(), - content: Some(user_prompt), - reasoning_content: None, - thinking_signature: None, - tool_calls: None, - tool_call_id: None, - name: None, - is_error: None, - tool_image_attachments: None, - }, - ]; +async fn call_goal_func_agent_with_context( + system_prompt: String, + context_messages: &[Message], + final_user_prompt: String, +) -> BitFunResult { + let mut messages = Vec::with_capacity(context_messages.len() + 2); + messages.push(AIMessage { + role: "system".to_string(), + content: Some(system_prompt), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }); + messages.extend(build_goal_context_ai_messages(context_messages)); + messages.push(AIMessage { + role: "user".to_string(), + content: Some(final_user_prompt), + reasoning_content: None, + thinking_signature: None, + tool_calls: None, + tool_call_id: None, + name: None, + is_error: None, + tool_image_attachments: None, + }); let ai_client_factory = crate::infrastructure::ai::get_global_ai_client_factory() .await - .map_err(|error| BitFunError::AIClient(format!("Failed to get AI client factory: {error}")))?; + .map_err(|error| { + user_facing_goal_mode_error(BitFunError::AIClient(format!( + "Failed to get AI client factory: {error}" + ))) + })?; let ai_client = ai_client_factory .get_client_by_func_agent(GOAL_MODE_FUNC_AGENT) .await - .map_err(|error| BitFunError::AIClient(format!("Failed to get goal func agent client: {error}")))?; + .map_err(|error| { + user_facing_goal_mode_error(BitFunError::AIClient(format!( + "Failed to get goal func agent client: {error}" + ))) + })?; let response = ai_client .send_message(messages, None) .await - .map_err(|error| BitFunError::ai(format!("Goal func agent call failed: {error}")))?; + .map_err(|error| { + user_facing_goal_mode_error(BitFunError::ai(format!( + "Goal func agent call failed: {error}" + ))) + })?; Ok(sanitize_plain_model_output(&response.text)) } pub async fn generate_goal_from_context( - context_summary: &str, + context_messages: &[Message], user_hint: Option<&str>, - final_response: Option<&str>, ) -> BitFunResult { let lang_code = get_app_language_code().await; let language_instruction = short_model_user_language_instruction(lang_code.as_str()); @@ -298,14 +320,15 @@ pub async fn generate_goal_from_context( .map(|value| format!("\nUser-provided goal focus: {value}")) .unwrap_or_default(); - let response_block = final_response - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| format!("\nLatest assistant response:\n{value}")) + let latest_assistant_note = last_assistant_message_text(context_messages) + .map(|_| { + "\nUse the full conversation above, paying special attention to the latest assistant message." + .to_string() + }) .unwrap_or_default(); let system_prompt = format!( - "You synthesize a single actionable session goal from conversation context.\n\ + "You synthesize a single actionable session goal from the conversation transcript above.\n\ Return ONLY valid JSON with this shape:\n\ {{\"goalText\":\"...\",\"successCriteria\":[\"...\",\"...\"]}}\n\ Requirements:\n\ @@ -315,19 +338,23 @@ Requirements:\n\ - Do not include markdown or commentary" ); - let user_prompt = format!( - "Conversation context:\n{context_summary}{hint_block}{response_block}\n\n\ + let final_user_prompt = format!( + "Based on the full conversation above,{latest_assistant_note}{hint_block}\n\n\ Synthesize the session goal JSON:" ); - let raw = call_goal_func_agent(system_prompt, user_prompt).await?; + let raw = call_goal_func_agent_with_context( + system_prompt, + context_messages, + final_user_prompt, + ) + .await?; parse_goal_generation(&raw) } pub async fn verify_goal_achievement( state: &GoalModeState, - context_summary: &str, - final_response: &str, + context_messages: &[Message], ) -> BitFunResult { let criteria = if state.success_criteria.is_empty() { "- Use the goal text itself as the completion standard.".to_string() @@ -340,36 +367,38 @@ pub async fn verify_goal_achievement( .join("\n") }; - let system_prompt = "You verify whether a coding-agent session goal has truly been achieved.\n\ + let system_prompt = format!( + "You verify whether a coding-agent session goal has truly been achieved.\n\ +Active goal: {}\n\ +Success criteria:\n{criteria}\n\ +Use the full conversation transcript above, especially the latest assistant work.\n\ Return ONLY valid JSON with this shape:\n\ -{\"achieved\":true|false,\"confidence\":0.0,\"gaps\":[\"...\"],\"guidance\":\"...\"}\n\ +{{\"achieved\":true|false,\"confidence\":0.0,\"gaps\":[\"...\"],\"guidance\":\"...\"}}\n\ Rules:\n\ - achieved=true ONLY when every success criterion is objectively satisfied in the actual work done\n\ - Be strict: partial progress, plans, or explanations without completed work means achieved=false\n\ - gaps must list concrete missing items when achieved=false\n\ - guidance must be actionable next steps for the agent\n\ -- Do not include markdown or commentary" - .to_string(); - - let user_prompt = format!( - "Goal: {}\n\ -Success criteria:\n{criteria}\n\ -Conversation context:\n{context_summary}\n\ -Latest assistant response:\n{final_response}\n\n\ -Verify goal completion JSON:", - state.goal_text.trim(), - criteria = criteria, - context_summary = context_summary, - final_response = final_response, +- Do not include markdown or commentary", + state.goal_text.trim() ); - let raw = call_goal_func_agent(system_prompt, user_prompt).await?; + let final_user_prompt = + "Verify whether the active session goal has been fully achieved. Return the JSON verdict." + .to_string(); + + let raw = call_goal_func_agent_with_context( + system_prompt, + context_messages, + final_user_prompt, + ) + .await?; parse_goal_verification(&raw) } fn parse_goal_generation(raw: &str) -> BitFunResult { let json = extract_json_from_ai_response(raw).ok_or_else(|| { - BitFunError::Validation(format!("Goal generation returned non-JSON output: {raw}")) + BitFunError::Validation("Goal generation returned an unreadable model response.".to_string()) })?; let mut parsed: GoalGenerationResult = serde_json::from_str(&json).map_err(|error| { BitFunError::Validation(format!("Failed to parse goal generation JSON: {error}")) @@ -391,7 +420,9 @@ fn parse_goal_generation(raw: &str) -> BitFunResult { fn parse_goal_verification(raw: &str) -> BitFunResult { let json = extract_json_from_ai_response(raw).ok_or_else(|| { - BitFunError::Validation(format!("Goal verification returned non-JSON output: {raw}")) + BitFunError::Validation( + "Goal verification returned an unreadable model response.".to_string(), + ) })?; let mut parsed: GoalVerificationResult = serde_json::from_str(&json).map_err(|error| { BitFunError::Validation(format!("Failed to parse goal verification JSON: {error}")) @@ -427,14 +458,29 @@ mod tests { } #[test] - fn build_recent_context_summary_keeps_user_and_assistant_messages() { + fn build_goal_context_ai_messages_keeps_full_user_and_assistant_messages() { + let long_assistant = format!("{}END", "x".repeat(1200)); let messages = vec![ Message::user("Implement /goal".to_string()), - Message::assistant("Working on it".to_string()), + Message::assistant(long_assistant.clone()), + ]; + let converted = build_goal_context_ai_messages(&messages); + assert_eq!(converted.len(), 2); + assert_eq!(converted[0].content.as_deref(), Some("Implement /goal")); + assert_eq!(converted[1].content.as_deref(), Some(long_assistant.as_str())); + } + + #[test] + fn last_assistant_message_text_returns_latest_assistant() { + let messages = vec![ + Message::assistant("older".to_string()), + Message::user("follow up".to_string()), + Message::assistant("latest".to_string()), ]; - let summary = build_recent_context_summary(&messages, 1000); - assert!(summary.contains("Implement /goal")); - assert!(summary.contains("Working on it")); + assert_eq!( + last_assistant_message_text(&messages).as_deref(), + Some("latest") + ); } #[test] @@ -444,6 +490,20 @@ mod tests { assert!(!should_skip_goal_verification_for_turn("fix bug", None)); } + #[test] + fn user_facing_goal_mode_error_hides_ai_client_details() { + let mapped = user_facing_goal_mode_error(BitFunError::AIClient( + "provider timeout".to_string(), + )); + match mapped { + BitFunError::Validation(message) => { + assert!(!message.contains("provider timeout")); + assert!(message.contains("Goal mode AI request failed")); + } + other => panic!("unexpected error: {other:?}"), + } + } + #[test] fn continuation_plan_includes_goal_text() { let state = GoalModeState { diff --git a/src/crates/core/src/agentic/session/file_read_state.rs b/src/crates/core/src/agentic/session/file_read_state.rs index d800547bc..14eb74920 100644 --- a/src/crates/core/src/agentic/session/file_read_state.rs +++ b/src/crates/core/src/agentic/session/file_read_state.rs @@ -18,7 +18,60 @@ pub struct FileReadState { impl FileReadState { pub fn is_full_file_read(&self) -> bool { - !self.is_partial_view && self.start_line == 1 && self.end_line >= self.total_lines + if self.is_partial_view { + return false; + } + + if self.total_lines == 0 { + return self.start_line == 0 && self.end_line == 0; + } + + self.start_line == 1 && self.end_line >= self.total_lines + } +} + +#[cfg(test)] +mod tests { + use super::FileReadState; + + fn sample_state( + start_line: usize, + end_line: usize, + total_lines: usize, + is_partial_view: bool, + ) -> FileReadState { + FileReadState { + content: String::new(), + timestamp_ms: 0, + start_line, + end_line, + total_lines, + is_partial_view, + } + } + + #[test] + fn is_full_file_read_accepts_nonempty_whole_file() { + let state = sample_state(1, 10, 10, false); + assert!(state.is_full_file_read()); + } + + #[test] + fn is_full_file_read_rejects_partial_view() { + let state = sample_state(1, 10, 10, true); + assert!(!state.is_full_file_read()); + } + + #[test] + fn is_full_file_read_accepts_empty_file_from_read_tool() { + let state = sample_state(0, 0, 0, false); + assert!(state.is_full_file_read()); + } + + #[test] + fn is_full_file_read_rejects_empty_file_with_one_based_range() { + let state = sample_state(1, 0, 0, false); + assert!(!state.is_full_file_read()); } } diff --git a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs index 0dc4ad9f4..f4348ba44 100644 --- a/src/crates/core/src/agentic/tools/file_read_state_runtime.rs +++ b/src/crates/core/src/agentic/tools/file_read_state_runtime.rs @@ -66,26 +66,48 @@ pub async fn validate_edit_against_read_state( )); } - let current_content = read_current_file_content(context, resolved).await.ok()?; + let current_content = match read_current_file_content(context, resolved).await { + Ok(content) => content, + Err(error) => { + return Some(format!( + "File {} could not be re-read before editing ({}). Read it again when the workspace is available.", + resolved.logical_path, error + )); + } + }; let current_mtime_ms = file_modification_time_ms(context, resolved).await; + validate_content_freshness_against_read_state( + &resolved.logical_path, + &read_state, + ¤t_content, + current_mtime_ms, + ) +} + +fn validate_content_freshness_against_read_state( + logical_path: &str, + read_state: &FileReadState, + current_content: &str, + current_mtime_ms: Option, +) -> Option { if let Some(current_mtime_ms) = current_mtime_ms { if current_mtime_ms > read_state.timestamp_ms { if read_state.is_full_file_read() - && normalize_string(¤t_content) == normalize_string(&read_state.content) + && normalize_string(current_content) == normalize_string(&read_state.content) { return None; } return Some(format!( "File {} has been modified since it was last read (by the user, another tool, or a linter). Read it again before editing.", - resolved.logical_path + logical_path )); } - } else if normalize_string(¤t_content) != normalize_string(&read_state.content) { + } else if normalize_string(current_content) != normalize_string(&read_state.content) { return Some(format!( "File {} no longer matches the last Read result. Read it again before editing.", - resolved.logical_path + logical_path )); } @@ -126,12 +148,17 @@ pub fn update_file_read_state_after_mutation( return; }; - let line_count = content.lines().count().max(1); + let line_count = content.lines().count(); + let (start_line, end_line) = if line_count == 0 { + (0, 0) + } else { + (1, line_count) + }; let state = FileReadState { content: content.to_string(), timestamp_ms, - start_line: 1, - end_line: line_count, + start_line, + end_line, total_lines: line_count, is_partial_view: false, }; @@ -216,6 +243,7 @@ pub fn local_file_modification_time_ms(path: &Path) -> u64 { #[cfg(test)] mod tests { use super::*; + use crate::agentic::session::FileReadState; use crate::agentic::tools::framework::{ToolPathBackend, ToolUseContext}; use crate::agentic::WorkspaceBinding; use std::collections::HashMap; @@ -272,4 +300,85 @@ mod tests { ) .is_none()); } + + fn read_state(content: &str, timestamp_ms: u64) -> FileReadState { + FileReadState { + content: content.to_string(), + timestamp_ms, + start_line: 1, + end_line: 1, + total_lines: 1, + is_partial_view: false, + } + } + + #[test] + fn validate_content_freshness_allows_matching_remote_content_without_mtime() { + let state = read_state("alpha\n", 100); + + assert!(validate_content_freshness_against_read_state( + "src/main.rs", + &state, + "alpha\n", + None, + ) + .is_none()); + } + + #[test] + fn validate_content_freshness_rejects_changed_remote_content_without_mtime() { + let state = read_state("alpha\n", 100); + + assert_eq!( + validate_content_freshness_against_read_state( + "src/main.rs", + &state, + "beta\n", + None, + ) + .as_deref(), + Some( + "File src/main.rs no longer matches the last Read result. Read it again before editing." + ) + ); + } + + #[test] + fn validate_content_freshness_allows_newer_mtime_when_full_read_content_matches() { + let state = read_state("alpha\n", 100); + + assert!(validate_content_freshness_against_read_state( + "src/main.rs", + &state, + "alpha\n", + Some(200), + ) + .is_none()); + } + + #[test] + fn validate_content_freshness_rejects_newer_mtime_when_content_differs() { + let state = read_state("alpha\n", 100); + + assert!(validate_content_freshness_against_read_state( + "src/main.rs", + &state, + "beta\n", + Some(200), + ) + .is_some()); + } + + #[test] + fn validate_content_freshness_ignores_older_mtime_even_when_content_differs() { + let state = read_state("alpha\n", 200); + + assert!(validate_content_freshness_against_read_state( + "src/main.rs", + &state, + "beta\n", + Some(100), + ) + .is_none()); + } } diff --git a/src/crates/tool-runtime/src/util/read_line_prefix.rs b/src/crates/tool-runtime/src/util/read_line_prefix.rs index 712fdac38..32cbaac10 100644 --- a/src/crates/tool-runtime/src/util/read_line_prefix.rs +++ b/src/crates/tool-runtime/src/util/read_line_prefix.rs @@ -32,7 +32,7 @@ pub fn strip_read_line_number_prefix(line: &str) -> String { /// Convert Read-tool cat -n output into raw file content (one line at a time). pub fn read_tool_output_to_file_content(formatted: &str) -> String { formatted - .split('\n') + .lines() .map(strip_read_line_number_prefix) .collect::>() .join("\n") @@ -81,6 +81,14 @@ mod tests { ); } + #[test] + fn read_tool_output_to_file_content_strips_crlf_line_endings() { + assert_eq!( + read_tool_output_to_file_content(" 1\talpha\r\n 2\tbeta\r\n"), + "alpha\nbeta" + ); + } + #[test] fn all_lines_have_read_prefix_requires_every_line() { assert!(all_lines_have_read_prefix(" 1\ta\n 2\tb")); diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 219496ca2..da95acf9d 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -1587,6 +1587,9 @@ export const ChatInput: React.FC = ({ userHint: parsed.userHint, failedTitle: t('chatInput.goalFailed', { defaultValue: 'Goal mode activation failed' }), unknownErrorMessage: t('error.unknown'), + aiFailedMessage: t('chatInput.goalAiFailed', { + defaultValue: 'Goal mode AI request failed. Check model configuration and try again.', + }), activatedTitle: t('chatInput.goalActivated', { defaultValue: 'Session goal activated' }), }); diff --git a/src/web-ui/src/flow_chat/services/goalService.ts b/src/web-ui/src/flow_chat/services/goalService.ts index 81f0cc61b..d50a931ff 100644 --- a/src/web-ui/src/flow_chat/services/goalService.ts +++ b/src/web-ui/src/flow_chat/services/goalService.ts @@ -10,6 +10,7 @@ export interface GoalCommandParams { userHint?: string; failedTitle: string; unknownErrorMessage: string; + aiFailedMessage: string; activatedTitle: string; } @@ -59,19 +60,37 @@ export async function runGoalCommand(params: GoalCommandParams): Promise { try { return await runGoalCommand(params); } catch (error) { - notificationService.error( - error instanceof Error ? error.message : params.unknownErrorMessage, - { - title: params.failedTitle, - duration: 5000, - } - ); + notificationService.error(resolveGoalCommandError(error, params), { + title: params.failedTitle, + duration: 5000, + }); return null; } } diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 8df75e3cc..3d4068ac0 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -496,6 +496,7 @@ "goalNoSession": "No active session for /goal", "goalUsage": "Use /goal with optional focus text, for example /goal fix the login bug.", "goalFailed": "Goal mode activation failed", + "goalAiFailed": "Goal mode AI request failed. Check model configuration and try again.", "goalActivated": "Session goal activated", "goalNestedDisabled": "Goal mode can only be started from the main session.", "initAction": "Generate AGENTS.md", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 7b9022c38..2d8664c45 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -490,6 +490,7 @@ "goalNoSession": "当前没有可用于 /goal 的会话", "goalUsage": "使用 /goal 并可选择附加目标描述,例如 /goal 修复登录问题。", "goalFailed": "目标模式激活失败", + "goalAiFailed": "目标模式 AI 请求失败,请检查模型配置后重试。", "goalActivated": "会话目标已激活", "goalNestedDisabled": "目标模式只能从主会话启动。", "initAction": "生成 AGENTS.md", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index b90272a41..e94fd926c 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -490,6 +490,7 @@ "goalNoSession": "當前沒有可用於 /goal 的會話", "goalUsage": "使用 /goal 並可選擇附加目標描述,例如 /goal 修復登入問題。", "goalFailed": "目標模式啟用失敗", + "goalAiFailed": "目標模式 AI 請求失敗,請檢查模型配置後重試。", "goalActivated": "會話目標已啟用", "goalNestedDisabled": "目標模式只能從主會話啟動。", "initAction": "生成 AGENTS.md", diff --git a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts index c90404d3d..e90973763 100644 --- a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts +++ b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts @@ -125,10 +125,6 @@ export function joinWorkspaceTargetPath(dir: string, fileName: string, remote = return `${base}${sep}${fileName}`; } -export function getParentPathFromFile(filePath: string): string { - return dirnameAbsolutePath(filePath); -} - export function resolveExplorerDropTargetDirectory( clientX: number, clientY: number,