From 7aab283b7e9b60efca93d454fc14ada363f7846f Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Mon, 30 Mar 2026 21:38:00 +0800 Subject: [PATCH] refactor: unify session title generation and persistence - route session title generation through a single backend path for AI and fallback results - persist manual title updates via backend API and avoid frontend-only metadata sync - prevent late auto-title updates from overwriting manually edited session titles - sanitize plain title outputs to strip leaked think markup before applying titles - use session-title-func-agent for title generation and seed its default config mapping --- src/apps/desktop/src/api/agentic_api.rs | 57 ++++ src/apps/desktop/src/lib.rs | 1 + .../src/agentic/coordination/coordinator.rs | 127 +++++---- .../src/agentic/session/session_manager.rs | 266 +++++++++++++++--- src/crates/core/src/service/config/manager.rs | 14 +- src/crates/core/src/service/config/types.rs | 7 +- src/crates/core/src/util/mod.rs | 2 + src/crates/core/src/util/plain_output.rs | 74 +++++ .../sections/sessions/SessionsSection.tsx | 2 +- src/web-ui/src/flow_chat/hooks/useFlowChat.ts | 12 +- .../src/flow_chat/services/FlowChatManager.ts | 5 + .../flow-chat-manager/EventHandlerModule.ts | 2 +- .../flow-chat-manager/MessageModule.ts | 12 +- .../flow-chat-manager/SessionModule.ts | 27 ++ .../services/flow-chat-manager/index.ts | 3 +- .../src/flow_chat/store/FlowChatStore.ts | 35 --- .../api/service-api/AgentAPI.ts | 16 ++ 17 files changed, 518 insertions(+), 144 deletions(-) create mode 100644 src/crates/core/src/util/plain_output.rs diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 5c753959..e842a043 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -60,6 +60,18 @@ pub struct UpdateSessionModelRequest { pub model_name: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateSessionTitleRequest { + pub session_id: String, + pub title: String, + pub workspace_path: Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartDialogTurnRequest { @@ -278,6 +290,51 @@ pub async fn update_session_model( .map_err(|e| format!("Failed to update session model: {}", e)) } +#[tauri::command] +pub async fn update_session_title( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: UpdateSessionTitleRequest, +) -> 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 renaming: {}", e))?; + } + + coordinator + .update_session_title(session_id, &request.title) + .await + .map_err(|e| format!("Failed to update session title: {}", e)) +} + /// Load the session into the coordinator process when it exists on disk but is not in memory. /// Uses the same remote→local session path mapping as `restore_session`. #[tauri::command] diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 2aaa2bbf..4110d00e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -313,6 +313,7 @@ pub async fn run() { theme::show_main_window, api::agentic_api::create_session, api::agentic_api::update_session_model, + api::agentic_api::update_session_title, api::agentic_api::ensure_coordinator_session, api::agentic_api::start_dialog_turn, api::agentic_api::compact_session, diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 3847be10..8b7f4c7e 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -124,21 +124,23 @@ impl ConversationCoordinator { let workspace_path = config.workspace_path.as_ref()?; let path_buf = PathBuf::from(workspace_path); - let identity = crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - config.remote_connection_id.as_deref(), - config.remote_ssh_host.as_deref(), - ) - .await?; - - if let Some(rid) = identity.remote_connection_id.as_deref() { - let connection_name = crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( + let identity = + crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( workspace_path, - Some(rid), + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), ) - .await - .map(|e| e.connection_name) - .unwrap_or_else(|| rid.to_string()); + .await?; + + if let Some(rid) = identity.remote_connection_id.as_deref() { + let connection_name = + crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( + workspace_path, + Some(rid), + ) + .await + .map(|e| e.connection_name) + .unwrap_or_else(|| rid.to_string()); return Some(WorkspaceBinding::new_remote( None, @@ -577,7 +579,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet use crate::agentic::core::SessionConfig; use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; - use crate::service::session::{DialogTurnData, SessionMetadata, SessionStatus, UserMessageData}; + use crate::service::session::{ + DialogTurnData, SessionMetadata, SessionStatus, UserMessageData, + }; let path_manager = match PathManager::new() { Ok(pm) => std::sync::Arc::new(pm), @@ -1413,37 +1417,36 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let eq = self.event_queue.clone(); let sid = session_id.clone(); let msg = original_user_input; + let expected_title = self + .session_manager + .get_session(&session_id) + .map(|session| session.session_name) + .unwrap_or_default(); tokio::spawn(async move { - let enabled = match crate::service::config::get_global_config_service().await { - Ok(svc) => svc - .get_config::(Some( - "app.ai_experience.enable_session_title_generation", - )) - .await - .unwrap_or(true), - Err(_) => true, - }; - if !enabled { - return; - } - match sm.generate_session_title(&msg, Some(20)).await { - Ok(title) => { - if let Err(e) = sm.update_session_title(&sid, &title).await { - debug!("Failed to persist auto-generated title: {e}"); - } + let allow_ai = is_ai_session_title_generation_enabled().await; + let resolved = sm.resolve_session_title(&msg, Some(20), allow_ai).await; + + match sm + .update_session_title_if_current(&sid, &expected_title, &resolved.title) + .await + { + Ok(true) => { let _ = eq .enqueue( AgenticEvent::SessionTitleGenerated { session_id: sid, - title, - method: "ai".to_string(), + title: resolved.title, + method: resolved.method.as_str().to_string(), }, Some(EventPriority::Normal), ) .await; } - Err(e) => { - debug!("Auto session title generation failed: {e}"); + Ok(false) => { + debug!("Skipped auto session title update because title changed"); + } + Err(error) => { + debug!("Auto session title generation failed to apply: {error}"); } } }); @@ -2077,32 +2080,48 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_message: &str, max_length: Option, ) -> BitFunResult { - let title = self + let allow_ai = is_ai_session_title_generation_enabled().await; + let resolved = self .session_manager - .generate_session_title(user_message, max_length) - .await?; + .resolve_session_title(user_message, max_length, allow_ai) + .await; - if let Err(e) = self - .session_manager - .update_session_title(session_id, &title) - .await - { - debug!("Failed to persist generated title: {e}"); - } + self.session_manager + .update_session_title(session_id, &resolved.title) + .await?; let event = AgenticEvent::SessionTitleGenerated { session_id: session_id.to_string(), - title: title.clone(), - method: "ai".to_string(), + title: resolved.title.clone(), + method: resolved.method.as_str().to_string(), }; self.emit_event(event).await; debug!( "Session title generation event sent: session_id={}, title={}", - session_id, title + session_id, resolved.title ); - Ok(title) + Ok(resolved.title) + } + + pub async fn update_session_title( + &self, + session_id: &str, + title: &str, + ) -> BitFunResult { + let normalized = title.trim().to_string(); + if normalized.is_empty() { + return Err(BitFunError::validation( + "Session title must not be empty".to_string(), + )); + } + + self.session_manager + .update_session_title(session_id, &normalized) + .await?; + + Ok(normalized) } /// Emit event @@ -2159,6 +2178,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } +async fn is_ai_session_title_generation_enabled() -> bool { + match crate::service::config::get_global_config_service().await { + Ok(service) => service + .get_config::(Some("app.ai_experience.enable_session_title_generation")) + .await + .unwrap_or(true), + Err(_) => true, + } +} + // Global coordinator singleton static GLOBAL_COORDINATOR: OnceLock> = OnceLock::new(); diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 36ff5718..abaa5dd2 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -16,6 +16,7 @@ use crate::service::session::{ }; use crate::service::snapshot::ensure_snapshot_manager_for_workspace; use crate::util::errors::{BitFunError, BitFunResult}; +use crate::util::sanitize_plain_model_output; use dashmap::DashMap; use log::{debug, error, info, warn}; use serde_json::json; @@ -44,6 +45,27 @@ impl Default for SessionManagerConfig { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionTitleMethod { + Ai, + Fallback, +} + +impl SessionTitleMethod { + pub fn as_str(self) -> &'static str { + match self { + Self::Ai => "ai", + Self::Fallback => "fallback", + } + } +} + +#[derive(Debug, Clone)] +pub struct ResolvedSessionTitle { + pub title: String, + pub method: SessionTitleMethod, +} + /// Session manager pub struct SessionManager { /// Active sessions in memory @@ -58,6 +80,67 @@ pub struct SessionManager { } impl SessionManager { + fn normalize_session_title_input(title: &str) -> BitFunResult { + let trimmed = title.trim(); + if trimmed.is_empty() { + return Err(BitFunError::validation( + "Session title must not be empty".to_string(), + )); + } + + Ok(trimmed.to_string()) + } + + fn normalize_whitespace(value: &str) -> String { + value.split_whitespace().collect::>().join(" ") + } + + fn truncate_chars(value: &str, max_length: usize) -> String { + value.chars().take(max_length).collect() + } + + fn fallback_session_title(user_message: &str, max_length: usize) -> String { + let max_length = max_length.max(1); + let normalized = Self::normalize_whitespace(user_message); + + if normalized.is_empty() { + return Self::truncate_chars("New Session", max_length); + } + + let truncated_chars: Vec = normalized.chars().take(max_length).collect(); + if normalized.chars().count() <= max_length { + return truncated_chars.iter().collect(); + } + + let sentence_break_chars = ['。', '!', '?', ';', '.', '!', '?']; + let break_chars = ['。', '!', '?', ';', '.', '!', '?', ',', ',', ' ']; + let min_break_index = max_length / 2; + let mut best_break_index: Option = None; + + for (idx, ch) in truncated_chars.iter().enumerate() { + if break_chars.contains(ch) && idx > min_break_index { + best_break_index = Some(idx); + } + } + + if let Some(idx) = best_break_index { + let candidate: String = truncated_chars[..=idx].iter().collect(); + if candidate + .chars() + .last() + .map(|ch| sentence_break_chars.contains(&ch)) + .unwrap_or(false) + { + return candidate; + } + + return format!("{}...", candidate.trim_end()); + } + + let truncated: String = truncated_chars.iter().collect(); + format!("{truncated}...") + } + fn paginate_messages( messages: &[Message], limit: usize, @@ -90,14 +173,17 @@ impl SessionManager { /// Resolve the effective storage path for a session's workspace. async fn effective_workspace_path_from_config(config: &SessionConfig) -> Option { let workspace_path = config.workspace_path.as_ref()?; - let identity = crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( - workspace_path, - config.remote_connection_id.as_deref(), - config.remote_ssh_host.as_deref(), - ) - .await?; + let identity = + crate::service::remote_ssh::workspace_state::resolve_workspace_session_identity( + workspace_path, + config.remote_connection_id.as_deref(), + config.remote_ssh_host.as_deref(), + ) + .await?; - if identity.hostname == crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST { + if identity.hostname + == crate::service::remote_ssh::workspace_state::LOCAL_WORKSPACE_SSH_HOST + { Some(PathBuf::from(identity.workspace_path)) } else if identity.hostname == "_unresolved" { Some( @@ -418,32 +504,75 @@ impl SessionManager { /// Update session title (in-memory + persistence) pub async fn update_session_title(&self, session_id: &str, title: &str) -> BitFunResult<()> { + let normalized_title = Self::normalize_session_title_input(title)?; let workspace_path = self.effective_session_workspace_path(session_id).await; - if let Some(mut session) = self.sessions.get_mut(session_id) { - session.session_name = title.to_string(); + { + let Some(mut session) = self.sessions.get_mut(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + session.session_name = normalized_title.clone(); session.updated_at = SystemTime::now(); session.last_activity_at = SystemTime::now(); } if self.config.enable_persistence { - if let (Some(workspace_path), Some(session)) = - (workspace_path.as_ref(), self.sessions.get(session_id)) - { - self.persistence_manager - .save_session(workspace_path, &session) - .await?; - } + let Some(workspace_path) = workspace_path.as_ref() else { + return Err(BitFunError::Session(format!( + "Workspace path is unavailable for session {}", + session_id + ))); + }; + let Some(session) = self.sessions.get(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + self.persistence_manager + .save_session(workspace_path, &session) + .await?; } info!( "Session title updated: session_id={}, title={}", - session_id, title + session_id, normalized_title ); Ok(()) } + pub async fn update_session_title_if_current( + &self, + session_id: &str, + expected_current_title: &str, + title: &str, + ) -> BitFunResult { + let Some(session) = self.sessions.get(session_id) else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + }; + + if session.session_name != expected_current_title { + debug!( + "Skipping auto-generated title because current title changed: session_id={}, expected_title={}, current_title={}", + session_id, + expected_current_title, + session.session_name + ); + return Ok(false); + } + drop(session); + + self.update_session_title(session_id, title).await?; + Ok(true) + } + /// Update session agent type (in-memory + persistence) pub async fn update_session_agent_type( &self, @@ -1452,18 +1581,13 @@ impl SessionManager { } } - /// Generate session title - /// - /// Generate a concise and accurate session title based on user message content using AI - pub async fn generate_session_title( + async fn try_generate_session_title_with_ai( &self, user_message: &str, - max_length: Option, - ) -> BitFunResult { + max_length: usize, + ) -> BitFunResult> { use crate::util::types::Message; - let max_length = max_length.unwrap_or(20); - // Match agent `LANGUAGE_PREFERENCE`: use `app.language`, not I18nService (see `app_language` module). let lang_code = get_app_language_code().await; let language_instruction = short_model_user_language_instruction(lang_code.as_str()); @@ -1517,7 +1641,7 @@ impl SessionManager { })?; let ai_client = ai_client_factory - .get_client_resolved("fast") + .get_client_by_func_agent("session-title-func-agent") .await .map_err(|e| BitFunError::AIClient(format!("Failed to get AI client: {}", e)))?; @@ -1526,14 +1650,10 @@ impl SessionManager { .await .map_err(|e| BitFunError::ai(format!("AI call failed: {}", e)))?; - let title = response.text.trim().to_string(); - - // If title is empty, use default title - let title = if title.is_empty() { - "New Session".to_string() - } else { - title - }; + let title = sanitize_plain_model_output(&response.text); + if title.is_empty() { + return Ok(None); + } // Truncate title let final_title = if title.chars().count() > max_length { @@ -1542,7 +1662,56 @@ impl SessionManager { title }; - Ok(final_title) + Ok(Some(final_title)) + } + + /// Generate a concise session title, using AI first and falling back to a local heuristic. + pub async fn resolve_session_title( + &self, + user_message: &str, + max_length: Option, + allow_ai: bool, + ) -> ResolvedSessionTitle { + let max_length = max_length.unwrap_or(20).max(1); + + if allow_ai { + match self + .try_generate_session_title_with_ai(user_message, max_length) + .await + { + Ok(Some(title)) => { + return ResolvedSessionTitle { + title, + method: SessionTitleMethod::Ai, + }; + } + Ok(None) => { + warn!("AI session title generation returned empty output; using fallback"); + } + Err(error) => { + warn!("AI session title generation failed; using fallback: {error}"); + } + } + } + + ResolvedSessionTitle { + title: Self::fallback_session_title(user_message, max_length), + method: SessionTitleMethod::Fallback, + } + } + + /// Generate session title + /// + /// Generate a concise and accurate session title based on user message content. + pub async fn generate_session_title( + &self, + user_message: &str, + max_length: Option, + ) -> BitFunResult { + Ok(self + .resolve_session_title(user_message, max_length, true) + .await + .title) } // ============ Background Tasks ============ @@ -1664,4 +1833,31 @@ mod tests { assert_eq!(messages.len(), 1); assert!(messages[0].is_actual_user_message()); } + + #[test] + fn fallback_session_title_uses_sentence_break_when_available() { + let title = SessionManager::fallback_session_title( + "Fix the flaky integration test. Add logging for retries.", + 20, + ); + + assert_eq!(title, "Fix the flaky..."); + } + + #[test] + fn fallback_session_title_appends_ellipsis_when_truncated_without_sentence_break() { + let title = SessionManager::fallback_session_title( + "Implement session title generation fallback", + 12, + ); + + assert_eq!(title, "Implement..."); + } + + #[test] + fn fallback_session_title_uses_default_for_blank_input() { + let title = SessionManager::fallback_session_title(" ", 20); + + assert_eq!(title, "New Session"); + } } diff --git a/src/crates/core/src/service/config/manager.rs b/src/crates/core/src/service/config/manager.rs index f6700317..6d9d8297 100644 --- a/src/crates/core/src/service/config/manager.rs +++ b/src/crates/core/src/service/config/manager.rs @@ -210,7 +210,12 @@ impl ConfigManager { fn add_default_func_agent_models_config( func_agent_models: &mut std::collections::HashMap, ) { - let func_agents_using_fast = vec!["compression", "startchat-func-agent", "git-func-agent"]; + let func_agents_using_fast = vec![ + "compression", + "startchat-func-agent", + "session-title-func-agent", + "git-func-agent", + ]; for key in func_agents_using_fast { if !func_agent_models.contains_key(key) { func_agent_models.insert(key.to_string(), "fast".to_string()); @@ -635,7 +640,12 @@ pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> BitFunResult { ai.insert("sub_agent_models".to_string(), serde_json::json!({})); } if !ai.contains_key("func_agent_models") { - let func_keys = ["compression", "startchat-func-agent", "git-func-agent"]; + let func_keys = [ + "compression", + "startchat-func-agent", + "session-title-func-agent", + "git-func-agent", + ]; let mut fa = serde_json::Map::new(); if let Some(am) = ai.get("agent_models").and_then(|v| v.as_object()) { for k in func_keys { diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 6e19d408..87514fbb 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -375,7 +375,7 @@ pub struct AIConfig { /// agent_type -> model_id pub agent_models: HashMap, - /// Model mapping for functional agents (e.g. startchat-func-agent, git-func-agent). + /// Model mapping for functional agents (e.g. startchat-func-agent, session-title-func-agent). /// func_agent_name -> model_id #[serde(default)] pub func_agent_models: HashMap, @@ -1355,7 +1355,10 @@ impl AIModelConfig { match self.category { ModelCategory::GeneralChat => vec![ModelCapability::TextChat], ModelCategory::Multimodal => { - vec![ModelCapability::TextChat, ModelCapability::ImageUnderstanding] + vec![ + ModelCapability::TextChat, + ModelCapability::ImageUnderstanding, + ] } ModelCategory::ImageGeneration => vec![ModelCapability::ImageGeneration], ModelCategory::Embedding => vec![ModelCapability::Embedding], diff --git a/src/crates/core/src/util/mod.rs b/src/crates/core/src/util/mod.rs index 9422a8c7..47595a42 100644 --- a/src/crates/core/src/util/mod.rs +++ b/src/crates/core/src/util/mod.rs @@ -4,6 +4,7 @@ pub mod errors; pub mod front_matter_markdown; pub mod json_checker; pub mod json_extract; +pub mod plain_output; pub mod process_manager; pub mod token_counter; pub mod types; @@ -12,6 +13,7 @@ pub use errors::*; pub use front_matter_markdown::FrontMatterMarkdown; pub use json_checker::JsonChecker; pub use json_extract::extract_json_from_ai_response; +pub use plain_output::sanitize_plain_model_output; pub use process_manager::*; pub use token_counter::*; pub use types::*; diff --git a/src/crates/core/src/util/plain_output.rs b/src/crates/core/src/util/plain_output.rs new file mode 100644 index 00000000..66695ee9 --- /dev/null +++ b/src/crates/core/src/util/plain_output.rs @@ -0,0 +1,74 @@ +//! Helpers for sanitizing plain-text model outputs in contexts that must not +//! include reasoning markup. + +const THINK_OPEN_TAG: &str = ""; +const THINK_CLOSE_TAG: &str = ""; + +/// Remove reasoning markup from model output intended to be consumed as plain text. +/// +/// Rules: +/// - Remove every complete `...` block. +/// - If a `` block is opened but never closed, discard everything from the +/// opening tag to the end of the string. +/// - If a dangling `` remains, keep only the content after the last one. +/// - Trim surrounding whitespace in the final result. +pub fn sanitize_plain_model_output(raw: &str) -> String { + let mut cleaned = raw.to_string(); + + loop { + let Some(open_idx) = cleaned.find(THINK_OPEN_TAG) else { + break; + }; + let content_start = open_idx + THINK_OPEN_TAG.len(); + + if let Some(relative_close_idx) = cleaned[content_start..].find(THINK_CLOSE_TAG) { + let close_end = content_start + relative_close_idx + THINK_CLOSE_TAG.len(); + cleaned.replace_range(open_idx..close_end, ""); + } else { + cleaned.truncate(open_idx); + break; + } + } + + if let Some((_, suffix)) = cleaned.rsplit_once(THINK_CLOSE_TAG) { + cleaned = suffix.to_string(); + } + + cleaned.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::sanitize_plain_model_output; + + #[test] + fn strips_complete_leading_think_block() { + let result = sanitize_plain_model_output("internalreal content"); + assert_eq!(result, "real content"); + } + + #[test] + fn strips_multiple_think_blocks() { + let result = + sanitize_plain_model_output("firstrealsecond content"); + assert_eq!(result, "real content"); + } + + #[test] + fn strips_prefix_before_dangling_closing_think_tag() { + let result = sanitize_plain_model_output("internal chainreal content"); + assert_eq!(result, "real content"); + } + + #[test] + fn drops_unclosed_think_block_tail() { + let result = sanitize_plain_model_output("real content internal"); + assert_eq!(result, "real content"); + } + + #[test] + fn returns_empty_when_only_think_content_exists() { + let result = sanitize_plain_model_output("internal only"); + assert_eq!(result, ""); + } +} diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 27618f36..4da6ede8 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -327,7 +327,7 @@ const SessionsSection: React.FC = ({ const trimmed = editingTitle.trim(); if (trimmed) { try { - await flowChatStore.updateSessionTitle(editingSessionId, trimmed, 'generated'); + await flowChatManager.renameChatSessionTitle(editingSessionId, trimmed); } catch (err) { log.error('Failed to update session title', err); } diff --git a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts index f6fb07c6..ff86667d 100644 --- a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts +++ b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts @@ -15,7 +15,6 @@ import { import { flowChatStore } from '../store/FlowChatStore'; import { flowChatManager } from '../services/FlowChatManager'; import type { UnlistenFn } from '@tauri-apps/api/event'; -import { aiExperienceConfigService } from '@/infrastructure/config/services'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { useI18n } from '@/infrastructure/i18n'; import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; @@ -257,19 +256,14 @@ export const useFlowChat = () => { startTime: Date.now() }; - const isFirstMessage = session && session.dialogTurns.length === 0 && !session.title; + const isFirstMessage = + session && session.dialogTurns.length === 0 && session.titleStatus !== 'generated'; flowChatStore.addDialogTurn(targetSessionId, dialogTurn); if (isFirstMessage) { const tempTitle = generateTempTitle(content, 20); - if (aiExperienceConfigService.isSessionTitleGenerationEnabled()) { - // Set temp title while waiting for coordinator's auto-generated AI title - // (delivered via SessionTitleGenerated event). - flowChatStore.updateSessionTitle(targetSessionId, tempTitle, 'generating'); - } else { - flowChatStore.updateSessionTitle(targetSessionId, tempTitle, 'generated'); - } + flowChatStore.updateSessionTitle(targetSessionId, tempTitle, 'generating'); } return dialogTurnId; diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index cddbb607..84e2918f 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -27,6 +27,7 @@ import { createChatSession as createChatSessionModule, switchChatSession as switchChatSessionModule, deleteChatSession as deleteChatSessionModule, + renameChatSessionTitle as renameChatSessionTitleModule, cleanupSaveState, cleanupSessionBuffers, sendMessage as sendMessageModule, @@ -191,6 +192,10 @@ export class FlowChatManager { return deleteChatSessionModule(this.context, sessionId); } + async renameChatSessionTitle(sessionId: string, title: string): Promise { + return renameChatSessionTitleModule(this.context, sessionId, title); + } + async resetWorkspaceSessions( workspace: Pick, options?: { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index a2918e30..1921ae46 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -465,7 +465,7 @@ function finalizePendingTurnCompletionNow(context: FlowChatContext, sessionId: s } /** - * Handle session title generated event (from AI auto-generation) + * Handle session title generated event (AI or fallback auto-generation) */ function handleSessionTitleGenerated(event: any): void { const { sessionId, title } = event; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 11e1f684..326ab915 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -5,7 +5,6 @@ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import { aiExperienceConfigService } from '@/infrastructure/config/services'; import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { notificationService } from '../../../shared/notification-system'; import { stateMachineManager } from '../../state-machine'; @@ -268,14 +267,9 @@ function handleTitleGeneration( message: string ): void { const tempTitle = generateTempTitle(message, 20); - - if (aiExperienceConfigService.isSessionTitleGenerationEnabled()) { - // Set temp title while waiting for coordinator's auto-generated AI title - // (delivered via SessionTitleGenerated event). - context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); - } else { - context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generated'); - } + // Show a readable placeholder immediately; backend later confirms the + // authoritative title via AI or local fallback generation. + context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); } export async function cancelCurrentTask(context: FlowChatContext): Promise { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 6e2e9540..b998a927 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -337,6 +337,33 @@ export async function deleteChatSession( } } +export async function renameChatSessionTitle( + context: FlowChatContext, + sessionId: string, + title: string +): Promise { + const session = context.flowChatStore.getState().sessions.get(sessionId); + if (!session) { + throw new Error(`Session does not exist: ${sessionId}`); + } + + const trimmedTitle = title.trim(); + if (!trimmedTitle) { + throw new Error('Session title must not be empty'); + } + + const updatedTitle = await agentAPI.updateSessionTitle({ + sessionId, + title: trimmedTitle, + workspacePath: session.workspacePath, + remoteConnectionId: session.remoteConnectionId, + remoteSshHost: session.remoteSshHost, + }); + + await context.flowChatStore.updateSessionTitle(sessionId, updatedTitle, 'generated'); + return updatedTitle; +} + /** * Ensure backend session exists (check before sending message) */ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index e460b40d..0b8a6bf8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -39,7 +39,8 @@ export { getModelMaxTokens, createChatSession, switchChatSession, - deleteChatSession + deleteChatSession, + renameChatSessionTitle } from './SessionModule'; export { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index d88640d9..42a4cac7 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -18,7 +18,6 @@ import { createLogger } from '@/shared/utils/logger'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import type { SessionKind } from '@/shared/types/session-history'; import { - buildSessionMetadata, deriveLastFinishedAtFromMetadata, deriveSessionRelationshipFromMetadata, normalizeSessionRelationship, @@ -1268,40 +1267,6 @@ export class FlowChatStore { sessions: newSessions }; }); - - if (status === 'generated') { - try { - const { sessionAPI } = await import('@/infrastructure/api'); - const session = this.state.sessions.get(sessionId); - if (!session) { - log.warn('Session not found, skipping title sync', { sessionId }); - return; - } - - const workspacePath = session.workspacePath; - if (!workspacePath) { - log.warn('Workspace path not available, skipping title sync', { sessionId }); - return; - } - - const metadata = await sessionAPI.loadSessionMetadata( - sessionId, - workspacePath, - session.remoteConnectionId, - session.remoteSshHost - ); - const nextMetadata = buildSessionMetadata(session, metadata); - - await sessionAPI.saveSessionMetadata( - nextMetadata, - workspacePath, - session.remoteConnectionId, - session.remoteSshHost - ); - } catch (error) { - log.error('Failed to sync session title', { sessionId, error }); - } - } } /** 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 2629356f..310d1d05 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -101,6 +101,14 @@ export interface UpdateSessionModelRequest { modelName: string; } +export interface UpdateSessionTitleRequest { + sessionId: string; + title: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; +} + export interface ModeInfo { id: string; @@ -279,6 +287,14 @@ export class AgentAPI { } } + async updateSessionTitle(request: UpdateSessionTitleRequest): Promise { + try { + return await api.invoke('update_session_title', { request }); + } catch (error) { + throw createTauriCommandError('update_session_title', error, request); + } + } + async listSessions(