diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index e2fdb0532..ded19c5e6 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -1,6 +1,6 @@ //! Agentic API -use log::warn; +use log::{debug, warn}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{AppHandle, State}; @@ -19,6 +19,13 @@ use bitfun_core::agentic::deep_review_policy::{ }; use bitfun_core::agentic::image_analysis::ImageContextData; use bitfun_core::agentic::tools::image_context::get_image_context; +use bitfun_core::service::session::DialogTurnData; + +const SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET: usize = 512 * 1024; +const SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT: usize = 16 * 1024; +const SESSION_VIEW_TRUNCATED_MARKER: &str = "\n...[truncated for session view]"; +const SESSION_VIEW_OMITTED_MARKER: &str = "[truncated for session view]"; + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CreateSessionRequest { @@ -157,6 +164,218 @@ pub struct SessionResponse { pub created_at: u64, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionWithTurnsResponse { + pub session: SessionResponse, + pub turns: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreSessionViewResponse { + pub session: SessionResponse, + pub turns: Vec, + pub context_restore_state: String, +} + +#[derive(Debug, Default)] +struct RestoreTurnPayloadStats { + tool_result_count: usize, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, + largest_raw_result_chars: usize, + largest_raw_result_path: String, + top_raw_results: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct RestoreToolOutputStats { + tool_name: String, + path: String, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, +} + +#[derive(Debug, Default)] +struct JsonStringStats { + total_chars: usize, + largest_chars: usize, + largest_path: String, +} + +fn collect_json_string_stats(value: &serde_json::Value, path: &str, stats: &mut JsonStringStats) { + match value { + serde_json::Value::String(text) => { + let char_count = text.chars().count(); + stats.total_chars += char_count; + if char_count > stats.largest_chars { + stats.largest_chars = char_count; + stats.largest_path = path.to_string(); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_json_string_stats(item, &format!("{}[{}]", path, index), stats); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + let next_path = if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path, key) + }; + collect_json_string_stats(item, &next_path, stats); + } + } + _ => {} + } +} + +fn push_top_raw_result(stats: &mut RestoreTurnPayloadStats, result: RestoreToolOutputStats) { + if result.raw_result_string_chars == 0 { + return; + } + stats.top_raw_results.push(result); + stats.top_raw_results.sort_by(|left, right| { + right + .raw_result_string_chars + .cmp(&left.raw_result_string_chars) + .then_with(|| left.tool_name.cmp(&right.tool_name)) + }); + stats.top_raw_results.truncate(3); +} + +fn format_top_raw_results(results: &[RestoreToolOutputStats]) -> String { + results + .iter() + .map(|item| { + format!( + "{}:{}:{}:{}", + item.tool_name, + item.raw_result_string_chars, + item.result_for_assistant_chars, + item.path + ) + }) + .collect::>() + .join("|") +} + +fn restore_turn_payload_stats(turns: &[DialogTurnData]) -> RestoreTurnPayloadStats { + let mut stats = RestoreTurnPayloadStats::default(); + for turn in turns { + for (round_index, round) in turn.model_rounds.iter().enumerate() { + for (tool_index, tool) in round.tool_items.iter().enumerate() { + let Some(result) = tool.tool_result.as_ref() else { + continue; + }; + stats.tool_result_count += 1; + let assistant_chars = result + .result_for_assistant + .as_deref() + .map(|text| text.chars().count()) + .unwrap_or(0); + stats.result_for_assistant_chars += assistant_chars; + let mut json_stats = JsonStringStats::default(); + collect_json_string_stats( + &result.result, + &format!( + "turn[{}].round[{}].tool[{}].{}", + turn.turn_index, round_index, tool_index, tool.tool_name + ), + &mut json_stats, + ); + stats.raw_result_string_chars += json_stats.total_chars; + if json_stats.largest_chars > stats.largest_raw_result_chars { + stats.largest_raw_result_chars = json_stats.largest_chars; + stats.largest_raw_result_path = json_stats.largest_path.clone(); + } + push_top_raw_result( + &mut stats, + RestoreToolOutputStats { + tool_name: tool.tool_name.clone(), + path: json_stats.largest_path, + raw_result_string_chars: json_stats.total_chars, + result_for_assistant_chars: assistant_chars, + }, + ); + } + } + } + stats +} + +fn truncate_string_for_session_view(text: &str, remaining_budget: &mut usize) -> Option { + let char_count = text.chars().count(); + let available = SESSION_VIEW_TOOL_RESULT_STRING_CHAR_LIMIT.min(*remaining_budget); + + if char_count <= available { + *remaining_budget = remaining_budget.saturating_sub(char_count); + return None; + } + + let omitted_chars = SESSION_VIEW_OMITTED_MARKER.chars().count(); + if available <= omitted_chars { + *remaining_budget = remaining_budget.saturating_sub(omitted_chars.min(*remaining_budget)); + return Some(SESSION_VIEW_OMITTED_MARKER.to_string()); + } + + let suffix_chars = SESSION_VIEW_TRUNCATED_MARKER.chars().count(); + if available <= suffix_chars { + *remaining_budget = remaining_budget.saturating_sub(omitted_chars.min(*remaining_budget)); + return Some(SESSION_VIEW_OMITTED_MARKER.to_string()); + } + + let keep_chars = available - suffix_chars; + let mut preview = text.chars().take(keep_chars).collect::(); + preview.push_str(SESSION_VIEW_TRUNCATED_MARKER); + let preview_chars = preview.chars().count(); + *remaining_budget = remaining_budget.saturating_sub(preview_chars); + Some(preview) +} + +fn compact_json_for_session_view(value: &mut serde_json::Value, remaining_budget: &mut usize) { + match value { + serde_json::Value::String(text) => { + if let Some(preview) = truncate_string_for_session_view(text, remaining_budget) { + *text = preview; + } + } + serde_json::Value::Array(items) => { + for item in items { + compact_json_for_session_view(item, remaining_budget); + } + } + serde_json::Value::Object(map) => { + for item in map.values_mut() { + compact_json_for_session_view(item, remaining_budget); + } + } + _ => {} + } +} + +fn compact_tool_results_for_session_view(turns: &mut [DialogTurnData]) { + let mut remaining_budget = SESSION_VIEW_TOOL_RESULT_TOTAL_CHAR_BUDGET; + for turn in turns { + for round in &mut turn.model_rounds { + for tool in &mut round.tool_items { + if let Some(result) = tool.tool_result.as_mut() { + result.result_for_assistant = None; + compact_json_for_session_view(&mut result.result, &mut remaining_budget); + } + } + } + } +} + +#[cfg(test)] +fn omit_assistant_only_tool_results_for_session_view(turns: &mut [DialogTurnData]) { + compact_tool_results_for_session_view(turns); +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CancelDialogTurnRequest { @@ -248,6 +467,8 @@ pub struct RestoreSessionRequest { pub remote_connection_id: Option, #[serde(default)] pub remote_ssh_host: Option, + #[serde(default)] + pub trace_id: Option, } #[derive(Debug, Deserialize)] @@ -879,6 +1100,140 @@ pub async fn restore_session( Ok(session_to_response(session)) } +#[tauri::command] +pub async fn restore_session_view( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: RestoreSessionRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_view request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = std::time::Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + debug!( + "restore_session_view storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + + let (session, mut turns) = coordinator + .restore_session_view(&effective_path, &request.session_id) + .await + .map_err(|e| format!("Failed to restore session view: {}", e))?; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_view payload diagnostics: trace_id={}, session_id={}, turn_count={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } + } + + compact_tool_results_for_session_view(&mut turns); + + debug!( + "restore_session_view completed: trace_id={}, session_id={}, turn_count={}, context_restore_state=pending, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionViewResponse { + session: session_to_response(session), + turns, + context_restore_state: "pending".to_string(), + }) +} + +#[tauri::command] +pub async fn restore_session_with_turns( + coordinator: State<'_, Arc>, + app_state: State<'_, AppState>, + request: RestoreSessionRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let trace_id = request.trace_id.as_deref().unwrap_or("none"); + debug!( + "restore_session_with_turns request received: trace_id={}, session_id={}", + trace_id, request.session_id + ); + let path_started_at = std::time::Instant::now(); + let effective_path = desktop_effective_session_storage_path( + &app_state, + &request.workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await; + debug!( + "restore_session_with_turns storage path resolved: trace_id={}, session_id={}, duration_ms={}", + trace_id, + request.session_id, + path_started_at.elapsed().as_millis() + ); + let (session, turns) = coordinator + .restore_session_with_turns(&effective_path, &request.session_id) + .await + .map_err(|e| format!("Failed to restore session: {}", e))?; + + if log::log_enabled!(log::Level::Debug) { + let payload_stats = restore_turn_payload_stats(&turns); + if payload_stats.raw_result_string_chars >= 1024 * 1024 + || payload_stats.result_for_assistant_chars >= 1024 * 1024 + { + debug!( + "restore_session_with_turns payload diagnostics: trace_id={}, session_id={}, turn_count={}, tool_result_count={}, raw_result_string_chars={}, result_for_assistant_chars={}, largest_raw_result_chars={}, largest_raw_result_path={}, top_raw_results={}", + trace_id, + request.session_id, + turns.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path, + format_top_raw_results(&payload_stats.top_raw_results) + ); + } + } + + debug!( + "restore_session_with_turns completed: trace_id={}, session_id={}, turn_count={}, duration_ms={}", + trace_id, + request.session_id, + turns.len(), + started_at.elapsed().as_millis() + ); + + Ok(RestoreSessionWithTurnsResponse { + session: session_to_response(session), + turns, + }) +} + #[tauri::command] pub async fn list_sessions( coordinator: State<'_, Arc>, @@ -1059,3 +1414,228 @@ fn system_time_to_unix_secs(time: std::time::SystemTime) -> u64 { } } } + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_core::service::session::{ + ModelRoundData, ToolCallData, ToolItemData, ToolResultData, TurnStatus, UserMessageData, + }; + use serde_json::json; + + fn tool_item( + tool_name: &str, + result: serde_json::Value, + assistant: Option<&str>, + ) -> ToolItemData { + ToolItemData { + id: format!("tool-{}", tool_name), + tool_name: tool_name.to_string(), + tool_call: ToolCallData { + id: format!("call-{}", tool_name), + input: json!({}), + }, + tool_result: Some(ToolResultData { + result, + success: true, + result_for_assistant: assistant.map(str::to_string), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_model_id: None, + subagent_model_alias: None, + status: None, + interruption_reason: None, + } + } + + #[test] + fn restore_turn_payload_stats_tracks_largest_outputs_without_contents() { + let turn = DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![ + tool_item("Read", json!({ "content": "abc" }), Some("assistant")), + tool_item("Bash", json!({ "output": "x".repeat(20) }), Some("short")), + ], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }; + + let stats = restore_turn_payload_stats(&[turn]); + + assert_eq!(stats.tool_result_count, 2); + assert_eq!(stats.raw_result_string_chars, 23); + assert_eq!(stats.result_for_assistant_chars, 14); + assert_eq!(stats.largest_raw_result_chars, 20); + assert_eq!(stats.top_raw_results.len(), 2); + assert_eq!(stats.top_raw_results[0].tool_name, "Bash"); + assert_eq!(stats.top_raw_results[0].raw_result_string_chars, 20); + assert_eq!(stats.top_raw_results[0].result_for_assistant_chars, 5); + assert_eq!(stats.top_raw_results[1].tool_name, "Read"); + assert_eq!(stats.top_raw_results[1].raw_result_string_chars, 3); + assert!(!stats.top_raw_results[0].path.contains(&"x".repeat(20))); + } + + #[test] + fn omit_assistant_only_tool_results_preserves_visible_results() { + let mut turns = vec![DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![tool_item( + "Bash", + json!({ "output": "visible output" }), + Some("assistant-only payload"), + )], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }]; + + omit_assistant_only_tool_results_for_session_view(&mut turns); + + let tool_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should remain present"); + assert_eq!(tool_result.result["output"], "visible output"); + assert_eq!(tool_result.result_for_assistant, None); + } + + #[test] + fn session_view_tool_result_compaction_truncates_large_visible_results() { + let large_output = "x".repeat(80 * 1024); + let mut turns = vec![DialogTurnData { + turn_id: "turn-1".to_string(), + turn_index: 0, + session_id: "session-1".to_string(), + timestamp: 1, + kind: Default::default(), + user_message: UserMessageData { + id: "user-1".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + model_rounds: vec![ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![tool_item( + "Bash", + json!({ "output": large_output, "exit_code": 0 }), + Some("assistant-only payload"), + )], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + status: TurnStatus::Completed, + }]; + + omit_assistant_only_tool_results_for_session_view(&mut turns); + + let tool_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should remain present"); + let output = tool_result.result["output"] + .as_str() + .expect("output should remain a visible string preview"); + assert!(output.len() < 80 * 1024); + assert!(output.contains("truncated for session view")); + assert_eq!(tool_result.result["exit_code"], 0); + assert_eq!(tool_result.result_for_assistant, None); + } +} diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 8a0297d75..a72c8e611 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -5,6 +5,7 @@ use bitfun_core::util::errors::BitFunError; use log::{error, info}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::BTreeMap; use tauri::State; #[derive(Debug, Deserialize)] @@ -15,6 +16,14 @@ pub struct GetConfigRequest { pub skip_retry_on_not_found: bool, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetConfigsRequest { + pub paths: Vec, + #[serde(default)] + pub skip_retry_on_not_found: bool, +} + #[derive(Debug, Deserialize)] pub struct SetConfigRequest { pub path: String, @@ -66,6 +75,41 @@ pub async fn get_config( } } +#[tauri::command] +pub async fn get_configs( + state: State<'_, AppState>, + request: GetConfigsRequest, +) -> Result, String> { + let config_service = &state.config_service; + let mut configs = BTreeMap::new(); + + for path in request.paths { + if configs.contains_key(&path) { + continue; + } + + match config_service + .get_config::(Some(path.as_str())) + .await + { + Ok(config) => { + configs.insert(path, config); + } + Err(e) => { + if request.skip_retry_on_not_found + && is_expected_config_path_not_found(&e, Some(path.as_str())) + { + return Err(format!("Failed to get config: {}", e)); + } + error!("Failed to get config: path={}, error={}", path, e); + return Err(format!("Failed to get config: {}", e)); + } + } + } + + Ok(configs) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/api/git_api.rs b/src/apps/desktop/src/api/git_api.rs index 1ae54fa99..ed7c6a239 100644 --- a/src/apps/desktop/src/api/git_api.rs +++ b/src/apps/desktop/src/api/git_api.rs @@ -11,7 +11,7 @@ use bitfun_core::service::git::{ GitBranch, GitCommit, GitOperationResult, GitRepository, GitStatus, }; use bitfun_core::service::remote_ssh::{lookup_remote_connection, normalize_remote_workspace_path}; -use log::{error, info}; +use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use tauri::State; @@ -588,6 +588,66 @@ pub async fn git_get_repository( }) } +#[tauri::command] +pub async fn git_get_repository_basic( + state: State<'_, AppState>, + request: GitRepositoryRequest, +) -> Result { + let started_at = std::time::Instant::now(); + let result = if let Some(target) = resolve_remote_git_target(&request.repository_path).await { + let current_branch = execute_remote_git_success( + &state, + &target, + &["branch".to_string(), "--show-current".to_string()], + ) + .await + .map(|s| { + let branch = s.trim(); + if branch.is_empty() { + "HEAD".to_string() + } else { + branch.to_string() + } + })?; + + let name = target + .repository_path + .rsplit('/') + .find(|part| !part.is_empty()) + .unwrap_or("/") + .to_string(); + + Ok(GitRepository { + path: target.repository_path, + name, + current_branch, + is_bare: false, + has_changes: false, + remotes: Vec::new(), + }) + } else { + GitService::get_repository_basic(&request.repository_path) + .await + .map_err(|e| { + error!( + "Failed to get basic Git repository info: path={}, error={}", + request.repository_path, e + ); + format!("Failed to get basic Git repository info: {}", e) + }) + }; + + let duration_ms = started_at.elapsed().as_millis(); + if duration_ms >= 80 { + debug!( + "Git basic repository lookup completed: path={}, duration_ms={}", + request.repository_path, duration_ms + ); + } + + result +} + #[tauri::command] pub async fn git_get_status( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 509ffad19..c50425dd9 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -233,9 +233,11 @@ async fn resolve_workspace_dir(workspace_path: &str) -> Result Ok(workspace_dir) } -async fn ensure_snapshot_manager_ready( +async fn ensure_snapshot_manager_ready_for( workspace_path: &str, + caller: &str, ) -> Result, String> { + let started_at = std::time::Instant::now(); // Remote workspaces don't support the snapshot system if is_remote_path(workspace_path).await { return Err(format!( @@ -247,11 +249,21 @@ async fn ensure_snapshot_manager_ready( let workspace_dir = resolve_workspace_dir(workspace_path).await?; if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_dir) { + let duration_ms = started_at.elapsed().as_millis(); + if duration_ms >= 20 { + log::debug!( + "Snapshot manager ready: caller={}, workspace={}, source=cache, duration_ms={}", + caller, + workspace_dir.display(), + duration_ms + ); + } return Ok(manager); } info!( - "Snapshot manager missing, initializing lazily: workspace={}", + "Snapshot manager missing, initializing lazily: caller={}, workspace={}", + caller, workspace_dir.display() ); @@ -265,8 +277,21 @@ async fn ensure_snapshot_manager_ready( ) })?; - ensure_snapshot_manager_for_workspace(&workspace_dir) - .map_err(|e| format!("Failed to get snapshot manager: {}", e)) + let manager = ensure_snapshot_manager_for_workspace(&workspace_dir) + .map_err(|e| format!("Failed to get snapshot manager: {}", e))?; + log::debug!( + "Snapshot manager ready: caller={}, workspace={}, source=lazy_init, duration_ms={}", + caller, + workspace_dir.display(), + started_at.elapsed().as_millis() + ); + Ok(manager) +} + +async fn ensure_snapshot_manager_ready( + workspace_path: &str, +) -> Result, String> { + ensure_snapshot_manager_ready_for(workspace_path, "unspecified").await } #[tauri::command] @@ -274,7 +299,8 @@ pub async fn record_file_change( app_handle: AppHandle, request: RecordFileChangeRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "record_file_change").await?; let operation_type = match request.operation_type.as_str() { "Create" => OperationType::Create, @@ -323,7 +349,8 @@ pub async fn rollback_session( return Ok(vec![]); } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_session").await?; let restored_files = manager .rollback_session(&request.session_id) @@ -373,7 +400,8 @@ pub async fn rollback_to_turn( } } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "rollback_to_turn").await?; let restored_files = manager .rollback_to_turn(&request.session_id, request.turn_index) @@ -469,7 +497,8 @@ pub async fn accept_session( app_handle: AppHandle, request: AcceptSessionRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "accept_session").await?; manager .accept_session(&request.session_id) @@ -553,7 +582,8 @@ pub async fn get_session_files(request: GetSessionFilesRequest) -> Result Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_file_diff_stats") + .await?; let stats = manager .get_session_file_diff_stats(&request.sessionId, &request.filePath) @@ -704,7 +736,8 @@ pub async fn get_session_file_diff_stats( pub async fn get_operation_summary( request: GetOperationSummaryRequest, ) -> Result { - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_operation_summary").await?; let summary = manager .get_operation_summary(&request.sessionId, &request.operationId) @@ -859,7 +892,8 @@ pub async fn get_session_stats( })); } - let manager = ensure_snapshot_manager_ready(&request.workspace_path).await?; + let manager = + ensure_snapshot_manager_ready_for(&request.workspace_path, "get_session_stats").await?; let stats = manager .get_session_stats(&request.session_id) diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a28081286..0eb2ca632 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -593,6 +593,8 @@ pub async fn run() { api::agentic_api::set_subagent_timeout, api::agentic_api::delete_session, api::agentic_api::restore_session, + api::agentic_api::restore_session_view, + api::agentic_api::restore_session_with_turns, webdriver_bridge_result, api::agentic_api::list_sessions, api::agentic_api::confirm_tool_execution, @@ -669,6 +671,7 @@ pub async fn run() { get_clipboard_files, paste_files, get_config, + get_configs, computer_use_get_status, computer_use_request_permissions, computer_use_open_system_settings, @@ -708,6 +711,7 @@ pub async fn run() { add_skill, delete_skill, git_is_repository, + git_get_repository_basic, git_get_repository, git_get_status, git_get_branches, diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index e5a45af01..29834a168 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -1,11 +1,13 @@ //! Theme System use std::sync::{OnceLock, RwLock}; +use std::time::Instant; use bitfun_core::infrastructure::try_get_path_manager_arc; use bitfun_core::service::config::types::GlobalConfig; use dark_light::Mode; use log::{debug, error, warn}; +use tauri::webview::PageLoadEvent; use tauri::{Manager, WebviewUrl}; const AGENT_COMPANION_WINDOW_LABEL: &str = "agent-companion-pet"; @@ -300,7 +302,6 @@ impl ThemeConfig { document.body.style.backgroundColor = '{bg_primary}'; }} - console.log('[Theme] Pre-injected theme: {id}'); return true; }} @@ -335,9 +336,14 @@ impl ThemeConfig { } pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) { + let total_started_at = Instant::now(); let theme = ThemeConfig::load_from_config(); let bg_color = theme.to_tauri_color(); let init_script = theme.generate_init_script(startup_trace_id); + debug!( + "Main window creation step completed: step=prepare_theme duration_ms={}", + total_started_at.elapsed().as_millis() + ); let main_url = if cfg!(debug_assertions) { match "http://localhost:1422".parse() { @@ -350,6 +356,11 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) } else { WebviewUrl::App("index.html".into()) }; + let main_url_kind = match &main_url { + WebviewUrl::External(_) => "external", + WebviewUrl::App(_) => "app", + _ => "other", + }; #[allow(unused_mut)] let mut builder = tauri::WebviewWindowBuilder::new(app_handle, "main", main_url) @@ -360,7 +371,24 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) .visible(false) .background_color(bg_color) .accept_first_mouse(true) - .initialization_script(&init_script); + .initialization_script(&init_script) + .on_page_load({ + let startup_trace_id = startup_trace_id.to_string(); + let total_started_at = total_started_at; + move |_window, payload| { + let event = match payload.event() { + PageLoadEvent::Started => "started", + PageLoadEvent::Finished => "finished", + }; + debug!( + "Main window page load event: trace_id={}, event={}, url={}, since_create_start_ms={}", + startup_trace_id, + event, + payload.url(), + total_started_at.elapsed().as_millis() + ); + } + }); // Keep HTML5 drag-and-drop working inside the webview for desktop UI drag targets. builder = builder.disable_drag_drop_handler(); @@ -379,8 +407,15 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) builder = builder.decorations(false); } + let build_started_at = Instant::now(); match builder.build() { Ok(window) => { + debug!( + "Main window creation step completed: step=build url_kind={} duration_ms={} total_duration_ms={}", + main_url_kind, + build_started_at.elapsed().as_millis(), + total_started_at.elapsed().as_millis() + ); #[cfg(any(debug_assertions, feature = "devtools"))] { if std::env::var("BITFUN_OPEN_DEVTOOLS") @@ -395,7 +430,11 @@ pub fn create_main_window(app_handle: &tauri::AppHandle, startup_trace_id: &str) let _ = window; } Err(e) => { - error!("Failed to create main window: {}", e); + error!( + "Failed to create main window: error={} duration_ms={}", + e, + total_started_at.elapsed().as_millis() + ); } } } @@ -563,7 +602,9 @@ fn resize_agent_companion_window( #[tauri::command] pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<(), String> { + let started_at = Instant::now(); let _guard = agent_companion_window_ops().lock().await; + debug!("Agent companion window show requested"); // Reuse any existing window: never destroy here. A previous implementation destroyed // whenever `is_visible` was false, which raced with another `show` that had built the @@ -578,6 +619,10 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) })?; + debug!( + "Agent companion window reused: total_duration_ms={}", + started_at.elapsed().as_millis() + ); return Ok(()); } @@ -601,21 +646,52 @@ pub async fn show_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( .shadow(false) .visible(false) .accept_first_mouse(true) - .background_color(tauri::window::Color(0, 0, 0, 0)); + .background_color(tauri::window::Color(0, 0, 0, 0)) + .on_page_load({ + let started_at = started_at; + move |_window, payload| { + let event = match payload.event() { + PageLoadEvent::Started => "started", + PageLoadEvent::Finished => "finished", + }; + debug!( + "Agent companion window page load event: event={}, url={}, since_show_request_ms={}", + event, + payload.url(), + started_at.elapsed().as_millis() + ); + } + }); builder = builder.disable_drag_drop_handler(); + let build_started_at = Instant::now(); let window = builder.build().map_err(|e| { - error!("Failed to create Agent companion window: {}", e); + error!( + "Failed to create Agent companion window: error={} duration_ms={}", + e, + build_started_at.elapsed().as_millis() + ); format!("Failed to create Agent companion window: {}", e) })?; + debug!( + "Agent companion window creation step completed: step=build duration_ms={} total_duration_ms={}", + build_started_at.elapsed().as_millis(), + started_at.elapsed().as_millis() + ); position_agent_companion_window(&app, &window); + let show_started_at = Instant::now(); window.show().map_err(|e| { error!("Failed to show Agent companion window: {}", e); format!("Failed to show Agent companion window: {}", e) })?; + debug!( + "Agent companion window shown: show_duration_ms={} total_duration_ms={}", + show_started_at.elapsed().as_millis(), + started_at.elapsed().as_millis() + ); Ok(()) } @@ -661,23 +737,34 @@ pub async fn hide_agent_companion_desktop_pet(app: tauri::AppHandle) -> Result<( #[tauri::command] pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { + let total_started_at = Instant::now(); if let Some(main_window) = app.get_webview_window("main") { #[cfg(target_os = "windows")] { // Work around Windows startup flicker: avoid creating the native window // in maximized mode, and maximize it right before showing instead. + let step_started_at = Instant::now(); main_window.maximize().map_err(|e| { error!("Failed to maximize main window: {}", e); format!("Failed to maximize main window: {}", e) })?; + debug!( + "Main window show step completed: step=maximize duration_ms={}", + step_started_at.elapsed().as_millis() + ); tokio::time::sleep(std::time::Duration::from_millis(150)).await; } + let step_started_at = Instant::now(); main_window.show().map_err(|e| { error!("Failed to show main window: {}", e); format!("Failed to show main window: {}", e) })?; + debug!( + "Main window show step completed: step=show duration_ms={}", + step_started_at.elapsed().as_millis() + ); #[cfg(target_os = "macos")] { @@ -685,14 +772,23 @@ pub async fn show_main_window(app: tauri::AppHandle) -> Result<(), String> { crate::mark_main_window_hidden_on_macos(false); } + let step_started_at = Instant::now(); main_window.set_focus().map_err(|e| { error!("Failed to focus main window: {}", e); format!("Failed to focus main window: {}", e) })?; + debug!( + "Main window show step completed: step=focus duration_ms={}", + step_started_at.elapsed().as_millis() + ); } else { error!("Main window not found"); return Err("Main window not found".to_string()); } + debug!( + "Main window shown: total_duration_ms={}", + total_started_at.elapsed().as_millis() + ); Ok(()) } diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index bb1d6f44e..c86bd837c 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -2468,6 +2468,28 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_session_with_turns(workspace_path, session_id) + .await + } + + /// Restore only the UI-visible persisted session view. + pub async fn restore_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + self.session_manager + .restore_session_view(workspace_path, session_id) + .await + } + /// List all sessions pub async fn list_sessions(&self, workspace_path: &Path) -> BitFunResult> { self.session_manager.list_sessions(workspace_path).await diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 4354105d7..546e7398d 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -17,14 +17,14 @@ use crate::service::session::{ }; use crate::service::workspace_runtime::WorkspaceRuntimeService; use crate::util::errors::{BitFunError, BitFunResult}; -use log::{info, warn}; +use log::{debug, info, warn}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::io::ErrorKind; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::fs; use tokio::sync::Mutex; @@ -35,6 +35,8 @@ const SESSION_TRANSCRIPT_PREVIEW_CHAR_LIMIT: usize = 120; static JSON_FILE_WRITE_LOCKS: OnceLock>>>> = OnceLock::new(); static SESSION_INDEX_LOCKS: OnceLock>>>> = OnceLock::new(); +static SESSION_METADATA_UPDATE_LOCKS: OnceLock>>>> = + OnceLock::new(); #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredDialogTurnFile { @@ -60,6 +62,83 @@ struct StoredTurnContextSnapshotFile { messages: Vec, } +#[derive(Debug, Default)] +struct ContextSnapshotPayloadStats { + tool_result_count: usize, + raw_result_string_chars: usize, + result_for_assistant_chars: usize, + largest_raw_result_chars: usize, + largest_raw_result_path: String, +} + +fn collect_json_string_stats( + value: &serde_json::Value, + path: &str, + total: &mut usize, + largest: &mut (usize, String), +) { + match value { + serde_json::Value::String(text) => { + let char_count = text.chars().count(); + *total += char_count; + if char_count > largest.0 { + *largest = (char_count, path.to_string()); + } + } + serde_json::Value::Array(items) => { + for (index, item) in items.iter().enumerate() { + collect_json_string_stats(item, &format!("{}[{}]", path, index), total, largest); + } + } + serde_json::Value::Object(map) => { + for (key, item) in map { + let next_path = if path.is_empty() { + key.to_string() + } else { + format!("{}.{}", path, key) + }; + collect_json_string_stats(item, &next_path, total, largest); + } + } + _ => {} + } +} + +fn context_snapshot_payload_stats(messages: &[Message]) -> ContextSnapshotPayloadStats { + let mut stats = ContextSnapshotPayloadStats::default(); + for (message_index, message) in messages.iter().enumerate() { + let MessageContent::ToolResult { + tool_name, + result, + result_for_assistant, + .. + } = &message.content + else { + continue; + }; + + stats.tool_result_count += 1; + if let Some(text) = result_for_assistant.as_deref() { + stats.result_for_assistant_chars += text.chars().count(); + } + + let mut raw_chars = 0usize; + let mut largest = (0usize, String::new()); + collect_json_string_stats( + result, + &format!("message[{}].{}", message_index, tool_name), + &mut raw_chars, + &mut largest, + ); + stats.raw_result_string_chars += raw_chars; + if largest.0 > stats.largest_raw_result_chars { + stats.largest_raw_result_chars = largest.0; + stats.largest_raw_result_path = largest.1; + } + } + stats +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct StoredSessionTranscriptFile { schema_version: u32, @@ -322,10 +401,22 @@ impl PersistenceManager { &self, path: &Path, ) -> BitFunResult> { - if !path.exists() { - return Ok(None); - } + let started_at = Instant::now(); + let metadata_started_at = Instant::now(); + let metadata = match fs::metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(BitFunError::io(format!( + "Failed to read JSON metadata {}: {}", + path.display(), + error + ))); + } + }; + let metadata_duration = metadata_started_at.elapsed(); + let read_started_at = Instant::now(); let content = fs::read_to_string(path).await.map_err(|e| { BitFunError::io(format!( "Failed to read JSON file {}: {}", @@ -333,7 +424,9 @@ impl PersistenceManager { e )) })?; + let read_duration = read_started_at.elapsed(); + let parse_started_at = Instant::now(); let value = serde_json::from_str::(&content).map_err(|e| { BitFunError::Deserialization(format!( "Failed to deserialize JSON file {}: {}", @@ -341,6 +434,21 @@ impl PersistenceManager { e )) })?; + let parse_duration = parse_started_at.elapsed(); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(80) || metadata.len() >= 1024 * 1024 { + debug!( + "Read JSON file: path={} type={} size_bytes={} metadata_duration_ms={} read_duration_ms={} parse_duration_ms={} total_duration_ms={}", + path.display(), + std::any::type_name::(), + metadata.len(), + metadata_duration.as_millis(), + read_duration.as_millis(), + parse_duration.as_millis(), + total_duration.as_millis() + ); + } Ok(Some(value)) } @@ -442,6 +550,20 @@ impl PersistenceManager { .clone() } + async fn get_session_metadata_update_lock( + &self, + workspace_path: &Path, + session_id: &str, + ) -> Arc> { + let metadata_path = self.metadata_path(workspace_path, session_id); + let registry = SESSION_METADATA_UPDATE_LOCKS.get_or_init(|| Mutex::new(HashMap::new())); + let mut registry_guard = registry.lock().await; + registry_guard + .entry(metadata_path) + .or_insert_with(|| Arc::new(Mutex::new(()))) + .clone() + } + fn build_temp_json_path(path: &Path, attempt: usize) -> BitFunResult { let parent = path.parent().ok_or_else(|| { BitFunError::io(format!( @@ -1521,12 +1643,15 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult)>> { + let started_at = Instant::now(); let dir = self.snapshots_dir(workspace_path, session_id); if !dir.exists() { return Ok(None); } + let scan_started_at = Instant::now(); let mut latest: Option = None; + let mut snapshot_file_count = 0usize; let mut rd = fs::read_dir(&dir) .await .map_err(|e| BitFunError::io(format!("Failed to read snapshots directory: {}", e)))?; @@ -1547,20 +1672,44 @@ impl PersistenceManager { continue; }; if let Ok(index) = index_str.parse::() { + snapshot_file_count += 1; latest = Some(latest.map(|value| value.max(index)).unwrap_or(index)); } } + let scan_duration = scan_started_at.elapsed(); let Some(turn_index) = latest else { return Ok(None); }; + let load_started_at = Instant::now(); let Some(messages) = self .load_turn_context_snapshot(workspace_path, session_id, turn_index) .await? else { return Ok(None); }; + let load_duration = load_started_at.elapsed(); + let total_duration = started_at.elapsed(); + + if total_duration >= Duration::from_millis(80) || snapshot_file_count >= 10 { + let payload_stats = context_snapshot_payload_stats(&messages); + debug!( + "Loaded latest context snapshot: session_id={} turn_index={} snapshot_file_count={} scan_duration_ms={} load_duration_ms={} total_duration_ms={} message_count={} tool_result_count={} raw_result_string_chars={} result_for_assistant_chars={} largest_raw_result_chars={} largest_raw_result_path={}", + session_id, + turn_index, + snapshot_file_count, + scan_duration.as_millis(), + load_duration.as_millis(), + total_duration.as_millis(), + messages.len(), + payload_stats.tool_result_count, + payload_stats.raw_result_string_chars, + payload_stats.result_for_assistant_chars, + payload_stats.largest_raw_result_chars, + payload_stats.largest_raw_result_path + ); + } Ok(Some((turn_index, messages))) } @@ -1639,6 +1788,18 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { + let (session, _) = self + .load_session_with_turns(workspace_path, session_id) + .await?; + Ok(session) + } + + /// Load session and return the persisted turns read while rebuilding the session header. + pub async fn load_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { let metadata = self .load_session_metadata(workspace_path, session_id) .await? @@ -1678,7 +1839,8 @@ impl PersistenceManager { let created_at = Self::unix_ms_to_system_time(metadata.created_at); let last_activity_at = Self::unix_ms_to_system_time(metadata.last_active_at); - Ok(Session { + let dialog_turn_ids = turns.iter().map(|turn| turn.turn_id.clone()).collect(); + let session = Session { session_id: metadata.session_id.clone(), session_name: metadata.session_name.clone(), agent_type: metadata.agent_type.clone(), @@ -1687,14 +1849,16 @@ impl PersistenceManager { snapshot_session_id: stored_state .and_then(|value| value.snapshot_session_id) .or(metadata.snapshot_session_id.clone()), - dialog_turn_ids: turns.into_iter().map(|turn| turn.turn_id).collect(), + dialog_turn_ids, state: runtime_state, config, compression_state, created_at, updated_at: last_activity_at, last_activity_at, - }) + }; + + Ok((session, turns)) } /// Save session state @@ -1780,12 +1944,74 @@ impl PersistenceManager { 1 + assistant_text_count } + fn refresh_metadata_from_turns( + metadata: &mut SessionMetadata, + workspace_path: &Path, + turns: &[DialogTurnData], + last_active_at: u64, + ) { + metadata.turn_count = turns.len(); + metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); + metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.last_active_at = last_active_at; + if metadata.workspace_path.is_none() { + metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); + } + } + + fn try_refresh_metadata_for_saved_turn( + metadata: &mut SessionMetadata, + workspace_path: &Path, + previous_turn: Option<&DialogTurnData>, + turn: &DialogTurnData, + last_active_at: u64, + ) -> bool { + let new_message_count = Self::estimate_turn_message_count(turn); + let new_tool_call_count = turn.count_tool_calls(); + + match previous_turn { + Some(previous) + if previous.session_id == turn.session_id + && previous.turn_index == turn.turn_index + && turn.turn_index < metadata.turn_count => + { + metadata.message_count = metadata + .message_count + .saturating_sub(Self::estimate_turn_message_count(previous)) + .saturating_add(new_message_count); + metadata.tool_call_count = metadata + .tool_call_count + .saturating_sub(previous.count_tool_calls()) + .saturating_add(new_tool_call_count); + } + None if turn.turn_index == metadata.turn_count => { + metadata.turn_count += 1; + metadata.message_count = metadata.message_count.saturating_add(new_message_count); + metadata.tool_call_count = + metadata.tool_call_count.saturating_add(new_tool_call_count); + } + _ => return false, + } + + metadata.last_active_at = last_active_at; + if metadata.workspace_path.is_none() { + metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); + } + + true + } + pub async fn save_dialog_turn( &self, workspace_path: &Path, turn: &DialogTurnData, ) -> BitFunResult<()> { + let save_started_at = Instant::now(); self.ensure_runtime_for_write(workspace_path).await?; + let metadata_update_lock = self + .get_session_metadata_update_lock(workspace_path, &turn.session_id) + .await; + let _metadata_update_guard = metadata_update_lock.lock().await; let mut metadata = self .load_session_metadata(workspace_path, &turn.session_id) .await? @@ -1796,32 +2022,81 @@ impl PersistenceManager { self.ensure_turns_dir(workspace_path, &turn.session_id) .await?; + let previous_turn = match self + .load_dialog_turn(workspace_path, &turn.session_id, turn.turn_index) + .await + { + Ok(turn) => turn, + Err(error) => { + warn!( + "Failed to load existing dialog turn before save; falling back to full metadata refresh: session_id={} turn_index={} error={}", + turn.session_id, + turn.turn_index, + error + ); + None + } + }; + let previous_turn_load_failed = previous_turn.is_none() + && self + .turn_path(workspace_path, &turn.session_id, turn.turn_index) + .exists(); + let file = StoredDialogTurnFile { schema_version: SESSION_STORAGE_SCHEMA_VERSION, turn: turn.clone(), }; + let write_started_at = Instant::now(); self.write_json_atomic( &self.turn_path(workspace_path, &turn.session_id, turn.turn_index), &file, ) .await?; + let write_duration = write_started_at.elapsed(); - let turns = self - .load_session_turns(workspace_path, &turn.session_id) - .await?; - metadata.turn_count = turns.len(); - metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); - metadata.last_active_at = turn + let last_active_at = turn .end_time .unwrap_or_else(|| Self::system_time_to_unix_ms(SystemTime::now())); - metadata.workspace_path = metadata.workspace_path.clone().or_else(|| { - turns - .first() - .and(None::) - .or_else(|| Some(workspace_path.to_string_lossy().to_string())) - }); - self.save_session_metadata(workspace_path, &metadata).await + let mut metadata_refresh_mode = "incremental"; + if previous_turn_load_failed + || !Self::try_refresh_metadata_for_saved_turn( + &mut metadata, + workspace_path, + previous_turn.as_ref(), + turn, + last_active_at, + ) + { + metadata_refresh_mode = "full_scan"; + let turns = self + .load_session_turns(workspace_path, &turn.session_id) + .await?; + Self::refresh_metadata_from_turns( + &mut metadata, + workspace_path, + &turns, + last_active_at, + ); + } + + let metadata_started_at = Instant::now(); + self.save_session_metadata(workspace_path, &metadata) + .await?; + let metadata_duration = metadata_started_at.elapsed(); + let total_duration = save_started_at.elapsed(); + if total_duration >= Duration::from_millis(80) || metadata_refresh_mode == "full_scan" { + debug!( + "Saved dialog turn: session_id={} turn_index={} metadata_refresh={} write_duration_ms={} metadata_duration_ms={} total_duration_ms={}", + turn.session_id, + turn.turn_index, + metadata_refresh_mode, + write_duration.as_millis(), + metadata_duration.as_millis(), + total_duration.as_millis() + ); + } + + Ok(()) } pub async fn load_dialog_turn( @@ -1845,11 +2120,13 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { + let started_at = Instant::now(); let turns_dir = self.turns_dir(workspace_path, session_id); if !turns_dir.exists() { return Ok(Vec::new()); } + let scan_started_at = Instant::now(); let mut indexed_paths = Vec::new(); let mut entries = fs::read_dir(&turns_dir) .await @@ -1877,8 +2154,11 @@ impl PersistenceManager { } indexed_paths.sort_by_key(|(index, _)| *index); + let scan_duration = scan_started_at.elapsed(); + let read_started_at = Instant::now(); let mut turns = Vec::with_capacity(indexed_paths.len()); + let turn_file_count = indexed_paths.len(); for (_, path) in indexed_paths { if let Some(file) = self .read_json_optional::(&path) @@ -1887,6 +2167,19 @@ impl PersistenceManager { turns.push(file.turn); } } + let read_duration = read_started_at.elapsed(); + let total_duration = started_at.elapsed(); + if total_duration >= Duration::from_millis(80) || turn_file_count >= 50 { + debug!( + "Loaded session turns: session_id={} turn_count={} turn_file_count={} scan_duration_ms={} read_duration_ms={} total_duration_ms={}", + session_id, + turns.len(), + turn_file_count, + scan_duration.as_millis(), + read_duration.as_millis(), + total_duration.as_millis() + ); + } Ok(turns) } @@ -2252,11 +2545,12 @@ impl PersistenceManager { #[cfg(test)] mod tests { - use super::PersistenceManager; - use crate::agentic::core::SessionKind; + use super::{context_snapshot_payload_stats, PersistenceManager}; + use crate::agentic::core::{Message, Session, SessionConfig, SessionKind, ToolResult}; use crate::infrastructure::PathManager; use crate::service::session::{ - DialogTurnData, SessionMetadata, SessionTranscriptExportOptions, UserMessageData, + DialogTurnData, ModelRoundData, SessionMetadata, SessionTranscriptExportOptions, + TextItemData, UserMessageData, }; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -2374,6 +2668,283 @@ mod tests { assert!(transcript.contains("hello transcript")); } + #[tokio::test] + async fn load_session_with_turns_returns_session_and_persisted_turns() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Load once".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let user_message = UserMessageData { + id: "user-1".to_string(), + content: "hello once".to_string(), + timestamp: 0, + metadata: None, + }; + let mut turn = + DialogTurnData::new("turn-1".to_string(), 0, session_id.clone(), user_message); + turn.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + let (loaded_session, loaded_turns) = manager + .load_session_with_turns(workspace.path(), &session_id) + .await + .expect("session and turns should load together"); + + assert_eq!(loaded_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(loaded_turns.len(), 1); + assert_eq!(loaded_turns[0].turn_id, "turn-1"); + } + + fn user_message(content: &str) -> UserMessageData { + UserMessageData { + id: format!("user-{}", content), + content: content.to_string(), + timestamp: 0, + metadata: None, + } + } + + fn text_item(id: &str, content: &str) -> TextItemData { + TextItemData { + id: id.to_string(), + content: content.to_string(), + is_streaming: false, + timestamp: 0, + is_markdown: true, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + status: None, + } + } + + fn round_with_text(turn_id: &str, text_items: Vec) -> ModelRoundData { + ModelRoundData { + id: format!("round-{}", turn_id), + turn_id: turn_id.to_string(), + round_index: 0, + timestamp: 0, + text_items, + tool_items: Vec::new(), + thinking_items: Vec::new(), + start_time: 0, + end_time: Some(0), + duration_ms: Some(0), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + } + } + + #[tokio::test] + async fn save_dialog_turn_updates_metadata_without_scanning_unrelated_turn_files() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Incremental metadata".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let mut turn_0 = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("first"), + ); + turn_0.model_rounds.push(round_with_text( + "turn-0", + vec![text_item("text-0", "first response")], + )); + turn_0.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("first turn should save"); + + let mut turn_1 = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("second"), + ); + turn_1.model_rounds.push(round_with_text( + "turn-1", + vec![text_item("text-1", "second response")], + )); + turn_1.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("second turn should save"); + + std::fs::write( + manager.turn_path(workspace.path(), &session_id, 0), + "{ not valid json", + ) + .expect("old turn file should be replaceable for test"); + + turn_1.model_rounds[0] + .text_items + .push(text_item("text-2", "additional response")); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("saving current turn should not scan unrelated old turn files"); + + let metadata = manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 2); + assert_eq!(metadata.message_count, 5); + } + + #[tokio::test] + async fn concurrent_dialog_turn_saves_keep_metadata_counts_consistent() { + let workspace = TestWorkspace::new(); + let manager = PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"); + let session_id = Uuid::new_v4().to_string(); + let session = Session::new_with_id( + session_id.clone(), + "Concurrent metadata".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + + manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let mut turn_0 = DialogTurnData::new( + "turn-0".to_string(), + 0, + session_id.clone(), + user_message("first"), + ); + turn_0.model_rounds.push(round_with_text( + "turn-0", + vec![text_item("text-0", "first response")], + )); + turn_0.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_0) + .await + .expect("first turn should save"); + + let mut turn_1 = DialogTurnData::new( + "turn-1".to_string(), + 1, + session_id.clone(), + user_message("second"), + ); + turn_1.model_rounds.push(round_with_text( + "turn-1", + vec![text_item("text-1", "second response")], + )); + turn_1.mark_completed(); + manager + .save_dialog_turn(workspace.path(), &turn_1) + .await + .expect("second turn should save"); + + let mut updated_turn_0 = turn_0.clone(); + updated_turn_0.model_rounds[0] + .text_items + .push(text_item("text-0b", "first follow-up")); + + let mut updated_turn_1 = turn_1.clone(); + updated_turn_1.model_rounds[0] + .text_items + .push(text_item("text-1b", "second follow-up")); + updated_turn_1.model_rounds[0] + .text_items + .push(text_item("text-1c", "second final")); + + let (first_result, second_result) = tokio::join!( + manager.save_dialog_turn(workspace.path(), &updated_turn_0), + manager.save_dialog_turn(workspace.path(), &updated_turn_1) + ); + first_result.expect("first concurrent save should succeed"); + second_result.expect("second concurrent save should succeed"); + + let metadata = manager + .load_session_metadata(workspace.path(), &session_id) + .await + .expect("metadata should load") + .expect("metadata should exist"); + assert_eq!(metadata.turn_count, 2); + assert_eq!(metadata.message_count, 7); + } + + #[test] + fn context_snapshot_payload_stats_counts_tool_result_payloads_without_contents() { + let messages = vec![ + Message::assistant("hello".to_string()), + Message::tool_result(ToolResult { + tool_id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + result: serde_json::json!({ "output": "x".repeat(40) }), + result_for_assistant: Some("assistant summary".to_string()), + is_error: false, + duration_ms: Some(1), + image_attachments: None, + }), + ]; + + let stats = context_snapshot_payload_stats(&messages); + + assert_eq!(stats.tool_result_count, 1); + assert_eq!(stats.raw_result_string_chars, 40); + assert_eq!(stats.result_for_assistant_chars, 17); + assert_eq!(stats.largest_raw_result_chars, 40); + assert_eq!(stats.largest_raw_result_path, "message[1].Bash.output"); + assert!(!stats.largest_raw_result_path.contains(&"x".repeat(40))); + } + #[tokio::test] async fn subagent_session_kind_is_hidden_from_visible_session_index() { let workspace = TestWorkspace::new(); diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 0837e2752..0888b60dd 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -79,11 +79,14 @@ pub struct ResolvedSessionTitle { #[cfg(test)] mod tests { use super::{SessionManager, SessionManagerConfig}; - use crate::agentic::core::{ProcessingPhase, Session, SessionConfig, SessionState}; + use crate::agentic::core::{Message, ProcessingPhase, Session, SessionConfig, SessionState}; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::SessionContextStore; use crate::infrastructure::PathManager; - use crate::service::session::{DialogTurnData, UserMessageData}; + use crate::service::session::{ + DialogTurnData, ModelRoundData, ToolCallData, ToolItemData, ToolResultData, UserMessageData, + }; + use serde_json::json; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; @@ -304,6 +307,194 @@ mod tests { assert_eq!(metadata.unread_completion, None); } + #[tokio::test] + async fn restore_session_view_loads_turns_without_restoring_runtime_context() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "Large history".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + let turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "hello".to_string(), + timestamp: 1, + metadata: None, + }, + ); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + persistence_manager + .save_turn_context_snapshot( + workspace.path(), + &session_id, + 0, + &[Message::user("snapshot prompt".to_string())], + ) + .await + .expect("context snapshot should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!(turns.len(), 1); + assert!(manager.get_session(&session_id).is_none()); + assert!(manager + .context_store + .get_context_messages(&session_id) + .is_empty()); + } + + #[tokio::test] + async fn restore_session_view_preserves_full_visible_tool_result_payload() { + let workspace = TestWorkspace::new(); + let persistence_manager = Arc::new( + PersistenceManager::new(Arc::new(PathManager::new().expect("path manager"))) + .expect("persistence manager"), + ); + let manager = test_manager(persistence_manager.clone()); + let session_id = Uuid::new_v4().to_string(); + let mut session = Session::new_with_id( + session_id.clone(), + "History with tool output".to_string(), + "agent".to_string(), + SessionConfig { + workspace_path: Some(workspace.path().to_string_lossy().to_string()), + ..Default::default() + }, + ); + session.dialog_turn_ids = vec!["turn-1".to_string()]; + + persistence_manager + .save_session(workspace.path(), &session) + .await + .expect("session should save"); + + let visible_output = "complete visible output ".repeat(128); + let assistant_output = "assistant visible summary ".repeat(16); + let mut turn = DialogTurnData::new( + "turn-1".to_string(), + 0, + session_id.clone(), + UserMessageData { + id: "turn-1-user".to_string(), + content: "show full output".to_string(), + timestamp: 1, + metadata: None, + }, + ); + turn.model_rounds.push(ModelRoundData { + id: "round-1".to_string(), + turn_id: "turn-1".to_string(), + round_index: 0, + timestamp: 1, + text_items: vec![], + tool_items: vec![ToolItemData { + id: "tool-1".to_string(), + tool_name: "Bash".to_string(), + tool_call: ToolCallData { + id: "call-1".to_string(), + input: json!({ "command": "printf output" }), + }, + tool_result: Some(ToolResultData { + result: json!({ + "stdout": visible_output, + "nested": { + "stderr": "also visible", + }, + }), + success: true, + result_for_assistant: Some(assistant_output.clone()), + error: None, + duration_ms: Some(1), + }), + ai_intent: None, + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: None, + order_index: None, + is_subagent_item: None, + parent_task_tool_id: None, + subagent_session_id: None, + subagent_model_id: None, + subagent_model_alias: None, + status: Some("completed".to_string()), + interruption_reason: None, + }], + thinking_items: vec![], + start_time: 1, + end_time: Some(2), + duration_ms: Some(1), + provider_id: None, + model_id: None, + model_alias: None, + first_chunk_ms: None, + first_visible_output_ms: None, + stream_duration_ms: None, + attempt_count: None, + failure_category: None, + token_details: None, + status: "completed".to_string(), + }); + persistence_manager + .save_dialog_turn(workspace.path(), &turn) + .await + .expect("turn should save"); + + let (view_session, turns) = manager + .restore_session_view(workspace.path(), &session_id) + .await + .expect("session view should restore"); + + let restored_result = turns[0].model_rounds[0].tool_items[0] + .tool_result + .as_ref() + .expect("tool result should be preserved"); + assert_eq!(view_session.dialog_turn_ids, vec!["turn-1".to_string()]); + assert_eq!( + restored_result.result["stdout"].as_str(), + Some(visible_output.as_str()) + ); + assert_eq!( + restored_result.result["nested"]["stderr"].as_str(), + Some("also visible") + ); + assert_eq!( + restored_result.result_for_assistant.as_deref(), + Some(assistant_output.as_str()) + ); + assert!(manager.get_session(&session_id).is_none()); + } + #[tokio::test] async fn rollback_context_deletes_persisted_turns_from_target() { let workspace = TestWorkspace::new(); @@ -1700,9 +1891,113 @@ impl SessionManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult { + let (session, _) = self + .restore_session_with_turns(workspace_path, session_id) + .await?; + Ok(session) + } + + /// Restore the persisted session header and turns needed by the UI view + /// without loading runtime context snapshots or inserting the session into + /// the in-memory coordinator state. + pub async fn restore_session_view( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); + let storage_path_started_at = Instant::now(); + let session_storage_path = { + let ws = workspace_path.to_string_lossy().to_string(); + let tmp_config = SessionConfig { + workspace_path: Some(ws), + ..Default::default() + }; + Self::effective_workspace_path_from_config(&tmp_config) + .await + .unwrap_or_else(|| workspace_path.to_path_buf()) + }; + debug!( + "Session view restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); + + let metadata_started_at = Instant::now(); + if self + .persistence_manager + .load_session_metadata(&session_storage_path, session_id) + .await? + .is_some_and(|metadata| metadata.should_hide_from_user_lists()) + { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + debug!( + "Session view restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) + ); + + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self + .persistence_manager + .load_session_with_turns(&session_storage_path, session_id) + .await?; + debug!( + "Session view restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) + ); + + if !matches!(session.state, SessionState::Idle) { + let old_state = session.state.clone(); + session.state = SessionState::Idle; + debug!( + "Resetting session state during view restore: session_id={}, state={:?} -> Idle", + session_id, old_state + ); + } + + let persisted_turn_ids: Vec = persisted_turns + .iter() + .map(|turn| turn.turn_id.clone()) + .collect(); + if session.dialog_turn_ids != persisted_turn_ids { + debug!( + "Session view restore normalized turn ids: session_id={}, session_turn_count={}, persisted_turn_count={}", + session_id, + session.dialog_turn_ids.len(), + persisted_turn_ids.len() + ); + session.dialog_turn_ids = persisted_turn_ids; + } + + debug!( + "Session view restored: session_id={}, session_name={}, turn_count={}, total_duration_ms={}", + session_id, + session.session_name, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) + ); + + Ok((session, persisted_turns)) + } + + /// Restore session and return the persisted turns read during restore. + pub async fn restore_session_with_turns( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<(Session, Vec)> { + let restore_started_at = Instant::now(); // Check if session is already in memory let session_already_in_memory = self.sessions.contains_key(session_id); + let storage_path_started_at = Instant::now(); let session_storage_path = { let ws = workspace_path.to_string_lossy().to_string(); let tmp_config = SessionConfig { @@ -1713,7 +2008,13 @@ impl SessionManager { .await .unwrap_or_else(|| workspace_path.to_path_buf()) }; + debug!( + "Session restore phase completed: session_id={}, phase=resolve_storage_path, duration_ms={}", + session_id, + elapsed_ms_u64(storage_path_started_at) + ); + let metadata_started_at = Instant::now(); if self .persistence_manager .load_session_metadata(&session_storage_path, session_id) @@ -1725,12 +2026,24 @@ impl SessionManager { session_id ))); } + debug!( + "Session restore phase completed: session_id={}, phase=load_metadata, duration_ms={}", + session_id, + elapsed_ms_u64(metadata_started_at) + ); - // 1. Load session from storage - let mut session = self + // 1. Load session and turns from storage in one pass + let session_started_at = Instant::now(); + let (mut session, persisted_turns) = self .persistence_manager - .load_session(&session_storage_path, session_id) + .load_session_with_turns(&session_storage_path, session_id) .await?; + debug!( + "Session restore phase completed: session_id={}, phase=load_session_with_turns, turn_count={}, duration_ms={}", + session_id, + persisted_turns.len(), + elapsed_ms_u64(session_started_at) + ); // Lazy migration: if the persisted model_id is no longer usable // (model deleted or disabled while the session was on disk), repoint @@ -1791,15 +2104,12 @@ impl SessionManager { // // This compensates for the fact that persistence is not transactional across // `session.json`, `turns/*.json`, and `snapshots/context-*.json`. - let persisted_turns = self - .persistence_manager - .load_session_turns(&session_storage_path, session_id) - .await?; let persisted_turn_ids: Vec = persisted_turns .iter() .map(|turn| turn.turn_id.clone()) .collect(); let mut latest_turn_index: Option = None; + let context_snapshot_started_at = Instant::now(); let mut messages = match self .persistence_manager .load_latest_turn_context_snapshot(&session_storage_path, session_id) @@ -1811,6 +2121,13 @@ impl SessionManager { } None => Self::build_messages_from_turns(&persisted_turns), }; + debug!( + "Session restore phase completed: session_id={}, phase=load_context_snapshot, snapshot_turn_index={:?}, message_count={}, duration_ms={}", + session_id, + latest_turn_index, + messages.len(), + elapsed_ms_u64(context_snapshot_started_at) + ); if let Some(snapshot_turn_index) = latest_turn_index { let delta_start = snapshot_turn_index.saturating_add(1); @@ -1840,8 +2157,15 @@ impl SessionManager { self.context_store.delete_session(session_id); } + let context_replace_started_at = Instant::now(); self.context_store .replace_context(session_id, messages.clone()); + debug!( + "Session restore phase completed: session_id={}, phase=replace_context, message_count={}, duration_ms={}", + session_id, + messages.len(), + elapsed_ms_u64(context_replace_started_at) + ); let recoverable_turn_count = latest_turn_index .map(|turn_index| turn_index + 1) @@ -1885,12 +2209,14 @@ impl SessionManager { let context_msg_count = self.context_store.get_context_messages(session_id).len(); - info!( - "Session restored: session_id={}, session_name={}, messages={}, context_messages={}", + debug!( + "Session restored: session_id={}, session_name={}, messages={}, context_messages={}, turn_count={}, total_duration_ms={}", session_id, session.session_name, messages.len(), - context_msg_count + context_msg_count, + persisted_turns.len(), + elapsed_ms_u64(restore_started_at) ); // Do not infer unread completion from persisted runtime state during restore. @@ -1904,7 +2230,7 @@ impl SessionManager { self.session_workspace_index .insert(session_id.to_string(), session_storage_path.clone()); - Ok(session) + Ok((session, persisted_turns)) } /// Rollback "model context" to before the start of specified turn (i.e., keep 0..target_turn-1) diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index 987bb5fb8..68d8497de 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -14,7 +14,13 @@ use serde_json::Value; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock, RwLock as StdRwLock}; -use tokio::sync::RwLock; +use std::time::Instant; +use tokio::sync::{Mutex as AsyncMutex, RwLock}; + +#[cfg(test)] +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +#[cfg(test)] +use std::time::Duration; /// Snapshot manager /// @@ -29,6 +35,9 @@ impl SnapshotManager { workspace_dir: PathBuf, config: Option, ) -> SnapshotResult { + #[cfg(test)] + record_snapshot_manager_new_for_test().await; + info!( "Creating snapshot manager: workspace={}", workspace_dir.display() @@ -304,6 +313,57 @@ fn snapshot_managers() -> &'static StdRwLock &'static AsyncMutex>>> { + static SNAPSHOT_MANAGER_INIT_LOCKS: OnceLock< + AsyncMutex>>>, + > = OnceLock::new(); + SNAPSHOT_MANAGER_INIT_LOCKS.get_or_init(|| AsyncMutex::new(HashMap::new())) +} + +async fn snapshot_manager_init_lock(workspace_dir: &Path) -> Arc> { + let mut locks = snapshot_manager_init_locks().lock().await; + locks + .entry(workspace_dir.to_path_buf()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +#[cfg(test)] +static SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST: AtomicUsize = AtomicUsize::new(0); +#[cfg(test)] +static SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST: AtomicU64 = AtomicU64::new(0); + +#[cfg(test)] +async fn record_snapshot_manager_new_for_test() { + SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.fetch_add(1, Ordering::SeqCst); + let delay_ms = SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST.load(Ordering::SeqCst); + if delay_ms > 0 { + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + } +} + +#[cfg(test)] +fn reset_snapshot_manager_new_count_for_test() { + SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.store(0, Ordering::SeqCst); +} + +#[cfg(test)] +fn snapshot_manager_new_count_for_test() -> usize { + SNAPSHOT_MANAGER_NEW_COUNT_FOR_TEST.load(Ordering::SeqCst) +} + +#[cfg(test)] +fn set_snapshot_manager_new_delay_for_test(delay: Duration) { + SNAPSHOT_MANAGER_NEW_DELAY_MS_FOR_TEST.store(delay.as_millis() as u64, Ordering::SeqCst); +} + +#[cfg(test)] +fn clear_snapshot_manager_for_test(workspace_dir: &Path) { + if let Ok(mut managers) = snapshot_managers().write() { + managers.remove(workspace_dir); + } +} + /// Ensures the registry always exposes the same tool implementation that will be /// executed at runtime. File-modifying tools are wrapped once at registration time /// so tool definitions, permission checks, and execution all share one source of truth. @@ -672,6 +732,22 @@ pub async fn get_or_create_snapshot_manager( return Ok(existing); } + let init_lock = snapshot_manager_init_lock(&workspace_dir).await; + let _init_guard = init_lock.lock().await; + + if let Some(existing) = get_snapshot_manager_for_workspace(&workspace_dir) { + debug!( + "Snapshot manager initialized by concurrent request: workspace={}", + workspace_dir.display() + ); + return Ok(existing); + } + + let started_at = Instant::now(); + info!( + "Snapshot manager cold initialization started: workspace={}", + workspace_dir.display() + ); let manager = Arc::new(SnapshotManager::new(workspace_dir.clone(), config).await?); { let mut managers = snapshot_managers().write().map_err(|_| { @@ -682,6 +758,10 @@ pub async fn get_or_create_snapshot_manager( } managers.insert(workspace_dir, manager.clone()); } + info!( + "Snapshot manager cold initialization completed: duration_ms={}", + started_at.elapsed().as_millis() + ); Ok(manager) } @@ -710,6 +790,63 @@ pub async fn initialize_snapshot_manager_for_workspace( config: Option, ) -> SnapshotResult<()> { get_or_create_snapshot_manager(workspace_dir, config).await?; - info!("Snapshot manager initialized for workspace"); + debug!("Snapshot manager initialized for workspace"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::{ + clear_snapshot_manager_for_test, get_or_create_snapshot_manager, + reset_snapshot_manager_new_count_for_test, set_snapshot_manager_new_delay_for_test, + snapshot_manager_new_count_for_test, + }; + use std::path::{Path, PathBuf}; + use std::sync::Arc; + use std::time::Duration; + use uuid::Uuid; + + struct TestWorkspace { + path: PathBuf, + } + + impl TestWorkspace { + fn new() -> Self { + let path = std::env::temp_dir() + .join(format!("bitfun-snapshot-manager-test-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).expect("test workspace should be created"); + Self { path } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for TestWorkspace { + fn drop(&mut self) { + clear_snapshot_manager_for_test(&self.path); + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_get_or_create_initializes_snapshot_manager_once_per_workspace() { + let workspace = TestWorkspace::new(); + clear_snapshot_manager_for_test(workspace.path()); + reset_snapshot_manager_new_count_for_test(); + set_snapshot_manager_new_delay_for_test(Duration::from_millis(80)); + + let first = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None); + let second = get_or_create_snapshot_manager(workspace.path().to_path_buf(), None); + let (first, second) = tokio::join!(first, second); + + set_snapshot_manager_new_delay_for_test(Duration::ZERO); + + let first = first.expect("first snapshot manager should initialize"); + let second = second.expect("second snapshot manager should initialize"); + + assert!(Arc::ptr_eq(&first, &second)); + assert_eq!(snapshot_manager_new_count_for_test(), 1); + } +} diff --git a/src/crates/core/src/service/snapshot/service.rs b/src/crates/core/src/service/snapshot/service.rs index 722c74e85..afcf6821d 100644 --- a/src/crates/core/src/service/snapshot/service.rs +++ b/src/crates/core/src/service/snapshot/service.rs @@ -7,9 +7,10 @@ use crate::service::snapshot::types::{ OperationType, SessionInfo, SnapshotConfig, SnapshotError, SnapshotResult, }; use crate::service::workspace_runtime::WorkspaceRuntimeContext; -use log::info; +use log::{debug, info}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Instant; use tokio::sync::RwLock; /// Snapshot-based change tracking service (operation-history + file snapshots, git-isolated). @@ -57,29 +58,51 @@ impl SnapshotService { return Ok(()); } + let total_started_at = Instant::now(); info!("Initializing snapshot/operation history service"); { + let step_started_at = Instant::now(); let mut isolation_manager = self.isolation_manager.write().await; isolation_manager.ensure_complete_isolation().await?; + debug!( + "Snapshot service initialize step completed: step=ensure_complete_isolation duration_ms={}", + step_started_at.elapsed().as_millis() + ); } { + let step_started_at = Instant::now(); let mut snapshot_core = self.snapshot_core.write().await; snapshot_core.initialize().await?; + debug!( + "Snapshot service initialize step completed: step=snapshot_core_initialize duration_ms={}", + step_started_at.elapsed().as_millis() + ); } + let step_started_at = Instant::now(); self.file_lock_manager.initialize().await?; + debug!( + "Snapshot service initialize step completed: step=file_lock_manager_initialize duration_ms={}", + step_started_at.elapsed().as_millis() + ); self.initialized = true; + let step_started_at = Instant::now(); let isolation_status = { let isolation_manager = self.isolation_manager.read().await; isolation_manager.check_isolation_status().await? }; + debug!( + "Snapshot service initialize step completed: step=check_isolation_status duration_ms={}", + step_started_at.elapsed().as_millis() + ); info!( - "Snapshot service initialized: git_isolated={} bitfun_dir={}", + "Snapshot service initialized: git_isolated={} bitfun_dir={} duration_ms={}", isolation_status, - self.runtime_context.runtime_root.display() + self.runtime_context.runtime_root.display(), + total_started_at.elapsed().as_millis() ); Ok(()) diff --git a/src/crates/core/src/service/snapshot/snapshot_core.rs b/src/crates/core/src/service/snapshot/snapshot_core.rs index b2ee98220..f191f5b3a 100644 --- a/src/crates/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/core/src/service/snapshot/snapshot_core.rs @@ -8,7 +8,7 @@ use log::{debug, info, warn}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -113,13 +113,26 @@ impl SnapshotCore { } pub async fn initialize(&mut self) -> SnapshotResult<()> { + let total_started_at = Instant::now(); info!("Initializing operation history system"); + let snapshot_system_started_at = Instant::now(); self.snapshot_system.initialize().await?; + debug!( + "Operation history initialize step completed: step=file_snapshot_system duration_ms={}", + snapshot_system_started_at.elapsed().as_millis() + ); + + let sessions_started_at = Instant::now(); self.load_all_sessions().await?; + debug!( + "Operation history initialize step completed: step=load_sessions duration_ms={}", + sessions_started_at.elapsed().as_millis() + ); info!( - "Operation history system initialized: loaded_sessions={}", - self.sessions.len() + "Operation history system initialized: loaded_sessions={} duration_ms={}", + self.sessions.len(), + total_started_at.elapsed().as_millis() ); Ok(()) } @@ -897,6 +910,7 @@ impl SnapshotCore { } async fn load_all_sessions(&mut self) -> SnapshotResult<()> { + let started_at = Instant::now(); if !self.sessions_dir.exists() { return Ok(()); } @@ -928,7 +942,11 @@ impl SnapshotCore { ), } } - debug!("Loaded {} session files", loaded); + debug!( + "Loaded session files: count={} duration_ms={}", + loaded, + started_at.elapsed().as_millis() + ); self.rebuild_operation_index(); Ok(()) } diff --git a/src/crates/core/src/service/snapshot/snapshot_system.rs b/src/crates/core/src/service/snapshot/snapshot_system.rs index c23eb1812..7eb0a292c 100644 --- a/src/crates/core/src/service/snapshot/snapshot_system.rs +++ b/src/crates/core/src/service/snapshot/snapshot_system.rs @@ -8,7 +8,7 @@ use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; -use std::time::SystemTime; +use std::time::{Instant, SystemTime}; use tokio::sync::RwLock; use uuid::Uuid; @@ -230,15 +230,27 @@ impl FileSnapshotSystem { /// Initializes the snapshot system. pub async fn initialize(&mut self) -> SnapshotResult<()> { + let total_started_at = Instant::now(); info!("Initializing file snapshot system"); + let directories_started_at = Instant::now(); self.ensure_directories().await?; + debug!( + "File snapshot initialize step completed: step=ensure_directories duration_ms={}", + directories_started_at.elapsed().as_millis() + ); + let index_started_at = Instant::now(); self.load_snapshot_index().await?; + debug!( + "File snapshot initialize step completed: step=load_snapshot_index duration_ms={}", + index_started_at.elapsed().as_millis() + ); info!( - "File snapshot system initialized: loaded_snapshots={}", - self.active_snapshots.len() + "File snapshot system initialized: loaded_snapshots={} duration_ms={}", + self.active_snapshots.len(), + total_started_at.elapsed().as_millis() ); Ok(()) } @@ -266,6 +278,7 @@ impl FileSnapshotSystem { /// Loads the existing snapshot index. async fn load_snapshot_index(&mut self) -> SnapshotResult<()> { + let started_at = Instant::now(); let metadata_dir = self.snapshot_metadata_dir.clone(); if !metadata_dir.exists() { @@ -300,7 +313,11 @@ impl FileSnapshotSystem { } } - debug!("Loaded {} snapshots", loaded_count); + debug!( + "Loaded snapshot metadata files: count={} duration_ms={}", + loaded_count, + started_at.elapsed().as_millis() + ); Ok(()) } diff --git a/src/crates/services-integrations/src/git/service.rs b/src/crates/services-integrations/src/git/service.rs index b0ca1f04c..3ad46a42f 100644 --- a/src/crates/services-integrations/src/git/service.rs +++ b/src/crates/services-integrations/src/git/service.rs @@ -58,6 +58,31 @@ impl GitService { }) } + /// Gets lightweight repository information without scanning worktree status. + pub async fn get_repository_basic>(path: P) -> Result { + let repo = + Repository::open(&path).map_err(|e| GitError::RepositoryNotFound(e.to_string()))?; + + let current_branch = get_current_branch(&repo)?; + let is_bare = repo.is_bare(); + let path_str = path.as_ref().to_string_lossy().to_string(); + let name = path + .as_ref() + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(); + + Ok(GitRepository { + path: path_str, + name, + current_branch, + is_bare, + has_changes: false, + remotes: Vec::new(), + }) + } + /// Gets repository status. pub async fn get_status>(path: P) -> Result { let repo = diff --git a/src/web-ui/src/app/App.tsx b/src/web-ui/src/app/App.tsx index 143a40ca9..ae96de50a 100644 --- a/src/web-ui/src/app/App.tsx +++ b/src/web-ui/src/app/App.tsx @@ -53,6 +53,7 @@ function App() { const mountTimeRef = useRef(Date.now()); const mainWindowShownRef = useRef(false); const interactiveShellReadyRef = useRef(false); + const [interactiveShellReady, setInteractiveShellReady] = useState(false); // Once the workspace finishes loading, wait for the remaining min-display // time and then begin the exit animation. @@ -110,6 +111,7 @@ function App() { } interactiveShellReadyRef.current = true; startupTrace.markPhase('interactive_shell_ready'); + setInteractiveShellReady(true); }, [workspaceLoading]); // If the early reveal path fails, keep the old post-splash show as a retry. @@ -179,24 +181,71 @@ function App() { }, []); useEffect(() => { - if (!isTauriRuntime()) return; + if (!isTauriRuntime() || !interactiveShellReady) return; + let disposed = false; + let startupSyncHandle: { promise: Promise; cancel: () => void } | null = null; const emitCurrentAgentCompanionActivity = () => { + if (disposed) { + return; + } void emitAgentCompanionActivity(buildAgentCompanionActivity()); }; void aiExperienceConfigService.getSettingsAsync().then(async settings => { - await syncAgentCompanionDesktopWindow(settings); - emitCurrentAgentCompanionActivity(); - window.setTimeout(emitCurrentAgentCompanionActivity, 250); + if (disposed) { + return; + } + + const { backgroundTaskScheduler, BackgroundTaskCancelledError } = await import('@/shared/utils/backgroundTaskScheduler'); + if (disposed) { + return; + } + + startupTrace.markPhase('agent_companion_sync_scheduled', { + source: 'startup_idle', + }); + startupSyncHandle = backgroundTaskScheduler.schedule(async signal => { + if (signal.aborted || disposed) { + return; + } + startupTrace.markPhase('agent_companion_sync_start', { + source: 'startup_idle', + }); + await syncAgentCompanionDesktopWindow(settings); + if (signal.aborted || disposed) { + return; + } + emitCurrentAgentCompanionActivity(); + window.setTimeout(emitCurrentAgentCompanionActivity, 250); + startupTrace.markPhase('agent_companion_sync_end', { + source: 'startup_idle', + }); + }, { + idle: true, + inFlightKey: 'agent-companion:startup-sync', + priority: 'low', + }); + + startupSyncHandle.promise.catch(error => { + if (!disposed && !(error instanceof BackgroundTaskCancelledError)) { + log.warn('Initial Agent companion sync task failed', error); + } + }); }); - return aiExperienceConfigService.addChangeListener(settings => { + + const removeSettingsListener = aiExperienceConfigService.addChangeListener(settings => { void syncAgentCompanionDesktopWindow(settings).then(() => { emitCurrentAgentCompanionActivity(); window.setTimeout(emitCurrentAgentCompanionActivity, 250); }); }); - }, []); + return () => { + disposed = true; + startupSyncHandle?.cancel(); + removeSettingsListener(); + }; + }, [interactiveShellReady]); useEffect(() => subscribeAgentCompanionActivity(activity => { void emitAgentCompanionActivity(activity); diff --git a/src/web-ui/src/app/components/NavPanel/MainNav.tsx b/src/web-ui/src/app/components/NavPanel/MainNav.tsx index cb7af2dd3..0d7c9fbfc 100644 --- a/src/web-ui/src/app/components/NavPanel/MainNav.tsx +++ b/src/web-ui/src/app/components/NavPanel/MainNav.tsx @@ -177,10 +177,16 @@ const MainNav: React.FC = ({ void flowChatStore.initializeFromDisk( workspace.rootPath, workspace.connectionId ?? undefined, - workspace.sshHost ?? undefined + workspace.sshHost ?? undefined, + 'main_nav_opened_remote_workspace' ); } else { - void flowChatStore.initializeFromDisk(workspace.rootPath); + void flowChatStore.initializeFromDisk( + workspace.rootPath, + undefined, + undefined, + 'main_nav_opened_local_workspace' + ); } }); }, [openedWorkspacesList]); diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 6feb635ee..599635952 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -494,7 +494,8 @@ export const SSHRemoteProvider: React.FC = ({ children } workspace.remotePath, workspace.connectionId, workspace.sshHost?.trim() || - sshHostForRemoteWorkspace(workspace.connectionId, workspace.remotePath) + sshHostForRemoteWorkspace(workspace.connectionId, workspace.remotePath), + 'ssh_remote_auto_restore_existing' ) .catch(() => {}); @@ -544,7 +545,8 @@ export const SSHRemoteProvider: React.FC = ({ children } sshHostForRemoteWorkspace( result.workspace.connectionId, result.workspace.remotePath - ) + ), + 'ssh_remote_auto_restore_reconnected' ) .catch(() => {}); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 8b72929d9..0972c4df2 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -62,6 +62,7 @@ export class FlowChatManager { }), pendingTurnCompletions: new Map(), pendingHistoryLoads: new Map(), + pendingContextRestores: new Map(), contentBuffers: new Map(), activeTextItems: new Map(), saveDebouncers: new Map(), @@ -112,7 +113,8 @@ export class FlowChatManager { await this.context.flowChatStore.initializeFromDisk( workspacePath, remoteConnectionId, - remoteSshHost + remoteSshHost, + 'flow_chat_manager' ); const sessionMatchesWorkspace = (session: { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 9ba7a0a7b..477ebda31 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ensureBackendSession, switchChatSession } from './SessionModule'; import type { Session } from '../../types/flow-chat'; @@ -97,12 +97,17 @@ function createContext(session: Session) { context: { flowChatStore, pendingHistoryLoads: new Map>(), + pendingContextRestores: new Map>(), } as any, flowChatStore, }; } describe('SessionModule historical session coordination', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + it('switches to a historical session immediately while hydrating in the background', async () => { const load = createDeferred(); const { context, flowChatStore } = createContext(createSession()); @@ -136,4 +141,89 @@ describe('SessionModule historical session coordination', () => { expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); expect(agentApiMocks.createSession).not.toHaveBeenCalled(); }); + + it('restores pending backend context for a view-restored session before send', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [{ id: 'turn-1' } as any], + } as any)); + agentApiMocks.ensureCoordinatorSession.mockResolvedValueOnce(undefined); + + await ensureBackendSession(context, 'history-1'); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'ready', + }); + }); + + it('dedupes concurrent backend context restore for a view-restored session', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [{ id: 'turn-1' } as any], + } as any)); + const restore = createDeferred(); + agentApiMocks.ensureCoordinatorSession.mockReturnValueOnce(restore.promise); + + const firstEnsure = ensureBackendSession(context, 'history-1'); + const secondEnsure = ensureBackendSession(context, 'history-1'); + await Promise.resolve(); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + + restore.resolve(); + await Promise.all([firstEnsure, secondEnsure]); + + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + expect(context.pendingContextRestores.size).toBe(0); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'ready', + }); + }); + + it('does not recreate a view-restored session with loaded turns when context restore fails', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [{ id: 'turn-1' } as any], + } as any)); + agentApiMocks.ensureCoordinatorSession.mockRejectedValueOnce( + new Error('Session metadata not found') + ); + + await expect(ensureBackendSession(context, 'history-1')).rejects.toThrow(); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + expect(agentApiMocks.createSession).not.toHaveBeenCalled(); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'failed', + }); + }); + + it('keeps recreate fallback for empty pending context sessions', async () => { + const { context } = createContext(createSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + dialogTurns: [], + } as any)); + agentApiMocks.ensureCoordinatorSession.mockRejectedValueOnce( + new Error('Session metadata not found') + ); + agentApiMocks.createSession.mockResolvedValueOnce(undefined); + + await ensureBackendSession(context, 'history-1'); + + expect(agentApiMocks.ensureCoordinatorSession).toHaveBeenCalledTimes(1); + expect(agentApiMocks.createSession).toHaveBeenCalledTimes(1); + expect(context.flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + contextRestoreState: 'ready', + }); + }); }); 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 171fbfdc5..01d9ea814 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 @@ -655,31 +655,91 @@ export async function ensureBackendSession( const isHistoricalSession = latestSession.isHistorical === true; const isFirstTurn = latestSession.dialogTurns.length <= 1; - const needsBackendSetup = isHistoricalSession || isFirstTurn; + const requiresContextRestore = + latestSession.contextRestoreState === 'pending' || + latestSession.contextRestoreState === 'failed'; + const needsBackendSetup = isHistoricalSession || isFirstTurn || requiresContextRestore; + const hasLoadedTurns = latestSession.dialogTurns.length > 0; /** Avoid createSession when historical data is already loaded but backend files are missing (e.g. new SSH connection id). */ const allowRecreateOnCoordinatorFailure = - needsBackendSetup && !(isHistoricalSession && session.dialogTurns.length > 1); + needsBackendSetup && + !(requiresContextRestore && hasLoadedTurns) && + !(isHistoricalSession && hasLoadedTurns); - const clearHistoricalFlag = () => { - if (!isHistoricalSession) return; + const markBackendContextReady = () => { + if (!isHistoricalSession && !requiresContextRestore) return; context.flowChatStore.setState(prev => { const newSessions = new Map(prev.sessions); const sess = newSessions.get(sessionId); if (sess) { - newSessions.set(sessionId, { ...sess, isHistorical: false, historyState: 'ready' }); + newSessions.set(sessionId, { + ...sess, + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); } return { ...prev, sessions: newSessions }; }); }; - try { + const markBackendContextFailed = () => { + if (!requiresContextRestore) return; + context.flowChatStore.setState(prev => { + const newSessions = new Map(prev.sessions); + const sess = newSessions.get(sessionId); + if (sess) { + newSessions.set(sessionId, { ...sess, contextRestoreState: 'failed' }); + } + return { ...prev, sessions: newSessions }; + }); + }; + + const ensureCoordinator = async () => { await agentAPI.ensureCoordinatorSession({ sessionId, workspacePath, remoteConnectionId: effectiveConnectionId, remoteSshHost: effectiveSshHost, }); - clearHistoricalFlag(); + markBackendContextReady(); + }; + + const restorePendingBackendContext = async () => { + if (!context.pendingContextRestores) { + context.pendingContextRestores = new Map(); + } + const restoreKey = [ + sessionId, + workspacePath, + effectiveConnectionId ?? '', + effectiveSshHost ?? '', + ].join('\u001f'); + const existingRestore = context.pendingContextRestores.get(restoreKey); + if (existingRestore) { + await existingRestore; + return; + } + + const restorePromise = ensureCoordinator().catch(error => { + markBackendContextFailed(); + throw error; + }).finally(() => { + if (context.pendingContextRestores?.get(restoreKey) === restorePromise) { + context.pendingContextRestores.delete(restoreKey); + } + }); + context.pendingContextRestores.set(restoreKey, restorePromise); + await restorePromise; + }; + + try { + if (requiresContextRestore) { + await restorePendingBackendContext(); + return; + } + + await ensureCoordinator(); } catch (e: any) { if (!allowRecreateOnCoordinatorFailure) { const raw = typeof e?.message === 'string' ? e.message : String(e); @@ -708,7 +768,7 @@ export async function ensureBackendSession( remoteSshHost: effectiveSshHost, } }); - clearHistoricalFlag(); + markBackendContextReady(); } } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts index 82242ea93..4a6862a32 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/types.ts @@ -23,6 +23,8 @@ export interface FlowChatContext { }>; /** In-flight historical session hydration: sessionId -> promise */ pendingHistoryLoads: Map>; + /** In-flight backend context restore for view-restored historical sessions. */ + pendingContextRestores?: Map>; /** Content buffers: sessionId -> (roundId -> content) */ contentBuffers: Map>; /** Active text items: sessionId -> (roundId -> textItemId) */ diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 95b91cd83..305fcf3d8 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -7,6 +7,8 @@ const apiMocks = vi.hoisted(() => ({ loadSessionTurns: vi.fn(), saveSessionTurn: vi.fn(), restoreSession: vi.fn(), + restoreSessionView: vi.fn(), + restoreSessionWithTurns: vi.fn(), })); const configManagerMock = vi.hoisted(() => ({ @@ -30,6 +32,10 @@ vi.mock('@/infrastructure/api', () => ({ }, agentAPI: { restoreSession: apiMocks.restoreSession, + get restoreSessionView() { + return apiMocks.restoreSessionView; + }, + restoreSessionWithTurns: apiMocks.restoreSessionWithTurns, }, })); @@ -42,6 +48,16 @@ vi.mock('../state-machine', () => ({ })); const resetStore = () => { + const metadataListRequests = (flowChatStore as any).metadataListRequests as + | Map }> + | undefined; + metadataListRequests?.forEach(request => { + if (request.cleanupTimer) { + clearTimeout(request.cleanupTimer); + } + }); + metadataListRequests?.clear(); + ((flowChatStore as any).unsupportedRestoreCommands as Set | undefined)?.clear(); flowChatStore.setState((): FlowChatState => ({ sessions: new Map(), activeSessionId: null, @@ -225,6 +241,9 @@ describe('FlowChatStore local usage reports', () => { describe('FlowChatStore historical session hydration state', () => { afterEach(() => { resetStore(); + if (typeof apiMocks.restoreSessionView !== 'function') { + (apiMocks as any).restoreSessionView = vi.fn(); + } vi.clearAllMocks(); }); @@ -316,10 +335,81 @@ describe('FlowChatStore historical session hydration state', () => { }); }); + it('reuses an in-flight metadata list for the same workspace and remote identity', async () => { + const sessions = createDeferred(); + apiMocks.listSessions.mockReturnValueOnce(sessions.promise); + + const firstLoad = flowChatStore.initializeFromDisk( + 'D:/workspace/BitFun', + undefined, + undefined, + 'first-source' + ); + const secondLoad = flowChatStore.initializeFromDisk( + 'D:/workspace/BitFun', + undefined, + undefined, + 'second-source' + ); + + await vi.waitFor(() => { + expect(apiMocks.listSessions).toHaveBeenCalledTimes(1); + }); + + sessions.resolve([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + createdAt: 10, + lastActiveAt: 20, + }, + ]); + + await Promise.all([firstLoad, secondLoad]); + + expect(apiMocks.listSessions).toHaveBeenCalledTimes(1); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + sessionId: 'history-1', + historyState: 'metadata-only', + }); + }); + + it('reuses a recently completed metadata list for the same workspace', async () => { + apiMocks.listSessions.mockResolvedValueOnce([ + { + sessionId: 'history-1', + title: 'Saved session', + agentType: 'agentic', + createdAt: 10, + lastActiveAt: 20, + }, + ]); + + await flowChatStore.initializeFromDisk('D:/workspace/BitFun', undefined, undefined, 'first-source'); + await flowChatStore.initializeFromDisk('D:/workspace/BitFun', undefined, undefined, 'second-source'); + + expect(apiMocks.listSessions).toHaveBeenCalledTimes(1); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + sessionId: 'history-1', + historyState: 'metadata-only', + }); + }); + it('marks historical sessions hydrating while turns are loading and ready after completion', async () => { const turns = createDeferred(); - apiMocks.restoreSession.mockResolvedValueOnce(undefined); - apiMocks.loadSessionTurns.mockReturnValueOnce(turns.promise); + apiMocks.restoreSessionView.mockImplementationOnce(async () => ({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 0, + createdAt: 1, + }, + turns: await turns.promise, + contextRestoreState: 'pending', + })); flowChatStore.setState(() => ({ sessions: new Map([ ['history-1', createSession({ @@ -347,7 +437,7 @@ describe('FlowChatStore historical session hydration state', () => { }); it('marks historical sessions failed when hydrate fails', async () => { - apiMocks.restoreSession.mockResolvedValueOnce(undefined); + apiMocks.restoreSessionView.mockRejectedValueOnce(new Error('restore failed')); apiMocks.loadSessionTurns.mockRejectedValueOnce(new Error('turn load failed')); flowChatStore.setState(() => ({ sessions: new Map([ @@ -364,6 +454,7 @@ describe('FlowChatStore historical session hydration state', () => { flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun') ).rejects.toThrow('turn load failed'); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ isHistorical: true, historyState: 'failed', @@ -371,8 +462,18 @@ describe('FlowChatStore historical session hydration state', () => { }); it('does not change the active session when an older hydrate completes', async () => { - apiMocks.restoreSession.mockResolvedValueOnce(undefined); - apiMocks.loadSessionTurns.mockResolvedValueOnce([]); + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 0, + createdAt: 1, + }, + turns: [], + contextRestoreState: 'pending', + }); flowChatStore.setState(() => ({ sessions: new Map([ ['history-1', createSession({ @@ -416,5 +517,410 @@ describe('FlowChatStore historical session hydration state', () => { await flowChatStore.loadSessionHistory('acp-1', 'D:/workspace/BitFun'); expect(apiMocks.restoreSession).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionView).not.toHaveBeenCalled(); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + }); + + it('uses view-restored turns without reading the turn files a second time', async () => { + const visibleOutput = 'complete visible output '.repeat(64); + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [ + { + id: 'round-1', + turnId: 'turn-1', + roundIndex: 0, + timestamp: 1, + textItems: [], + toolItems: [ + { + id: 'tool-1', + toolName: 'Bash', + toolCall: { id: 'call-1', input: { command: 'printf output' } }, + toolResult: { + result: { + stdout: visibleOutput, + nested: { stderr: 'also visible' }, + }, + success: true, + durationMs: 1, + }, + startTime: 1, + endTime: 2, + durationMs: 1, + status: 'completed', + }, + ], + thinkingItems: [], + startTime: 1, + endTime: 2, + durationMs: 1, + status: 'completed', + }, + ], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + contextRestoreState: 'pending', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + }); + const toolItem = flowChatStore + .getState() + .sessions.get('history-1') + ?.dialogTurns[0] + ?.modelRounds[0] + ?.items.find(item => item.type === 'tool') as any; + expect(toolItem?.toolResult?.result?.stdout).toBe(visibleOutput); + expect(toolItem?.toolResult?.result?.nested?.stderr).toBe('also visible'); + expect(toolItem?.toolResult?.resultForAssistant).toBeUndefined(); + }); + + it('falls back to restoreSessionWithTurns when view restore is unavailable', async () => { + (apiMocks as any).restoreSessionView = undefined; + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionWithTurns.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); + }); + + it('falls back to restoreSessionWithTurns when the view restore command is unavailable on the backend', async () => { + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionView.mockRejectedValueOnce( + new Error('unknown command restore_session_view') + ); + apiMocks.restoreSessionWithTurns.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + }); + }); + + it('does not retry an unsupported view restore command for later sessions in the same runtime', async () => { + const restoredTurn = (sessionId: string) => ({ + turnId: `${sessionId}-turn-1`, + turnIndex: 0, + sessionId, + timestamp: 1, + userMessage: { id: `${sessionId}-user-1`, content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }); + apiMocks.restoreSessionView.mockRejectedValueOnce( + new Error('unknown command restore_session_view') + ); + apiMocks.restoreSessionWithTurns + .mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-1')], + }) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-2', + sessionName: 'History 2', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-2')], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ['history-2', createSession({ + sessionId: 'history-2', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + await flowChatStore.loadSessionHistory('history-2', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(2); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + }); + + it('scopes unsupported restore command caching by remote identity', async () => { + const restoredTurn = (sessionId: string) => ({ + turnId: `${sessionId}-turn-1`, + turnIndex: 0, + sessionId, + timestamp: 1, + userMessage: { id: `${sessionId}-user-1`, content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }); + apiMocks.restoreSessionView + .mockRejectedValueOnce(new Error('unknown command restore_session_view')) + .mockResolvedValueOnce({ + session: { + sessionId: 'history-2', + sessionName: 'History 2', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-2')], + contextRestoreState: 'pending', + }); + apiMocks.restoreSessionWithTurns.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn('history-1')], + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ['history-2', createSession({ + sessionId: 'history-2', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory( + 'history-1', + '/remote/workspace', + undefined, + 'remote-1', + 'old.example' + ); + await flowChatStore.loadSessionHistory('history-2', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(2); + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + }); + + it('falls back to legacy restore and turn loading when restoreSessionWithTurns is unavailable on the backend', async () => { + (apiMocks as any).restoreSessionView = undefined; + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionWithTurns.mockRejectedValueOnce( + new Error('unknown command restore_session_with_turns') + ); + apiMocks.restoreSession.mockResolvedValueOnce({ + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }); + apiMocks.loadSessionTurns.mockResolvedValueOnce([restoredTurn]); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionWithTurns).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSession).toHaveBeenCalledTimes(1); + expect(apiMocks.loadSessionTurns).toHaveBeenCalledTimes(1); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + dialogTurns: expect.arrayContaining([ + expect.objectContaining({ id: 'turn-1' }), + ]), + }); + }); + + it('uses view restore when available and marks backend context pending', async () => { + const restoredTurn = { + turnId: 'turn-1', + turnIndex: 0, + sessionId: 'history-1', + timestamp: 1, + userMessage: { id: 'user-1', content: 'hello', timestamp: 1 }, + modelRounds: [], + startTime: 1, + status: 'completed', + }; + apiMocks.restoreSessionView.mockResolvedValueOnce({ + session: { + sessionId: 'history-1', + sessionName: 'History 1', + agentType: 'agentic', + state: 'Idle', + turnCount: 1, + createdAt: 1, + }, + turns: [restoredTurn], + contextRestoreState: 'pending', + }); + flowChatStore.setState(() => ({ + sessions: new Map([ + ['history-1', createSession({ + sessionId: 'history-1', + isHistorical: true, + historyState: 'metadata-only', + })], + ]), + activeSessionId: 'history-1', + })); + + await flowChatStore.loadSessionHistory('history-1', 'D:/workspace/BitFun'); + + expect(apiMocks.restoreSessionView).toHaveBeenCalledTimes(1); + expect(apiMocks.restoreSessionWithTurns).not.toHaveBeenCalled(); + expect(apiMocks.loadSessionTurns).not.toHaveBeenCalled(); + expect(flowChatStore.getState().sessions.get('history-1')).toMatchObject({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + }); }); }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index e10a15728..a0ec4e20d 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -14,13 +14,18 @@ import { ImageAnalysisResult, AnyFlowItem, SessionConfig, + SessionContextRestoreState, SessionHistoryState, } from '../types/flow-chat'; import { createLogger } from '@/shared/utils/logger'; -import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace'; +import { + isRemoteTraceContext, + markPhaseAfterAnimationFrames, + startupTrace, +} from '@/shared/utils/startupTrace'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; -import type { LocalCommandMetadata, SessionKind } from '@/shared/types/session-history'; +import type { DialogTurnData, LocalCommandMetadata, SessionKind } from '@/shared/types/session-history'; import { deriveLastFinishedAtFromMetadata, deriveSessionRelationshipFromMetadata, @@ -48,6 +53,53 @@ import { sessionMatchesWorkspace } from '../utils/workspaceScope'; const log = createLogger('FlowChatStore'); const VALID_AGENT_TYPES = new Set(['agentic', 'debug', 'Plan', 'Cowork', 'Claw', 'Team', 'DeepResearch']); +const METADATA_LIST_RECENT_DEDUPE_TTL_MS = 1000; + +interface MetadataListRequest { + promise: Promise; + completedAtMs?: number; + cleanupTimer?: ReturnType; +} + +function isUnsupportedTauriCommandError(error: unknown, command: string): boolean { + const anyError = error as any; + const originalError = anyError?.context?.originalError; + const messageParts = [ + anyError?.message, + typeof originalError === 'string' ? originalError : originalError?.message, + ].filter((part): part is string => typeof part === 'string'); + const normalizedMessage = messageParts.join(' ').toLowerCase(); + const normalizedCommand = command.toLowerCase(); + const contextCommand = + typeof anyError?.context?.command === 'string' + ? anyError.context.command.toLowerCase() + : ''; + const mentionsCommand = + contextCommand === normalizedCommand || + normalizedMessage.includes(normalizedCommand); + + if (!mentionsCommand) { + return false; + } + + return normalizedMessage.includes('unknown command') || + normalizedMessage.includes('command not found') || + (normalizedMessage.includes('command') && normalizedMessage.includes('not found')) || + normalizedMessage.includes('not registered') || + normalizedMessage.includes('is not a function'); +} + +function restoreCommandSupportKey( + command: string, + remoteConnectionId?: string, + remoteSshHost?: string +): string { + return JSON.stringify([ + command, + remoteConnectionId?.trim() || 'local', + remoteSshHost?.trim().toLowerCase() || '', + ]); +} function isValidPersistedAgentType(agentType: string): boolean { return VALID_AGENT_TYPES.has(agentType) || agentType.startsWith('acp:'); @@ -58,6 +110,8 @@ export class FlowChatStore { private state: FlowChatState; private listeners: Set<(state: FlowChatState) => void> = new Set(); private silentMode = false; + private metadataListRequests = new Map(); + private unsupportedRestoreCommands = new Set(); private onPersistUnreadCompletion?: (sessionId: string, value: 'completed' | 'error' | 'interrupted' | undefined) => void; private constructor() { @@ -104,6 +158,18 @@ export class FlowChatStore { return this.state; } + private getMetadataListRequestKey( + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + ): string { + return JSON.stringify([ + workspacePath, + remoteConnectionId || '', + remoteSshHost || '', + ]); + } + public setState(updater: (prevState: FlowChatState) => FlowChatState): void { const newState = updater(this.state); this.state = newState; @@ -1734,17 +1800,93 @@ export class FlowChatStore { public async initializeFromDisk( workspacePath: string, remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + traceSource = 'unknown' ): Promise { + const requestKey = this.getMetadataListRequestKey(workspacePath, remoteConnectionId, remoteSshHost); + const existingRequest = this.metadataListRequests.get(requestKey); + const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); + if (existingRequest) { + const completedAtMs = existingRequest.completedAtMs; + const isRecentCompletedRequest = + completedAtMs !== undefined && + elapsedMs(completedAtMs) <= METADATA_LIST_RECENT_DEDUPE_TTL_MS; + + if (completedAtMs === undefined || isRecentCompletedRequest) { + startupTrace.markPhase('session_metadata_list_deduped', { + remote, + source: traceSource, + dedupeState: completedAtMs === undefined ? 'in-flight' : 'recent', + }); + return existingRequest.promise; + } + + if (existingRequest.cleanupTimer) { + clearTimeout(existingRequest.cleanupTimer); + } + this.metadataListRequests.delete(requestKey); + } + + let succeeded = false; + const loadPromise = this.initializeFromDiskUncached( + workspacePath, + remoteConnectionId, + remoteSshHost, + traceSource, + ).then(result => { + succeeded = result; + }); + + const request: MetadataListRequest = { promise: loadPromise }; + this.metadataListRequests.set(requestKey, request); + + loadPromise.finally(() => { + const currentRequest = this.metadataListRequests.get(requestKey); + if (currentRequest !== request) { + return; + } + + if (!succeeded) { + this.metadataListRequests.delete(requestKey); + return; + } + + request.completedAtMs = nowMs(); + request.cleanupTimer = setTimeout(() => { + if (this.metadataListRequests.get(requestKey) === request) { + this.metadataListRequests.delete(requestKey); + } + }, METADATA_LIST_RECENT_DEDUPE_TTL_MS); + }); + + return loadPromise; + } + + private async initializeFromDiskUncached( + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + traceSource = 'unknown' + ): Promise { const traceStartedAt = nowMs(); const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); + const metadataListTraceId = `metadata-${Math.random().toString(36).slice(2, 8)}`; let sessionCount = 0; - startupTrace.markPhase('session_metadata_list_start', { remote }); + startupTrace.markPhase('session_metadata_list_start', { + remote, + source: traceSource, + metadataListTraceId, + }); try { const { sessionAPI } = await import('@/infrastructure/api'); const sessions = await sessionAPI.listSessions(workspacePath, remoteConnectionId, remoteSshHost); sessionCount = sessions.length; - startupTrace.markPhase('session_metadata_list_loaded', { remote, sessionCount }); + startupTrace.markPhase('session_metadata_list_loaded', { + remote, + source: traceSource, + metadataListTraceId, + sessionCount, + }); const { stateMachineManager } = await import('../state-machine'); @@ -1874,16 +2016,22 @@ export class FlowChatStore { await Promise.all(sessions.map(processSession)); startupTrace.markPhase('session_metadata_list_end', { remote, + source: traceSource, + metadataListTraceId, sessionCount, durationMs: elapsedMs(traceStartedAt), }); + return true; } catch (error) { startupTrace.markPhase('session_metadata_list_failed', { remote, + source: traceSource, + metadataListTraceId, sessionCount, durationMs: elapsedMs(traceStartedAt), }); log.error('Failed to load persisted sessions', error); + return false; } } @@ -1907,6 +2055,29 @@ export class FlowChatStore { }); } + public setSessionContextRestoreState( + sessionId: string, + contextRestoreState: SessionContextRestoreState + ): void { + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session || session.contextRestoreState === contextRestoreState) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + contextRestoreState, + }); + + return { + ...prev, + sessions: newSessions, + }; + }); + } + /** * Lazy load session history (convert historical data to FlowChat format) */ @@ -1919,7 +2090,8 @@ export class FlowChatStore { ): Promise { const traceStartedAt = nowMs(); const remote = isRemoteTraceContext(remoteConnectionId, remoteSshHost); - startupTrace.markPhase('historical_session_hydrate_start', { remote }); + const sessionTraceId = `${sessionId.slice(0, 8)}-${Math.random().toString(36).slice(2, 8)}`; + startupTrace.markPhase('historical_session_hydrate_start', { remote, sessionTraceId }); this.setSessionHistoryState(sessionId, 'hydrating'); try { const { stateMachineManager } = await import('../state-machine'); @@ -1928,30 +2100,148 @@ export class FlowChatStore { const existingSession = this.state.sessions.get(sessionId); const isAcpSession = existingSession?.mode?.startsWith('acp:') || existingSession?.config.agentType?.startsWith('acp:'); + let turns: DialogTurnData[] | undefined; + let contextRestoreState: SessionContextRestoreState = 'ready'; if (!isAcpSession) { + const restoreStartedAt = nowMs(); + startupTrace.markPhase('historical_session_restore_start', { remote, sessionTraceId }); try { const { agentAPI } = await import('@/infrastructure/api'); - await agentAPI.restoreSession(sessionId, workspacePath, remoteConnectionId, remoteSshHost); + const restoreSessionViewSupportKey = restoreCommandSupportKey( + 'restore_session_view', + remoteConnectionId, + remoteSshHost + ); + const restoreSessionWithTurnsSupportKey = restoreCommandSupportKey( + 'restore_session_with_turns', + remoteConnectionId, + remoteSshHost + ); + const restoreWithTurnsOrSession = async (): Promise => { + if ( + typeof agentAPI.restoreSessionWithTurns === 'function' && + !this.unsupportedRestoreCommands.has(restoreSessionWithTurnsSupportKey) + ) { + try { + const restored = await agentAPI.restoreSessionWithTurns( + sessionId, + workspacePath, + remoteConnectionId, + remoteSshHost, + sessionTraceId + ); + turns = restored.turns; + contextRestoreState = 'ready'; + return; + } catch (error) { + if (!isUnsupportedTauriCommandError(error, 'restore_session_with_turns')) { + throw error; + } + this.unsupportedRestoreCommands.add(restoreSessionWithTurnsSupportKey); + startupTrace.markPhase('historical_session_restore_fallback', { + remote, + sessionTraceId, + from: 'restore_session_with_turns', + to: 'restore_session', + reason: 'unsupported-command', + }); + } + } + + await agentAPI.restoreSession( + sessionId, + workspacePath, + remoteConnectionId, + remoteSshHost, + sessionTraceId + ); + contextRestoreState = 'ready'; + }; + + if ( + typeof agentAPI.restoreSessionView === 'function' && + !this.unsupportedRestoreCommands.has(restoreSessionViewSupportKey) + ) { + try { + const restored = await agentAPI.restoreSessionView( + sessionId, + workspacePath, + remoteConnectionId, + remoteSshHost, + sessionTraceId + ); + turns = restored.turns; + contextRestoreState = + restored.contextRestoreState === 'ready' ? 'ready' : 'pending'; + } catch (error) { + if (!isUnsupportedTauriCommandError(error, 'restore_session_view')) { + throw error; + } + this.unsupportedRestoreCommands.add(restoreSessionViewSupportKey); + startupTrace.markPhase('historical_session_restore_fallback', { + remote, + sessionTraceId, + from: 'restore_session_view', + to: 'restore_session_with_turns', + reason: 'unsupported-command', + }); + await restoreWithTurnsOrSession(); + } + } else { + await restoreWithTurnsOrSession(); + } + startupTrace.markPhase('historical_session_restore_end', { + remote, + sessionTraceId, + turnCount: Array.isArray(turns) ? turns.length : 0, + contextRestoreState, + durationMs: elapsedMs(restoreStartedAt), + }); } catch (error) { + contextRestoreState = 'pending'; + startupTrace.markPhase('historical_session_restore_failed', { + remote, + sessionTraceId, + durationMs: elapsedMs(restoreStartedAt), + }); log.warn('Backend session restore failed (may be new session)', { sessionId, error }); } } - const { sessionAPI } = await import('@/infrastructure/api'); - const turns = await sessionAPI.loadSessionTurns( - sessionId, - workspacePath, - limit, - remoteConnectionId, - remoteSshHost - ); + if (!turns) { + const turnsLoadStartedAt = nowMs(); + startupTrace.markPhase('historical_session_turns_load_start', { remote, sessionTraceId }); + const { sessionAPI } = await import('@/infrastructure/api'); + turns = await sessionAPI.loadSessionTurns( + sessionId, + workspacePath, + limit, + remoteConnectionId, + remoteSshHost + ); + startupTrace.markPhase('historical_session_turns_load_end', { + remote, + sessionTraceId, + turnCount: Array.isArray(turns) ? turns.length : 0, + durationMs: elapsedMs(turnsLoadStartedAt), + }); + } startupTrace.markPhase('historical_session_turns_loaded', { remote, + sessionTraceId, turnCount: Array.isArray(turns) ? turns.length : 0, }); + const convertStartedAt = nowMs(); const dialogTurns = this.convertToDialogTurns(turns); + startupTrace.markPhase('historical_session_convert_end', { + remote, + sessionTraceId, + turnCount: dialogTurns.length, + durationMs: elapsedMs(convertStartedAt), + }); + const stateCommitStartedAt = nowMs(); this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; @@ -1961,6 +2251,7 @@ export class FlowChatStore { dialogTurns, isHistorical: false, historyState: 'ready' as const, + contextRestoreState, error: null, }; @@ -1972,12 +2263,26 @@ export class FlowChatStore { sessions: newSessions, }; }); + startupTrace.markPhase('historical_session_state_commit_end', { + remote, + sessionTraceId, + turnCount: dialogTurns.length, + durationMs: elapsedMs(stateCommitStartedAt), + }); + markPhaseAfterAnimationFrames(startupTrace, 'historical_session_after_state_commit_frame', { + remote, + sessionTraceId, + turnCount: dialogTurns.length, + }, { + frameCount: 2, + }); // Reset state machine to IDLE after loading history // This handles the case where restoreSession triggered events that left the state machine in PROCESSING stateMachineManager.reset(sessionId); startupTrace.markPhase('historical_session_hydrate_end', { remote, + sessionTraceId, turnCount: dialogTurns.length, durationMs: elapsedMs(traceStartedAt), }); @@ -2000,6 +2305,7 @@ export class FlowChatStore { }); startupTrace.markPhase('historical_session_hydrate_failed', { remote, + sessionTraceId, durationMs: elapsedMs(traceStartedAt), }); log.error('Failed to load session history', { sessionId, error }); diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 5732899fb..d1235585d 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -239,6 +239,11 @@ export type SessionHistoryState = | 'ready' | 'failed'; +export type SessionContextRestoreState = + | 'ready' + | 'pending' + | 'failed'; + // Session state. export interface Session { sessionId: string; @@ -280,6 +285,13 @@ export interface Session { * - 'failed': hydrate failed and the UI should offer retry instead of showing a new session. */ historyState?: SessionHistoryState; + + /** + * Backend runtime-context lifecycle for sessions restored through the fast + * history view path. Turns may be visible while model context is still loaded + * lazily; message sending must ensure this becomes 'ready' first. + */ + contextRestoreState?: SessionContextRestoreState; todos?: TodoItem[]; diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts new file mode 100644 index 000000000..bc7b0af8e --- /dev/null +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; +import { isExpectedTauriRequestError } from './tauri-adapter'; + +describe('Tauri adapter expected errors', () => { + it('classifies optional get_config not found as expected', () => { + expect(isExpectedTauriRequestError( + 'get_config', + { + request: { + path: 'font', + skipRetryOnNotFound: true, + }, + }, + new Error("Config path not found: 'font'") + )).toBe(true); + }); + + it('does not hide non-optional get_config failures', () => { + expect(isExpectedTauriRequestError( + 'get_config', + { + request: { + path: 'font', + }, + }, + new Error("Config path not found: 'font'") + )).toBe(false); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts index 73c63a9c4..92ecfbded 100644 --- a/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/tauri-adapter.ts @@ -7,6 +7,25 @@ import { sanitizeErrorForLog } from '../logSanitizer'; const log = createLogger('TauriAdapter'); +export function isExpectedTauriRequestError(action: string, params: unknown, error: unknown): boolean { + if (action !== 'get_config') { + return false; + } + + const request = (params as { request?: unknown } | undefined)?.request; + if (!request || typeof request !== 'object') { + return false; + } + + if (!(request as Record).skipRetryOnNotFound) { + return false; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + const normalized = errorMessage.toLowerCase(); + return normalized.includes('not found') && normalized.includes('config path'); +} + export class TauriTransportAdapter implements ITransportAdapter { private unlistenFunctions: UnlistenFn[] = []; private connected: boolean = false; @@ -69,7 +88,9 @@ export class TauriTransportAdapter implements ITransportAdapter { return result as T; } catch (error) { - log.error('Request failed', { action, error: sanitizeErrorForLog(error) }); + if (!isExpectedTauriRequestError(action, params, error)) { + log.error('Request failed', { action, error: sanitizeErrorForLog(error) }); + } throw 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 ed318f988..dd40b7f0b 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -2,6 +2,7 @@ import { api } from './ApiClient'; import { createTauriCommandError } from '../errors/TauriCommandError'; +import type { DialogTurnData } from '@/shared/types/session-history'; import type { ImageContextData as ImageInputContextData } from './ImageContextTypes'; @@ -83,6 +84,17 @@ export interface SessionInfo { createdAt: number; } +export interface RestoreSessionWithTurnsResponse { + session: SessionInfo; + turns: DialogTurnData[]; +} + +export interface RestoreSessionViewResponse { + session: SessionInfo; + turns: DialogTurnData[]; + contextRestoreState: 'ready' | 'pending'; +} + export interface EnsureAssistantBootstrapRequest { sessionId: string; workspacePath: string; @@ -359,17 +371,50 @@ export class AgentAPI { sessionId: string, workspacePath: string, remoteConnectionId?: string, - remoteSshHost?: string + remoteSshHost?: string, + traceId?: string ): Promise { try { return await api.invoke('restore_session', { - request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost }, + request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost, traceId }, }); } catch (error) { throw createTauriCommandError('restore_session', error, { sessionId, workspacePath }); } } + async restoreSessionWithTurns( + sessionId: string, + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + traceId?: string + ): Promise { + try { + return await api.invoke('restore_session_with_turns', { + request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost, traceId }, + }); + } catch (error) { + throw createTauriCommandError('restore_session_with_turns', error, { sessionId, workspacePath }); + } + } + + async restoreSessionView( + sessionId: string, + workspacePath: string, + remoteConnectionId?: string, + remoteSshHost?: string, + traceId?: string + ): Promise { + try { + return await api.invoke('restore_session_view', { + request: { sessionId, workspacePath, remoteConnectionId, remoteSshHost, traceId }, + }); + } catch (error) { + throw createTauriCommandError('restore_session_view', error, { sessionId, workspacePath }); + } + } + /** * No-op if the session is already in the coordinator; otherwise loads it from disk * using the same workspace path resolution as restore_session (required for SSH remote workspaces). diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts new file mode 100644 index 000000000..677940a1f --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ApiClient } from './ApiClient'; + +const adapterMocks = vi.hoisted(() => ({ + request: vi.fn(), + listen: vi.fn(), + connect: vi.fn(), + disconnect: vi.fn(), + isConnected: vi.fn(() => true), +})); + +const traceMocks = vi.hoisted(() => ({ + estimateJsonBytes: vi.fn(() => 1), + recordApiCall: vi.fn(), +})); + +const loggerMocks = vi.hoisted(() => ({ + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../adapters', () => ({ + getTransportAdapter: () => adapterMocks, +})); + +vi.mock('@/shared/utils/logger', () => ({ + createLogger: () => loggerMocks, +})); + +vi.mock('@/shared/utils/startupTrace', () => ({ + estimateJsonBytes: traceMocks.estimateJsonBytes, + isRemoteTraceRequest: vi.fn(() => false), + startupTrace: traceMocks, +})); + +describe('ApiClient startup trace classification', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not record optional get_config not found as a startup failure', async () => { + adapterMocks.request.mockRejectedValueOnce(new Error("Config path not found: 'font'")); + const client = new ApiClient({ enableLogging: true, retries: 0 }); + + await expect( + client.invoke('get_config', { + request: { + path: 'font', + skipRetryOnNotFound: true, + }, + }) + ).rejects.toThrow(); + + expect(traceMocks.recordApiCall).toHaveBeenCalledWith(expect.objectContaining({ + command: 'get_config', + outcome: 'success', + })); + expect(client.getStats()).toMatchObject({ + successfulRequests: 1, + failedRequests: 0, + }); + expect(loggerMocks.error).not.toHaveBeenCalled(); + }); + + it('uses a bounded response estimate cap for session view restore', async () => { + adapterMocks.request.mockResolvedValueOnce({ turns: [] }); + const client = new ApiClient({ enableLogging: false, retries: 0 }); + + await client.invoke('restore_session_view', { + request: { + sessionId: 'history-1', + workspacePath: 'D:/workspace/BitFun', + }, + }); + + expect(traceMocks.estimateJsonBytes).toHaveBeenCalledWith( + { turns: [] }, + 2 * 1024 * 1024 + ); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts index 8c2e0fe4c..9f95cd58b 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ApiClient.ts @@ -20,6 +20,42 @@ import { sanitizeErrorForLog, sanitizeLogValue, sanitizeTextForLog } from '../lo const log = createLogger('ApiClient'); const sanitizeForLog = sanitizeLogValue; +const SESSION_RESPONSE_ESTIMATE_MAX_BYTES = 2 * 1024 * 1024; + +function responseEstimateMaxBytes(command: string): number | undefined { + return command === 'restore_session_view' || + command === 'restore_session_with_turns' || + command === 'load_session_turns' + ? SESSION_RESPONSE_ESTIMATE_MAX_BYTES + : undefined; +} + +function isOptionalConfigNotFoundCommand(config: TauriCommandConfig, error: unknown): boolean { + if (config.command !== 'get_config') { + return false; + } + + const requestPayload = config.args?.request; + if (!requestPayload || typeof requestPayload !== 'object') { + return false; + } + + if (!(requestPayload as Record).skipRetryOnNotFound) { + return false; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + const normalized = errorMessage.toLowerCase(); + return normalized.includes('not found') && normalized.includes('config path'); +} + +function isOptionalConfigNotFound(request: ApiRequest, error: unknown): boolean { + if (request.type !== 'tauri') { + return false; + } + + return isOptionalConfigNotFoundCommand(request.config as TauriCommandConfig, error); +} export class ApiClient implements IApiClient { private config: ApiConfig; @@ -172,7 +208,10 @@ export class ApiClient implements IApiClient { const durationMs = elapsedMs(startedAt); const responseEstimateStartedAt = nowMs(); - const responseBytes = estimateJsonBytes(response.data); + const responseBytes = estimateJsonBytes( + response.data, + responseEstimateMaxBytes(traceCommand) + ); const responseEstimateDurationMs = elapsedMs(responseEstimateStartedAt); startupTrace.recordApiCall({ type: request.type, @@ -202,19 +241,22 @@ export class ApiClient implements IApiClient { this.activeRequests.delete(request.id); } } catch (error) { - this.updateStats({ failedRequests: this.stats.failedRequests + 1 }); + const optionalConfigNotFound = isOptionalConfigNotFound(request, error); + this.updateStats(optionalConfigNotFound + ? { successfulRequests: this.stats.successfulRequests + 1 } + : { failedRequests: this.stats.failedRequests + 1 }); startupTrace.recordApiCall({ type: request.type, command: traceCommand, durationMs: elapsedMs(startedAt), - outcome: 'failure', + outcome: optionalConfigNotFound ? 'success' : 'failure', requestBytes, payloadEstimateDurationMs: requestPayloadEstimateDurationMs, remote, }); - if (request.retryCount < (request.config.retries || this.config.retries)) { + if (!optionalConfigNotFound && request.retryCount < (request.config.retries || this.config.retries)) { const delay = (request.config.retryDelay || this.config.retryDelay) * Math.pow(2, request.retryCount); @@ -233,7 +275,7 @@ export class ApiClient implements IApiClient { } - if (this.config.enableLogging) { + if (this.config.enableLogging && !optionalConfigNotFound) { log.error('Request failed after retries', { requestId: request.id, retryCount: request.retryCount, @@ -264,20 +306,23 @@ export class ApiClient implements IApiClient { const isExpectedError = errorMessage.includes('not found') || errorMessage.includes('Config path') || errorMessage.includes('Configuration error'); + const optionalConfigNotFound = isOptionalConfigNotFoundCommand(config, error); - if (isExpectedError && this.config.enableLogging) { - log.debug('Command returned expected result', { - command: config.command, - message: sanitizeTextForLog(errorMessage) - }); - } else { - log.error('Command failed', { - command: config.command, - args: sanitizeForLog(config.args), - error: sanitizeTextForLog(errorMessage), - rawError: sanitizeErrorForLog(error) - }); + if (this.config.enableLogging) { + if (isExpectedError && !optionalConfigNotFound) { + log.debug('Command returned expected result', { + command: config.command, + message: sanitizeTextForLog(errorMessage) + }); + } else if (!isExpectedError) { + log.error('Command failed', { + command: config.command, + args: sanitizeForLog(config.args), + error: sanitizeTextForLog(errorMessage), + rawError: sanitizeErrorForLog(error) + }); + } } throw this.createApiError('COMMAND_FAILED', errorMessage, error); diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts new file mode 100644 index 000000000..d5328ffdc --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ConfigAPI } from './ConfigAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { + invoke: invokeMock, + }, +})); + +describe('ConfigAPI batch config reads', () => { + let configAPI: ConfigAPI; + + beforeEach(() => { + configAPI = new ConfigAPI(); + invokeMock.mockReset(); + }); + + it('reads multiple config paths through one batch command', async () => { + const configs = { + 'ai.models': [], + 'ai.default_models': { chat: 'gpt-5' }, + }; + invokeMock.mockResolvedValueOnce(configs); + + await expect( + configAPI.getConfigs(['ai.models', 'ai.models', 'ai.default_models']) + ).resolves.toEqual(configs); + + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith('get_configs', { + request: { + paths: ['ai.models', 'ai.default_models'], + skipRetryOnNotFound: false, + }, + }); + }); + + it('falls back to existing single-path reads when the batch command fails', async () => { + invokeMock.mockImplementation((command: string, args?: any) => { + if (command === 'get_configs') { + return Promise.reject(new Error('unknown command get_configs')); + } + + return Promise.resolve(`value:${args.request.path}`); + }); + + await expect(configAPI.getConfigs(['ai.models', 'ai.default_models'])).resolves.toEqual({ + 'ai.models': 'value:ai.models', + 'ai.default_models': 'value:ai.default_models', + }); + + expect(invokeMock).toHaveBeenCalledTimes(3); + expect(invokeMock).toHaveBeenNthCalledWith(1, 'get_configs', { + request: { + paths: ['ai.models', 'ai.default_models'], + skipRetryOnNotFound: false, + }, + }); + expect(invokeMock).toHaveBeenNthCalledWith(2, 'get_config', { + request: { + path: 'ai.models', + skipRetryOnNotFound: false, + }, + }, undefined); + expect(invokeMock).toHaveBeenNthCalledWith(3, 'get_config', { + request: { + path: 'ai.default_models', + skipRetryOnNotFound: false, + }, + }, undefined); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts index de87d4529..fd2df70c1 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts @@ -89,6 +89,35 @@ export class ConfigAPI { } } + async getConfigs( + paths: string[], + options?: { skipRetryOnNotFound?: boolean } + ): Promise> { + const uniquePaths = Array.from(new Set(paths)); + if (uniquePaths.length === 0) { + return {}; + } + + const shouldSkipRetry = options?.skipRetryOnNotFound ?? false; + + try { + return await api.invoke('get_configs', { + request: { + paths: uniquePaths, + skipRetryOnNotFound: shouldSkipRetry, + }, + }); + } catch { + const entries = await Promise.all( + uniquePaths.map(async (path) => [ + path, + await this.getConfig(path, options), + ] as const) + ); + return Object.fromEntries(entries); + } + } + async setConfig(path: string, value: any): Promise { try { diff --git a/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts b/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts index fd5591fae..ea9218a98 100644 --- a/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/GitAPI.ts @@ -200,6 +200,17 @@ export class GitAPI { } } + + async getRepositoryBasic(repositoryPath: string): Promise { + try { + return await api.invoke('git_get_repository_basic', { + request: { repositoryPath } + }); + } catch (error) { + throw createTauriCommandError('git_get_repository_basic', error, { repositoryPath }); + } + } + async getStatus(repositoryPath: string): Promise { try { diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts new file mode 100644 index 000000000..7f9a0fd6d --- /dev/null +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { SnapshotAPI } from './SnapshotAPI'; + +const invokeMock = vi.hoisted(() => vi.fn()); + +vi.mock('./ApiClient', () => ({ + api: { + invoke: invokeMock, + }, +})); + +describe('SnapshotAPI request dedupe', () => { + let snapshotAPI: SnapshotAPI; + + beforeEach(() => { + snapshotAPI = new SnapshotAPI(); + invokeMock.mockReset(); + }); + + it('deduplicates concurrent session stats requests for the same session and workspace', async () => { + const stats = { + session_id: 'session-1', + total_files: 2, + total_turns: 3, + total_changes: 4, + }; + invokeMock.mockResolvedValueOnce(stats); + + const first = snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + const second = snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + + await expect(Promise.all([first, second])).resolves.toEqual([stats, stats]); + expect(invokeMock).toHaveBeenCalledTimes(1); + expect(invokeMock).toHaveBeenCalledWith('get_session_stats', { + request: { + session_id: 'session-1', + workspacePath: 'D:/workspace/BitFun', + }, + }); + }); + + it('allows a new session stats request after the in-flight request settles', async () => { + invokeMock + .mockResolvedValueOnce({ + session_id: 'session-1', + total_files: 1, + total_turns: 1, + total_changes: 1, + }) + .mockResolvedValueOnce({ + session_id: 'session-1', + total_files: 2, + total_turns: 2, + total_changes: 2, + }); + + await snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + await snapshotAPI.getSessionStats('session-1', 'D:/workspace/BitFun'); + + expect(invokeMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts index d5903a575..1c772884d 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SnapshotAPI.ts @@ -137,6 +137,23 @@ export interface CleanupSandboxDataRequest { } export class SnapshotAPI { + private readonly inFlightRequests = new Map>(); + + private dedupeInFlight(key: string, load: () => Promise): Promise { + const existing = this.inFlightRequests.get(key) as Promise | undefined; + if (existing) { + return existing; + } + + const request = load().finally(() => { + if (this.inFlightRequests.get(key) === request) { + this.inFlightRequests.delete(key); + } + }); + this.inFlightRequests.set(key, request); + return request; + } + async getSessionStats(sessionId: string, workspacePath?: string): Promise<{ session_id: string; @@ -146,9 +163,10 @@ export class SnapshotAPI { }> { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_session_stats', { - request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } - }); + const key = `get_session_stats:${resolvedWorkspacePath}:${sessionId}`; + return await this.dedupeInFlight(key, () => api.invoke('get_session_stats', { + request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } + })); } catch (error) { throw createTauriCommandError('get_session_stats', error, { sessionId, workspacePath }); } @@ -158,9 +176,10 @@ export class SnapshotAPI { async getSessionFiles(sessionId: string, workspacePath?: string): Promise { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_session_files', { - request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } - }); + const key = `get_session_files:${resolvedWorkspacePath}:${sessionId}`; + return await this.dedupeInFlight(key, () => api.invoke('get_session_files', { + request: { session_id: sessionId, workspacePath: resolvedWorkspacePath } + })); } catch (error) { throw createTauriCommandError('get_session_files', error, { sessionId, workspacePath }); } @@ -195,9 +214,10 @@ export class SnapshotAPI { ): Promise { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_session_file_diff_stats', { + const key = `get_session_file_diff_stats:${resolvedWorkspacePath}:${sessionId}:${filePath}`; + return await this.dedupeInFlight(key, () => api.invoke('get_session_file_diff_stats', { request: { sessionId, filePath, workspacePath: resolvedWorkspacePath }, - }); + })); } catch (error) { throw createTauriCommandError('get_session_file_diff_stats', error, { sessionId, @@ -214,9 +234,10 @@ export class SnapshotAPI { ): Promise { try { const resolvedWorkspacePath = requireSessionWorkspacePath(sessionId, workspacePath); - return await api.invoke('get_operation_summary', { + const key = `get_operation_summary:${resolvedWorkspacePath}:${sessionId}:${operationId}`; + return await this.dedupeInFlight(key, () => api.invoke('get_operation_summary', { request: { sessionId, operationId, workspacePath: resolvedWorkspacePath } - }); + })); } catch (error) { throw createTauriCommandError('get_operation_summary', error, { sessionId, diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts index e2cef02bc..942d9892f 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionWindowService.ts @@ -15,20 +15,33 @@ export async function syncAgentCompanionDesktopWindow( if (!isTauriRuntime()) return; const run = async (): Promise => { + const startedAt = performance.now(); const command = settings.enable_agent_companion && settings.agent_companion_display_mode === 'desktop' ? 'show_agent_companion_desktop_pet' : 'hide_agent_companion_desktop_pet'; try { + log.debug('Agent companion desktop window sync started', { + command, + displayMode: settings.agent_companion_display_mode, + }); const { invoke } = await import('@tauri-apps/api/core'); await invoke(command); if (command === 'show_agent_companion_desktop_pet') { const { emit } = await import('@tauri-apps/api/event'); await emit('agent-companion://settings-updated', settings); } + log.debug('Agent companion desktop window sync completed', { + command, + durationMs: Number((performance.now() - startedAt).toFixed(1)), + }); } catch (error) { - log.error('Failed to sync Agent companion desktop window', { command, error }); + log.error('Failed to sync Agent companion desktop window', { + command, + durationMs: Number((performance.now() - startedAt).toFixed(1)), + error, + }); } }; diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts index b48657995..15053c7dc 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.test.ts @@ -3,6 +3,7 @@ import { configManager } from './ConfigManager'; const configApiMocks = vi.hoisted(() => ({ getConfig: vi.fn(), + getConfigs: vi.fn(), setConfig: vi.fn(), resetConfig: vi.fn(), exportConfig: vi.fn(), @@ -54,4 +55,25 @@ describe('ConfigManager', () => { await expect(Promise.all([first, second])).resolves.toEqual(['debug', 'debug']); expect(configApiMocks.getConfig).toHaveBeenCalledTimes(1); }); + + it('reloads startup config paths through one batch call', async () => { + configApiMocks.getConfigs.mockResolvedValueOnce({ + 'ai.models': [], + 'ai.agent_models': { coder: 'gpt-5' }, + 'ai.func_agent_models': { title: 'gpt-5-mini' }, + 'ai.default_models': { chat: 'gpt-5' }, + }); + + await configManager.reload(); + + expect(configApiMocks.getConfigs).toHaveBeenCalledTimes(1); + expect(configApiMocks.getConfigs).toHaveBeenCalledWith([ + 'ai.models', + 'ai.agent_models', + 'ai.func_agent_models', + 'ai.default_models', + ]); + expect(configApiMocks.getConfig).not.toHaveBeenCalled(); + expect(configManager.get('ai.default_models')).toEqual({ chat: 'gpt-5' }); + }); }); diff --git a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts index 2d2f4d377..ee05b0eb0 100644 --- a/src/web-ui/src/infrastructure/config/services/ConfigManager.ts +++ b/src/web-ui/src/infrastructure/config/services/ConfigManager.ts @@ -85,6 +85,22 @@ class ConfigManagerImpl implements IConfigManager { return resolvedConfig as T; } + private async readConfigs(paths: string[]): Promise> { + const configs = await configAPI.getConfigs(paths); + const resolvedConfigs: Record = {}; + + for (const path of paths) { + const resolvedConfig = path === 'ai.models' + ? await this.migrateLegacyAiModelsIfNeeded(configs[path]) + : configs[path]; + + this.configCache.set(path, resolvedConfig); + resolvedConfigs[path] = resolvedConfig; + } + + return resolvedConfigs; + } + async getConfig(path?: string): Promise { try { @@ -286,11 +302,13 @@ class ConfigManagerImpl implements IConfigManager { try { this.configCache.clear(); this.inFlightReads.clear(); - - await this.getConfig('ai.models'); - await this.getConfig('ai.agent_models'); - await this.getConfig('ai.func_agent_models'); - await this.getConfig('ai.default_models'); + + await this.readConfigs([ + 'ai.models', + 'ai.agent_models', + 'ai.func_agent_models', + 'ai.default_models', + ]); } catch (error) { log.error('Failed to reload config', error); throw error; diff --git a/src/web-ui/src/shared/utils/startupTrace.test.ts b/src/web-ui/src/shared/utils/startupTrace.test.ts index a35cbd5c3..874d6c20e 100644 --- a/src/web-ui/src/shared/utils/startupTrace.test.ts +++ b/src/web-ui/src/shared/utils/startupTrace.test.ts @@ -4,6 +4,7 @@ import { estimateJsonBytes, isRemoteTraceContext, isRemoteTraceRequest, + markPhaseAfterAnimationFrames, } from './startupTrace'; import type { LoggerLike } from './timing'; @@ -222,6 +223,50 @@ describe('startupTrace', () => { expect(logger.info).not.toHaveBeenCalled(); }); + it('marks deferred phases only after the requested animation frames', () => { + const logger = createTestLogger(); + let now = 100; + const callbacks: Array<(time: number) => void> = []; + const trace = createStartupTrace({ + logger, + traceId: 'trace-test', + now: () => now, + }); + + markPhaseAfterAnimationFrames(trace, 'historical_session_first_paint', { + sessionTraceId: 'session-trace', + remote: false, + }, { + frameCount: 2, + now: () => now, + requestAnimationFrame: callback => { + callbacks.push(callback); + return callbacks.length; + }, + }); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(callbacks).toHaveLength(1); + + now = 116; + callbacks.shift()?.(now); + expect(logger.debug).not.toHaveBeenCalled(); + expect(callbacks).toHaveLength(1); + + now = 132; + callbacks.shift()?.(now); + + expect(logger.debug).toHaveBeenCalledTimes(1); + const [, payload] = logger.debug.mock.calls[0]; + expect(payload).toMatchObject({ + traceId: 'trace-test', + phase: 'historical_session_first_paint', + sessionTraceId: 'session-trace', + remote: false, + durationMs: 32, + }); + }); + it('uses the desktop injected trace id when available', () => { const previousTraceId = (globalThis as { __BITFUN_STARTUP_TRACE_ID__?: string }) .__BITFUN_STARTUP_TRACE_ID__; diff --git a/src/web-ui/src/shared/utils/startupTrace.ts b/src/web-ui/src/shared/utils/startupTrace.ts index 6e3eece77..744656537 100644 --- a/src/web-ui/src/shared/utils/startupTrace.ts +++ b/src/web-ui/src/shared/utils/startupTrace.ts @@ -26,6 +26,12 @@ export interface StartupTraceOptions { maxPhaseEvents?: number; } +export interface DeferredAnimationFrameTraceOptions { + frameCount?: number; + now?: NowFn; + requestAnimationFrame?: (callback: (time: number) => void) => number; +} + interface CommandAggregate { command: string; count: number; @@ -368,3 +374,42 @@ export function createStartupTrace(options: StartupTraceOptions = {}): StartupTr } export const startupTrace = createStartupTrace(); + +export function markPhaseAfterAnimationFrames( + trace: StartupTrace, + phase: string, + data?: TraceData, + options: DeferredAnimationFrameTraceOptions = {} +): void { + const frameCount = Math.max(1, Math.floor(options.frameCount ?? 2)); + const now = options.now ?? (() => globalThis.performance?.now?.() ?? Date.now()); + const requestFrame = options.requestAnimationFrame ?? globalThis.requestAnimationFrame?.bind(globalThis); + const startedAt = now(); + + if (!requestFrame) { + trace.markPhase(phase, { + ...(data ?? {}), + frameCount: 0, + durationMs: 0, + }); + return; + } + + let remainingFrames = frameCount; + const scheduleNextFrame = () => { + requestFrame(() => { + remainingFrames -= 1; + if (remainingFrames <= 0) { + trace.markPhase(phase, { + ...(data ?? {}), + frameCount, + durationMs: roundDurationMs(now() - startedAt), + }); + return; + } + scheduleNextFrame(); + }); + }; + + scheduleNextFrame(); +} diff --git a/src/web-ui/src/tools/git/state/GitStateManager.test.ts b/src/web-ui/src/tools/git/state/GitStateManager.test.ts index 24c1790ea..67276b686 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.test.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.test.ts @@ -3,6 +3,7 @@ import { GitStateManager } from './GitStateManager'; const gitApiMocks = vi.hoisted(() => ({ isGitRepository: vi.fn(), + getRepositoryBasic: vi.fn(), getRepository: vi.fn(), getStatus: vi.fn(), getBranches: vi.fn(), @@ -60,6 +61,14 @@ describe('GitStateManager refresh performance guards', () => { manager.setCacheConfig({ basic: 0, status: 0, detailed: 0 }); gitApiMocks.isGitRepository.mockResolvedValue(true); + gitApiMocks.getRepositoryBasic.mockResolvedValue({ + path: repositoryPath, + name: 'BitFun', + current_branch: 'main', + is_bare: false, + has_changes: false, + remotes: [], + }); gitApiMocks.getRepository.mockResolvedValue({ path: repositoryPath, name: 'BitFun', @@ -97,12 +106,13 @@ describe('GitStateManager refresh performance guards', () => { await refresh; expect(gitApiMocks.isGitRepository).toHaveBeenCalledTimes(1); - expect(gitApiMocks.getRepository).toHaveBeenCalledTimes(1); + expect(gitApiMocks.getRepositoryBasic).toHaveBeenCalledTimes(1); + expect(gitApiMocks.getRepository).not.toHaveBeenCalled(); expect(gitApiMocks.getStatus).not.toHaveBeenCalled(); expect(manager.getState(repositoryPath)).toMatchObject({ isRepository: true, currentBranch: 'main', - hasChanges: true, + hasChanges: false, }); }); diff --git a/src/web-ui/src/tools/git/state/GitStateManager.ts b/src/web-ui/src/tools/git/state/GitStateManager.ts index 4bcb326e9..7275de98f 100644 --- a/src/web-ui/src/tools/git/state/GitStateManager.ts +++ b/src/web-ui/src/tools/git/state/GitStateManager.ts @@ -478,11 +478,12 @@ export class GitStateManager { const shouldRefreshStatus = layersToRefresh.includes('status'); if (!shouldRefreshStatus) { - const repository = await gitAPI.getRepository(repositoryPath); + const repository = await gitAPI.getRepositoryBasic(repositoryPath); + const currentState = this.getOrCreateState(repositoryPath); this.updateState(repositoryPath, { isRepository: true, currentBranch: repository.current_branch || repository.branch || null, - hasChanges: Boolean(repository.has_changes), + hasChanges: currentState.hasChanges, }); return; } diff --git a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts new file mode 100644 index 000000000..9d8c0356b --- /dev/null +++ b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { shouldRefreshSnapshotForSession } from './snapshotRefreshPolicy'; + +describe('snapshot refresh policy', () => { + it('defers snapshot refresh while persisted history is not ready', () => { + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'metadata-only', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'hydrating', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'failed', + })).toBe(false); + }); + + it('keeps snapshot refresh enabled for ready, new, and unknown sessions', () => { + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'ready', + })).toBe(true); + expect(shouldRefreshSnapshotForSession({ + isHistorical: false, + historyState: 'new', + })).toBe(true); + expect(shouldRefreshSnapshotForSession(null)).toBe(true); + }); + + it('defers snapshot refresh while backend context restore is pending', () => { + expect(shouldRefreshSnapshotForSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'pending', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: true, + historyState: 'ready', + contextRestoreState: 'pending', + })).toBe(false); + expect(shouldRefreshSnapshotForSession({ + isHistorical: false, + historyState: 'ready', + contextRestoreState: 'ready', + })).toBe(true); + }); +}); diff --git a/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts new file mode 100644 index 000000000..bf73df98c --- /dev/null +++ b/src/web-ui/src/tools/snapshot_system/hooks/snapshotRefreshPolicy.ts @@ -0,0 +1,19 @@ +import type { Session } from '@/flow_chat/types/flow-chat'; + +type SnapshotRefreshSession = Pick; + +export function shouldRefreshSnapshotForSession( + session?: SnapshotRefreshSession | null +): boolean { + if (!session || !session.isHistorical) { + return session?.contextRestoreState !== 'pending'; + } + + if (session.contextRestoreState === 'pending') { + return false; + } + + return session.historyState === undefined || + session.historyState === 'new' || + session.historyState === 'ready'; +} diff --git a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts index e2679244a..06a8bce44 100644 --- a/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts +++ b/src/web-ui/src/tools/snapshot_system/hooks/useSnapshotState.ts @@ -5,6 +5,8 @@ import { SnapshotEventBus, SNAPSHOT_EVENTS } from '../core/SnapshotEventBus'; import { DiffDisplayEngine, CompactDiffResult, FullDiffResult } from '../core/DiffDisplayEngine'; import SnapshotLazyLoader from '../core/SnapshotLazyLoader'; import { createLogger } from '@/shared/utils/logger'; +import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { shouldRefreshSnapshotForSession } from './snapshotRefreshPolicy'; const log = createLogger('useSnapshotState'); @@ -37,6 +39,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => // Track the active session to avoid applying stale events after session switches. const activeSessionIdRef = useRef(sessionId); + const refreshGenerationRef = useRef(0); const stateManager = SnapshotStateManager.getInstance(); const eventBus = SnapshotEventBus.getInstance(); @@ -45,23 +48,57 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => const refreshSession = useCallback(async () => { if (!sessionId) return; + const session = flowChatStore.getState().sessions.get(sessionId); + if (!shouldRefreshSnapshotForSession(session)) { + refreshGenerationRef.current += 1; + setLoading(false); + setError(null); + setSessionState(null); + setFiles([]); + return; + } + + const refreshGeneration = refreshGenerationRef.current + 1; + refreshGenerationRef.current = refreshGeneration; + setLoading(true); setError(null); try { await SnapshotLazyLoader.ensureInitialized(); + + if ( + refreshGenerationRef.current !== refreshGeneration || + activeSessionIdRef.current !== sessionId || + !shouldRefreshSnapshotForSession(flowChatStore.getState().sessions.get(sessionId)) + ) { + return; + } await stateManager.refreshSessionState(sessionId); + + if ( + refreshGenerationRef.current !== refreshGeneration || + activeSessionIdRef.current !== sessionId || + !shouldRefreshSnapshotForSession(flowChatStore.getState().sessions.get(sessionId)) + ) { + return; + } + const newSessionState = stateManager.getSessionState(sessionId); const newFiles = stateManager.getSessionFiles(sessionId); setSessionState(newSessionState); setFiles(newFiles); } catch (err) { - log.error('Failed to refresh session state', { sessionId, error: err }); - setError(t('snapshotSystem.errors.refreshSessionFailed')); + if (refreshGenerationRef.current === refreshGeneration && activeSessionIdRef.current === sessionId) { + log.error('Failed to refresh session state', { sessionId, error: err }); + setError(t('snapshotSystem.errors.refreshSessionFailed')); + } } finally { - setLoading(false); + if (refreshGenerationRef.current === refreshGeneration && activeSessionIdRef.current === sessionId) { + setLoading(false); + } } }, [sessionId, stateManager, t]); @@ -192,6 +229,7 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => } activeSessionIdRef.current = sessionId; + refreshGenerationRef.current += 1; setFiles([]); setSessionState(null); @@ -221,11 +259,36 @@ export const useSnapshotState = (sessionId?: string): UseSnapshotStateReturn => } }); - refreshSession(); + let canRefresh = shouldRefreshSnapshotForSession( + flowChatStore.getState().sessions.get(sessionId) + ); + + if (canRefresh) { + refreshSession(); + } + + const unsubscribeFlowChat = flowChatStore.subscribe((state) => { + const nextCanRefresh = shouldRefreshSnapshotForSession(state.sessions.get(sessionId)); + if (nextCanRefresh && !canRefresh) { + canRefresh = true; + refreshSession(); + return; + } + + if (!nextCanRefresh && canRefresh) { + canRefresh = false; + refreshGenerationRef.current += 1; + setLoading(false); + setFiles([]); + setSessionState(null); + } + }); return () => { + refreshGenerationRef.current += 1; unsubscribeSession(); unsubscribeFile(); + unsubscribeFlowChat(); }; }, [sessionId, stateManager, refreshSession]);