Skip to content

Commit c4fe2bd

Browse files
committed
[core] Support durable external thread goals
1 parent dd4ce53 commit c4fe2bd

9 files changed

Lines changed: 636 additions & 8 deletions

File tree

codex-rs/app-server/src/in_process.rs

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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]\ngoals = 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]\ngoals = 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;

codex-rs/app-server/src/message_processor.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,9 @@ impl MessageProcessor {
340340
let thread_store = thread_store.unwrap_or_else(|| {
341341
codex_core::thread_store_from_config(config.as_ref(), state_db.clone())
342342
});
343+
// External durable stores can own the same SQLite projection/runtime used by goal
344+
// extensions without exposing a local rollout path.
345+
let state_db = thread_store.state_db_handle().or(state_db);
343346
let environment_manager_for_requests = Arc::clone(&environment_manager);
344347
let environment_manager_for_extensions = Arc::clone(&environment_manager);
345348
let restriction_product = session_source.restriction_product();
@@ -483,6 +486,7 @@ impl MessageProcessor {
483486
thread_state_manager.clone(),
484487
state_db.clone(),
485488
Arc::clone(&goal_service),
489+
Arc::clone(&thread_store),
486490
);
487491
let thread_processor = ThreadRequestProcessor::new(
488492
auth_manager.clone(),

0 commit comments

Comments
 (0)