From 7290e33276a78dcb20368f23d0ee055669a172b5 Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 13 Oct 2026 11:21:23 +0800 Subject: [PATCH] fix(session): Harden session copying Add a dedicated copy_session API and preserve selected-turn fork requests. Require whole_session_copy_v1 before copying on a peer host. Disable copying empty sessions using persisted history counts. Preserve copy success when metadata refresh fails and fence device switches. Refresh capability projections and cover unsupported peers, host downgrades, and metadata failure paths. Refs: #3350 --- docs/interactive-capabilities/README.md | 4 +- .../capabilities.json | 10 ++--- .../capabilities/feature.ai-assistant.md | 4 +- .../technical/product-control-open-audit.json | 7 +-- .../technical/tauri-command-map.json | 24 ++++++++-- src/apps/desktop/AGENTS.md | 4 ++ src/apps/desktop/src/api/session_api.rs | 34 ++++++++++---- src/apps/desktop/src/lib.rs | 1 + .../src/runtime/session_application.rs | 23 ++++++---- .../generated/product-control-catalog.json | 10 ++--- .../generated/remote-surface-registry.json | 16 ++++++- .../src/remote_surface/capabilities.rs | 9 ++++ .../product-domains/src/remote_surface/mod.rs | 15 +++++++ .../src/remote_surface/table.rs | 1 + .../interactive-capabilities/catalog.json | 5 ++- .../sections/sessions/SessionsSection.tsx | 38 ++++++++++++---- .../sessions/sessionRowContextMenu.test.ts | 5 +++ .../generated/interactive-capabilities.json | 10 ++--- .../src/flow_chat/services/FlowChatManager.ts | 2 +- .../flow-chat-manager/SessionModule.test.ts | 43 ++++++++++++++++++ .../flow-chat-manager/SessionModule.ts | 25 ++++++++--- .../src/flow_chat/store/FlowChatStore.test.ts | 4 +- .../src/flow_chat/store/FlowChatStore.ts | 2 + src/web-ui/src/flow_chat/types/flow-chat.ts | 2 + .../src/flow_chat/utils/sessionCopy.test.ts | 33 ++++++++++++++ src/web-ui/src/flow_chat/utils/sessionCopy.ts | 13 ++++++ .../api/adapters/peer-device-adapter.test.ts | 45 ++++++++++++++++++- .../api/adapters/peer-device-adapter.ts | 8 ++++ .../api/adapters/websocket-adapter.test.ts | 31 ++----------- .../api/adapters/websocket-adapter.ts | 37 +++------------ .../api/generated/productControl.ts | 2 +- .../api/generated/remoteSurface.ts | 4 +- .../api/service-api/SessionAPI.test.ts | 28 ++++++++++++ .../api/service-api/SessionAPI.ts | 6 +-- .../peer-device/PeerConnectionManager.test.ts | 27 +++++++++++ .../peer-device/PeerConnectionManager.ts | 6 +++ .../src/infrastructure/peer-device/README.md | 10 +++++ .../peerCapabilityResolution.test.ts | 11 +++++ .../peer-device/peerCapabilityResolution.ts | 8 ++++ src/web-ui/src/locales/en-US/common.json | 3 ++ src/web-ui/src/locales/zh-CN/common.json | 3 ++ src/web-ui/src/locales/zh-TW/common.json | 3 ++ 42 files changed, 450 insertions(+), 126 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/sessionCopy.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/sessionCopy.ts diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 92279a638b..51712ae6b8 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -27,9 +27,9 @@ OpenBitFun Playbook currently contains **22 features**, **21 settings pages**, a - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` - Generated low-level audit map: `docs/interactive-capabilities/technical/tauri-command-map.json` -说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **673** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 +说明书、网站、搜索和智能体只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **674** 个 Tauri 命令只用于实现覆盖审计。产品 UI 交互源码会在生成和检查时扫描并校验,但不会保存成随普通 UI 改动频繁变化的版本化快照。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **673** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **674** Tauri commands remain implementation-audit evidence only. Product UI interaction sources are scanned and validated during generation and checks, but are not stored as a versioned snapshot that churns with ordinary UI changes. ## 控制边界 / Control boundary diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 5f7122a344..eb885e40d5 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "09e162e571595bbe38f6c2e10e061e0f7243baae9e6db56a2e5b8948304a0e32", + "digest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", "searchAcceptance": [ { @@ -19435,8 +19435,8 @@ }, { "id": "session-lifecycle", - "titleZh": "重命名、归档、恢复、删除、分叉与导出会话", - "titleEn": "Rename, archive, restore, delete, fork, and export sessions", + "titleZh": "重命名、归档、恢复、删除、复制、分叉与导出会话", + "titleEn": "Rename, archive, restore, delete, copy, fork, and export sessions", "control": { "kind": "open", "reasonCode": "visualSelection", @@ -19735,8 +19735,8 @@ "Safely customize the running OpenBitFun frontend in Creative mode", "打开并新建一个 AI 会话", "Open and create a new AI session", - "重命名、归档、恢复、删除、分叉与导出会话", - "Rename, archive, restore, delete, fork, and export sessions", + "重命名、归档、恢复、删除、复制、分叉与导出会话", + "Rename, archive, restore, delete, copy, fork, and export sessions", "为会话选择模型、工作模式、记忆模式与权限模式", "Choose the model, work mode, memory mode, and permission mode per session", "让创造模式扩展客户端界面与运行时能力", diff --git a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md index 48c27208ff..54ad8079ec 100644 --- a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md +++ b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md @@ -19,8 +19,8 @@ Work with AI in project context, create sessions, continue tasks, answer permiss - **智能体可直接控制 / Direct Agent control** · 打开并新建一个 AI 会话 - Open and create a new AI session -- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 重命名、归档、恢复、删除、分叉与导出会话 - - Rename, archive, restore, delete, fork, and export sessions +- **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 重命名、归档、恢复、删除、复制、分叉与导出会话 + - Rename, archive, restore, delete, copy, fork, and export sessions - **智能体可定位入口,需交互完成 / Agent opens; interaction required** · 为会话选择模型、工作模式、记忆模式与权限模式 - Choose the model, work mode, memory mode, and permission mode per session - **由专用智能体工具控制 / Delegated Agent tool** · `FrontendWorkbench` / `Read` / `Write` / `Edit` · 让创造模式扩展客户端界面与运行时能力 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index 9f0a370511..32b49113eb 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "09e162e571595bbe38f6c2e10e061e0f7243baae9e6db56a2e5b8948304a0e32", + "catalogDigest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", "count": 216, "reasonCounts": { "externalAuth": 4, @@ -13,8 +13,8 @@ { "capabilityId": "feature.ai-assistant", "itemId": "session-lifecycle", - "titleZh": "重命名、归档、恢复、删除、分叉与导出会话", - "titleEn": "Rename, archive, restore, delete, fork, and export sessions", + "titleZh": "重命名、归档、恢复、删除、复制、分叉与导出会话", + "titleEn": "Rename, archive, restore, delete, copy, fork, and export sessions", "reasonCode": "visualSelection", "reasonZh": "这些操作依赖选中的会话、当前运行状态或删除确认;智能体可准确打开会话界面,但不把新建会话冒充为完整生命周期控制。", "reasonEn": "These actions depend on the selected session, its live state, or deletion confirmation; the Agent can open the exact session UI without pretending that session creation controls the full lifecycle.", @@ -27,6 +27,7 @@ "command:archive_session", "command:restore_session", "command:delete_session", + "command:copy_session", "command:fork_session", "command:export_session_transcript", "command:archive_all_sessions", diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 4e74d152ef..64f50851c4 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "09e162e571595bbe38f6c2e10e061e0f7243baae9e6db56a2e5b8948304a0e32", - "commandCount": 673, + "catalogDigest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", + "commandCount": 674, "coverage": { - "commandCount": 673, - "documentedCommandCount": 625, + "commandCount": 674, + "documentedCommandCount": 626, "implementationCommandCount": 48, "implementationDigest": "f6d38a24a70708988cb47ada81d07eccf0668684e8734c9ee5155b9ffa7e3db8" }, @@ -1644,6 +1644,22 @@ "signature": "fn control_deep_review_queue( request: ControlDeepReviewQueueRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "LegacyUnaudited" }, + { + "id": "copy_session", + "moduleId": "session", + "capabilityId": "feature.ai-assistant", + "capabilityIds": [ + "feature.ai-assistant" + ], + "documentedItemIds": [ + "feature.ai-assistant:session-lifecycle" + ], + "visibility": "documented", + "rustPath": "copy_session", + "sourceFile": "src/apps/desktop/src/api/session_api.rs", + "signature": "fn copy_session( request: CopySessionRequest, runtime: State<'_, DesktopRuntimeContext>, ) -> Result", + "remoteWorkspacePolicy": "RemoteRouted" + }, { "id": "create_acp_flow_session", "moduleId": "acp_client", diff --git a/src/apps/desktop/AGENTS.md b/src/apps/desktop/AGENTS.md index 864bfd590e..9a55bb2f2a 100644 --- a/src/apps/desktop/AGENTS.md +++ b/src/apps/desktop/AGENTS.md @@ -124,6 +124,10 @@ For session storage directory access, run for session storage directory resolution (these tests do not launch a file manager), and `cargo test -p openbitfun-desktop --lib remote_workspace_policy` after registration changes. +For whole-session copy routing, run +`cargo test --locked -p openbitfun-desktop --lib whole_session_copy_uses_the_latest_persisted_turn_fork`. +After command registration changes, also run the `remote_workspace_policy` filter. + For model selector request compatibility and configuration effect routing: ```bash diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index bdb80b5981..0ce00bc46a 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -240,12 +240,7 @@ fn default_include_hidden_subagents() -> bool { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ForkSessionRequest { pub source_session_id: String, - /// Turn to copy the history through. Omitted means "copy the whole - /// session": the host then forks at the session's latest persisted turn. - /// - /// The field stays on the wire for existing callers and older frontends. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub source_turn_id: Option, + pub source_turn_id: String, #[serde(default)] pub workspace_id: Option, #[serde(default)] @@ -258,6 +253,14 @@ pub struct ForkSessionRequest { pub type ForkSessionResponse = SessionBranchResult; +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CopySessionRequest { + pub source_session_id: String, + pub workspace_id: String, +} + +pub type CopySessionResponse = SessionBranchResult; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ArchiveSessionRequest { pub session_id: String, @@ -728,8 +731,7 @@ pub async fn load_persisted_session_metadata( result } -/// Forks a session through a selected turn, or copies the whole session when -/// `source_turn_id` is omitted. +/// Forks a session through the caller's selected persisted turn. #[tauri::command] pub async fn fork_session( request: ForkSessionRequest, @@ -751,6 +753,22 @@ pub async fn fork_session( .map_err(|error| format!("Failed to fork session: {}", desktop_session_error(error))) } +/// Copies the whole session through its latest persisted turn. +#[tauri::command] +pub async fn copy_session( + request: CopySessionRequest, + runtime: State<'_, DesktopRuntimeContext>, +) -> Result { + runtime + .session_application() + .copy_session( + desktop_session_scope(Some(request.workspace_id), String::new(), None, None), + request.source_session_id, + ) + .await + .map_err(|error| format!("Failed to copy session: {}", desktop_session_error(error))) +} + #[tauri::command] pub async fn archive_session( request: ArchiveSessionRequest, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6d32375a46..2d6228dacc 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1618,6 +1618,7 @@ pub async fn run() { touch_session_activity, load_persisted_session_metadata, fork_session, + copy_session, archive_session, unarchive_session, archive_all_sessions, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 970651c752..00e8e4a37e 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -621,18 +621,12 @@ impl DesktopSessionApplication { Ok(report) } - /// Copies a session into a new independent session. - /// - /// `source_turn_id` selects a prefix of the source history. Omitting it - /// copies the whole session, which is the runtime's latest-persisted-turn - /// fork (`AgentSessionForkRequest`); the Desktop "Copy session" action uses - /// that form so the controller never has to load a session's turns just to - /// ask for a copy. + /// Forks a session through the caller's selected persisted turn. pub(crate) async fn fork_session( &self, request: DesktopSessionScopeRequest, source_session_id: String, - source_turn_id: Option, + source_turn_id: String, ) -> DesktopSessionApplicationResult { let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; @@ -640,11 +634,22 @@ impl DesktopSessionApplication { &self.agent_runtime, scope, source_session_id, - source_turn_id, + Some(source_turn_id), ) .await } + /// Copies the whole session using the existing runtime-owned fork algorithm. + pub(crate) async fn copy_session( + &self, + request: DesktopSessionScopeRequest, + source_session_id: String, + ) -> DesktopSessionApplicationResult { + let scope = self.resolved_scope(request).await?; + self.ensure_runtime_ownership(&scope)?; + fork_session_in_scope(&self.agent_runtime, scope, source_session_id, None).await + } + pub(crate) async fn set_session_archived( &self, request: DesktopSessionScopeRequest, diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index b9bea45ada..3c42b47440 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "09e162e571595bbe38f6c2e10e061e0f7243baae9e6db56a2e5b8948304a0e32", + "digest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", "searchAcceptance": [ { @@ -19435,8 +19435,8 @@ }, { "id": "session-lifecycle", - "titleZh": "重命名、归档、恢复、删除、分叉与导出会话", - "titleEn": "Rename, archive, restore, delete, fork, and export sessions", + "titleZh": "重命名、归档、恢复、删除、复制、分叉与导出会话", + "titleEn": "Rename, archive, restore, delete, copy, fork, and export sessions", "control": { "kind": "open", "reasonCode": "visualSelection", @@ -19739,8 +19739,8 @@ "Safely customize the running OpenBitFun frontend in Creative mode", "打开并新建一个 AI 会话", "Open and create a new AI session", - "重命名、归档、恢复、删除、分叉与导出会话", - "Rename, archive, restore, delete, fork, and export sessions", + "重命名、归档、恢复、删除、复制、分叉与导出会话", + "Rename, archive, restore, delete, copy, fork, and export sessions", "为会话选择模型、工作模式、记忆模式与权限模式", "Choose the model, work mode, memory mode, and permission mode per session", "让创造模式扩展客户端界面与运行时能力", diff --git a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json index 507978c381..4ce7d5e3c6 100644 --- a/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json +++ b/src/crates/contracts/product-domains/src/generated/remote-surface-registry.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "digest": "fnv1a64:c96edb696f388992", + "digest": "fnv1a64:e2b40d572990839b", "retiredCommandPrefixes": [ { "prefix": "lsp_", @@ -27,6 +27,7 @@ "tool_catalog", "chat_mcp_catalog_v1", "workspace_id_references_v1", + "whole_session_copy_v1", "user_question_response", "user_question_interaction_v1" ], @@ -49,6 +50,7 @@ "tool_catalog", "chat_mcp_catalog_v1", "workspace_id_references_v1", + "whole_session_copy_v1", "user_question_response", "user_question_interaction_v1" ], @@ -1272,6 +1274,18 @@ "reason": "the CLI peer host has no handler for this command" } }, + { + "id": "copy_session", + "surface": "tauri_command", + "remoteWorkspace": "RemoteRouted", + "peer": { + "kind": "proxied" + }, + "cliPeer": { + "kind": "unsupported", + "reason": "the CLI peer host has no handler for this command" + } + }, { "id": "create_acp_flow_session", "surface": "tauri_command", diff --git a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs index 6a4c0c5a03..7e91ce2d4a 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/capabilities.rs @@ -58,6 +58,8 @@ pub enum PeerHostCapability { ChatMcpCatalogV1, /// Workspace references can carry the owning host's persisted ID. WorkspaceIdReferencesV1, + /// `copy_session` copies a whole session without a caller-selected turn id. + WholeSessionCopyV1, /// `submit_user_answers` is implemented for Runtime-owned questions. UserQuestionResponse, /// First human interaction disables the unattended question timeout. @@ -85,6 +87,7 @@ impl PeerHostCapability { Self::ToolCatalog, Self::ChatMcpCatalogV1, Self::WorkspaceIdReferencesV1, + Self::WholeSessionCopyV1, Self::UserQuestionResponse, Self::UserQuestionInteractionV1, ]; @@ -110,6 +113,7 @@ impl PeerHostCapability { Self::ToolCatalog => "tool_catalog", Self::ChatMcpCatalogV1 => "chat_mcp_catalog_v1", Self::WorkspaceIdReferencesV1 => "workspace_id_references_v1", + Self::WholeSessionCopyV1 => "whole_session_copy_v1", Self::UserQuestionResponse => "user_question_response", Self::UserQuestionInteractionV1 => "user_question_interaction_v1", } @@ -221,5 +225,10 @@ mod tests { assert!(!cli.contains_key("miniapp_agent_context_files_v1")); assert!(!cli.contains_key("product_control_native_v1")); assert!(!cli.contains_key("product_control_presentation_v1")); + assert!(!cli.contains_key("whole_session_copy_v1")); + assert_eq!( + capability_map(PeerHostKind::Desktop).get("whole_session_copy_v1"), + Some(&Value::Bool(true)) + ); } } diff --git a/src/crates/contracts/product-domains/src/remote_surface/mod.rs b/src/crates/contracts/product-domains/src/remote_surface/mod.rs index 19638a38cc..5c4f478919 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/mod.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/mod.rs @@ -795,6 +795,21 @@ mod tests { } } + #[test] + fn whole_session_copy_runs_on_desktop_and_is_explicitly_unsupported_on_cli() { + let copy = operation("copy_session").unwrap(); + assert_eq!(copy.remote_workspace, RemoteWorkspaceStance::Routed); + assert_eq!(copy.peer, PeerStance::Proxied); + assert_eq!( + peer_host_verdict("copy_session", PeerHostKind::Desktop), + PeerHostVerdict::Execute + ); + assert!(matches!( + peer_host_verdict("copy_session", PeerHostKind::Cli), + PeerHostVerdict::Refuse(PeerRefusal::HostUnsupported { .. }) + )); + } + #[test] fn remote_workspace_stance_keeps_historical_policy_names() { for (stance, expected) in [ diff --git a/src/crates/contracts/product-domains/src/remote_surface/table.rs b/src/crates/contracts/product-domains/src/remote_surface/table.rs index 2a6931b3f0..8a31fbd2a3 100644 --- a/src/crates/contracts/product-domains/src/remote_surface/table.rs +++ b/src/crates/contracts/product-domains/src/remote_surface/table.rs @@ -185,6 +185,7 @@ pub(super) const OPERATIONS: &[OperationDefinition] = &[ op("confirm_frontend_update", LocalOnly, ControllerLocal, REFUSED), op("control_background_command", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("control_deep_review_queue", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), + op("copy_session", Routed, Proxied, CLI_NOT_IMPLEMENTED), op("create_acp_flow_session", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("create_assistant_workspace", Unaudited, Proxied, CLI_NOT_IMPLEMENTED), op("create_control_conversation", Agnostic, Proxied, HANDLED), diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 47c0b850c5..1c9943ac0e 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -323,8 +323,8 @@ }, { "id": "session-lifecycle", - "titleZh": "重命名、归档、恢复、删除、分叉与导出会话", - "titleEn": "Rename, archive, restore, delete, fork, and export sessions", + "titleZh": "重命名、归档、恢复、删除、复制、分叉与导出会话", + "titleEn": "Rename, archive, restore, delete, copy, fork, and export sessions", "control": { "kind": "open", "reasonCode": "visualSelection", @@ -336,6 +336,7 @@ "command:archive_session", "command:restore_session", "command:delete_session", + "command:copy_session", "command:fork_session", "command:export_session_transcript", "command:archive_all_sessions", diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index a344a93a3b..3a67a1ee17 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -1,6 +1,9 @@ import { lazyWithRecovery } from '@/shared/utils/lazyWithRecovery'; import { useDeviceDirectory, resolveDeviceName } from '@/infrastructure/account/deviceDirectory'; import { requireSessionOwningWorkspaceId } from '@/flow_chat/utils/sessionOrdering'; +import { isSessionEmptyForCopy } from '@/flow_chat/utils/sessionCopy'; +import { usePeerDeviceModeOptional } from '@/infrastructure/peer-device/peerDeviceContextState'; +import { canCopySessionsOnSurface } from '@/infrastructure/peer-device/peerCapabilityResolution'; /** * SessionsSection — inline accordion content for the "Sessions" nav item. * @@ -54,7 +57,7 @@ import { isDefaultSessionTitle, resolveSessionTitle } from '@/flow_chat/utils/se import { useSessionTitleNumbers } from '@/flow_chat/hooks/useSessionTitleNumbers'; import { SessionTitleNumber } from '@/flow_chat/components/SessionTitleNumber'; import { useSessionComposerStore } from '@/flow_chat/store/sessionComposerStore'; -import { getActiveSurfaceId, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; +import { getActiveSurfaceId, isSurfaceChangedError, surfaceScopedKey } from '@/infrastructure/peer-device/deviceSurface'; import { isSessionNavRowActive } from './sessionNavSelection'; import { isSessionRowPointerTarget } from './sessionOpenPointer'; import { @@ -253,6 +256,11 @@ const SessionsSection: React.FC = ({ const [flowChatState, setFlowChatState] = useState(() => flowChatStore.getState() ); + const peerDevice = usePeerDeviceModeOptional(); + const canCopySessions = canCopySessionsOnSurface( + peerDevice?.peerMode.active ?? false, + peerDevice?.currentPeerCapabilities ?? null, + ); const backgroundSubagentActivities = useBackgroundSubagentActivityStore(state => state.activities); const dispatchTransportByJobId = useDispatchJobStore(state => state.transportByJobId); const [editingSessionId, setEditingSessionId] = useState(null); @@ -1305,11 +1313,19 @@ const SessionsSection: React.FC = ({ setDuplicatingSessionId(sessionId); try { const copied = await flowChatManager.duplicateChatSession(sessionId); - notificationService.success( - t('nav.sessions.duplicateSuccess', { name: copied.sessionName }), - { duration: 3000 }, - ); + if (copied.metadataProjected) { + notificationService.success( + t('nav.sessions.duplicateSuccess', { name: copied.sessionName }), + { duration: 3000 }, + ); + } else { + notificationService.warning( + t('nav.sessions.duplicateCreatedRefreshFailed', { name: copied.sessionName }), + { duration: 5000 }, + ); + } } catch (error) { + if (isSurfaceChangedError(error)) return; log.error('Failed to copy session', { sessionId, error }); notificationService.error( error instanceof Error && error.message @@ -1983,13 +1999,19 @@ const SessionsSection: React.FC = ({ // history is not a stable copy boundary; a session // owned by another device has no local history. disabled={isDispatched + || !canCopySessions + || isSessionEmptyForCopy(session) || runningSessionIds.has(session.sessionId) || duplicatingSessionId === session.sessionId} title={isDispatched ? t('nav.sessions.duplicateDispatchUnsupported') - : runningSessionIds.has(session.sessionId) - ? t('nav.sessions.duplicateRunning') - : undefined} + : !canCopySessions + ? t('nav.sessions.duplicateHostUnsupported') + : isSessionEmptyForCopy(session) + ? t('nav.sessions.duplicateEmpty') + : runningSessionIds.has(session.sessionId) + ? t('nav.sessions.duplicateRunning') + : undefined} data-testid="nav-session-menu-duplicate" data-session-id={session.sessionId} > diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowContextMenu.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowContextMenu.test.ts index 42c4393502..c7848d4c0b 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowContextMenu.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionRowContextMenu.test.ts @@ -64,5 +64,10 @@ describe('SessionsSection session row context menu', () => { expect(source).toContain('|| duplicatingSessionId === session.sessionId}'); expect(source).toContain("t('nav.sessions.duplicateDispatchUnsupported')"); expect(source).toContain("t('nav.sessions.duplicateRunning')"); + expect(source).toContain('|| !canCopySessions'); + expect(source).toContain('|| isSessionEmptyForCopy(session)'); + expect(source).toContain("t('nav.sessions.duplicateEmpty')"); + expect(source).toContain("t('nav.sessions.duplicateHostUnsupported')"); + expect(source).toContain("t('nav.sessions.duplicateCreatedRefreshFailed'"); }); }); diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 5f7122a344..eb885e40d5 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "OpenBitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "09e162e571595bbe38f6c2e10e061e0f7243baae9e6db56a2e5b8948304a0e32", + "digest": "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825", "ownerDigest": "c989ace53614c2a526fefe64816fdd6895ec71edcc28f44b6f80d1edb75bb33e", "searchAcceptance": [ { @@ -19435,8 +19435,8 @@ }, { "id": "session-lifecycle", - "titleZh": "重命名、归档、恢复、删除、分叉与导出会话", - "titleEn": "Rename, archive, restore, delete, fork, and export sessions", + "titleZh": "重命名、归档、恢复、删除、复制、分叉与导出会话", + "titleEn": "Rename, archive, restore, delete, copy, fork, and export sessions", "control": { "kind": "open", "reasonCode": "visualSelection", @@ -19735,8 +19735,8 @@ "Safely customize the running OpenBitFun frontend in Creative mode", "打开并新建一个 AI 会话", "Open and create a new AI session", - "重命名、归档、恢复、删除、分叉与导出会话", - "Rename, archive, restore, delete, fork, and export sessions", + "重命名、归档、恢复、删除、复制、分叉与导出会话", + "Rename, archive, restore, delete, copy, fork, and export sessions", "为会话选择模型、工作模式、记忆模式与权限模式", "Choose the model, work mode, memory mode, and permission mode per session", "让创造模式扩展客户端界面与运行时能力", diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 940005031a..007d8776a8 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -749,7 +749,7 @@ export class FlowChatManager { async duplicateChatSession( sourceSessionId: string, - ): Promise<{ sessionId: string; sessionName: string }> { + ): Promise<{ sessionId: string; sessionName: string; metadataProjected: boolean }> { return duplicateChatSessionModule(this.context, sourceSessionId); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index c9a87326af..ac1c4b2867 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -645,6 +645,7 @@ describe('duplicateChatSession', () => { await expect(duplicateChatSession(context, source.sessionId)).resolves.toEqual({ sessionId: 'copy-1', sessionName: 'Source (1)', + metadataProjected: true, }); // The host resolves the copy boundary: no turn id leaves the controller. @@ -659,6 +660,48 @@ describe('duplicateChatSession', () => { expect(flowChatStore.getState().sessions.has('copy-source')).toBe(true); }); + it.each(['missing', 'timeout'])('preserves copy success after a metadata %s', async failure => { + const source = createSession({ sessionId: 'copy-source', persistedTurnCount: 3 }); + const { context, flowChatStore } = createContext(source); + sessionApiMocks.duplicateSession.mockResolvedValueOnce({ + sessionId: 'copy-1', sessionName: 'Source (1)', agentType: 'Standard', + }); + if (failure === 'missing') { + flowChatStore.ensurePersistedSessionMetadata.mockResolvedValueOnce(false); + } else { + flowChatStore.ensurePersistedSessionMetadata.mockRejectedValueOnce(new Error('metadata timed out')); + } + await expect(duplicateChatSession(context, source.sessionId)).resolves.toEqual({ + sessionId: 'copy-1', sessionName: 'Source (1)', metadataProjected: false, + }); + expect(sessionApiMocks.duplicateSession).toHaveBeenCalledTimes(1); + expect(flowChatStore.switchSession).not.toHaveBeenCalled(); + }); + + it('does not read copied metadata on a device selected after the copy started', async () => { + const source = createSession({ sessionId: 'copy-source' }); + const { context, flowChatStore } = createContext(source); + const pending = createDeferred<{ sessionId: string; sessionName: string; agentType: string }>(); + sessionApiMocks.duplicateSession.mockReturnValueOnce(pending.promise); + const copy = duplicateChatSession(context, source.sessionId); + activateSurface('peer-other'); + pending.resolve({ sessionId: 'copy-1', sessionName: 'Source (1)', agentType: 'Standard' }); + await expect(copy).rejects.toMatchObject({ name: 'SurfaceChangedError' }); + expect(flowChatStore.ensurePersistedSessionMetadata).not.toHaveBeenCalled(); + activateSurface(LOCAL_SURFACE_ID); + }); + + it.each([ + { historyState: 'new' as const }, + { persistedTurnCount: 0 }, + ])('rejects a known empty session before sending the copy mutation', async overrides => { + const source = createSession({ sessionId: 'copy-source', ...overrides }); + const { context } = createContext(source); + await expect(duplicateChatSession(context, source.sessionId)) + .rejects.toThrow('common:nav.sessions.duplicateEmpty'); + expect(sessionApiMocks.duplicateSession).not.toHaveBeenCalled(); + }); + it('refuses a detached dispatch session before asking the host', async () => { const dispatched = createSession({ sessionId: 'dispatch-source', 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 0fb0b00dce..b474606a05 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 @@ -1,4 +1,5 @@ import { requireSessionOwningWorkspaceId } from '../../utils/sessionOrdering'; +import { isSessionEmptyForCopy } from '../../utils/sessionCopy'; /** * Session management module * Handles session creation, switching, deletion, and other operations @@ -841,7 +842,8 @@ export async function forkChatSession( export async function duplicateChatSession( context: FlowChatContext, sourceSessionId: string, -): Promise<{ sessionId: string; sessionName: string }> { +): Promise<{ sessionId: string; sessionName: string; metadataProjected: boolean }> { + const scope = getActiveSurfaceScope(); const sourceSession = context.flowChatStore.getState().sessions.get(sourceSessionId); if (!sourceSession) { throw new Error(`Session does not exist: ${sourceSessionId}`); @@ -849,16 +851,27 @@ export async function duplicateChatSession( if (driverForSession(sourceSessionId, sourceSession).id === 'dispatch') { throw new Error('Copying a detached dispatch session is not supported'); } + if (isSessionEmptyForCopy(sourceSession)) { + throw new Error(i18nService.t('common:nav.sessions.duplicateEmpty')); + } const workspaceId = requireSessionOwningWorkspaceId(sourceSession); const response = await sessionAPI.duplicateSession(sourceSessionId, workspaceId); + scope.assertCurrent('copy session'); // The host already created the copy. Metadata projection only decides when the // navigation list shows it, so a projection miss is reported, not rethrown. - const projected = await context.flowChatStore.ensurePersistedSessionMetadata( - response.sessionId, - workspaceId, - ); + let projected = false; + try { + projected = await context.flowChatStore.ensurePersistedSessionMetadata( + response.sessionId, + workspaceId, + ); + } catch (error) { + if (isSurfaceChangedError(error)) throw error; + log.warn('Failed to project copied session metadata', { sessionId: response.sessionId, error }); + } + scope.assertCurrent('project copied session metadata'); if (!projected) { log.warn('Copied session metadata is unavailable for the navigation list', { sessionId: response.sessionId, @@ -867,7 +880,7 @@ export async function duplicateChatSession( }); } - return { sessionId: response.sessionId, sessionName: response.sessionName }; + return { sessionId: response.sessionId, sessionName: response.sessionName, metadataProjected: projected }; } /** 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 7c5d3be18f..28e019204e 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -1790,13 +1790,14 @@ describe('FlowChatStore historical session hydration state', () => { vi.unstubAllGlobals(); }); - it('loads persisted metadata as metadata-only historical sessions', async () => { + it.each([0, 3, undefined])('loads persisted metadata as metadata-only historical sessions (turns=%s)', async turnCount => { apiMocks.listSessions.mockResolvedValueOnce([ { sessionId: 'history-1', title: 'Saved session', agentType: 'Standard', modelName: 'primary', + turnCount, createdAt: 10, lastActiveAt: 20, }, @@ -1809,6 +1810,7 @@ describe('FlowChatStore historical session hydration state', () => { sessionId: 'history-1', isHistorical: true, historyState: 'metadata-only', + persistedTurnCount: turnCount, dialogTurns: [], }); }); diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 2fbe6ac04a..ac5a6f23a3 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -7442,6 +7442,7 @@ export class FlowChatStore { dialogTurns: [], status: 'idle', persistedStatus: metadata.status, + persistedTurnCount: metadata.turnCount, config: { agentType: validatedAgentType, modelName: metadata.modelName, @@ -7894,6 +7895,7 @@ export class FlowChatStore { dialogTurns: [], status: 'idle', persistedStatus: metadata.status, + persistedTurnCount: metadata.turnCount, config: { agentType: validatedAgentType, modelName: metadata.modelName, 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 18e794e738..3394300f8d 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -445,6 +445,8 @@ export interface Session { * - 'failed': hydrate failed and the UI should offer retry instead of showing a new session. */ historyState?: SessionHistoryState; + /** Last authoritative metadata count; absent on legacy projections. */ + persistedTurnCount?: number; /** * Backend runtime-context lifecycle for sessions restored through the fast diff --git a/src/web-ui/src/flow_chat/utils/sessionCopy.test.ts b/src/web-ui/src/flow_chat/utils/sessionCopy.test.ts new file mode 100644 index 0000000000..15f18a143e --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionCopy.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { isSessionEmptyForCopy } from './sessionCopy'; + +type CopySession = Parameters[0]; +const session = (overrides: Partial = {}): CopySession => ({ + dialogTurns: [], historyState: 'metadata-only', ...overrides, +}); + +describe('isSessionEmptyForCopy', () => { + it('disables newly created and fully hydrated empty sessions', () => { + expect(isSessionEmptyForCopy(session({ historyState: 'new' }))).toBe(true); + expect(isSessionEmptyForCopy(session({ historyState: 'ready' }))).toBe(true); + }); + + it('uses persisted counts instead of unloaded transcript arrays', () => { + expect(isSessionEmptyForCopy(session({ persistedTurnCount: 0 }))).toBe(true); + expect(isSessionEmptyForCopy(session({ persistedTurnCount: 5 }))).toBe(false); + expect(isSessionEmptyForCopy(session())).toBe(false); + expect(isSessionEmptyForCopy(session({ historyState: 'failed' }))).toBe(false); + }); + + it('does not classify a partial empty preview as a fully loaded empty history', () => { + expect(isSessionEmptyForCopy(session({ historyState: 'ready', isPartial: true }))).toBe(false); + }); + + it('lets newer history counts override stale empty metadata', () => { + expect(isSessionEmptyForCopy(session({ persistedTurnCount: 0, totalTurnCount: 3 }))).toBe(false); + expect(isSessionEmptyForCopy(session({ persistedTurnCount: 3, totalTurnCount: 0 }))).toBe(true); + expect(isSessionEmptyForCopy(session({ + persistedTurnCount: 0, dialogTurns: [{}] as CopySession['dialogTurns'], + }))).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/sessionCopy.ts b/src/web-ui/src/flow_chat/utils/sessionCopy.ts new file mode 100644 index 0000000000..f540d011f8 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/sessionCopy.ts @@ -0,0 +1,13 @@ +import type { Session } from '../types/flow-chat'; + +/** Unloaded history is not evidence that a persisted session is empty. */ +export function isSessionEmptyForCopy(session: Pick): boolean { + if (session.draft) return true; + if (session.dialogTurns.length > 0) return false; + if (session.totalTurnCount !== undefined) return session.totalTurnCount === 0; + if (session.historyState === 'ready' && !session.isPartial) return true; + return session.persistedTurnCount === 0 + || session.historyState === 'new'; +} diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts index f0966930bd..b80acdfa81 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.test.ts @@ -20,6 +20,46 @@ import { import { TauriTransportAdapter } from './tauri-adapter'; import { isSurfaceChangedError } from '@/infrastructure/peer-device/deviceSurface'; +describe('whole-session copy capability', () => { + it('rejects copying before RPC on a host without the new request contract', async () => { + const deviceRpc = vi.fn(); + const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc); + await expect(adapter.request('copy_session', { + request: { source_session_id: 'source', workspace_id: 'workspace-1' }, + })).rejects.toThrow('whole_session_copy_v1_unsupported'); + expect(deviceRpc).not.toHaveBeenCalled(); + }); + + it('preserves explicit-turn fork payloads for an older host', async () => { + const result = { sessionId: 'copy-1', sessionName: 'Source (1)', agentType: 'Standard' }; + const deviceRpc = vi.fn().mockResolvedValue(JSON.stringify({ + resp: 'host_invoke_result', ok: true, value: result, + })); + const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc); + const params = { request: { + source_session_id: 'source', source_turn_id: 'turn-1', workspace_id: 'workspace-1', + } }; + await expect(adapter.request('fork_session', params)).resolves.toEqual(result); + expect(JSON.parse(deviceRpc.mock.calls[0][1])).toMatchObject({ command: 'fork_session', args: params }); + }); + + it('forwards the dedicated copy command once, then rejects it if the host downgrades', async () => { + const result = { sessionId: 'copy-1', sessionName: 'Source (1)', agentType: 'Standard' }; + const deviceRpc = vi.fn().mockResolvedValue(JSON.stringify({ + resp: 'host_invoke_result', ok: true, value: result, + })); + const adapter = new PeerDeviceTransportAdapter('peer-1', deviceRpc, { + supportsWholeSessionCopyV1: true, + }); + const params = { request: { source_session_id: 'source', workspace_id: 'workspace-1' } }; + await expect(adapter.request('copy_session', params)).resolves.toEqual(result); + expect(JSON.parse(deviceRpc.mock.calls[0][1])).toMatchObject({ command: 'copy_session', args: params }); + adapter.setHostCapabilities({ supportsWholeSessionCopyV1: false }); + await expect(adapter.request('copy_session', params)).rejects.toThrow('whole_session_copy_v1_unsupported'); + expect(deviceRpc).toHaveBeenCalledTimes(1); + }); +}); + describe('isPeerLocalOnlyCommand', () => { it('keeps file-search streaming on the negotiated response path', () => { const adapter = new PeerDeviceTransportAdapter('peer-1', vi.fn()); @@ -207,10 +247,11 @@ describe('peerInvokePriorityFor', () => { expect(isPeerRetryableReadCommand('start_dialog_turn')).toBe(false); expect(isPeerRetryableReadCommand('delete_session')).toBe(false); expect(isPeerRetryableReadCommand('respond_permission')).toBe(false); - // Copying a session rides `fork_session`. It carries no host-side + // Copying a session carries no host-side // idempotency key, so a retry would create a second copy of the session. expect(isPeerRetryableReadCommand('fork_session')).toBe(false); - expect(isPeerRetryableIdempotentMutation('fork_session', { + expect(isPeerRetryableReadCommand('copy_session')).toBe(false); + expect(isPeerRetryableIdempotentMutation('copy_session', { request: { workspace_id: 'workspace-1', source_session_id: 'session-1' }, })).toBe(false); }); diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index e281ede977..c52a8f742b 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -294,6 +294,7 @@ export interface PeerDeviceTransportHooks { /** Enables MiniApp Agent runs with immutable virtual context files. */ supportsMiniAppAgentContextFilesV1?: boolean; supportsWslWorkspacesV1?: boolean; + supportsWholeSessionCopyV1?: boolean; supportsComputerUseSharingV1?: boolean; /** Enables the versioned typed ProductControl HostInvoke contract. */ supportsProductControlV1?: boolean; @@ -430,6 +431,7 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { | 'supportsTokenUsageStatistics' | 'supportsMiniAppAgentContextFilesV1' | 'supportsWslWorkspacesV1' + | 'supportsWholeSessionCopyV1' | 'supportsComputerUseSharingV1' | 'supportsProductControlV1' >, @@ -494,6 +496,12 @@ export class PeerDeviceTransportAdapter implements ITransportAdapter { this.assertRenderedTransport(action); + if (action === 'copy_session' && this.hooks.supportsWholeSessionCopyV1 !== true) { + throw new PeerProductCommandError( + 'whole_session_copy_v1_unsupported: The connected Peer host does not support whole-session copying', + ); + } + if ( action === 'rollback_session_to_turn' && this.hooks.supportsTargetedSessionRollback !== true diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts index edb57d200a..f98dded44a 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.test.ts @@ -5,7 +5,6 @@ import { decodeResponseBody, encodeRequestBody, resolveWsMethod, - resolveWsMethodForBody, WebSocketTransportAdapter, } from './websocket-adapter'; import type { @@ -13,30 +12,6 @@ import type { SubmitDialogTurnResponse, } from '@/generated/api'; -describe('resolveWsMethodForBody', () => { - it('routes a whole-session copy to session/fork and a turn copy to session/forkAtTurn', () => { - // One desktop command (`fork_session`) covers the app-server's two fork - // methods. `session/forkAtTurn` rejects a missing turn id, so a body without - // one must ride `session/fork` (the host's latest-persisted-turn copy). - expect(resolveWsMethodForBody('fork_session', { source_session_id: 's1' })) - .toBe('session/fork'); - expect(resolveWsMethodForBody('fork_session', { source_session_id: 's1', source_turn_id: '' })) - .toBe('session/fork'); - expect(resolveWsMethodForBody('fork_session', { source_session_id: 's1', source_turn_id: ' ' })) - .toBe('session/fork'); - expect(resolveWsMethodForBody('fork_session', { source_session_id: 's1', source_turn_id: 't2' })) - .toBe('session/forkAtTurn'); - }); - - it('leaves every other command on its table method', () => { - expect(resolveWsMethodForBody('archive_session', { session_id: 's1' })) - .toBe('session/setArchived'); - expect(resolveWsMethodForBody('delete_session', { session_id: 's1' })) - .toBe('agent/deleteSession'); - expect(resolveWsMethodForBody('ping', undefined)).toBe('ping'); - }); -}); - describe('resolveWsMethod', () => { it('maps every app-server agent command to its agent/* surface method', () => { // The snake_case Tauri command names the service-API call sites use are @@ -48,6 +23,7 @@ describe('resolveWsMethod', () => { expect(resolveWsMethod('list_sessions')).toBe('agent/listSessions'); expect(resolveWsMethod('delete_session')).toBe('agent/deleteSession'); expect(resolveWsMethod('fork_session')).toBe('session/forkAtTurn'); + expect(resolveWsMethod('copy_session')).toBe('session/fork'); expect(resolveWsMethod('archive_session')).toBe('session/setArchived'); expect(resolveWsMethod('unarchive_session')).toBe('session/setArchived'); // `start_dialog_turn` -> `agent/submitDialogTurn` (NOT `agent/submitTurn`): @@ -185,7 +161,8 @@ describe('resolveWsMethod', () => { expect(AGENT_COMMAND_SCHEMA.start_dialog_turn.method).toBe( 'agent/submitDialogTurn' ); - expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(39); + expect(AGENT_COMMAND_SCHEMA.copy_session.method).toBe('session/fork'); + expect(Object.keys(AGENT_COMMAND_SCHEMA).length).toBe(40); // Touch the locals so noUnusedLocals does not flag them under vitest's // transformed build (tsc --noEmit is the real gate; this is belt-and-suspenders). @@ -327,7 +304,7 @@ describe('encodeRequestBody', () => { // The copy action lets the host resolve the boundary from the session's own // latest persisted turn; inventing a turn id would move that decision into // the controller. - expect(encodeRequestBody('fork_session', { + expect(encodeRequestBody('copy_session', { workspace_id: 'workspace-1', source_session_id: 's1', })).toEqual({ diff --git a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts index 8a970231a4..736ec0d2f1 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -78,11 +78,13 @@ export const AGENT_COMMAND_SCHEMA = { delete_session: { method: 'agent/deleteSession' }, fork_session: { method: 'session/forkAtTurn', - // The typed slot covers the turn-selected form. A whole-session copy (no - // `source_turn_id`) rides `session/fork`; see `resolveWsMethodForBody`. request: null as unknown as AgentSessionForkAtTurnRequest, response: null as unknown as ForkSessionResponse, }, + copy_session: { + method: 'session/fork', + response: null as unknown as ForkSessionResponse, + }, archive_session: { method: 'session/setArchived', request: null as unknown as AgentSessionArchiveStateRequest, @@ -231,29 +233,6 @@ export function resolveWsMethod(action: string): string { return entry?.method ?? action; } -/** - * Resolve the app-server method for one request body. - * - * `fork_session` covers two app-server methods: `session/forkAtTurn` copies the - * history through a caller-supplied turn, and `session/fork` copies the whole - * session at the host's latest persisted turn. A body without a source turn id - * asks for the whole-session copy, and that method rejects a missing turn id, so - * it must not ride `session/forkAtTurn`. - */ -export function resolveWsMethodForBody(action: string, body: unknown): string { - if (action === 'fork_session' && !forkBodyHasTurnId(body)) { - return 'session/fork'; - } - return resolveWsMethod(action); -} - -/** Whether a `fork_session` body selects an explicit source turn. */ -function forkBodyHasTurnId(body: unknown): boolean { - if (!body || typeof body !== 'object') return false; - const turnId = (body as Record).source_turn_id; - return typeof turnId === 'string' && turnId.trim().length > 0; -} - // --------------------------------------------------------------------------- // Protocol conversion layer (Review2-Item2) // --------------------------------------------------------------------------- @@ -290,15 +269,14 @@ export function encodeRequestBody(action: string, body: any): any { case 'respond_permission_batch': return encodePermissionResponseBody(body); case 'fork_session': + case 'copy_session': return { // The workspace ID selects the session's owner; the path and SSH // fields are the upgrade-only projection for pre-ID hosts. ...(body.workspace_id !== undefined ? { workspaceId: body.workspace_id } : {}), ...(body.workspace_path !== undefined ? { workspacePath: body.workspace_path } : {}), sourceSessionId: body.source_session_id, - // Omitted for a whole-session copy: the host resolves the copy boundary - // from the source session's own latest persisted turn. - ...(body.source_turn_id !== undefined ? { sourceTurnId: body.source_turn_id } : {}), + ...(action === 'fork_session' ? { sourceTurnId: body.source_turn_id } : {}), ...(body.remote_connection_id !== undefined ? { remoteConnectionId: body.remote_connection_id } : {}), @@ -701,8 +679,7 @@ export class WebSocketTransportAdapter implements ITransportAdapter { : params ?? {}; // Translate the frontend snake_case command to the app-server JSON-RPC // method so agent-kernel requests reach the app-server surface in web mode. - // A few commands cover more than one method; the body disambiguates them. - const method = resolveWsMethodForBody(action, envelope); + const method = resolveWsMethod(action); // Encode the frontend body into the app-server schema wire shape (field // names, tagged enums, attachment projection). Mirrors the conversions the // desktop host does in Rust (`agentic_api.rs`). diff --git a/src/web-ui/src/infrastructure/api/generated/productControl.ts b/src/web-ui/src/infrastructure/api/generated/productControl.ts index 4a286fd037..c28cb5da84 100644 --- a/src/web-ui/src/infrastructure/api/generated/productControl.ts +++ b/src/web-ui/src/infrastructure/api/generated/productControl.ts @@ -1,5 +1,5 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. -export const PRODUCT_CONTROL_GRAPH_DIGEST = "09e162e571595bbe38f6c2e10e061e0f7243baae9e6db56a2e5b8948304a0e32" as const; +export const PRODUCT_CONTROL_GRAPH_DIGEST = "dcea667ca017ed22790f12c28d5ccd4011314fca16d7e2ce9fc9cf87ff283825" as const; export type ProductControlCapabilityId = "feature.ai-assistant" | "feature.agents" | "feature.personal-assistants" | "feature.projects" | "feature.files-editor" | "feature.terminal" | "feature.git" | "feature.code-review" | "feature.browser" | "feature.computer-use" | "feature.skills" | "feature.miniapps" | "feature.canvas" | "feature.tasks-automation" | "feature.insights" | "feature.ecosystem-compatibility" | "feature.remote-workspaces" | "feature.remote-connect" | "feature.detached-dispatch" | "feature.pages" | "feature.voice-input" | "feature.desktop-pet" | "setting.application.general" | "setting.application.appearance" | "setting.application.pet" | "setting.application.input" | "setting.application.shortcuts" | "setting.application.development" | "setting.ai.models" | "setting.ai.memory" | "setting.workspace.session" | "setting.workspace.worktrees" | "setting.tools.execution" | "setting.application.terminal" | "setting.tools.desktop-control" | "setting.tools.browser-control" | "setting.tools.automation" | "setting.tools.web-search" | "setting.tools.mcp" | "setting.tools.acp" | "setting.data.usage" | "setting.data.archived" | "setting.data.diagnostics"; diff --git a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts index d35059f5f1..00a2936b33 100644 --- a/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts +++ b/src/web-ui/src/infrastructure/api/generated/remoteSurface.ts @@ -1,6 +1,6 @@ // Generated by scripts/generate-interactive-capabilities.mjs; do not edit. // Source: openbitfun_product_domains::remote_surface (Product Operation Registry). -export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:c96edb696f388992" as const; +export const REMOTE_SURFACE_REGISTRY_DIGEST = "fnv1a64:e2b40d572990839b" as const; /** * Registered Tauri commands the Peer Device controller keeps on the controller @@ -175,6 +175,7 @@ export const PEER_HOST_CAPABILITY_IDS = [ "tool_catalog", "chat_mcp_catalog_v1", "workspace_id_references_v1", + "whole_session_copy_v1", "user_question_response", "user_question_interaction_v1", ] as const; @@ -204,6 +205,7 @@ export const PEER_HOST_ADVERTISED_CAPABILITIES: Readonly< "tool_catalog", "chat_mcp_catalog_v1", "workspace_id_references_v1", + "whole_session_copy_v1", "user_question_response", "user_question_interaction_v1", ], diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts index c4cd196767..5ee0390297 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.test.ts @@ -40,6 +40,34 @@ describe('SessionAPI paged metadata reads', () => { expect(invokeMock).not.toHaveBeenCalled(); }); + it('does not copy an equal session ID on a peer selected during request preparation', async () => { + const pending = sessionAPI.duplicateSession('same-session-id', 'same-workspace-id'); + activateSurface('peer-with-the-same-workspace'); + + await expect(pending).rejects.toSatisfy(isSurfaceChangedError); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + it('copies with a dedicated ID-only command without loading history or workspace paths', async () => { + const result = { sessionId: 'copy', sessionName: 'Source (1)', agentType: 'Standard' }; + invokeMock.mockResolvedValueOnce(result); + + await expect(sessionAPI.duplicateSession('source', 'workspace-1')).resolves.toBe(result); + expect(invokeMock).toHaveBeenCalledExactlyOnceWith('copy_session', { + request: { source_session_id: 'source', workspace_id: 'workspace-1' }, + }); + }); + + it('keeps selected-turn forks on their original command and required turn payload', async () => { + const result = { sessionId: 'fork', sessionName: 'Source (1)', agentType: 'Standard' }; + invokeMock.mockResolvedValueOnce(result); + + await expect(sessionAPI.forkSession('source', 'turn-2', 'workspace-1')).resolves.toBe(result); + expect(invokeMock).toHaveBeenCalledExactlyOnceWith('fork_session', { + request: { source_session_id: 'source', source_turn_id: 'turn-2', workspace_id: 'workspace-1' }, + }); + }); + it.each(['local-project', 'remote-loopback', 'remote-project'])('reads %s by ID without path or SSH hints', async workspaceId => { const page = { sessions: [], hasMore: false }; invokeMock.mockResolvedValueOnce(page); diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts index a10b0af07e..e189fd8bb7 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts @@ -379,14 +379,14 @@ export class SessionAPI { workspaceId: string ): Promise<{ sessionId: string; sessionName: string; agentType: string }> { try { - return await invokePrepared('fork_session', async () => ({ + return await invokePrepared('copy_session', async () => ({ request: { source_session_id: sourceSessionId, - ...await sessionWorkspaceIdRequest(workspaceId), + workspace_id: workspaceId, } })); } catch (error) { - throw createTauriCommandError('fork_session', error, { + throw createTauriCommandError('copy_session', error, { sourceSessionId, workspaceId, }); diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts index 90e4220711..e40a780e32 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.test.ts @@ -91,6 +91,7 @@ describe('PeerConnectionManager attach', () => { user_question_interaction_v1: true, miniapp_agent_context_files_v1: true, wsl_workspaces_v1: true, + whole_session_copy_v1: true, btw_initial_model_selection_v1: true, }, }, @@ -105,6 +106,7 @@ describe('PeerConnectionManager attach', () => { expect(caps.chatMcpCatalogV1).toBe(true); expect(caps.miniAppAgentContextFilesV1).toBe(true); expect(caps.wslWorkspacesV1).toBe(true); + expect(caps.wholeSessionCopyV1).toBe(true); expect(caps.btwInitialModelSelectionV1).toBe(true); }); @@ -133,6 +135,7 @@ describe('PeerConnectionManager attach', () => { const desktop = await makeManager('desktop').connect('peer-1', 'Studio'); expect(desktop.getState().capabilities.hostKind).toBe('desktop'); expect(desktop.getState().capabilities.inlineImageAttachmentsV1).toBe(false); + expect(desktop.getState().capabilities.wholeSessionCopyV1).toBe(false); expect(desktop.getState().capabilities.btwInitialModelSelectionV1).toBe(false); expect(desktop.getState().capabilities.chatMcpCatalogV1).toBe(false); @@ -509,6 +512,30 @@ describe('PeerConnectionManager disposal', () => { expect(snapshots.length).toBeGreaterThan(0); }); + it('withdraws session copying from UI and transport when a ready host downgrades', async () => { + let capabilities: Record = { whole_session_copy_v1: true, tool_catalog: true }; + const deviceRpc = vi.fn(async (_target: string, commandJson: string) => { + const parsed = JSON.parse(commandJson) as { command?: string }; + return JSON.stringify({ resp: 'host_invoke_result', ok: true, value: + parsed.command === 'peer_mode_ping' ? { host_type: 'desktop', capabilities } : null, + }); + }); + const manager = createManager(deviceRpc); + const connection = await manager.connect('peer-1', 'Studio'); + expect(connection.getState().capabilities.wholeSessionCopyV1).toBe(true); + const listener = vi.fn(); + manager.subscribe(listener); + capabilities = { tool_catalog: true }; + await vi.advanceTimersByTimeAsync(KEEPALIVE_MS); + expect(connection.getState().capabilities.wholeSessionCopyV1).toBe(false); + expect(listener).toHaveBeenCalled(); + deviceRpc.mockClear(); + await expect(connection.adapter.request('copy_session', { + request: { source_session_id: 'source', workspace_id: 'workspace-1' }, + })).rejects.toThrow('whole_session_copy_v1_unsupported'); + expect(deviceRpc).not.toHaveBeenCalled(); + }); + it('does not let a stale health probe classify a replacement connection', async () => { const healthCatalog = deferred(); const commands: string[] = []; diff --git a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts index bde631be02..6766d1722b 100644 --- a/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts +++ b/src/web-ui/src/infrastructure/peer-device/PeerConnectionManager.ts @@ -50,6 +50,7 @@ export interface PeerHostCapabilities { /** MiniApp Agent runs accept immutable virtual context-file snapshots. */ readonly miniAppAgentContextFilesV1: boolean; readonly wslWorkspacesV1?: boolean; + readonly wholeSessionCopyV1?: boolean; readonly computerUseSharingV1?: boolean; /** Typed ProductControl HostInvoke, including shared config read-back. */ readonly productControlV1?: boolean; @@ -175,6 +176,7 @@ const NO_CAPABILITIES: PeerHostCapabilities = { tokenUsageStatistics: false, miniAppAgentContextFilesV1: false, wslWorkspacesV1: false, + wholeSessionCopyV1: false, computerUseSharingV1: false, productControlV1: false, productControlNativeV1: false, @@ -429,6 +431,7 @@ export class PeerConnectionManager { supportsTokenUsageStatistics: entry.capabilities.tokenUsageStatistics, supportsMiniAppAgentContextFilesV1: entry.capabilities.miniAppAgentContextFilesV1, supportsWslWorkspacesV1: entry.capabilities.wslWorkspacesV1, + supportsWholeSessionCopyV1: entry.capabilities.wholeSessionCopyV1, supportsComputerUseSharingV1: entry.capabilities.computerUseSharingV1, supportsProductControlV1: entry.capabilities.productControlV1, }); @@ -515,6 +518,7 @@ export class PeerConnectionManager { tokenUsageStatistics: caps?.token_usage_statistics === true, miniAppAgentContextFilesV1: caps?.miniapp_agent_context_files_v1 === true, wslWorkspacesV1: caps?.wsl_workspaces_v1 === true, + wholeSessionCopyV1: caps?.whole_session_copy_v1 === true, computerUseSharingV1: caps?.computer_use_sharing_v1 === true, productControlV1: caps?.product_control_v1 === true, productControlNativeV1: caps?.product_control_native_v1 === true, @@ -611,6 +615,7 @@ export class PeerConnectionManager { supportsTokenUsageStatistics: capabilities.tokenUsageStatistics, supportsMiniAppAgentContextFilesV1: capabilities.miniAppAgentContextFilesV1, supportsWslWorkspacesV1: capabilities.wslWorkspacesV1, + supportsWholeSessionCopyV1: capabilities.wholeSessionCopyV1, supportsComputerUseSharingV1: capabilities.computerUseSharingV1, supportsProductControlV1: capabilities.productControlV1, }); @@ -755,6 +760,7 @@ function capabilitiesEqual( a.tokenUsageStatistics === b.tokenUsageStatistics && a.miniAppAgentContextFilesV1 === b.miniAppAgentContextFilesV1 && a.wslWorkspacesV1 === b.wslWorkspacesV1 && + a.wholeSessionCopyV1 === b.wholeSessionCopyV1 && a.computerUseSharingV1 === b.computerUseSharingV1 && a.cancelTool === b.cancelTool && a.toolCatalog === b.toolCatalog && diff --git a/src/web-ui/src/infrastructure/peer-device/README.md b/src/web-ui/src/infrastructure/peer-device/README.md index dbf1856e00..23046493af 100644 --- a/src/web-ui/src/infrastructure/peer-device/README.md +++ b/src/web-ui/src/infrastructure/peer-device/README.md @@ -242,3 +242,13 @@ listener alone recovers an interaction emitted before attachment. before RPC. Missing capability means unsupported, including older Desktop and CLI peers. Discovery reports the host's OS availability; it must never inspect or execute the controller's WSL installation as a fallback. + +19. **Session copying needs an explicitly negotiated request contract.** + `whole_session_copy_v1` enables the dedicated `copy_session` command. + Older Desktop hosts and CLI peer hosts do not implement that command. + Missing capability disables the copy action and rejects the request before + RPC; there is no fallback to `fork_session`. Explicit-turn forks retain + their existing payload with a required `source_turn_id`. Reconnect + replaces the capability, including when a host restarts on an older build. + Once a copy is acknowledged, a metadata-read failure reports the created + copy and asks for a list refresh; it must not replay the copy mutation. diff --git a/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.test.ts b/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.test.ts index 45d37470d1..c9b625cad6 100644 --- a/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.test.ts +++ b/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + canCopySessionsOnSurface, canQueryToolCatalogOnSurface, canSubmitUserQuestionsOnSurface, } from './peerCapabilityResolution'; @@ -20,6 +21,16 @@ function caps(overrides: Partial): PeerHostCapabilities { }; } +describe('canCopySessionsOnSurface', () => { + it('requires explicit peer support even for older Desktop hosts', () => { + expect(canCopySessionsOnSurface(false, null)).toBe(true); + expect(canCopySessionsOnSurface(true, null)).toBe(false); + expect(canCopySessionsOnSurface(true, caps({ hostKind: 'desktop' }))).toBe(false); + expect(canCopySessionsOnSurface(true, caps({ hostKind: 'cli' }))).toBe(false); + expect(canCopySessionsOnSurface(true, caps({ wholeSessionCopyV1: true }))).toBe(true); + }); +}); + describe('canQueryToolCatalogOnSurface', () => { it('is always queryable on the local (controller) surface', () => { expect(canQueryToolCatalogOnSurface(false, null)).toBe(true); diff --git a/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.ts b/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.ts index db54449c2e..befd6e0b2d 100644 --- a/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.ts +++ b/src/web-ui/src/infrastructure/peer-device/peerCapabilityResolution.ts @@ -1,5 +1,13 @@ import type { PeerHostCapabilities } from './PeerConnectionManager'; +/** Older Desktop and CLI peer hosts do not implement the dedicated copy command. */ +export function canCopySessionsOnSurface( + peerActive: boolean, + capabilities: PeerHostCapabilities | null, +): boolean { + return !peerActive || capabilities?.wholeSessionCopyV1 === true; +} + /** * Resolve whether the currently rendered surface can be queried for the * read-only tool catalog (`get_all_tools_info`), so the UI skips the invoke diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 94aeebc63f..5e4336b6f1 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -363,6 +363,9 @@ "duplicate": "Copy session", "duplicateSuccess": "Copied to “{{name}}”", "duplicateFailed": "Failed to copy session", + "duplicateCreatedRefreshFailed": "Copied to “{{name}}”. Refresh the session list to see it; copying again creates another copy.", + "duplicateHostUnsupported": "The connected host does not support session copying. Upgrade the host to use this action.", + "duplicateEmpty": "Send a message before copying this session.", "duplicateRunning": "Wait for the running turn to finish before copying this session.", "duplicateDispatchUnsupported": "Sessions running on another device cannot be copied.", "openStorageDirectory": "Open session storage directory", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index fe110a9a0b..dce66d518d 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -363,6 +363,9 @@ "duplicate": "复制会话", "duplicateSuccess": "已复制为「{{name}}」", "duplicateFailed": "复制会话失败", + "duplicateCreatedRefreshFailed": "已复制为「{{name}}」。请刷新会话列表查看,再次复制会创建另一个副本。", + "duplicateHostUnsupported": "连接的宿主不支持复制会话,请升级宿主后使用。", + "duplicateEmpty": "请发送消息后再复制该会话。", "duplicateRunning": "请等待当前对话结束后再复制该会话。", "duplicateDispatchUnsupported": "运行在其他设备上的会话无法复制。", "openStorageDirectory": "打开会话存储目录", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 68c9607446..0db3da2cbb 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -363,6 +363,9 @@ "duplicate": "複製會話", "duplicateSuccess": "已複製為「{{name}}」", "duplicateFailed": "複製會話失敗", + "duplicateCreatedRefreshFailed": "已複製為「{{name}}」。請重新整理會話清單查看,再次複製會建立另一個副本。", + "duplicateHostUnsupported": "連線的宿主不支援複製會話,請升級宿主後使用。", + "duplicateEmpty": "請傳送訊息後再複製該會話。", "duplicateRunning": "請等待目前對話結束後再複製該會話。", "duplicateDispatchUnsupported": "執行在其他裝置上的會話無法複製。", "openStorageDirectory": "開啟會話儲存目錄",