diff --git a/src/apps/cli/src/modes/chat/session_lineage.rs b/src/apps/cli/src/modes/chat/session_lineage.rs index 1ae48da598..67294d7849 100644 --- a/src/apps/cli/src/modes/chat/session_lineage.rs +++ b/src/apps/cli/src/modes/chat/session_lineage.rs @@ -1239,6 +1239,7 @@ mod session_lineage_tests { user_input: "continue".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }, ); assert_eq!( @@ -1290,6 +1291,7 @@ mod session_lineage_tests { user_input: "continue".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }, AgenticEvent::TextChunk { session_id: "first".to_string(), @@ -1525,6 +1527,7 @@ mod session_lineage_tests { user_input: "question".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }, false, ); diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 9dc5bf13b0..25acfb1d0a 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -4689,6 +4689,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_input: prompt.clone(), original_user_input: (display_input != prompt).then_some(display_input), user_message_metadata: Some(user_message_metadata.clone()), + session_binding: self.session_manager.session_workspace_binding(&session_id), }) .await; self.emit_event(AgenticEvent::ModelRoundStarted { @@ -6213,6 +6214,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_input: MANUAL_COMPACTION_COMMAND.to_string(), original_user_input: None, user_message_metadata: user_message_metadata.clone(), + session_binding: self.session_manager.session_workspace_binding(&session_id), }) .await; @@ -7469,6 +7471,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet None }, user_message_metadata: user_message_metadata.clone(), + session_binding: self.session_manager.session_workspace_binding(&session_id), }) .await; @@ -11225,6 +11228,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_input: user_input_text.clone(), original_user_input: None, user_message_metadata: None, + session_binding: self.session_manager.session_workspace_binding(&session_id), }) .await; } @@ -15984,6 +15988,9 @@ impl openbitfun_runtime_ports::AgentUserShellCommandPort for ConversationCoordin user_input: format!("!{}", request.command), original_user_input: None, user_message_metadata: None, + session_binding: self + .session_manager + .session_workspace_binding(&request.session_id), }; let session_manager = Arc::clone(&self.session_manager); @@ -21791,10 +21798,22 @@ pub(crate) mod tests { .expect("load turns"); let turn = turns.last().expect("shell turn"); let events = coordinator.event_queue.dequeue_batch(100).await; - assert!(events.iter().any(|envelope| matches!( - &envelope.event, - AgenticEvent::DialogTurnStarted { turn_id, .. } if turn_id == "turn-shell" - ))); + let started_binding = events.iter().find_map(|envelope| match &envelope.event { + AgenticEvent::DialogTurnStarted { + turn_id, + session_binding, + .. + } if turn_id == "turn-shell" => Some(session_binding.clone()), + _ => None, + }); + // A surface that has not loaded the session attributes the Turn to + // its owning workspace from this binding, not to its current one. + assert_eq!( + started_binding + .expect("turn started event") + .and_then(|binding| binding.workspace_path), + session.config.workspace_path + ); assert!(!events.iter().any(|envelope| matches!( &envelope.event, AgenticEvent::DialogTurnFailed { turn_id, .. } if turn_id == "turn-shell" diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 9757a403ff..7bd3a26eec 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -3017,6 +3017,25 @@ impl SessionManager { self.sessions.get(session_id).map(|s| s.clone()) } + /// Owning workspace facts for Turn lifecycle events, read without cloning + /// the session. `None` when this host has not loaded the session. + pub fn session_workspace_binding( + &self, + session_id: &str, + ) -> Option { + self.sessions.get(session_id).map(|session| { + let config = &session.config; + openbitfun_events::SessionWorkspaceBinding { + workspace_path: config.workspace_path.clone(), + project_workspace_path: config.project_workspace_path.clone(), + execution_target: config.execution_target.clone(), + workspace_id: config.workspace_id.clone(), + remote_connection_id: config.remote_connection_id.clone(), + remote_ssh_host: config.remote_ssh_host.clone(), + } + }) + } + /// Read selector facts without restoring history, reserving runtime capacity, /// or waiting for an executing Turn's mutation permit. An omitted locator /// supports legacy callers only when this host already owns the session. diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 03f00f4e11..7ae4be96b8 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -94,6 +94,27 @@ pub struct DeepReviewQueueState { pub session_concurrency_high: bool, } +/// Workspace binding of the session that owns a Turn. A surface that has not +/// loaded the session (remote control, a bot, another device) uses it to +/// attribute the Turn to its owning workspace instead of guessing one. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct SessionWorkspaceBinding { + /// Execution root of the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_path: Option, + /// Main project root that owns persistence for the session. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_workspace_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_ssh_host: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AgenticEvent { @@ -182,6 +203,9 @@ pub enum AgenticEvent { original_user_input: Option, /// Image metadata JSON for UI rendering (id, name, data_url, mime_type, image_path) user_message_metadata: Option, + /// Owning workspace of the session. Absent from older hosts. + #[serde(default, skip_serializing_if = "Option::is_none")] + session_binding: Option, }, /// Low-frequency linking event that associates a hidden subagent session diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index d9ad0c6442..f783067e75 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -85,17 +85,41 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( - "agentic://dialog-turn-started", - json!({ + session_binding, + } => { + let mut payload = json!({ "sessionId": session_id, "turnId": turn_id, "turnIndex": turn_index, "userInput": user_input, "originalUserInput": original_user_input, "userMessageMetadata": user_message_metadata, - }), - )), + }); + // Same keys as `agentic://session-created`, so a surface that has + // not loaded the session attributes it to its owning workspace. + if let (Some(binding), Some(fields)) = (session_binding, payload.as_object_mut()) { + let binding_fields = [ + ("workspacePath", json!(binding.workspace_path)), + ( + "projectWorkspacePath", + json!(binding.project_workspace_path), + ), + ("executionTarget", json!(binding.execution_target)), + ("workspaceId", json!(binding.workspace_id)), + ("remoteConnectionId", json!(binding.remote_connection_id)), + ("remoteSshHost", json!(binding.remote_ssh_host)), + ]; + for (key, value) in binding_fields { + if !value.is_null() { + fields.insert(key.to_string(), value); + } + } + } + Some(AgenticFrontendEvent::new( + "agentic://dialog-turn-started", + payload, + )) + } AgenticEvent::SubagentSessionLinked { session_id, subagent_dialog_turn_id, @@ -607,6 +631,56 @@ mod tests { assert_eq!(projected.payload["workspaceId"], "workspace-wt-1"); } + #[test] + fn dialog_turn_started_projects_owning_workspace_binding() { + let projected = project_agentic_frontend_event(AgenticEvent::DialogTurnStarted { + session_id: "session-1".to_string(), + turn_id: "turn-1".to_string(), + turn_index: 20, + user_input: "continue".to_string(), + original_user_input: None, + user_message_metadata: None, + session_binding: Some(crate::SessionWorkspaceBinding { + workspace_path: Some("D:/repo".to_string()), + project_workspace_path: Some("D:/repo".to_string()), + execution_target: None, + workspace_id: Some("workspace-repo".to_string()), + remote_connection_id: None, + remote_ssh_host: None, + }), + }) + .expect("projected"); + + assert_eq!(projected.event_name, "agentic://dialog-turn-started"); + assert_eq!(projected.payload["workspacePath"], "D:/repo"); + assert_eq!(projected.payload["projectWorkspacePath"], "D:/repo"); + assert_eq!(projected.payload["workspaceId"], "workspace-repo"); + let fields = projected.payload.as_object().expect("object payload"); + assert!(!fields.contains_key("executionTarget")); + assert!(!fields.contains_key("remoteConnectionId")); + } + + #[test] + fn dialog_turn_started_without_binding_keeps_legacy_payload() { + // An older host serializes the event without `session_binding`. + let legacy = serde_json::json!({ + "type": "DialogTurnStarted", + "session_id": "session-1", + "turn_id": "turn-1", + "turn_index": 0, + "user_input": "hello", + "original_user_input": null, + "user_message_metadata": null, + }); + let event: AgenticEvent = serde_json::from_value(legacy).expect("legacy event"); + let projected = project_agentic_frontend_event(event).expect("projected"); + + let fields = projected.payload.as_object().expect("object payload"); + assert!(!fields.contains_key("workspacePath")); + assert!(!fields.contains_key("workspaceId")); + assert_eq!(projected.payload["sessionId"], "session-1"); + } + #[test] fn thinking_chunk_projects_to_text_chunk_event() { let projected = project_agentic_frontend_event(AgenticEvent::ThinkingChunk { diff --git a/src/crates/contracts/events/src/lib.rs b/src/crates/contracts/events/src/lib.rs index b6b2ca13ee..66116f9a42 100644 --- a/src/crates/contracts/events/src/lib.rs +++ b/src/crates/contracts/events/src/lib.rs @@ -15,7 +15,8 @@ pub mod types; pub use agentic::{ AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, DeepReviewQueueReason, DeepReviewQueueState, DeepReviewQueueStatus, ModelRoundAttemptDiagnostic, - ModelRoundAttemptToolDiagnostic, SubagentParentInfo, ToolEventData, ToolEventIdentity, + ModelRoundAttemptToolDiagnostic, SessionWorkspaceBinding, SubagentParentInfo, ToolEventData, + ToolEventIdentity, }; pub use backend::{ BackgroundCommandLifecycleInfo, ToolExecutionCompletedInfo, ToolExecutionErrorInfo, diff --git a/src/crates/execution/agent-runtime/src/session_event_journal.rs b/src/crates/execution/agent-runtime/src/session_event_journal.rs index aeb6502e7e..2bfcd46301 100644 --- a/src/crates/execution/agent-runtime/src/session_event_journal.rs +++ b/src/crates/execution/agent-runtime/src/session_event_journal.rs @@ -736,6 +736,7 @@ mod tests { user_input: "prompt".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, } } diff --git a/src/crates/services/services-integrations/src/remote_connect.rs b/src/crates/services/services-integrations/src/remote_connect.rs index 7bcd98f083..a90e9ad0f8 100644 --- a/src/crates/services/services-integrations/src/remote_connect.rs +++ b/src/crates/services/services-integrations/src/remote_connect.rs @@ -6290,6 +6290,7 @@ mod tests { user_input: "hidden".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.mark_persistence_clean(); let previous_version = tracker.version(); @@ -6380,6 +6381,7 @@ mod tests { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::DialogTurnCompleted { session_id: "session-a".to_string(), @@ -6448,6 +6450,7 @@ mod tests { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::DialogTurnCompleted { session_id: "session-a".to_string(), @@ -6538,6 +6541,7 @@ mod tests { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::DialogTurnFailed { session_id: "session-a".to_string(), @@ -6567,6 +6571,7 @@ mod tests { user_input: "queued follow-up".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::TextChunk { session_id: "session-a".to_string(), diff --git a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs index d40f6b9b14..b5cc338db5 100644 --- a/src/crates/services/services-integrations/src/remote_connect/relay_client.rs +++ b/src/crates/services/services-integrations/src/remote_connect/relay_client.rs @@ -517,6 +517,10 @@ impl RelayClient { tokio::time::sleep(poll).await; } } + /// Forward one relay frame. Returns `false` only when the loop must stop + /// (a newer connection epoch or a closed event receiver). A malformed frame + /// from another client or relay version is dropped: ending the loop would + /// leave the host unreachable with no disconnect event and no reconnect. async fn route( owner: &Arc>, events: &mpsc::Sender, @@ -529,14 +533,13 @@ impl RelayClient { match event { Incoming::RpcRequest(event) => { let value = event.payload.0; - let Some(source) = value["sourceDeviceId"].as_str() else { - return false; - }; - let Some(data) = value["params"]["encrypted_data"].as_str() else { - return false; - }; - let Some(nonce) = value["params"]["nonce"].as_str() else { - return false; + let (Some(source), Some(data), Some(nonce)) = ( + value["sourceDeviceId"].as_str(), + value["params"]["encrypted_data"].as_str(), + value["params"]["nonce"].as_str(), + ) else { + log::warn!("Dropped malformed relay RPC request frame"); + return true; }; let correlation = format!("rpc-{}", uuid::Uuid::new_v4()); { @@ -578,7 +581,10 @@ impl RelayClient { .send(RelayEvent::DevicePresence { devices }) .await .is_ok(), - Err(_) => false, + Err(error) => { + log::warn!("Dropped malformed relay device presence frame: {error}"); + true + } } } else if value["type"] == "device-event" { let (Some(source), Some(data), Some(nonce)) = ( @@ -586,7 +592,8 @@ impl RelayClient { value["params"]["encrypted_data"].as_str(), value["params"]["nonce"].as_str(), ) else { - return false; + log::warn!("Dropped malformed relay device event frame"); + return true; }; events .send(RelayEvent::DeviceMessageReceived { @@ -959,6 +966,44 @@ mod tests { )); } + #[tokio::test] + async fn malformed_frames_are_dropped_without_stopping_the_relay_loop() { + let owner = Arc::new(Mutex::new(Owner { + epoch: 1, + state: ConnectionState::Connected, + url: None, + task: None, + sender: None, + replies: HashMap::new(), + })); + let (events, mut received) = mpsc::channel(4); + let ephemeral = |payload: serde_json::Value| { + Incoming::Ephemeral(Event { + payload: super::super::realtime_client::Ephemeral(payload), + id: (), + attachments: (), + }) + }; + for frame in [ + serde_json::json!({ "type": "device-presence", "devices": "not-a-list" }), + serde_json::json!({ "type": "device-event", "params": {} }), + ] { + assert!(RelayClient::route(&owner, &events, 1, ephemeral(frame)).await); + } + assert!(received.try_recv().is_err()); + + // A stale epoch still stops the loop. + assert!( + !RelayClient::route( + &owner, + &events, + 0, + ephemeral(serde_json::json!({ "type": "device-presence", "devices": [] })), + ) + .await + ); + } + #[tokio::test] async fn server_close_reconnects_and_restores_device_presence() { use futures::{SinkExt, StreamExt}; diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 8107c14720..ee56a11b26 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -3316,6 +3316,7 @@ fn remote_connect_tracker_preserves_streaming_snapshot_contract() { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::ModelRoundStarted { session_id: "session-1".to_string(), @@ -3374,6 +3375,7 @@ fn remote_connect_tracker_keeps_steering_between_assistant_text_segments() { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::TextChunk { session_id: "session-1".to_string(), @@ -3460,6 +3462,7 @@ async fn remote_connect_tracker_broadcasts_tool_and_turn_events() { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::ToolEvent { session_id: "session-1".to_string(), @@ -3514,6 +3517,7 @@ fn remote_connect_tracker_keeps_finished_turn_snapshot_until_persistence_finaliz user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::TextChunk { session_id: "session-1".to_string(), @@ -3660,6 +3664,7 @@ fn remote_connect_poll_helpers_preserve_delta_and_completion_policy() { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::TextChunk { session_id: "session-1".to_string(), @@ -3755,6 +3760,7 @@ fn remote_connect_tracker_ignores_unrelated_direct_session_events() { user_input: "hello".to_string(), original_user_input: None, user_message_metadata: None, + session_binding: None, }); tracker.handle_agentic_event(&AgenticEvent::TextChunk { session_id: "session-2".to_string(), diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx index f6faa6b250..f2c563aef4 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionStatusIndicator.tsx @@ -50,6 +50,7 @@ export const SessionStatusMark = memo(function SessionStatusMark({ data-status={status.kind} role="img" aria-label={label} + title={label} > { expect(actions).toContain('await flowChatManager.duplicateChatSession(sessionId)'); expect(actions).toContain("t('nav.sessions.duplicateSuccess'"); expect(actions).toContain('{ name: copied.sessionName }'); - expect(actions).toContain("t('nav.sessions.duplicateFailed')"); + // Failure copy is localized (`nav.sessions.duplicateFailed`), never raw host text. + expect(actions).toContain("sessionMutationFailureMessage('duplicate', error)"); }); it('keeps copy unavailable while a turn runs or another device owns the session', () => { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index ee4387e374..3a5d57eaf7 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -123,6 +123,79 @@ describe('external BTW turn projection', () => { }); }); +describe('remote-driven turn on an unloaded session', () => { + beforeEach(() => { resetFlowChatStore(); stateMachineManager.clear(); }); + afterEach(() => { resetFlowChatStore(); stateMachineManager.clear(); }); + + const onAssistantWorkspace = (): FlowChatContext => ({ + ...createFlowChatContext(), + currentWorkspaceId: 'assistant-workspace', + currentWorkspacePath: 'C:/Users/u/.openbitfun/personal_assistant/workspace', + }); + + it('attributes the placeholder to the owning workspace the host reports', () => { + __test_only__.handleDialogTurnStarted(onAssistantWorkspace(), { + sessionId: 'persisted-session', turnId: 'harmony-turn-1', turnIndex: 20, userInput: 'Continue', + workspacePath: 'D:/repo', projectWorkspacePath: 'D:/repo', workspaceId: 'project-workspace', + }); + const session = FlowChatStore.getInstance().getState().sessions.get('persisted-session'); + expect(session?.workspaceId).toBe('project-workspace'); + expect(session?.workspacePath).toBe('D:/repo'); + expect(session?.config.workspaceId).toBe('project-workspace'); + expect(session?.projectWorkspacePath).toBe('D:/repo'); + expect(session?.config.projectWorkspacePath).toBe('D:/repo'); + }); + + it('marks a placeholder for a persisted session so it hydrates and never renames it', () => { + __test_only__.handleDialogTurnStarted(onAssistantWorkspace(), { + sessionId: 'persisted-session', turnId: 'harmony-turn-1', turnIndex: 20, userInput: 'Continue', + workspacePath: 'D:/repo', workspaceId: 'project-workspace', + }); + expect(FlowChatStore.getInstance().getState().sessions.get('persisted-session')).toMatchObject({ + externalPlaceholder: true, isHistorical: true, historyState: 'metadata-only', + }); + }); + + it('rebinds an image-analysis placeholder when the turn start names the owning workspace', () => { + const context = onAssistantWorkspace(); + __test_only__.handleImageAnalysisStarted(context, { + sessionId: 'persisted-session', imageCount: 1, userInput: 'Look at this', + } as any); + expect(FlowChatStore.getInstance().getState().sessions.get('persisted-session')?.workspaceId) + .toBe('assistant-workspace'); + + __test_only__.handleDialogTurnStarted(context, { + sessionId: 'persisted-session', turnId: 'harmony-turn-2', turnIndex: 21, userInput: 'Look at this', + workspacePath: 'D:/repo', workspaceId: 'project-workspace', + }); + expect(FlowChatStore.getInstance().getState().sessions.get('persisted-session')).toMatchObject({ + workspaceId: 'project-workspace', workspacePath: 'D:/repo', historyState: 'metadata-only', + }); + }); + + it('lets the creation event correct a placeholder projected before it', () => { + const context = onAssistantWorkspace(); + __test_only__.handleImageAnalysisStarted(context, { + sessionId: 'new-remote-session', imageCount: 1, userInput: 'Look at this', + } as any); + __test_only__.handleSessionCreated(context, { + sessionId: 'new-remote-session', sessionName: 'New session', agentType: 'Standard', + workspacePath: 'D:/repo', workspaceId: 'project-workspace', + }); + expect(FlowChatStore.getInstance().getState().sessions.get('new-remote-session')).toMatchObject({ + workspaceId: 'project-workspace', workspacePath: 'D:/repo', + }); + }); + + it('keeps the current-workspace fallback for an older host without binding facts', () => { + __test_only__.handleDialogTurnStarted(onAssistantWorkspace(), { + sessionId: 'legacy-session', turnId: 'turn-1', turnIndex: 0, userInput: 'Hello', + }); + const session = FlowChatStore.getInstance().getState().sessions.get('legacy-session'); + expect(session?.workspaceId).toBe('assistant-workspace'); + }); +}); + describe('turn finalization', () => { beforeEach(() => { resetFlowChatStore(); stateMachineManager.clear(); }); afterEach(() => { resetFlowChatStore(); stateMachineManager.clear(); }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 4cf3a01a61..10207653c0 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -227,10 +227,12 @@ function mergeParamsPartialEventData( export const __test_only__ = { relayRecordsBeforeEvent, handleSessionDeleted, + handleSessionCreated, resolveDialogTurnDisplayContent, mergeParamsPartialEventData, findSubagentParentInfoByRound, handleDialogTurnStarted, + handleImageAnalysisStarted, handleDialogTurnFailed, handleSubagentSessionLinked, handleModelRoundStart, @@ -1072,21 +1074,24 @@ function handleSessionCreated(context: FlowChatContext, event: any): void { const store = FlowChatStore.getInstance(); const existing = store.getState().sessions.get(sessionId); - const { workspaceId, workspacePath } = resolveExternalSessionWorkspace(context, event); - const projectWorkspacePath = - (typeof event.projectWorkspacePath === 'string' && event.projectWorkspacePath) - || (typeof event.project_workspace_path === 'string' && event.project_workspace_path) - || workspacePath; - const executionTarget = - event.executionTarget && typeof event.executionTarget === 'object' - ? event.executionTarget - : event.execution_target && typeof event.execution_target === 'object' - ? event.execution_target - : undefined; + const { + workspaceId, workspacePath, projectWorkspacePath, executionTarget, + } = resolveExternalSessionBinding(context, event); const remoteConnectionId = extractEventRemoteConnectionId(event); const remoteSshHost = extractEventRemoteSshHost(event); - if (existing) return; + if (existing) { + // An earlier event (image analysis) may have projected this session + // before its creation event arrived; correct that guess, never a + // session this surface loaded itself. + if (existing.externalPlaceholder && workspaceId) { + store.rebindExternalPlaceholder(sessionId, { + workspaceId, workspacePath, projectWorkspacePath, executionTarget, remoteConnectionId, remoteSshHost, + }); + adoptPersistedPlaceholderMetadata(sessionId, workspaceId); + } + return; + } store.addExternalSession( sessionId, @@ -1103,6 +1108,51 @@ function handleSessionCreated(context: FlowChatContext, event: any): void { ); } +/** + * Owning workspace, persistence root and execution target of an external + * session, read from the facts the host attaches to `session-created` and + * (from newer hosts) `dialog-turn-started`. + */ +function resolveExternalSessionBinding( + context: FlowChatContext, + event: Record, +): ExternalSessionWorkspace & { + projectWorkspacePath?: string; + executionTarget?: Session['config']['executionTarget']; +} { + const workspace = resolveExternalSessionWorkspace(context, event); + const projectWorkspacePath = + (typeof event.projectWorkspacePath === 'string' && event.projectWorkspacePath) + || (typeof event.project_workspace_path === 'string' && event.project_workspace_path) + || workspace.workspacePath; + const executionTarget = + event.executionTarget && typeof event.executionTarget === 'object' + ? event.executionTarget + : event.execution_target && typeof event.execution_target === 'object' + ? event.execution_target + : undefined; + return { ...workspace, projectWorkspacePath, executionTarget }; +} + +function hasExternalSessionBindingFacts(event: Record): boolean { + return [event.workspaceId, event.workspace_id, event.workspacePath, event.workspace_path] + .some(value => typeof value === 'string' && value.trim().length > 0); +} + +/** + * Replace a remote-driven placeholder's guessed title and identity with the + * host's persisted record. A missing record (a session that is genuinely new) + * keeps the placeholder; it is never an error to surface. + */ +function adoptPersistedPlaceholderMetadata(sessionId: string, workspaceId: string | undefined): void { + if (!workspaceId) return; + void FlowChatStore.getInstance() + .ensurePersistedSessionMetadata(sessionId, workspaceId, true) + .catch(error => { + log.debug('Remote placeholder metadata is not available yet', { sessionId, workspaceId, error }); + }); +} + interface ExternalSessionWorkspace { /** Owning workspace ID; the identity every projection is keyed by. */ workspaceId?: string; @@ -1800,7 +1850,7 @@ function handleImageAnalysisStarted(context: FlowChatContext, event: ImageAnalys 'Remote Session', 'Standard', workspace.workspacePath, - { workspaceId: workspace.workspaceId }, + { workspaceId: workspace.workspaceId, externalPlaceholder: true }, extractEventRemoteConnectionId(event as any), extractEventRemoteSshHost(event as any) ); @@ -2001,7 +2051,10 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { ? userMessageMetadata.appId : undefined; log.warn('DialogTurnStarted: session not in store, creating placeholder', { sessionId, sessionsCount: state.sessions.size, isMiniAppAgentRun }); - const workspace = resolveExternalSessionWorkspace(context, event); + // Newer hosts attach the session's owning workspace. Without it the + // placeholder falls back to the current workspace, which misattributes a + // persisted session driven from another surface (remote control, bot). + const workspace = resolveExternalSessionBinding(context, event); store.addExternalSession( sessionId, isMiniAppAgentRun ? (miniAppId ? `MiniApp: ${miniAppId}` : 'MiniApp Agent') : isBtwRun ? 'Side thread' : 'Remote Session', @@ -2012,10 +2065,26 @@ function handleDialogTurnStarted(context: FlowChatContext, event: any): void { : isBtwRun ? { sessionKind: 'btw', isTransient: true, agentBackedTransient: true, parentSessionId: userMessageMetadata.parentSessionId, workspaceId: workspace.workspaceId } - : { workspaceId: workspace.workspaceId }, + : { + workspaceId: workspace.workspaceId, + projectWorkspacePath: workspace.projectWorkspacePath, + executionTarget: workspace.executionTarget, + externalPlaceholder: true, + persistedHistory: typeof turnIndex === 'number' && turnIndex > 0, + }, extractEventRemoteConnectionId(event), extractEventRemoteSshHost(event) ); + if (!isMiniAppAgentRun && !isBtwRun) adoptPersistedPlaceholderMetadata(sessionId, workspace.workspaceId); + } else if (session.externalPlaceholder && hasExternalSessionBindingFacts(event)) { + const workspace = resolveExternalSessionBinding(context, event); + store.rebindExternalPlaceholder(sessionId, { + ...workspace, + remoteConnectionId: extractEventRemoteConnectionId(event), + remoteSshHost: extractEventRemoteSshHost(event), + persistedHistory: typeof turnIndex === 'number' && turnIndex > 0, + }); + adoptPersistedPlaceholderMetadata(sessionId, workspace.workspaceId); } // Extract image display data from metadata (sent by coordinator for all platforms) diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts index 6dce3e0f93..8e441b237d 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts @@ -196,6 +196,27 @@ describe('PersistenceModule', () => { } }); + it('skips a full checkpoint when the stored record cannot be read', async () => { + mockSaveSessionMetadata.mockClear(); + mockLoadSessionMetadata.mockReset().mockRejectedValue(new Error('SSH channel closed')); + await updateSessionMetadata(createContext(createDialogTurn('completed')), SESSION_ID); + expect(mockSaveSessionMetadata).not.toHaveBeenCalled(); + }); + + it('never writes a remote placeholder title over the persisted session name', async () => { + mockSaveSessionMetadata.mockClear(); + mockLoadSessionMetadata.mockReset().mockResolvedValue({ sessionId: SESSION_ID, sessionName: 'Fuse gate kernels' }); + await updateSessionMetadata( + createContext(createDialogTurn('completed'), { title: 'Remote Session', externalPlaceholder: true }), + SESSION_ID, + ); + expect(mockSaveSessionMetadata).toHaveBeenCalledTimes(1); + const fields = mockSaveSessionMetadata.mock.calls[0][2] as string[]; + expect(fields).not.toContain('sessionName'); + expect(fields).not.toContain('titleMetadata'); + expect(fields).toContain('needsUserAttention'); + }); + it('preserves actual routing facts in frontend checkpoints', () => { const turn = createDialogTurn('completed'); turn.modelRounds = [{ id: 'r', index: 0, items: [], isStreaming: false, isComplete: true, startTime: 1, diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 56e5fd70c2..61e2dd5143 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -605,8 +605,12 @@ export async function updateSessionMetadata( sessionId, requireSessionOwningWorkspaceId(session)); } - } catch { - // ignore + } catch (error) { + // A full checkpoint merges into the stored record. Without it (an + // unreachable remote workspace, a transient read failure) the write + // would reset tags and title facts; the next checkpoint retries. + log.debug('Skipped session metadata checkpoint: stored record unreadable', { sessionId, error }); + return; } if (!scope.isCurrent()) return; @@ -625,17 +629,15 @@ export async function updateSessionMetadata( } } + // A remote-driven placeholder knows only a guessed title; until it adopts + // the host's record it must not rename the persisted session. + const defaultFields: UiSessionMetadataField[] = session.externalPlaceholder + ? ['tags', 'todos', 'unreadCompletion', 'needsUserAttention'] + : ['sessionName', 'tags', 'todos', 'unreadCompletion', 'needsUserAttention', 'titleMetadata']; await sessionAPI.saveSessionMetadata( metadata, requireSessionOwningWorkspaceId(session), - fields ?? [ - 'sessionName', - 'tags', - 'todos', - 'unreadCompletion', - 'needsUserAttention', - 'titleMetadata', - ]); + fields ?? defaultFields); } catch (error) { log.warn('Failed to update session metadata', { sessionId, error }); } 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 c1de711f0c..fdd86d02f5 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 @@ -16,6 +16,10 @@ import { createLogger } from '@/shared/utils/logger'; import { isRemoteTraceContext, startupTrace } from '@/shared/utils/startupTrace'; import { elapsedMs, nowMs } from '@/shared/utils/timing'; import { i18nService } from '@/infrastructure/i18n'; +import { + isSessionWorkspaceBindingMismatch, + sessionMutationFailureMessage, +} from '../../utils/sessionMutationFailure'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { getActiveSurfaceScope, @@ -803,8 +807,8 @@ export async function deleteChatSession( } catch (error) { if (isSurfaceChangedError(error)) throw error; log.error('Failed to delete chat session', { sessionId, error }); - notificationService.error('Failed to delete session', { - duration: 3000 + notificationService.error(sessionMutationFailureMessage('delete', error), { + duration: isSessionWorkspaceBindingMismatch(error) ? 8000 : 3000 }); throw error; } @@ -842,8 +846,8 @@ export async function archiveChatSession( } catch (error) { if (isSurfaceChangedError(error)) throw error; log.error('Failed to archive chat session', { sessionId, error }); - notificationService.error('Failed to archive session', { - duration: 3000 + notificationService.error(sessionMutationFailureMessage('archive', error), { + duration: isSessionWorkspaceBindingMismatch(error) ? 8000 : 3000 }); throw error; } 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 cc904f6fa9..e7552a8f49 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -891,6 +891,64 @@ describe('FlowChatStore metadata persistence callbacks', () => { }); }); +describe('FlowChatStore remote-driven placeholder adoption', () => { + beforeEach(() => { resetStore(); apiMocks.loadSessionMetadata.mockReset(); }); + afterEach(() => { resetStore(); }); + + const addPlaceholder = (workspaceId: string) => { + flowChatStore.addExternalSession('persisted-session', 'Remote Session', 'Standard', 'D:/repo', { + workspaceId, projectWorkspacePath: 'D:/repo', externalPlaceholder: true, persistedHistory: true, + }); + flowChatStore.addDialogTurn('persisted-session', { + id: 'harmony-turn-1', sessionId: 'persisted-session', status: 'processing', modelRounds: [], + userMessage: { id: 'user-1', content: 'Continue', timestamp: 1 }, startTime: 1, backendTurnIndex: 20, + } as any); + }; + + it('hydrates history on open and adopts the persisted title, mode and binding', async () => { + const workspaceId = fixtureWorkspaceId('D:/repo'); + addPlaceholder(workspaceId); + expect(flowChatStore.getState().sessions.get('persisted-session')).toMatchObject({ + externalPlaceholder: true, isHistorical: true, historyState: 'metadata-only', + }); + apiMocks.loadSessionMetadata.mockResolvedValueOnce({ + sessionId: 'persisted-session', sessionName: 'Fuse gate kernels', agentType: 'Claw', + modelName: 'primary', createdAt: 10, lastActiveAt: 20, turnCount: 21, messageCount: 42, + toolCallCount: 0, status: 'active', workspacePath: 'D:/repo', workspaceId, + }); + + await expect(flowChatStore.ensurePersistedSessionMetadata('persisted-session', workspaceId, true)) + .resolves.toBe(true); + + const adopted = flowChatStore.getState().sessions.get('persisted-session'); + expect(adopted).toMatchObject({ + title: 'Fuse gate kernels', mode: 'Claw', workspaceId, historyState: 'metadata-only', isHistorical: true, + }); + expect(adopted?.externalPlaceholder).toBeUndefined(); + expect(adopted?.dialogTurns.map(turn => turn.id)).toEqual(['harmony-turn-1']); + }); + + it('rebinds a placeholder created before the owning workspace was known', () => { + flowChatStore.addExternalSession('persisted-session', 'Remote Session', 'Standard', 'C:/assistant', { + workspaceId: 'assistant-workspace', externalPlaceholder: true, + }); + flowChatStore.rebindExternalPlaceholder('persisted-session', { + workspaceId: 'project-workspace', workspacePath: 'D:/repo', persistedHistory: true, + }); + expect(flowChatStore.getState().sessions.get('persisted-session')).toMatchObject({ + workspaceId: 'project-workspace', workspacePath: 'D:/repo', projectWorkspacePath: 'D:/repo', + historyState: 'metadata-only', config: expect.objectContaining({ workspaceId: 'project-workspace' }), + }); + }); + + it('never rebinds a session loaded from the host', () => { + const session = createSession(); + flowChatStore.setState(() => ({ sessions: new Map([[session.sessionId, session]]) })); + flowChatStore.rebindExternalPlaceholder(session.sessionId, { workspaceId: 'other-workspace' }); + expect(flowChatStore.getState().sessions.get(session.sessionId)).toBe(session); + }); +}); + describe('FlowChatStore session removal active selection', () => { beforeEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 16a3e9c459..8e5c492878 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2279,6 +2279,30 @@ function sessionFromPersistedMetadata(metadata: any, context: { }; } +/** + * A remote-driven placeholder becomes the persisted session: identity, title, + * binding and mode come from the host's record; the live Turns it already + * projected stay. Earlier Turns hydrate on open like any unopened session. + */ +function adoptPersistedMetadataIntoPlaceholder(placeholder: Session, persisted: Session): Session { + const hasEarlierTurns = (persisted.persistedTurnCount ?? 0) > placeholder.dialogTurns.length; + return { + ...persisted, + dialogTurns: placeholder.dialogTurns, + status: placeholder.status, + error: placeholder.error, + lastFinishedAt: placeholder.lastFinishedAt ?? persisted.lastFinishedAt, + lastActiveAt: Math.max(placeholder.lastActiveAt ?? 0, persisted.lastActiveAt ?? 0), + currentTokenUsage: placeholder.currentTokenUsage ?? persisted.currentTokenUsage, + btwThreads: placeholder.btwThreads ?? persisted.btwThreads, + isHistorical: placeholder.isHistorical || hasEarlierTurns, + historyState: placeholder.historyState === 'new' && !hasEarlierTurns + ? placeholder.historyState + : persisted.historyState, + externalPlaceholder: undefined, + }; +} + const EMPTY_SESSION_MAP: ReadonlyMap = new Map(); /** The host's record of an unread result, as its read receipt must name it. */ @@ -4859,6 +4883,10 @@ export class FlowChatStore { continuationPolicy?: Session['continuationPolicy']; isTransient?: boolean; agentBackedTransient?: boolean; + /** See `Session.externalPlaceholder`. */ + externalPlaceholder?: boolean; + /** The host already holds earlier Turns; opening must hydrate them. */ + persistedHistory?: boolean; deepReviewRunManifest?: Session['deepReviewRunManifest']; focusedReviewDisplayLabel?: Session['focusedReviewDisplayLabel']; reviewTargetEvidence?: Session['reviewTargetEvidence']; @@ -4908,8 +4936,9 @@ export class FlowChatStore { mode: mode || 'Standard', lastUserDialogMode: undefined, lastSubmittedMode: undefined, - isHistorical: false, - historyState: 'new', + isHistorical: meta?.persistedHistory === true, + historyState: meta?.persistedHistory ? 'metadata-only' : 'new', + externalPlaceholder: meta?.externalPlaceholder || undefined, workspacePath, projectWorkspacePath: meta?.projectWorkspacePath, workspaceId: meta?.workspaceId, @@ -4941,6 +4970,50 @@ export class FlowChatStore { }); } + /** + * Correct a remote-driven placeholder once an event names its owning + * workspace (an earlier event, such as image analysis, may have carried no + * binding). Sessions loaded from the host are never rebound here. + */ + public rebindExternalPlaceholder(sessionId: string, binding: { + workspaceId?: string; + workspacePath?: string; + projectWorkspacePath?: string; + executionTarget?: Session['config']['executionTarget']; + remoteConnectionId?: string; + remoteSshHost?: string; + persistedHistory?: boolean; + }): void { + this.setState(prev => { + const existing = prev.sessions.get(sessionId); + if (!existing?.externalPlaceholder || !binding.workspaceId) return prev; + const historyState = binding.persistedHistory && existing.historyState === 'new' + ? 'metadata-only' as const + : existing.historyState; + if (existing.workspaceId === binding.workspaceId && historyState === existing.historyState) return prev; + const projectWorkspacePath = binding.projectWorkspacePath || binding.workspacePath; + const sessions = new Map(prev.sessions); + sessions.set(sessionId, { + ...existing, + workspaceId: binding.workspaceId, + workspacePath: binding.workspacePath ?? existing.workspacePath, + projectWorkspacePath: projectWorkspacePath ?? existing.projectWorkspacePath, + remoteConnectionId: binding.remoteConnectionId, + remoteSshHost: binding.remoteSshHost, + isHistorical: existing.isHistorical || historyState === 'metadata-only', + historyState, + config: { + ...existing.config, + workspaceId: binding.workspaceId, + workspacePath: binding.workspacePath ?? existing.config.workspacePath, + projectWorkspacePath: projectWorkspacePath ?? existing.config.projectWorkspacePath, + executionTarget: binding.executionTarget ?? existing.config.executionTarget, + }, + }); + return { ...prev, sessions }; + }); + } + public switchSession(sessionId: string): void { const previousSessionId = this.state.activeSessionId; const targetSessionExists = this.state.sessions.has(sessionId); @@ -8048,6 +8121,7 @@ export class FlowChatStore { const seededSession = this.isRebuildableSeed(metadata.sessionId); const existingSession = seededSession ? undefined : this.state.sessions.get(metadata.sessionId); if ((existingSession?.workspaceId || existingSession?.config.workspaceId) + && !existingSession?.externalPlaceholder && !(existingSession?.sessionKind === 'subagent' && existingSession.continuationPolicy === undefined) && !existingSession?.externalHistory && !existingSession?.externalBranchOf && !metadata.customMetadata?.externalHistoryMode && !metadata.customMetadata?.externalBranchOf) { @@ -8099,6 +8173,16 @@ export class FlowChatStore { if (!scope.isCurrent()) return prev; const existing = seededSession ? undefined : prev.sessions.get(metadata.sessionId); this.activeSurface.seededSessions.delete(metadata.sessionId); + if (existing?.externalPlaceholder) { + const sessions = new Map(prev.sessions); + sessions.set(metadata.sessionId, adoptPersistedMetadataIntoPlaceholder( + existing, + sessionFromPersistedMetadata(metadata, { + workspaceId, projectWorkspaceId, workspacePath, remoteConnectionId, remoteSshHost, maxContextTokens, + }), + )); + return { ...prev, sessions }; + } if (existing) { const continuationPolicy = existing.continuationPolicy ?? relationship.continuationPolicy; const importedSource = metadata.customMetadata?.importedFrom ?? existing.importedSource; 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 6d5fbdcb00..9b6b1d97f0 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -661,6 +661,13 @@ export interface Session { /** Transient UI session backed by a real agent session. */ agentBackedTransient?: boolean; + + /** + * Created from a remote-driven event before this surface loaded the + * session. It adopts the host's persisted metadata when that arrives and + * never writes its own placeholder title over the persisted one. + */ + externalPlaceholder?: boolean; } export interface SessionConfig { diff --git a/src/web-ui/src/flow_chat/utils/sessionMutationFailure.test.ts b/src/web-ui/src/flow_chat/utils/sessionMutationFailure.test.ts new file mode 100644 index 0000000000..3b4cd424c9 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionMutationFailure.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest'; +import { isSessionWorkspaceBindingMismatch, sessionMutationFailureMessage } from './sessionMutationFailure'; + +vi.mock('@/infrastructure/i18n', () => ({ + i18nService: { t: (key: string) => key }, +})); + +const bindingMismatch = new Error( + 'Failed to delete session: Validation error: Session ID is already bound to another workspace: ' + + 'session_id=s, existing_storage_path=C:/a/sessions, requested_storage_path=C:/b/sessions', +); + +describe('sessionMutationFailureMessage', () => { + it('explains a workspace binding mismatch without exposing storage paths', () => { + expect(isSessionWorkspaceBindingMismatch(bindingMismatch)).toBe(true); + for (const kind of ['delete', 'archive', 'rename', 'duplicate'] as const) { + expect(sessionMutationFailureMessage(kind, bindingMismatch)) + .toBe('common:nav.sessions.workspaceBindingMismatch'); + } + }); + + it('uses the localized generic copy for any other failure', () => { + const offline = new Error('Relay connection timed out; request was not submitted'); + expect(sessionMutationFailureMessage('delete', offline)).toBe('common:nav.sessions.deleteFailed'); + expect(sessionMutationFailureMessage('archive', offline)).toBe('common:nav.sessions.archiveFailed'); + expect(sessionMutationFailureMessage('rename', 'boom')).toBe('common:nav.sessions.renameFailed'); + expect(sessionMutationFailureMessage('duplicate', undefined)).toBe('common:nav.sessions.duplicateFailed'); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/sessionMutationFailure.ts b/src/web-ui/src/flow_chat/utils/sessionMutationFailure.ts new file mode 100644 index 0000000000..93e3fa70e0 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionMutationFailure.ts @@ -0,0 +1,30 @@ +import { i18nService } from '@/infrastructure/i18n'; + +/** + * Host refusal for a session mutation addressed to a workspace other than the + * one that owns the session's storage (see `claim_session_storage_path`). + */ +const WORKSPACE_BINDING_MISMATCH = 'already bound to another workspace'; + +export function isSessionWorkspaceBindingMismatch(error: unknown): boolean { + const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''; + return message.includes(WORKSPACE_BINDING_MISMATCH); +} + +export type SessionMutationKind = 'delete' | 'archive' | 'rename' | 'duplicate'; + +/** + * User-facing copy for a failed session mutation. Host and transport text is + * never shown raw: it is English-only and may carry storage paths. + */ +export function sessionMutationFailureMessage(kind: SessionMutationKind, error: unknown): string { + if (isSessionWorkspaceBindingMismatch(error)) { + return i18nService.t('common:nav.sessions.workspaceBindingMismatch'); + } + switch (kind) { + case 'delete': return i18nService.t('common:nav.sessions.deleteFailed'); + case 'archive': return i18nService.t('common:nav.sessions.archiveFailed'); + case 'rename': return i18nService.t('common:nav.sessions.renameFailed'); + case 'duplicate': return i18nService.t('common:nav.sessions.duplicateFailed'); + } +} diff --git a/src/web-ui/src/flow_chat/utils/sessionNavStatus.test.ts b/src/web-ui/src/flow_chat/utils/sessionNavStatus.test.ts index 1c05b99dc3..4170ea8440 100644 --- a/src/web-ui/src/flow_chat/utils/sessionNavStatus.test.ts +++ b/src/web-ui/src/flow_chat/utils/sessionNavStatus.test.ts @@ -113,6 +113,11 @@ describe('session navigation status', () => { it('keeps an unavailable status explicit', () => { expect(deriveSessionNavStatus({ session: session(), unavailable: true }).kind).toBe('syncing'); }); + + it('keeps a delivered approval answerable while activity is unavailable', () => { + expect(deriveSessionNavStatus({ session: session([turn('work', 'processing')]), unavailable: true, + permissions: [permission()] })).toEqual({ kind: 'approval', pendingCount: 1 }); + }); it('shows execution for ordinary project sessions as well as selected sessions', () => { const value = session([turn('work', 'processing')]); expect(deriveSessionNavStatus({ session: value }).kind).toBe('running'); diff --git a/src/web-ui/src/flow_chat/utils/sessionNavStatus.ts b/src/web-ui/src/flow_chat/utils/sessionNavStatus.ts index 1b56321671..94b30b01db 100644 --- a/src/web-ui/src/flow_chat/utils/sessionNavStatus.ts +++ b/src/web-ui/src/flow_chat/utils/sessionNavStatus.ts @@ -42,7 +42,6 @@ export function deriveSessionNavStatus({ session, machine, permissions = [], reachability, activity, unavailable, }: SessionNavStatusInput): SessionNavStatus { if (!session) return status('idle'); - if (unavailable) return status('syncing'); const latestTurn = lastUserDialogTurn(session); const trackedId = machine?.context.currentDialogTurnId; const turn = trackedId && latestTurn?.id !== trackedId @@ -69,6 +68,9 @@ export function deriveSessionNavStatus({ && terminalToolStates.has(item.status))); }); if (pending.length > 0) return status('approval', new Set(pending.map(request => request.requestId)).size); + // A delivered approval is answerable even while the activity read cannot + // describe the session; only then does the row fall back to syncing. + if (unavailable) return status('syncing'); // An unopened Session has no transcript/state-machine projection. The host // summary also corrects old hydrated history after a reconnect or restart. diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 93d790cc3d..0b2676dcbf 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -518,7 +518,11 @@ "discoveryWarningTitle": "External session discovery", "discoveryReadFailed": "Some {{source}} session records could not be read. Existing sessions remain available.", "discoveryWorkspaceUnresolved": "Some {{source}} sessions could not be matched to a workspace. Existing sessions remain available.", - "discoveringExternal": "Discovering external sessions…" + "discoveringExternal": "Discovering external sessions…", + "deleteFailed": "Failed to delete session", + "archiveFailed": "Failed to archive session", + "renameFailed": "Failed to rename session", + "workspaceBindingMismatch": "This session belongs to a different workspace, so it cannot be changed from here. Reopen OpenBitFun to reload it under its own workspace." }, "scheduledJobs": { "title": "Scheduled Jobs", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 7340e0eb4c..a30cb46934 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -518,7 +518,11 @@ "discoveryWarningTitle": "外部会话发现", "discoveryReadFailed": "部分 {{source}} 会话记录无法读取,已有会话仍可使用。", "discoveryWorkspaceUnresolved": "部分 {{source}} 会话无法匹配到工作区,已有会话仍可使用。", - "discoveringExternal": "正在发现外部会话…" + "discoveringExternal": "正在发现外部会话…", + "deleteFailed": "删除会话失败", + "archiveFailed": "归档会话失败", + "renameFailed": "重命名会话失败", + "workspaceBindingMismatch": "该会话属于其他工作区,无法在此处修改。请重新打开 OpenBitFun,会话会在其所属工作区下重新加载。" }, "scheduledJobs": { "title": "定时任务", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 562837251c..d311d7549e 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -518,7 +518,11 @@ "discoveryWarningTitle": "外部工作階段探索", "discoveryReadFailed": "部分 {{source}} 工作階段記錄無法讀取,既有工作階段仍可使用。", "discoveryWorkspaceUnresolved": "部分 {{source}} 工作階段無法對應到工作區,既有工作階段仍可使用。", - "discoveringExternal": "正在探索外部工作階段…" + "discoveringExternal": "正在探索外部工作階段…", + "deleteFailed": "刪除會話失敗", + "archiveFailed": "歸檔會話失敗", + "renameFailed": "重新命名會話失敗", + "workspaceBindingMismatch": "此會話屬於其他工作區,無法在此處修改。請重新開啟 OpenBitFun,會話會在其所屬工作區下重新載入。" }, "scheduledJobs": { "title": "定時任務",