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": "開啟會話儲存目錄",