Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/apps/desktop/src/api/session_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
#[serde(default)]
pub workspace_id: Option<String>,
#[serde(default)]
Expand Down Expand Up @@ -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,
Expand Down
177 changes: 155 additions & 22 deletions src/apps/desktop/src/runtime/session_application.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String>,
) -> DesktopSessionApplicationResult<SessionBranchResult> {
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(
Expand Down Expand Up @@ -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<String>,
) -> DesktopSessionApplicationResult<SessionBranchResult> {
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1164,6 +1211,92 @@ mod tests {
.expect("delete test runtime")
}

struct RecordingForkPort {
calls: Arc<Mutex<Vec<&'static str>>>,
workspace_id: Arc<Mutex<Option<String>>>,
}

#[async_trait]
impl AgentSessionForkPort for RecordingForkPort {
async fn fork_session(
&self,
request: AgentSessionForkRequest,
) -> PortResult<AgentSessionForkResult> {
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<AgentSessionForkResult> {
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<Mutex<Vec<&'static str>>>,
workspace_id: Arc<Mutex<Option<String>>>,
) -> 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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,8 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
/** Second level of the session menu: pick what a Markdown export includes. */
const [isExportScopeMenu, setIsExportScopeMenu] = useState(false);
const [exportingSessionId, setExportingSessionId] = useState<string | null>(null);
/** Session whose copy request is in flight; one copy runs at a time. */
const [duplicatingSessionId, setDuplicatingSessionId] = useState<string | null>(null);
const orderingRevision = useSyncExternalStore(
sessionNavStatusService.subscribeOrdering,
sessionNavStatusService.getOrderingSnapshot,
Expand Down Expand Up @@ -1289,6 +1291,39 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
[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
Expand Down Expand Up @@ -1938,6 +1973,28 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
>
<span>{t('nav.sessions.copySessionId')}</span>
</MenuItem>
<MenuItem
type="button"
leading={duplicatingSessionId === session.sessionId
? <Loader2 className="openbitfun-nav-panel__inline-toggle-spinner" aria-hidden />
: <Icon name="files" size="sm" />}
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}
>
<span>{t('nav.sessions.duplicate')}</span>
</MenuItem>
<MenuItem
type="button"
leading={<Icon glyph={FolderOpen} size="sm" />}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')");
});
});
7 changes: 7 additions & 0 deletions src/web-ui/src/flow_chat/services/FlowChatManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
renameChatSessionTitle as renameChatSessionTitleModule,
reloadSessionTitle as reloadSessionTitleModule,
forkChatSession as forkChatSessionModule,
duplicateChatSession as duplicateChatSessionModule,
cleanupSaveState,
cleanupSessionBuffers,
sendMessage as sendMessageModule,
Expand Down Expand Up @@ -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<WorkspaceInfo, 'id' | 'rootPath' | 'workspaceKind' | 'connectionId' | 'sshHost'>,
options?: {
Expand Down
Loading
Loading