@@ -810,6 +810,13 @@ mod tests {
810810 use codex_app_server_protocol:: ConfigRequirementsReadResponse ;
811811 use codex_app_server_protocol:: ExternalAgentConfigImportCompletedNotification ;
812812 use codex_app_server_protocol:: SessionSource as ApiSessionSource ;
813+ use codex_app_server_protocol:: ThreadGoal ;
814+ use codex_app_server_protocol:: ThreadGoalClearParams ;
815+ use codex_app_server_protocol:: ThreadGoalClearResponse ;
816+ use codex_app_server_protocol:: ThreadGoalGetParams ;
817+ use codex_app_server_protocol:: ThreadGoalGetResponse ;
818+ use codex_app_server_protocol:: ThreadGoalSetParams ;
819+ use codex_app_server_protocol:: ThreadGoalSetResponse ;
813820 use codex_app_server_protocol:: ThreadListParams ;
814821 use codex_app_server_protocol:: ThreadStartParams ;
815822 use codex_app_server_protocol:: ThreadStartResponse ;
@@ -818,7 +825,9 @@ mod tests {
818825 use codex_app_server_protocol:: TurnItemsView ;
819826 use codex_app_server_protocol:: TurnStatus ;
820827 use codex_core:: config:: ConfigBuilder ;
828+ use codex_state:: StateRuntime ;
821829 use codex_thread_store:: InMemoryThreadStore ;
830+ use codex_thread_store:: ThreadStore ;
822831 use pretty_assertions:: assert_eq;
823832 use std:: path:: Path ;
824833 use tempfile:: TempDir ;
@@ -988,6 +997,224 @@ mod tests {
988997 . expect ( "in-process runtime should shutdown cleanly" ) ;
989998 }
990999
1000+ #[ tokio:: test]
1001+ async fn external_goal_store_allows_goals_for_rolloutless_threads ( ) {
1002+ let codex_home = TempDir :: new ( ) . expect ( "temp dir" ) ;
1003+ std:: fs:: write (
1004+ codex_home. path ( ) . join ( "config.toml" ) ,
1005+ "[features]\n goals = true\n " ,
1006+ )
1007+ . expect ( "write goals config" ) ;
1008+ let config = Arc :: new ( build_test_config ( codex_home. path ( ) ) . await ) ;
1009+ let state_db = StateRuntime :: init (
1010+ codex_home. path ( ) . join ( "sqlite" ) ,
1011+ config. model_provider_id . clone ( ) ,
1012+ )
1013+ . await
1014+ . expect ( "state db should initialize" ) ;
1015+ let thread_store = Arc :: new ( InMemoryThreadStore :: with_external_thread_goal_state (
1016+ state_db,
1017+ ) ) ;
1018+ let args = InProcessStartArgs {
1019+ arg0_paths : Arg0DispatchPaths :: default ( ) ,
1020+ config,
1021+ cli_overrides : Vec :: new ( ) ,
1022+ loader_overrides : LoaderOverrides :: default ( ) ,
1023+ strict_config : false ,
1024+ cloud_config_bundle : CloudConfigBundleLoader :: default ( ) ,
1025+ thread_config_loader : Arc :: new ( codex_config:: NoopThreadConfigLoader ) ,
1026+ feedback : CodexFeedback :: new ( ) ,
1027+ log_db : None ,
1028+ state_db : None ,
1029+ environment_manager : Arc :: new ( EnvironmentManager :: default_for_tests ( ) ) ,
1030+ config_warnings : Vec :: new ( ) ,
1031+ session_source : SessionSource :: Cli ,
1032+ enable_codex_api_key_env : false ,
1033+ initialize : InitializeParams {
1034+ client_info : ClientInfo {
1035+ name : "codex-in-process-test" . to_string ( ) ,
1036+ title : None ,
1037+ version : "0.0.0" . to_string ( ) ,
1038+ } ,
1039+ capabilities : None ,
1040+ } ,
1041+ channel_capacity : DEFAULT_IN_PROCESS_CHANNEL_CAPACITY ,
1042+ } ;
1043+ let mut client = start_with_options (
1044+ args,
1045+ InProcessStartOptions :: default ( ) . with_thread_store ( thread_store. clone ( ) ) ,
1046+ )
1047+ . await
1048+ . expect ( "in-process runtime should start" ) ;
1049+ client. _test_codex_home = Some ( codex_home) ;
1050+
1051+ let response = client
1052+ . request ( ClientRequest :: ThreadStart {
1053+ request_id : RequestId :: Integer ( 4 ) ,
1054+ params : ThreadStartParams :: default ( ) ,
1055+ } )
1056+ . await
1057+ . expect ( "request transport should work" )
1058+ . expect ( "thread/start should succeed" ) ;
1059+ let started: ThreadStartResponse =
1060+ serde_json:: from_value ( response) . expect ( "thread/start response should parse" ) ;
1061+ let thread_id = started. thread . id ;
1062+
1063+ let response = client
1064+ . request ( ClientRequest :: ThreadGoalSet {
1065+ request_id : RequestId :: Integer ( 5 ) ,
1066+ params : ThreadGoalSetParams {
1067+ thread_id : thread_id. clone ( ) ,
1068+ objective : Some ( "Finish the external goal" . to_string ( ) ) ,
1069+ status : None ,
1070+ token_budget : Some ( Some ( 321 ) ) ,
1071+ } ,
1072+ } )
1073+ . await
1074+ . expect ( "request transport should work" )
1075+ . expect ( "external goal store should accept goal/set" ) ;
1076+ let set: ThreadGoalSetResponse =
1077+ serde_json:: from_value ( response) . expect ( "goal/set response should parse" ) ;
1078+ assert_eq ! ( set. goal. objective, "Finish the external goal" ) ;
1079+ assert_eq ! ( set. goal. token_budget, Some ( 321 ) ) ;
1080+
1081+ let persisted = thread_store
1082+ . load_external_thread_goal (
1083+ codex_protocol:: ThreadId :: from_string ( thread_id. as_str ( ) )
1084+ . expect ( "thread id should parse" ) ,
1085+ )
1086+ . await
1087+ . expect ( "external goal should load" )
1088+ . expect ( "goal snapshot should be durable" ) ;
1089+ assert_eq ! ( persisted. objective, "Finish the external goal" ) ;
1090+
1091+ let response = client
1092+ . request ( ClientRequest :: ThreadGoalGet {
1093+ request_id : RequestId :: Integer ( 6 ) ,
1094+ params : ThreadGoalGetParams {
1095+ thread_id : thread_id. clone ( ) ,
1096+ } ,
1097+ } )
1098+ . await
1099+ . expect ( "request transport should work" )
1100+ . expect ( "external goal store should accept goal/get" ) ;
1101+ let get: ThreadGoalGetResponse =
1102+ serde_json:: from_value ( response) . expect ( "goal/get response should parse" ) ;
1103+ assert_eq ! (
1104+ get. goal. map( |goal: ThreadGoal | goal. objective) ,
1105+ Some ( "Finish the external goal" . to_string( ) )
1106+ ) ;
1107+
1108+ let response = client
1109+ . request ( ClientRequest :: ThreadGoalClear {
1110+ request_id : RequestId :: Integer ( 7 ) ,
1111+ params : ThreadGoalClearParams {
1112+ thread_id : thread_id. clone ( ) ,
1113+ } ,
1114+ } )
1115+ . await
1116+ . expect ( "request transport should work" )
1117+ . expect ( "external goal store should accept goal/clear" ) ;
1118+ let clear: ThreadGoalClearResponse =
1119+ serde_json:: from_value ( response) . expect ( "goal/clear response should parse" ) ;
1120+ assert ! ( clear. cleared) ;
1121+
1122+ let response = client
1123+ . request ( ClientRequest :: ThreadGoalGet {
1124+ request_id : RequestId :: Integer ( 8 ) ,
1125+ params : ThreadGoalGetParams { thread_id } ,
1126+ } )
1127+ . await
1128+ . expect ( "request transport should work" )
1129+ . expect ( "external goal store should accept cleared goal/get" ) ;
1130+ let get: ThreadGoalGetResponse =
1131+ serde_json:: from_value ( response) . expect ( "goal/get response should parse" ) ;
1132+ assert_eq ! ( get. goal, None ) ;
1133+
1134+ client
1135+ . shutdown ( )
1136+ . await
1137+ . expect ( "in-process runtime should shutdown cleanly" ) ;
1138+ }
1139+
1140+ #[ tokio:: test]
1141+ async fn rolloutless_thread_without_external_goal_opt_in_stays_ephemeral ( ) {
1142+ let codex_home = TempDir :: new ( ) . expect ( "temp dir" ) ;
1143+ std:: fs:: write (
1144+ codex_home. path ( ) . join ( "config.toml" ) ,
1145+ "[features]\n goals = true\n " ,
1146+ )
1147+ . expect ( "write goals config" ) ;
1148+ let config = Arc :: new ( build_test_config ( codex_home. path ( ) ) . await ) ;
1149+ let thread_store = Arc :: new ( InMemoryThreadStore :: default ( ) ) ;
1150+ let args = InProcessStartArgs {
1151+ arg0_paths : Arg0DispatchPaths :: default ( ) ,
1152+ config,
1153+ cli_overrides : Vec :: new ( ) ,
1154+ loader_overrides : LoaderOverrides :: default ( ) ,
1155+ strict_config : false ,
1156+ cloud_config_bundle : CloudConfigBundleLoader :: default ( ) ,
1157+ thread_config_loader : Arc :: new ( codex_config:: NoopThreadConfigLoader ) ,
1158+ feedback : CodexFeedback :: new ( ) ,
1159+ log_db : None ,
1160+ state_db : None ,
1161+ environment_manager : Arc :: new ( EnvironmentManager :: default_for_tests ( ) ) ,
1162+ config_warnings : Vec :: new ( ) ,
1163+ session_source : SessionSource :: Cli ,
1164+ enable_codex_api_key_env : false ,
1165+ initialize : InitializeParams {
1166+ client_info : ClientInfo {
1167+ name : "codex-in-process-test" . to_string ( ) ,
1168+ title : None ,
1169+ version : "0.0.0" . to_string ( ) ,
1170+ } ,
1171+ capabilities : None ,
1172+ } ,
1173+ channel_capacity : DEFAULT_IN_PROCESS_CHANNEL_CAPACITY ,
1174+ } ;
1175+ let mut client = start_with_options (
1176+ args,
1177+ InProcessStartOptions :: default ( ) . with_thread_store ( thread_store) ,
1178+ )
1179+ . await
1180+ . expect ( "in-process runtime should start" ) ;
1181+ client. _test_codex_home = Some ( codex_home) ;
1182+
1183+ let response = client
1184+ . request ( ClientRequest :: ThreadStart {
1185+ request_id : RequestId :: Integer ( 9 ) ,
1186+ params : ThreadStartParams :: default ( ) ,
1187+ } )
1188+ . await
1189+ . expect ( "request transport should work" )
1190+ . expect ( "thread/start should succeed" ) ;
1191+ let started: ThreadStartResponse =
1192+ serde_json:: from_value ( response) . expect ( "thread/start response should parse" ) ;
1193+
1194+ let error = client
1195+ . request ( ClientRequest :: ThreadGoalGet {
1196+ request_id : RequestId :: Integer ( 10 ) ,
1197+ params : ThreadGoalGetParams {
1198+ thread_id : started. thread . id ,
1199+ } ,
1200+ } )
1201+ . await
1202+ . expect ( "request transport should work" )
1203+ . expect_err ( "rolloutless thread without opt-in should reject goals" ) ;
1204+ assert ! (
1205+ error
1206+ . message
1207+ . contains( "ephemeral thread does not support goals" ) ,
1208+ "unexpected goal/get error: {}" ,
1209+ error. message
1210+ ) ;
1211+
1212+ client
1213+ . shutdown ( )
1214+ . await
1215+ . expect ( "in-process runtime should shutdown cleanly" ) ;
1216+ }
1217+
9911218 async fn saturated_warning_shutdown ( event_delivery : InProcessEventDelivery ) -> Vec < String > {
9921219 let ( codex_home, mut args) =
9931220 build_test_start_args ( SessionSource :: Cli , /*channel_capacity*/ 1 ) . await ;
0 commit comments