From 8b7ed1f1fb4a7d810bf58a9774873c999fd7d547 Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Thu, 28 May 2026 19:43:49 +0800 Subject: [PATCH] feat(chat): add prompt cache guard for mode switching - split session mode tracking into current selection, last surviving dialog mode, and last submitted mode - expose prompt cache scope metadata through core, desktop API, and web UI session/mode types - warn on send when switching to a mode that invalidates prompt cache reuse - keep dialog turn agent type metadata in sync for restore, rollback, and queue flows - preserve the input draft when the prompt cache confirmation is dismissed --- src/apps/desktop/src/api/agentic_api.rs | 11 +++ .../core/src/agentic/agents/registry/types.rs | 10 +++ .../src/agentic/coordination/coordinator.rs | 70 ++++++++++----- .../src/agentic/coordination/scheduler.rs | 24 +++++ src/crates/core/src/agentic/core/session.rs | 28 +++++- .../core/src/agentic/persistence/manager.rs | 18 +++- .../src/agentic/session/session_manager.rs | 44 ++++++++- src/crates/services-core/src/session/types.rs | 58 ++++++++++-- .../src/flow_chat/components/ChatInput.tsx | 89 ++++++++++++++++--- .../src/flow_chat/reducers/modeReducer.ts | 5 ++ .../flow-chat-manager/MessageModule.ts | 5 ++ .../src/flow_chat/store/FlowChatStore.ts | 71 ++++++++++++++- src/web-ui/src/flow_chat/types/flow-chat.ts | 21 ++++- .../api/service-api/AgentAPI.ts | 11 +++ src/web-ui/src/locales/en-US/flow-chat.json | 4 + src/web-ui/src/locales/zh-CN/flow-chat.json | 4 + src/web-ui/src/locales/zh-TW/flow-chat.json | 4 + .../src/shared/types/session-history.ts | 21 +++++ 18 files changed, 445 insertions(+), 53 deletions(-) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 8a1b632a1..065b5d461 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -200,7 +200,12 @@ pub struct GetSessionRequest { pub struct SessionResponse { pub session_id: String, pub session_name: String, + /// Current/default mode selection for the next dialog turn. pub agent_type: String, + /// Mode of the last surviving user dialog turn in session history. + pub last_user_dialog_agent_type: Option, + /// Mode of the most recent user submission accepted by the scheduler. + pub last_submitted_agent_type: Option, pub state: String, pub turn_count: usize, pub created_at: u64, @@ -1459,6 +1464,8 @@ pub async fn list_sessions( session_id: summary.session_id, session_name: summary.session_name, agent_type: summary.agent_type, + last_user_dialog_agent_type: summary.last_user_dialog_agent_type, + last_submitted_agent_type: summary.last_submitted_agent_type, state: format!("{:?}", summary.state), turn_count: summary.turn_count, created_at: system_time_to_unix_secs(summary.created_at), @@ -1522,6 +1529,7 @@ pub async fn get_available_modes(state: State<'_, AppState>) -> Result, + pub prompt_cache_scope_key: String, } fn assistant_bootstrap_outcome_to_response( @@ -1600,6 +1609,8 @@ fn session_to_response(session: Session) -> SessionResponse { session_id: session.session_id, session_name: session.session_name, agent_type: session.agent_type, + last_user_dialog_agent_type: session.last_user_dialog_agent_type, + last_submitted_agent_type: session.last_submitted_agent_type, state: format!("{:?}", session.state), turn_count: session.dialog_turn_ids.len(), created_at: system_time_to_unix_secs(session.created_at), diff --git a/src/crates/core/src/agentic/agents/registry/types.rs b/src/crates/core/src/agentic/agents/registry/types.rs index 7bb1bcc7e..b28fb0906 100644 --- a/src/crates/core/src/agentic/agents/registry/types.rs +++ b/src/crates/core/src/agentic/agents/registry/types.rs @@ -93,6 +93,11 @@ pub struct AgentInfo { pub is_review: bool, pub tool_count: usize, pub default_tools: Vec, + /// Combined prompt-cache compatibility key for frontend mode-switch guards. + /// + /// Modes that share this key can reuse the same session-level prompt cache + /// for the next accepted submission. + pub prompt_cache_scope_key: String, #[serde(default)] pub default_enabled: bool, #[serde(default = "default_true")] @@ -182,6 +187,11 @@ impl AgentInfo { is_review: is_review_agent_entry(entry), tool_count: default_tools.len(), default_tools, + prompt_cache_scope_key: format!( + "{}||{}", + agent.system_prompt_cache_identity(None).scope_key, + agent.user_context_cache_identity().scope_key + ), default_enabled: true, effective_enabled: true, override_state: None, diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index e38ef78be..92d8fd215 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -1257,6 +1257,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: session_id.to_string(), session_name: "Recovered Session".to_string(), agent_type: "agentic".to_string(), + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, created_by: None, session_kind: SessionKind::Standard, model_name: "default".to_string(), @@ -1391,7 +1393,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } } - (crate::service::session::TurnStatus::Completed, final_response) + ( + crate::service::session::TurnStatus::Completed, + final_response, + ) } async fn persist_cancelled_dialog_turn( @@ -1401,7 +1406,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_id: &str, turn_id: &str, ) -> crate::service::session::TurnStatus { - info!("Dialog turn cancelled: session={}, turn={}", session_id, turn_id); + info!( + "Dialog turn cancelled: session={}, turn={}", + session_id, turn_id + ); // The execution engine only emits DialogTurnCancelled when cancellation is // detected between rounds. If cancellation interrupted streaming mid-round, @@ -1422,7 +1430,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ); } - if let Err(error) = session_manager.cancel_dialog_turn(session_id, turn_id).await { + if let Err(error) = session_manager + .cancel_dialog_turn(session_id, turn_id) + .await + { error!( "Failed to cancel dialog turn in persistence: session_id={}, turn_id={}, error={}", session_id, turn_id, error @@ -2812,19 +2823,17 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await { - Ok(execution_result) => { - Some( - Self::persist_completed_dialog_turn( - session_manager.as_ref(), - scheduler_notify_tx.as_ref(), - &session_id_clone, - &turn_id_clone, - &execution_result, - ) - .await - .0, + Ok(execution_result) => Some( + Self::persist_completed_dialog_turn( + session_manager.as_ref(), + scheduler_notify_tx.as_ref(), + &session_id_clone, + &turn_id_clone, + &execution_result, ) - } + .await + .0, + ), Err(e) => { if matches!(&e, BitFunError::Cancelled(_)) { Some( @@ -4241,14 +4250,16 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // Persist turn lifecycle before cleaning up the hidden subagent runtime. let (workspace_turn_status, response_text) = match result { - Ok(exec_result) => Self::persist_completed_dialog_turn( - self.session_manager.as_ref(), - None, - &session_id, - &dialog_turn_id, - &exec_result, - ) - .await, + Ok(exec_result) => { + Self::persist_completed_dialog_turn( + self.session_manager.as_ref(), + None, + &session_id, + &dialog_turn_id, + &exec_result, + ) + .await + } Err(e) => { let turn_status = if matches!(&e, BitFunError::Cancelled(_)) { Self::persist_cancelled_dialog_turn( @@ -4836,6 +4847,19 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .await } + /// Update the session-level prompt-cache guard mode for the latest + /// scheduler-accepted user submission. + pub async fn update_last_submitted_agent_type( + &self, + session_id: &str, + agent_type: &str, + ) -> BitFunResult<()> { + let normalized = Self::normalize_agent_type(agent_type); + self.session_manager + .update_last_submitted_agent_type(session_id, &normalized) + .await + } + /// Emit event async fn emit_event(&self, event: AgenticEvent) { let _ = self diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index 101d04360..5870e74d3 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -419,6 +419,8 @@ impl DialogScheduler { match state { None => { let tid = self.start_turn(&session_id, &queued_turn).await?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; Ok(DialogSubmitOutcome::Started { session_id, turn_id: tid, @@ -428,6 +430,8 @@ impl DialogScheduler { Some(SessionState::Error { .. }) => { self.clear_queue(&session_id); let tid = self.start_turn(&session_id, &queued_turn).await?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; Ok(DialogSubmitOutcome::Started { session_id, turn_id: tid, @@ -443,6 +447,8 @@ impl DialogScheduler { if queue_non_empty { self.enqueue(&session_id, queued_turn.clone())?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; let started_tid = self.try_start_next_queued(&session_id).await?; let outcome = match started_tid { Some(tid) if tid == resolved_turn_id => DialogSubmitOutcome::Started { @@ -457,6 +463,8 @@ impl DialogScheduler { Ok(outcome) } else { let tid = self.start_turn(&session_id, &queued_turn).await?; + self.record_last_submitted_agent_type(&session_id, &queued_turn.agent_type) + .await; Ok(DialogSubmitOutcome::Started { session_id, turn_id: tid, @@ -466,7 +474,10 @@ impl DialogScheduler { Some(SessionState::Processing { .. }) => { let may_preempt = Self::user_message_may_preempt(&queued_turn.policy); + let accepted_agent_type = queued_turn.agent_type.clone(); self.enqueue(&session_id, queued_turn)?; + self.record_last_submitted_agent_type(&session_id, &accepted_agent_type) + .await; if may_preempt { self.round_yield_flags.request_yield(&session_id); } @@ -478,6 +489,19 @@ impl DialogScheduler { } } + async fn record_last_submitted_agent_type(&self, session_id: &str, agent_type: &str) { + if let Err(error) = self + .coordinator + .update_last_submitted_agent_type(session_id, agent_type) + .await + { + warn!( + "Failed to record last submitted agent type: session_id={}, agent_type={}, error={}", + session_id, agent_type, error + ); + } + } + /// Number of messages currently queued for a session. pub fn queue_depth(&self, session_id: &str) -> usize { self.queues.get(session_id).map(|q| q.len()).unwrap_or(0) diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index 05fa12896..b677c0a1a 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -12,14 +12,25 @@ pub struct Session { pub session_id: String, pub session_name: String, /// Current/default mode selection for the session. - /// This reflects what the next turn should run with by default, not - /// necessarily what the last surviving history turn used. + /// + /// This is the mode the next dialog turn should run with by default. It is + /// not required to match either the last surviving history turn or the last + /// message submission accepted by the scheduler. pub agent_type: String, /// Cached mode of the last surviving user dialog turn in history. - /// `previous_agent_type` reminders should read this instead of `agent_type` - /// so rollback-to-empty can still be treated as a fresh mode entry. + /// + /// Reminder builders use this value for `previous_agent_type` so + /// first-entry vs ongoing mode prompts follow the surviving transcript + /// after rollbacks or turn truncation. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_user_dialog_agent_type: Option, + /// Mode of the most recent user submission accepted by the scheduler. + /// + /// Unlike `last_user_dialog_agent_type`, this value is not rewound by + /// history rollback. It tracks session-level prompt-cache compatibility for + /// the next accepted submission. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_submitted_agent_type: Option, #[serde( default, skip_serializing_if = "Option::is_none", @@ -80,6 +91,7 @@ impl Session { session_name, agent_type, last_user_dialog_agent_type: None, + last_submitted_agent_type: None, created_by: None, kind: SessionKind::Standard, snapshot_session_id: None, @@ -105,6 +117,7 @@ impl Session { session_name, agent_type, last_user_dialog_agent_type: None, + last_submitted_agent_type: None, created_by: None, kind: SessionKind::Standard, snapshot_session_id: None, @@ -175,7 +188,14 @@ impl Default for SessionConfig { pub struct SessionSummary { pub session_id: String, pub session_name: String, + /// Current/default mode selection for the session. pub agent_type: String, + /// Mode of the last surviving user dialog turn in the session history. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_user_dialog_agent_type: Option, + /// Mode of the most recent user submission accepted by the scheduler. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_submitted_agent_type: Option, #[serde( default, skip_serializing_if = "Option::is_none", diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index d13bc7761..688ee29a5 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -56,6 +56,11 @@ struct StoredSessionStateFile { // on persisted dialog turns via `DialogTurnData.agent_type`. #[serde(default, skip_serializing_if = "Option::is_none")] last_user_dialog_agent_type: Option, + // Session-level prompt-cache guard state. This records the most recent user + // submission accepted by the scheduler and intentionally does not rewind on + // history rollback. + #[serde(default, skip_serializing_if = "Option::is_none")] + last_submitted_agent_type: Option, compression_state: CompressionState, runtime_state: SessionState, } @@ -867,6 +872,8 @@ impl PersistenceManager { session_id: session.session_id.clone(), session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), + last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), + last_submitted_agent_type: session.last_submitted_agent_type.clone(), created_by: session .created_by .clone() @@ -1934,6 +1941,7 @@ impl PersistenceManager { config: session.config.clone(), snapshot_session_id: session.snapshot_session_id.clone(), last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), + last_submitted_agent_type: session.last_submitted_agent_type.clone(), compression_state: session.compression_state.clone(), runtime_state: Self::sanitize_runtime_state(&session.state), }; @@ -2005,7 +2013,12 @@ impl PersistenceManager { agent_type: metadata.agent_type.clone(), last_user_dialog_agent_type: stored_state .as_ref() - .and_then(|value| value.last_user_dialog_agent_type.clone()), + .and_then(|value| value.last_user_dialog_agent_type.clone()) + .or_else(|| metadata.last_user_dialog_agent_type.clone()), + last_submitted_agent_type: stored_state + .as_ref() + .and_then(|value| value.last_submitted_agent_type.clone()) + .or_else(|| metadata.last_submitted_agent_type.clone()), created_by: metadata.created_by.clone(), kind: metadata.session_kind, snapshot_session_id: stored_state @@ -2042,6 +2055,7 @@ impl PersistenceManager { }, snapshot_session_id: None, last_user_dialog_agent_type: None, + last_submitted_agent_type: None, compression_state: CompressionState::default(), runtime_state: SessionState::Idle, }); @@ -2085,6 +2099,8 @@ impl PersistenceManager { session_id: metadata.session_id, session_name: metadata.session_name, agent_type: metadata.agent_type, + last_user_dialog_agent_type: metadata.last_user_dialog_agent_type, + last_submitted_agent_type: metadata.last_submitted_agent_type, created_by: metadata.created_by, kind: metadata.session_kind, turn_count: metadata.turn_count, diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 66bd8e09a..865b39ca3 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -1518,6 +1518,45 @@ impl SessionManager { Ok(()) } + /// Update the most recent scheduler-accepted user submission mode. + /// + /// This state is intentionally independent from rollback-sensitive history + /// semantics. Prompt-cache guards should read this instead of deriving from + /// surviving dialog turns. + pub async fn update_last_submitted_agent_type( + &self, + session_id: &str, + agent_type: &str, + ) -> BitFunResult<()> { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.last_submitted_agent_type = Some(agent_type.to_string()); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + + if self.should_persist_session_id(session_id) { + let effective_path = self.effective_session_workspace_path(session_id).await; + let session_snapshot = self.sessions.get(session_id).map(|s| s.clone()); + if let (Some(workspace_path), Some(session)) = (effective_path, session_snapshot) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + } + + debug!( + "Session last submitted agent type updated: session_id={}, agent_type={}", + session_id, agent_type + ); + + Ok(()) + } + fn derive_last_user_dialog_agent_type_from_turns( turns: &[DialogTurnData], fallback_agent_type: Option<&str>, @@ -2305,6 +2344,8 @@ impl SessionManager { session_id: session.session_id.clone(), session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), + last_user_dialog_agent_type: session.last_user_dialog_agent_type.clone(), + last_submitted_agent_type: session.last_submitted_agent_type.clone(), created_by: session.created_by.clone(), kind: session.kind, turn_count: session.dialog_turn_ids.len(), @@ -3876,8 +3917,7 @@ fn extract_subagent_relationship( mod tests { use super::{SessionManager, SessionManagerConfig}; use crate::agentic::core::{ - Message, MessageContent, MessageRole, ProcessingPhase, Session, SessionConfig, - SessionState, + Message, MessageContent, MessageRole, ProcessingPhase, Session, SessionConfig, SessionState, }; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::{ diff --git a/src/crates/services-core/src/session/types.rs b/src/crates/services-core/src/session/types.rs index 6705efb4c..77986467d 100644 --- a/src/crates/services-core/src/session/types.rs +++ b/src/crates/services-core/src/session/types.rs @@ -20,17 +20,41 @@ pub enum SessionRelationshipKind { pub struct SessionRelationship { #[serde(default, skip_serializing_if = "Option::is_none")] pub kind: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "parent_session_id")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "parent_session_id" + )] pub parent_session_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "parent_request_id")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "parent_request_id" + )] pub parent_request_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "parent_dialog_turn_id")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "parent_dialog_turn_id" + )] pub parent_dialog_turn_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "parent_turn_index")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "parent_turn_index" + )] pub parent_turn_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "parent_tool_call_id")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "parent_tool_call_id" + )] pub parent_tool_call_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "subagent_type")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "subagent_type" + )] pub subagent_type: Option, } @@ -49,6 +73,26 @@ pub struct SessionMetadata { /// Agent type #[serde(alias = "agent_type")] pub agent_type: String, + /// Mode of the last surviving user dialog turn in the persisted history. + /// + /// This follows rollback and turn-truncation semantics and is used for + /// first-entry vs ongoing mode reminders. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "last_user_dialog_agent_type" + )] + pub last_user_dialog_agent_type: Option, + /// Mode of the most recent user submission accepted by the scheduler. + /// + /// This is a session-level prompt-cache guard signal and intentionally does + /// not rewind when history is rolled back. + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "last_submitted_agent_type" + )] + pub last_submitted_agent_type: Option, /// Creator identity for future permission checks #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by")] @@ -668,6 +712,8 @@ impl SessionMetadata { session_id, session_name, agent_type, + last_user_dialog_agent_type: None, + last_submitted_agent_type: None, created_by: None, session_kind: SessionKind::Standard, model_name, diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 19a87a4fb..3361db881 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -46,7 +46,7 @@ import { launchDeepReviewSession, } from '../services/DeepReviewService'; import { createLogger } from '@/shared/utils/logger'; -import { Tooltip, IconButton } from '@/component-library'; +import { Tooltip, IconButton, confirmWarning } from '@/component-library'; import { PendingQueuePanel } from './PendingQueuePanel'; import { useAgentCanvasStore } from '@/app/components/panels/content-canvas/stores'; import { openBtwSessionInAuxPane, selectActiveBtwSessionTab } from '../services/openBtwSession'; @@ -514,6 +514,57 @@ export const ChatInput: React.FC = ({ currentAgentType: modeState.current, }); + const modeInfoById = useMemo( + () => new Map(modeState.available.map(mode => [mode.id, mode])), + [modeState.available], + ); + + const getModeDisplayName = useCallback((modeId?: string) => { + if (!modeId) { + return ''; + } + + return ( + t(`chatInput.modeNames.${modeId}`, { defaultValue: '' }) || + modeInfoById.get(modeId)?.name || + modeId + ); + }, [modeInfoById, t]); + + const confirmPromptCacheGuardIfNeeded = useCallback(async () => { + const nextMode = currentMode.trim(); + const lastSubmittedMode = effectiveTargetSession?.lastSubmittedMode?.trim(); + if (!nextMode || !lastSubmittedMode || nextMode === lastSubmittedMode) { + return true; + } + + const nextScopeKey = modeInfoById.get(nextMode)?.promptCacheScopeKey; + const previousScopeKey = modeInfoById.get(lastSubmittedMode)?.promptCacheScopeKey; + if (!nextScopeKey || !previousScopeKey || nextScopeKey === previousScopeKey) { + return true; + } + + return confirmWarning( + t('chatInput.promptCacheGuardTitle', { + defaultValue: 'Switching this mode will reset prompt cache reuse', + }), + t('chatInput.promptCacheGuardBody', { + defaultValue: + 'The next request will switch from {{fromMode}} to {{toMode}}, so this session will stop reusing its current prompt cache. Continue?', + fromMode: getModeDisplayName(lastSubmittedMode), + toMode: getModeDisplayName(nextMode), + }), + { + confirmText: t('chatInput.promptCacheGuardConfirm', { + defaultValue: 'Send anyway', + }), + cancelText: t('chatInput.promptCacheGuardCancel', { + defaultValue: 'Stay here', + }), + }, + ); + }, [currentMode, effectiveTargetSession?.lastSubmittedMode, getModeDisplayName, modeInfoById, t]); + const [mcpPromptCommands, setMcpPromptCommands] = useState([]); const [mcpPromptCommandsLoading, setMcpPromptCommandsLoading] = useState(false); @@ -1796,6 +1847,11 @@ export const ChatInput: React.FC = ({ return; } + const confirmed = await confirmPromptCacheGuardIfNeeded(); + if (!confirmed) { + return; + } + const originalPendingLargePastes = { ...pendingLargePastesRef.current }; if (effectiveTargetSessionId) { addToHistory(effectiveTargetSessionId, originalMessage); @@ -1850,6 +1906,7 @@ export const ChatInput: React.FC = ({ }, [ clearPendingLargePastes, addToHistory, + confirmPromptCacheGuardIfNeeded, effectiveTargetSessionId, inputState.value, loadMcpPromptCommands, @@ -1953,18 +2010,6 @@ export const ChatInput: React.FC = ({ return; } - // Add to history before clearing (session-scoped) - if (effectiveTargetSessionId) { - addToHistory(effectiveTargetSessionId, message); - } - setHistoryIndex(-1); - setSavedDraft(''); - - dispatchInput({ type: 'CLEAR_VALUE' }); - clearPendingLargePastes(); - // Clear machine queue too; otherwise the queuedInput→input sync effect puts the text back after send. - setQueuedInput(null); - if (messageCharCount > CHAT_INPUT_CONFIG.largePaste.maxMessageChars) { notificationService.error( t('input.messageTooLarge', { @@ -1980,6 +2025,23 @@ export const ChatInput: React.FC = ({ return; } + const confirmed = await confirmPromptCacheGuardIfNeeded(); + if (!confirmed) { + return; + } + + // Add to history before clearing (session-scoped) + if (effectiveTargetSessionId) { + addToHistory(effectiveTargetSessionId, message); + } + setHistoryIndex(-1); + setSavedDraft(''); + + dispatchInput({ type: 'CLEAR_VALUE' }); + clearPendingLargePastes(); + // Clear machine queue too; otherwise the queuedInput→input sync effect puts the text back after send. + setQueuedInput(null); + try { await sendMessage(message, { displayMessage: originalMessage, @@ -2014,6 +2076,7 @@ export const ChatInput: React.FC = ({ submitInitFromInput, submitDeepreviewFromInput, submitMcpPromptFromInput, + confirmPromptCacheGuardIfNeeded, t, resolveTypedMcpPromptCommand, ]); diff --git a/src/web-ui/src/flow_chat/reducers/modeReducer.ts b/src/web-ui/src/flow_chat/reducers/modeReducer.ts index 3c5b43543..5bb4c7e75 100644 --- a/src/web-ui/src/flow_chat/reducers/modeReducer.ts +++ b/src/web-ui/src/flow_chat/reducers/modeReducer.ts @@ -9,6 +9,11 @@ export interface ModeInfo { isReadonly: boolean; toolCount: number; defaultTools?: string[]; + /** + * Combined prompt-cache compatibility key returned by the backend. + * Modes with the same key can reuse the same session-level prompt cache. + */ + promptCacheScopeKey: string; } export interface ModeState { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 665df76b3..b66e5a202 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -236,6 +236,7 @@ export async function sendMessage( const dialogTurn: DialogTurn = { id: dialogTurnId, sessionId: sessionId, + agentType: currentAgentType, userMessage: { id: `user_${Date.now()}`, content: displayMessage || message, @@ -317,6 +318,7 @@ export async function sendMessage( remoteConnectionId: updatedSession.remoteConnectionId, remoteSshHost: updatedSession.remoteSshHost, }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); } else { try { await agentAPI.startDialogTurn({ @@ -329,6 +331,7 @@ export async function sendMessage( imageContexts: options?.imageContexts, userMessageMetadata: options?.userMessageMetadata, }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); } catch (error: any) { if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { log.warn('Backend session still not found, retrying creation', { @@ -346,7 +349,9 @@ export async function sendMessage( agentType: currentAgentType, workspacePath, imageContexts: options?.imageContexts, + userMessageMetadata: options?.userMessageMetadata, }); + context.flowChatStore.updateSessionLastSubmittedMode(sessionId, currentAgentType); } else { throw error; } diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index b24623177..e1e6ebb9c 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -27,6 +27,7 @@ import { import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import type { DialogTurnData, LocalCommandMetadata, SessionKind } from '@/shared/types/session-history'; +import type { SessionInfo as AgentSessionInfo } from '@/infrastructure/api/service-api/AgentAPI'; import { deriveLastFinishedAtFromMetadata, deriveSessionRelationshipFromMetadata, @@ -292,6 +293,19 @@ export class FlowChatStore { this.onPersistUnreadCompletion = callback; } + private deriveLastUserDialogMode(dialogTurns: DialogTurn[]): string | undefined { + for (let index = dialogTurns.length - 1; index >= 0; index -= 1) { + const turn = dialogTurns[index]; + const kind = turn.kind || 'user_dialog'; + const agentType = turn.agentType?.trim(); + if (kind === 'user_dialog' && agentType) { + return agentType; + } + } + + return undefined; + } + public createSession( sessionId: string, config: SessionConfig, @@ -331,6 +345,8 @@ export class FlowChatStore { historyState: 'new', maxContextTokens: maxContextTokens || 128128, mode: mode || 'agentic', + lastUserDialogMode: undefined, + lastSubmittedMode: undefined, workspacePath, workspaceId: config.workspaceId, remoteConnectionId, @@ -403,6 +419,8 @@ export class FlowChatStore { error: null, maxContextTokens: 128128, mode: mode || 'agentic', + lastUserDialogMode: undefined, + lastSubmittedMode: undefined, isHistorical: false, historyState: 'new', workspacePath, @@ -486,6 +504,36 @@ export class FlowChatStore { }); } + /** + * Record the mode used by the most recent user submission accepted by the runtime. + * Unlike `lastUserDialogMode`, this does not rewind when history is rolled back. + */ + public updateSessionLastSubmittedMode(sessionId: string, mode: string): void { + const normalizedMode = mode.trim(); + if (!normalizedMode) { + return; + } + + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if (!session || session.lastSubmittedMode === normalizedMode) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + lastSubmittedMode: normalizedMode, + lastActiveAt: Date.now(), + }); + + return { + ...prev, + sessions: newSessions, + }; + }); + } + public setGoalModeActive(sessionId: string, active: boolean): void { this.setState(prev => { const session = prev.sessions.get(sessionId); @@ -952,9 +1000,11 @@ export class FlowChatStore { return prev; } + const updatedDialogTurns = [...session.dialogTurns, dialogTurn]; const updatedSession = { ...session, - dialogTurns: [...session.dialogTurns, dialogTurn], + dialogTurns: updatedDialogTurns, + lastUserDialogMode: this.deriveLastUserDialogMode(updatedDialogTurns), lastActiveAt: Date.now() }; @@ -1176,6 +1226,7 @@ export class FlowChatStore { const updatedSession = { ...session, dialogTurns: updatedDialogTurns, + lastUserDialogMode: this.deriveLastUserDialogMode(updatedDialogTurns), lastActiveAt: Date.now() }; @@ -1200,9 +1251,11 @@ export class FlowChatStore { if (!session) return prev; const clampedIndex = Math.max(0, Math.min(turnIndex, session.dialogTurns.length)); + const updatedDialogTurns = session.dialogTurns.slice(0, clampedIndex); const updatedSession = { ...session, - dialogTurns: session.dialogTurns.slice(0, clampedIndex), + dialogTurns: updatedDialogTurns, + lastUserDialogMode: this.deriveLastUserDialogMode(updatedDialogTurns), lastActiveAt: Date.now() }; @@ -2166,6 +2219,8 @@ export class FlowChatStore { todos: (metadata as any).todos || [], maxContextTokens, mode: validatedAgentType, + lastUserDialogMode: metadata.lastUserDialogAgentType, + lastSubmittedMode: metadata.lastSubmittedAgentType, workspacePath: (metadata as any).workspacePath || workspacePath, remoteConnectionId: metadata.remoteConnectionId || remoteConnectionId, remoteSshHost: @@ -2290,6 +2345,7 @@ export class FlowChatStore { const isAcpSession = existingSession?.mode?.startsWith('acp:') || existingSession?.config.agentType?.startsWith('acp:'); let turns: DialogTurnData[] | undefined; + let restoredSessionInfo: AgentSessionInfo | undefined; let contextRestoreState: SessionContextRestoreState = 'ready'; if (!isAcpSession) { const restoreStartedAt = nowMs(); @@ -2320,6 +2376,7 @@ export class FlowChatStore { sessionTraceId, options?.includeInternal, ); + restoredSessionInfo = restored.session; turns = restored.turns; contextRestoreState = 'ready'; return; @@ -2338,7 +2395,7 @@ export class FlowChatStore { } } - await agentAPI.restoreSession( + restoredSessionInfo = await agentAPI.restoreSession( sessionId, workspacePath, remoteConnectionId, @@ -2362,6 +2419,7 @@ export class FlowChatStore { sessionTraceId, options?.includeInternal, ); + restoredSessionInfo = restored.session; turns = restored.turns; contextRestoreState = restored.contextRestoreState === 'ready' ? 'ready' : 'pending'; @@ -2426,6 +2484,8 @@ export class FlowChatStore { const convertStartedAt = nowMs(); const dialogTurns = this.convertToDialogTurns(turns); + const restoredLastUserDialogMode = + restoredSessionInfo?.lastUserDialogAgentType || this.deriveLastUserDialogMode(dialogTurns); startupTrace.markPhase('historical_session_convert_end', { remote, sessionTraceId, @@ -2445,6 +2505,10 @@ export class FlowChatStore { historyState: 'ready' as const, contextRestoreState, error: null, + mode: restoredSessionInfo?.agentType || session.mode, + lastUserDialogMode: restoredLastUserDialogMode, + lastSubmittedMode: + restoredSessionInfo?.lastSubmittedAgentType ?? session.lastSubmittedMode, }; const newSessions = new Map(prev.sessions); @@ -2550,6 +2614,7 @@ export class FlowChatStore { id: turn.turnId, sessionId: turn.sessionId, kind: turn.kind || 'user_dialog', + agentType: turn.agentType, userMessage: { id: turn.userMessage.id, type: 'user' as const, 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 304ddf49b..5b030541e 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -193,6 +193,11 @@ export interface DialogTurn { id: string; sessionId: string; // Used for event filtering. kind?: DialogTurnKind; + /** + * Mode used when this user-facing dialog turn was submitted. + * Local utility turns may leave this empty. + */ + agentType?: string; userMessage: { id: string; content: string; @@ -310,8 +315,22 @@ export interface Session { currentAcpContextUsage?: AcpContextUsage; maxContextTokens?: number; - // Session mode is synced to the input when switching sessions. + /** + * Current/default mode selection in the chat input for this session. + * This controls what the next dialog turn should use by default. + */ mode?: string; + /** + * Mode of the last surviving user dialog turn in the current session + * history. Rollback and turn truncation should follow this value. + */ + lastUserDialogMode?: string; + /** + * Mode of the most recent user submission accepted by the runtime. + * This is used for prompt-cache guard semantics and does not rewind on + * rollback. + */ + lastSubmittedMode?: string; // Workspace this session belongs to. Used for sidebar display filtering. // Sessions are always kept in store for event processing; only display is filtered. 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 b674f7e51..d68b921dd 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -86,8 +86,13 @@ export interface CompactSessionRequest { export interface SessionInfo { sessionId: string; + /** Current/default mode selection for the next dialog turn. */ sessionName: string; agentType: string; + /** Mode of the last surviving user dialog turn in session history. */ + lastUserDialogAgentType?: string; + /** Mode of the most recent user submission accepted by the runtime. */ + lastSubmittedAgentType?: string; state: string; turnCount: number; createdAt: number; @@ -154,6 +159,11 @@ export interface ModeInfo { isReadonly: boolean; toolCount: number; defaultTools?: string[]; + /** + * Combined prompt-cache compatibility key for mode-switch guards. Modes that + * share the same key can reuse the same session-level prompt cache. + */ + promptCacheScopeKey: string; } @@ -793,6 +803,7 @@ export class AgentAPI { description: `${agentType} agent`, isReadonly: false, toolCount: 0, + promptCacheScopeKey: agentType, agent_type: agentType, when_to_use: `Use ${agentType} for related tasks`, tools: 'all', diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index 9d27adf18..9c67db7d2 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -480,6 +480,10 @@ "resetToAgentic": "Back to default mode", "noIncrementalModes": "No add-on modes available", "switchMode": "Switch Mode", + "promptCacheGuardTitle": "Switching this mode will reset prompt cache reuse", + "promptCacheGuardBody": "The next request will switch from {{fromMode}} to {{toMode}}, so this session will stop reusing its current prompt cache. Continue?", + "promptCacheGuardConfirm": "Send anyway", + "promptCacheGuardCancel": "Stay here", "quickAction": "Quick action", "compactAction": "Compact session", "compactNoSession": "No active session for /compact", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 83976af8c..c02477d59 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -480,6 +480,10 @@ "resetToAgentic": "恢复默认模式", "noIncrementalModes": "暂无可附加模式", "switchMode": "切换模式", + "promptCacheGuardTitle": "切换该模式会重置提示词缓存复用", + "promptCacheGuardBody": "下一次请求将从 {{fromMode}} 切换到 {{toMode}},当前会话将无法继续复用现有的提示词缓存。要继续发送吗?", + "promptCacheGuardConfirm": "仍然发送", + "promptCacheGuardCancel": "先不发送", "quickAction": "快捷操作", "compactAction": "压缩会话", "compactNoSession": "当前没有可用于 /compact 的会话", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 95a478ec2..9d8408170 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -480,6 +480,10 @@ "resetToAgentic": "恢復默認模式", "noIncrementalModes": "暫無可附加模式", "switchMode": "切換模式", + "promptCacheGuardTitle": "切換該模式會重置提示詞快取復用", + "promptCacheGuardBody": "下一次請求將從 {{fromMode}} 切換到 {{toMode}},目前工作階段將無法繼續復用現有的提示詞快取。要繼續發送嗎?", + "promptCacheGuardConfirm": "仍然發送", + "promptCacheGuardCancel": "先不發送", "quickAction": "快捷操作", "compactAction": "壓縮會話", "compactNoSession": "當前沒有可用於 /compact 的會話", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index c5dc11c83..62be36804 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -43,7 +43,23 @@ export interface SessionCustomMetadata extends Record { export interface SessionMetadata { sessionId: string; sessionName: string; + /** + * Current/default mode selection for the next dialog turn in this session. + * This is not guaranteed to match either the last surviving history turn or + * the most recent submission accepted by the runtime. + */ agentType: string; + /** + * Mode of the last surviving user dialog turn in persisted history. + * Rollback and turn truncation update this value. + */ + lastUserDialogAgentType?: string; + /** + * Mode of the most recent user submission accepted by the runtime. + * This is used for prompt-cache guard semantics and does not rewind on + * rollback. + */ + lastSubmittedAgentType?: string; sessionKind?: PersistedSessionKind; modelName: string; createdAt: number; @@ -124,6 +140,11 @@ export interface DialogTurnData { sessionId: string; timestamp: number; kind?: DialogTurnKind; + /** + * Mode used when this turn was submitted as a user dialog. Local utility + * turns may leave this empty. + */ + agentType?: string; userMessage: UserMessageData; modelRounds: ModelRoundData[]; startTime: number;