@@ -11,10 +11,10 @@ use std::time::Instant;
1111use async_trait:: async_trait;
1212use openbitfun_agent_runtime:: sdk:: {
1313 AgentLocalCommandTurnRecordRequest , AgentRuntime , AgentSessionArchiveStateRequest ,
14- AgentSessionDeleteRequest , AgentSessionForkAtTurnRequest , AgentSessionLineageRequest ,
15- AgentSessionLineageSnapshot , AgentSessionRenameRequest , AgentSessionUsageRequest ,
16- PortErrorKind , RuntimeError , SessionEventBackfill , SessionEventJournal ,
17- SessionEventProjectionSnapshot , SessionInteractionSnapshot ,
14+ AgentSessionDeleteRequest , AgentSessionForkAtTurnRequest , AgentSessionForkRequest ,
15+ AgentSessionLineageRequest , AgentSessionLineageSnapshot , AgentSessionRenameRequest ,
16+ AgentSessionUsageRequest , PortErrorKind , RuntimeError , SessionEventBackfill ,
17+ SessionEventJournal , SessionEventProjectionSnapshot , SessionInteractionSnapshot ,
1818} ;
1919use openbitfun_core:: agentic:: coordination:: { ConversationCoordinator , DialogScheduler } ;
2020use openbitfun_core:: agentic:: core:: Session ;
@@ -621,31 +621,28 @@ impl DesktopSessionApplication {
621621 Ok ( report)
622622 }
623623
624+ /// Copies a session into a new independent session.
625+ ///
626+ /// `source_turn_id` selects a prefix of the source history. Omitting it
627+ /// copies the whole session, which is the runtime's latest-persisted-turn
628+ /// fork (`AgentSessionForkRequest`); the Desktop "Copy session" action uses
629+ /// that form so the controller never has to load a session's turns just to
630+ /// ask for a copy.
624631 pub ( crate ) async fn fork_session (
625632 & self ,
626633 request : DesktopSessionScopeRequest ,
627634 source_session_id : String ,
628- source_turn_id : String ,
635+ source_turn_id : Option < String > ,
629636 ) -> DesktopSessionApplicationResult < SessionBranchResult > {
630637 let scope = self . resolved_scope ( request) . await ?;
631638 self . ensure_runtime_ownership ( & scope) ?;
632- let result = self
633- . agent_runtime
634- . fork_session_at_turn ( AgentSessionForkAtTurnRequest {
635- workspace_id : Some ( scope. workspace_id . clone ( ) ) ,
636- workspace_path : String :: new ( ) ,
637- source_session_id,
638- source_turn_id,
639- remote_connection_id : scope. remote_connection_id ,
640- remote_ssh_host : scope. resolved_remote_ssh_host ,
641- } )
642- . await
643- . map_err ( |error| DesktopSessionApplicationError :: Runtime ( error. into_message ( ) ) ) ?;
644- Ok ( SessionBranchResult {
645- session_id : result. session_id ,
646- session_name : result. session_name ,
647- agent_type : result. agent_type ,
648- } )
639+ fork_session_in_scope (
640+ & self . agent_runtime ,
641+ scope,
642+ source_session_id,
643+ source_turn_id,
644+ )
645+ . await
649646 }
650647
651648 pub ( crate ) async fn set_session_archived (
@@ -917,6 +914,55 @@ impl DesktopSessionApplication {
917914 }
918915}
919916
917+ /// Copies a session into a new independent session.
918+ ///
919+ /// This is the Agent Runtime's session fork, which is the only owner of session
920+ /// copy semantics: it copies every persisted turn, turn context snapshot, skill
921+ /// snapshot, compression transcript, and evidence-ledger event under a new
922+ /// session id, and resets the inherited child/relationship state. Passing a
923+ /// turn id copies the history through that turn; passing `None` copies the whole
924+ /// session by forking at the latest persisted turn. The source session keeps its
925+ /// identity, history, and metadata.
926+ async fn fork_session_in_scope (
927+ agent_runtime : & AgentRuntime ,
928+ scope : ResolvedDesktopSessionScope ,
929+ source_session_id : String ,
930+ source_turn_id : Option < String > ,
931+ ) -> DesktopSessionApplicationResult < SessionBranchResult > {
932+ let workspace_id = Some ( scope. workspace_id . clone ( ) ) ;
933+ let result = match source_turn_id {
934+ Some ( source_turn_id) => {
935+ agent_runtime
936+ . fork_session_at_turn ( AgentSessionForkAtTurnRequest {
937+ workspace_id,
938+ workspace_path : String :: new ( ) ,
939+ source_session_id,
940+ source_turn_id,
941+ remote_connection_id : scope. remote_connection_id ,
942+ remote_ssh_host : scope. resolved_remote_ssh_host ,
943+ } )
944+ . await
945+ }
946+ None => {
947+ agent_runtime
948+ . fork_session ( AgentSessionForkRequest {
949+ workspace_id,
950+ workspace_path : String :: new ( ) ,
951+ source_session_id,
952+ remote_connection_id : scope. remote_connection_id ,
953+ remote_ssh_host : scope. resolved_remote_ssh_host ,
954+ } )
955+ . await
956+ }
957+ }
958+ . map_err ( |error| DesktopSessionApplicationError :: Runtime ( error. into_message ( ) ) ) ?;
959+ Ok ( SessionBranchResult {
960+ session_id : result. session_id ,
961+ session_name : result. session_name ,
962+ agent_type : result. agent_type ,
963+ } )
964+ }
965+
920966async fn delete_session_with_host_effects (
921967 agent_runtime : & AgentRuntime ,
922968 host_effects : & dyn DesktopSessionHostEffects ,
@@ -970,6 +1016,7 @@ mod tests {
9701016 use super :: * ;
9711017 use openbitfun_agent_runtime:: sdk:: {
9721018 AgentRuntimeBuilder , AgentSessionCreateRequest , AgentSessionCreateResult ,
1019+ AgentSessionForkPort , AgentSessionForkRequest , AgentSessionForkResult ,
9731020 AgentSessionListRequest , AgentSessionManagementPort , AgentSessionSummary ,
9741021 AgentSessionWorkspaceBinding , AgentSessionWorkspaceRequest , AgentSubmissionPort ,
9751022 AgentSubmissionRequest , AgentSubmissionResult , PortError , PortErrorKind , PortResult ,
@@ -1164,6 +1211,92 @@ mod tests {
11641211 . expect ( "delete test runtime" )
11651212 }
11661213
1214+ struct RecordingForkPort {
1215+ calls : Arc < Mutex < Vec < & ' static str > > > ,
1216+ workspace_id : Arc < Mutex < Option < String > > > ,
1217+ }
1218+
1219+ #[ async_trait]
1220+ impl AgentSessionForkPort for RecordingForkPort {
1221+ async fn fork_session (
1222+ & self ,
1223+ request : AgentSessionForkRequest ,
1224+ ) -> PortResult < AgentSessionForkResult > {
1225+ self . calls . lock ( ) . unwrap ( ) . push ( "whole_session" ) ;
1226+ * self . workspace_id . lock ( ) . unwrap ( ) = request. workspace_id . clone ( ) ;
1227+ Ok ( AgentSessionForkResult {
1228+ session_id : "copy-1" . to_string ( ) ,
1229+ session_name : "Source (1)" . to_string ( ) ,
1230+ agent_type : "Standard" . to_string ( ) ,
1231+ } )
1232+ }
1233+
1234+ async fn fork_session_at_turn (
1235+ & self ,
1236+ _request : openbitfun_agent_runtime:: sdk:: AgentSessionForkAtTurnRequest ,
1237+ ) -> PortResult < AgentSessionForkResult > {
1238+ self . calls . lock ( ) . unwrap ( ) . push ( "through_turn" ) ;
1239+ Ok ( AgentSessionForkResult {
1240+ session_id : "prefix-1" . to_string ( ) ,
1241+ session_name : "Source (2)" . to_string ( ) ,
1242+ agent_type : "Standard" . to_string ( ) ,
1243+ } )
1244+ }
1245+ }
1246+
1247+ fn fork_test_runtime (
1248+ calls : Arc < Mutex < Vec < & ' static str > > > ,
1249+ workspace_id : Arc < Mutex < Option < String > > > ,
1250+ ) -> AgentRuntime {
1251+ AgentRuntimeBuilder :: new ( )
1252+ . with_submission_port ( Arc :: new ( NoopSubmissionPort ) )
1253+ . with_session_fork_port ( Arc :: new ( RecordingForkPort {
1254+ calls,
1255+ workspace_id,
1256+ } ) )
1257+ . build ( )
1258+ . expect ( "fork test runtime" )
1259+ }
1260+
1261+ /// The Desktop "copy session" action must copy the whole session without a
1262+ /// caller-supplied turn id, so it rides the runtime's latest-persisted-turn
1263+ /// fork. A selected-turn copy keeps using the explicit-turn fork.
1264+ #[ tokio:: test]
1265+ async fn whole_session_copy_uses_the_latest_persisted_turn_fork ( ) {
1266+ let calls = Arc :: new ( Mutex :: new ( Vec :: new ( ) ) ) ;
1267+ let workspace_id = Arc :: new ( Mutex :: new ( None ) ) ;
1268+ let runtime = fork_test_runtime ( calls. clone ( ) , workspace_id. clone ( ) ) ;
1269+
1270+ let copied =
1271+ fork_session_in_scope ( & runtime, delete_test_scope ( ) , "session-1" . to_string ( ) , None )
1272+ . await
1273+ . expect ( "whole-session copy should succeed" ) ;
1274+
1275+ assert_eq ! ( calls. lock( ) . unwrap( ) . as_slice( ) , [ "whole_session" ] ) ;
1276+ assert_eq ! (
1277+ workspace_id. lock( ) . unwrap( ) . as_deref( ) ,
1278+ Some ( "test-workspace" )
1279+ ) ;
1280+ assert_eq ! ( copied. session_id, "copy-1" ) ;
1281+ assert_eq ! ( copied. session_name, "Source (1)" ) ;
1282+ assert_eq ! ( copied. agent_type, "Standard" ) ;
1283+
1284+ let prefixed = fork_session_in_scope (
1285+ & runtime,
1286+ delete_test_scope ( ) ,
1287+ "session-1" . to_string ( ) ,
1288+ Some ( "turn-2" . to_string ( ) ) ,
1289+ )
1290+ . await
1291+ . expect ( "turn-prefix copy should succeed" ) ;
1292+
1293+ assert_eq ! (
1294+ calls. lock( ) . unwrap( ) . as_slice( ) ,
1295+ [ "whole_session" , "through_turn" ]
1296+ ) ;
1297+ assert_eq ! ( prefixed. session_id, "prefix-1" ) ;
1298+ }
1299+
11671300 #[ test]
11681301 fn live_processing_state_overlays_sanitized_view_state ( ) {
11691302 let mut restored = Session :: new_with_id (
0 commit comments