From 44ee5989c58da5f487623c6936b3f386fd19aba1 Mon Sep 17 00:00:00 2001 From: czs914893385 Date: Sun, 11 Oct 2026 23:09:12 +0800 Subject: [PATCH] feat(desktop): copy a session from the session row menu Add "Copy session" to the session row menu (the row's more button and the right-click menu share one menu). The copy is produced by the existing session fork port instead of a second copy algorithm: ForkSessionRequest.source_turn_id becomes optional, and omitting it copies the whole session through its latest persisted turn, so the controller never has to load a session's turns for a navigation action. - route the web/WS adapter by body: session/forkAtTurn with a turn id, session/fork without one - keep the copy out of the peer-device retry and idempotent allowlists: the host has no idempotency key for it, so a retry would create a second copy - disable the action while a turn is running (not a stable copy boundary) and for sessions owned by another device; archived sessions can be copied and the active session is not switched - reuse the existing fork lineage naming (title -> "title (2)") - i18n: en-US / zh-CN / zh-TW --- src/apps/desktop/src/api/session_api.rs | 9 +- .../src/runtime/session_application.rs | 177 +++++++++++++++--- .../sections/sessions/SessionsSection.tsx | 57 ++++++ .../sessions/sessionRowContextMenu.test.ts | 23 +++ .../src/flow_chat/services/FlowChatManager.ts | 7 + .../flow-chat-manager/SessionModule.test.ts | 61 ++++++ .../flow-chat-manager/SessionModule.ts | 41 ++++ .../services/flow-chat-manager/index.ts | 1 + .../api/adapters/peer-device-adapter.test.ts | 6 + .../api/adapters/websocket-adapter.test.ts | 38 ++++ .../api/adapters/websocket-adapter.ts | 36 +++- .../api/service-api/SessionAPI.ts | 27 +++ src/web-ui/src/locales/en-US/common.json | 5 + src/web-ui/src/locales/zh-CN/common.json | 5 + src/web-ui/src/locales/zh-TW/common.json | 5 + 15 files changed, 471 insertions(+), 27 deletions(-) diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index bf035cabe6..bdb80b5981 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -240,7 +240,12 @@ fn default_include_hidden_subagents() -> bool { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ForkSessionRequest { pub source_session_id: String, - pub source_turn_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, #[serde(default)] pub workspace_id: Option, #[serde(default)] @@ -723,6 +728,8 @@ 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. #[tauri::command] pub async fn fork_session( request: ForkSessionRequest, diff --git a/src/apps/desktop/src/runtime/session_application.rs b/src/apps/desktop/src/runtime/session_application.rs index 55b029ddc8..970651c752 100644 --- a/src/apps/desktop/src/runtime/session_application.rs +++ b/src/apps/desktop/src/runtime/session_application.rs @@ -11,10 +11,10 @@ use std::time::Instant; use async_trait::async_trait; use openbitfun_agent_runtime::sdk::{ AgentLocalCommandTurnRecordRequest, AgentRuntime, AgentSessionArchiveStateRequest, - AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionLineageRequest, - AgentSessionLineageSnapshot, AgentSessionRenameRequest, AgentSessionUsageRequest, - PortErrorKind, RuntimeError, SessionEventBackfill, SessionEventJournal, - SessionEventProjectionSnapshot, SessionInteractionSnapshot, + AgentSessionDeleteRequest, AgentSessionForkAtTurnRequest, AgentSessionForkRequest, + AgentSessionLineageRequest, AgentSessionLineageSnapshot, AgentSessionRenameRequest, + AgentSessionUsageRequest, PortErrorKind, RuntimeError, SessionEventBackfill, + SessionEventJournal, SessionEventProjectionSnapshot, SessionInteractionSnapshot, }; use openbitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler}; use openbitfun_core::agentic::core::Session; @@ -621,31 +621,28 @@ 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. pub(crate) async fn fork_session( &self, request: DesktopSessionScopeRequest, source_session_id: String, - source_turn_id: String, + source_turn_id: Option, ) -> DesktopSessionApplicationResult { let scope = self.resolved_scope(request).await?; self.ensure_runtime_ownership(&scope)?; - let result = self - .agent_runtime - .fork_session_at_turn(AgentSessionForkAtTurnRequest { - workspace_id: Some(scope.workspace_id.clone()), - workspace_path: String::new(), - source_session_id, - source_turn_id, - remote_connection_id: scope.remote_connection_id, - remote_ssh_host: scope.resolved_remote_ssh_host, - }) - .await - .map_err(|error| DesktopSessionApplicationError::Runtime(error.into_message()))?; - Ok(SessionBranchResult { - session_id: result.session_id, - session_name: result.session_name, - agent_type: result.agent_type, - }) + fork_session_in_scope( + &self.agent_runtime, + scope, + source_session_id, + source_turn_id, + ) + .await } pub(crate) async fn set_session_archived( @@ -917,6 +914,55 @@ impl DesktopSessionApplication { } } +/// Copies a session into a new independent session. +/// +/// This is the Agent Runtime's session fork, which is the only owner of session +/// copy semantics: it copies every persisted turn, turn context snapshot, skill +/// snapshot, compression transcript, and evidence-ledger event under a new +/// session id, and resets the inherited child/relationship state. Passing a +/// turn id copies the history through that turn; passing `None` copies the whole +/// session by forking at the latest persisted turn. The source session keeps its +/// identity, history, and metadata. +async fn fork_session_in_scope( + agent_runtime: &AgentRuntime, + scope: ResolvedDesktopSessionScope, + source_session_id: String, + source_turn_id: Option, +) -> DesktopSessionApplicationResult { + let workspace_id = Some(scope.workspace_id.clone()); + let result = match source_turn_id { + Some(source_turn_id) => { + agent_runtime + .fork_session_at_turn(AgentSessionForkAtTurnRequest { + workspace_id, + workspace_path: String::new(), + source_session_id, + source_turn_id, + remote_connection_id: scope.remote_connection_id, + remote_ssh_host: scope.resolved_remote_ssh_host, + }) + .await + } + None => { + agent_runtime + .fork_session(AgentSessionForkRequest { + workspace_id, + workspace_path: String::new(), + source_session_id, + remote_connection_id: scope.remote_connection_id, + remote_ssh_host: scope.resolved_remote_ssh_host, + }) + .await + } + } + .map_err(|error| DesktopSessionApplicationError::Runtime(error.into_message()))?; + Ok(SessionBranchResult { + session_id: result.session_id, + session_name: result.session_name, + agent_type: result.agent_type, + }) +} + async fn delete_session_with_host_effects( agent_runtime: &AgentRuntime, host_effects: &dyn DesktopSessionHostEffects, @@ -970,6 +1016,7 @@ mod tests { use super::*; use openbitfun_agent_runtime::sdk::{ AgentRuntimeBuilder, AgentSessionCreateRequest, AgentSessionCreateResult, + AgentSessionForkPort, AgentSessionForkRequest, AgentSessionForkResult, AgentSessionListRequest, AgentSessionManagementPort, AgentSessionSummary, AgentSessionWorkspaceBinding, AgentSessionWorkspaceRequest, AgentSubmissionPort, AgentSubmissionRequest, AgentSubmissionResult, PortError, PortErrorKind, PortResult, @@ -1164,6 +1211,92 @@ mod tests { .expect("delete test runtime") } + struct RecordingForkPort { + calls: Arc>>, + workspace_id: Arc>>, + } + + #[async_trait] + impl AgentSessionForkPort for RecordingForkPort { + async fn fork_session( + &self, + request: AgentSessionForkRequest, + ) -> PortResult { + self.calls.lock().unwrap().push("whole_session"); + *self.workspace_id.lock().unwrap() = request.workspace_id.clone(); + Ok(AgentSessionForkResult { + session_id: "copy-1".to_string(), + session_name: "Source (1)".to_string(), + agent_type: "Standard".to_string(), + }) + } + + async fn fork_session_at_turn( + &self, + _request: openbitfun_agent_runtime::sdk::AgentSessionForkAtTurnRequest, + ) -> PortResult { + self.calls.lock().unwrap().push("through_turn"); + Ok(AgentSessionForkResult { + session_id: "prefix-1".to_string(), + session_name: "Source (2)".to_string(), + agent_type: "Standard".to_string(), + }) + } + } + + fn fork_test_runtime( + calls: Arc>>, + workspace_id: Arc>>, + ) -> AgentRuntime { + AgentRuntimeBuilder::new() + .with_submission_port(Arc::new(NoopSubmissionPort)) + .with_session_fork_port(Arc::new(RecordingForkPort { + calls, + workspace_id, + })) + .build() + .expect("fork test runtime") + } + + /// The Desktop "copy session" action must copy the whole session without a + /// caller-supplied turn id, so it rides the runtime's latest-persisted-turn + /// fork. A selected-turn copy keeps using the explicit-turn fork. + #[tokio::test] + async fn whole_session_copy_uses_the_latest_persisted_turn_fork() { + let calls = Arc::new(Mutex::new(Vec::new())); + let workspace_id = Arc::new(Mutex::new(None)); + let runtime = fork_test_runtime(calls.clone(), workspace_id.clone()); + + let copied = + fork_session_in_scope(&runtime, delete_test_scope(), "session-1".to_string(), None) + .await + .expect("whole-session copy should succeed"); + + assert_eq!(calls.lock().unwrap().as_slice(), ["whole_session"]); + assert_eq!( + workspace_id.lock().unwrap().as_deref(), + Some("test-workspace") + ); + assert_eq!(copied.session_id, "copy-1"); + assert_eq!(copied.session_name, "Source (1)"); + assert_eq!(copied.agent_type, "Standard"); + + let prefixed = fork_session_in_scope( + &runtime, + delete_test_scope(), + "session-1".to_string(), + Some("turn-2".to_string()), + ) + .await + .expect("turn-prefix copy should succeed"); + + assert_eq!( + calls.lock().unwrap().as_slice(), + ["whole_session", "through_turn"] + ); + assert_eq!(prefixed.session_id, "prefix-1"); + } + #[test] fn live_processing_state_overlays_sanitized_view_state() { let mut restored = Session::new_with_id( 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 bd65f94e49..a344a93a3b 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 @@ -294,6 +294,8 @@ const SessionsSection: React.FC = ({ /** Second level of the session menu: pick what a Markdown export includes. */ const [isExportScopeMenu, setIsExportScopeMenu] = useState(false); const [exportingSessionId, setExportingSessionId] = useState(null); + /** Session whose copy request is in flight; one copy runs at a time. */ + const [duplicatingSessionId, setDuplicatingSessionId] = useState(null); const orderingRevision = useSyncExternalStore( sessionNavStatusService.subscribeOrdering, sessionNavStatusService.getOrderingSnapshot, @@ -1289,6 +1291,39 @@ const SessionsSection: React.FC = ({ [t] ); + /** + * Copy the session into a new independent session. The copy is created by the + * host and appears in this list; the current row and the active session stay + * untouched (delete and archive never move the user either). + */ + const handleDuplicateSession = useCallback( + async (e: React.MouseEvent, sessionId: string) => { + e.stopPropagation(); + closeSessionMenu(); + if (duplicatingSessionId) return; + + setDuplicatingSessionId(sessionId); + try { + const copied = await flowChatManager.duplicateChatSession(sessionId); + notificationService.success( + t('nav.sessions.duplicateSuccess', { name: copied.sessionName }), + { duration: 3000 }, + ); + } catch (error) { + log.error('Failed to copy session', { sessionId, error }); + notificationService.error( + error instanceof Error && error.message + ? error.message + : t('nav.sessions.duplicateFailed'), + { duration: 4000 }, + ); + } finally { + setDuplicatingSessionId(null); + } + }, + [closeSessionMenu, duplicatingSessionId, t], + ); + /** * A worktree directory is registered for execution but not opened, so the * session row is the only place it can be reached from. Opening it as a @@ -1938,6 +1973,28 @@ const SessionsSection: React.FC = ({ > {t('nav.sessions.copySessionId')} + + : } + onClick={e => { void handleDuplicateSession(e, session.sessionId); }} + // A running turn is still being persisted, so its + // history is not a stable copy boundary; a session + // owned by another device has no local history. + disabled={isDispatched + || runningSessionIds.has(session.sessionId) + || duplicatingSessionId === session.sessionId} + title={isDispatched + ? t('nav.sessions.duplicateDispatchUnsupported') + : runningSessionIds.has(session.sessionId) + ? t('nav.sessions.duplicateRunning') + : undefined} + data-testid="nav-session-menu-duplicate" + data-session-id={session.sessionId} + > + {t('nav.sessions.duplicate')} + } 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 4f2e3eeb0e..42c4393502 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 @@ -42,4 +42,27 @@ describe('SessionsSection session row context menu', () => { expect(source).toContain("visibility: sessionMenuPosition ? 'visible' : 'hidden'"); expect(source).toContain('data-testid="nav-session-menu"'); }); + + it('exposes the copy action to both menu entry points as one shared row', () => { + const source = readSessionsSectionSource(); + // The copy action lives in the single shared Menu, so the "more" button and + // right click open the same entry with the same handler. + expect(source).toContain('data-testid="nav-session-menu-duplicate"'); + expect(source).toMatch(/handleDuplicateSession = useCallback\(\s*async \(e: React\.MouseEvent, sessionId: string\)/); + expect(source).toContain('await flowChatManager.duplicateChatSession(sessionId)'); + expect(source).toContain("t('nav.sessions.duplicateSuccess'"); + expect(source).toContain('{ name: copied.sessionName }'); + expect(source).toContain("t('nav.sessions.duplicateFailed')"); + }); + + it('keeps copy unavailable while a turn runs or another device owns the session', () => { + const source = readSessionsSectionSource(); + // A running turn is not a stable copy boundary, and a dispatch-owned session + // has no local history to copy. Both entry points share these guards. + expect(source).toContain('disabled={isDispatched'); + expect(source).toContain('|| runningSessionIds.has(session.sessionId)'); + expect(source).toContain('|| duplicatingSessionId === session.sessionId}'); + expect(source).toContain("t('nav.sessions.duplicateDispatchUnsupported')"); + expect(source).toContain("t('nav.sessions.duplicateRunning')"); + }); }); diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index 0429da544e..940005031a 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -50,6 +50,7 @@ import { renameChatSessionTitle as renameChatSessionTitleModule, reloadSessionTitle as reloadSessionTitleModule, forkChatSession as forkChatSessionModule, + duplicateChatSession as duplicateChatSessionModule, cleanupSaveState, cleanupSessionBuffers, sendMessage as sendMessageModule, @@ -746,6 +747,12 @@ export class FlowChatManager { return forkChatSessionModule(this.context, sourceSessionId, sourceTurnId); } + async duplicateChatSession( + sourceSessionId: string, + ): Promise<{ sessionId: string; sessionName: string }> { + return duplicateChatSessionModule(this.context, sourceSessionId); + } + async resetWorkspaceSessions( workspace: Pick, options?: { 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 87a746a710..c9a87326af 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 @@ -3,6 +3,7 @@ import { archiveChatSession, createChatSession, deleteChatSession, + duplicateChatSession, ensureBackendSession, forkChatSession, hydrateSessionHistoryForDetail, @@ -45,6 +46,7 @@ const configManagerMocks = vi.hoisted(() => ({ const sessionApiMocks = vi.hoisted(() => ({ archiveSession: vi.fn(), forkSession: vi.fn(), + duplicateSession: vi.fn(), loadSessionMetadata: vi.fn(), })); @@ -222,6 +224,7 @@ function createContext( state = { ...state, activeSessionId: sessionId }; }), loadSessionHistory: vi.fn(), + ensurePersistedSessionMetadata: vi.fn(async () => true), getCascadeSessionIds: vi.fn((sessionId: string) => ( options?.getCascadeSessionIdsImpl?.(sessionId) ?? [sessionId] )), @@ -621,6 +624,64 @@ describe('forkChatSession', () => { }); }); +describe('duplicateChatSession', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('copies the source session without switching away from the active session', async () => { + const source = createSession({ sessionId: 'copy-source', title: 'Source' }); + const other = createSession({ sessionId: 'other-session' }); + const { context, flowChatStore } = createContext(source, { + additionalSessions: [other], + activeSessionId: other.sessionId, + }); + sessionApiMocks.duplicateSession.mockResolvedValueOnce({ + sessionId: 'copy-1', + sessionName: 'Source (1)', + agentType: 'Standard', + }); + + await expect(duplicateChatSession(context, source.sessionId)).resolves.toEqual({ + sessionId: 'copy-1', + sessionName: 'Source (1)', + }); + + // The host resolves the copy boundary: no turn id leaves the controller. + expect(sessionApiMocks.duplicateSession).toHaveBeenCalledWith('copy-source', 'workspace-1'); + // The copy is projected from its authoritative metadata record, so the + // navigation list can show it without the controller loading its history. + expect(flowChatStore.ensurePersistedSessionMetadata).toHaveBeenCalledWith('copy-1', 'workspace-1'); + expect(flowChatStore.loadSessionHistory).not.toHaveBeenCalled(); + // Delete/archive do not move the user either: the copy must not activate. + expect(flowChatStore.switchSession).not.toHaveBeenCalled(); + expect(flowChatStore.getState().activeSessionId).toBe('other-session'); + expect(flowChatStore.getState().sessions.has('copy-source')).toBe(true); + }); + + it('refuses a detached dispatch session before asking the host', async () => { + const dispatched = createSession({ + sessionId: 'dispatch-source', + config: { agentType: 'Standard', dispatchJobId: 'job-1' }, + }); + const { context } = createContext(dispatched); + + await expect(duplicateChatSession(context, dispatched.sessionId)) + .rejects.toThrow('Copying a detached dispatch session is not supported'); + + expect(sessionApiMocks.duplicateSession).not.toHaveBeenCalled(); + }); + + it('reports a missing session instead of calling the host', async () => { + const { context } = createContext(createSession({ sessionId: 'copy-source' })); + + await expect(duplicateChatSession(context, 'missing-session')) + .rejects.toThrow('Session does not exist: missing-session'); + + expect(sessionApiMocks.duplicateSession).not.toHaveBeenCalled(); + }); +}); + describe('reloadSessionTitle', () => { afterEach(() => { vi.clearAllMocks(); 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 4f3e2b5e0e..0fb0b00dce 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 @@ -829,6 +829,47 @@ export async function forkChatSession( return response.sessionId; } +/** + * Copy a session into a new independent session without leaving the current one. + * + * The host owns the copy: it duplicates the source session's persisted history + * under a new session id and leaves the source session untouched. The copy is + * projected into the store from its authoritative metadata record instead of + * being switched to, so the navigation list gains a row while the user keeps + * their place (delete and archive behave the same way). + */ +export async function duplicateChatSession( + context: FlowChatContext, + sourceSessionId: string, +): Promise<{ sessionId: string; sessionName: string }> { + const sourceSession = context.flowChatStore.getState().sessions.get(sourceSessionId); + if (!sourceSession) { + throw new Error(`Session does not exist: ${sourceSessionId}`); + } + if (driverForSession(sourceSessionId, sourceSession).id === 'dispatch') { + throw new Error('Copying a detached dispatch session is not supported'); + } + + const workspaceId = requireSessionOwningWorkspaceId(sourceSession); + const response = await sessionAPI.duplicateSession(sourceSessionId, workspaceId); + + // 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, + ); + if (!projected) { + log.warn('Copied session metadata is unavailable for the navigation list', { + sessionId: response.sessionId, + sourceSessionId, + workspaceId, + }); + } + + return { sessionId: response.sessionId, sessionName: response.sessionName }; +} + /** * Ensure backend session exists (check before sending message) */ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index 791c396472..945815ef53 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -40,6 +40,7 @@ export { renameChatSessionTitle, reloadSessionTitle, forkChatSession, + duplicateChatSession, } from './SessionModule'; export { 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 1b97db6a4e..2843b596b5 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 @@ -216,6 +216,12 @@ 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 + // idempotency key, so a retry would create a second copy of the session. + expect(isPeerRetryableReadCommand('fork_session')).toBe(false); + expect(isPeerRetryableIdempotentMutation('fork_session', { + request: { workspace_id: 'workspace-1', source_session_id: 'session-1' }, + })).toBe(false); }); it('does not retry side-effecting announcement get_* commands', () => { 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 3846027aa0..edb57d200a 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,6 +5,7 @@ import { decodeResponseBody, encodeRequestBody, resolveWsMethod, + resolveWsMethodForBody, WebSocketTransportAdapter, } from './websocket-adapter'; import type { @@ -12,6 +13,30 @@ 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 @@ -298,6 +323,19 @@ describe('encodeRequestBody', () => { }); }); + it('encodes a whole-session copy without a source turn id', () => { + // 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', { + workspace_id: 'workspace-1', + source_session_id: 's1', + })).toEqual({ + workspaceId: 'workspace-1', + sourceSessionId: 's1', + }); + }); + it('passes unknown actions through unchanged', () => { const body = { foo: 'bar' }; expect(encodeRequestBody('some_unknown_action', body)).toBe(body); 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 75d3188a6a..8a970231a4 100644 --- a/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/websocket-adapter.ts @@ -78,6 +78,8 @@ 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, }, @@ -229,6 +231,29 @@ 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) // --------------------------------------------------------------------------- @@ -271,7 +296,9 @@ export function encodeRequestBody(action: string, body: any): any { ...(body.workspace_id !== undefined ? { workspaceId: body.workspace_id } : {}), ...(body.workspace_path !== undefined ? { workspacePath: body.workspace_path } : {}), sourceSessionId: body.source_session_id, - sourceTurnId: body.source_turn_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 } : {}), ...(body.remote_connection_id !== undefined ? { remoteConnectionId: body.remote_connection_id } : {}), @@ -664,9 +691,6 @@ export class WebSocketTransportAdapter implements ITransportAdapter { await this.ensureConnected(); } - // 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. - const method = resolveWsMethod(action); // The service layer calls `api.invoke('cmd', { request: {...} })` (Tauri // convention). ACP `on_receive_request` deserializes the bare body, so // unwrap the `{request}` envelope here. @@ -675,6 +699,10 @@ export class WebSocketTransportAdapter implements ITransportAdapter { && Object.keys(params).length === 1) ? params.request ?? {} : 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); // 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/service-api/SessionAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts index a5f83e3bf0..a10b0af07e 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts @@ -366,6 +366,33 @@ export class SessionAPI { } } + /** + * Copy a session into a new independent session. + * + * The host copies the source session's persisted history under a new session + * id and leaves the source session untouched. No turn id is sent: the host + * resolves the copy boundary from the session's own latest persisted turn, so + * the controller never has to load a session's turns to ask for a copy. + */ + async duplicateSession( + sourceSessionId: string, + workspaceId: string + ): Promise<{ sessionId: string; sessionName: string; agentType: string }> { + try { + return await invokePrepared('fork_session', async () => ({ + request: { + source_session_id: sourceSessionId, + ...await sessionWorkspaceIdRequest(workspaceId), + } + })); + } catch (error) { + throw createTauriCommandError('fork_session', error, { + sourceSessionId, + workspaceId, + }); + } + } + async listSessions(workspaceId: string): Promise { return invokePrepared('list_persisted_sessions', async () => ({ request: await sessionWorkspaceIdRequest(workspaceId), diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 700941ec23..95de99d3a7 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -360,6 +360,11 @@ "copySessionId": "Copy ID", "copySessionIdSuccess": "Session ID copied", "copySessionIdFailed": "Failed to copy session ID", + "duplicate": "Copy session", + "duplicateSuccess": "Copied to “{{name}}”", + "duplicateFailed": "Failed to copy 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", "openStorageDirectoryFailed": "Failed to open session storage directory", "storageDirectoryDesktopOnly": "Available only for session data stored on this desktop instance.", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index b1f3ff31d7..60bd20fe50 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -360,6 +360,11 @@ "copySessionId": "复制 ID", "copySessionIdSuccess": "已复制会话 ID", "copySessionIdFailed": "复制会话 ID 失败", + "duplicate": "复制会话", + "duplicateSuccess": "已复制为「{{name}}」", + "duplicateFailed": "复制会话失败", + "duplicateRunning": "请等待当前对话结束后再复制该会话。", + "duplicateDispatchUnsupported": "运行在其他设备上的会话无法复制。", "openStorageDirectory": "打开会话存储目录", "openStorageDirectoryFailed": "打开会话存储目录失败", "storageDirectoryDesktopOnly": "仅支持打开存储在当前桌面实例上的会话目录。", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 38d2a50cef..39160b09b7 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -360,6 +360,11 @@ "copySessionId": "複製 ID", "copySessionIdSuccess": "已複製會話 ID", "copySessionIdFailed": "複製會話 ID 失敗", + "duplicate": "複製會話", + "duplicateSuccess": "已複製為「{{name}}」", + "duplicateFailed": "複製會話失敗", + "duplicateRunning": "請等待目前對話結束後再複製該會話。", + "duplicateDispatchUnsupported": "執行在其他裝置上的會話無法複製。", "openStorageDirectory": "開啟會話儲存目錄", "openStorageDirectoryFailed": "開啟會話儲存目錄失敗", "storageDirectoryDesktopOnly": "僅支援開啟儲存在目前桌面執行個體上的會話目錄。",