Add support for fork, resume

# Conflicts:
#	codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts
#	codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts
#	codex-rs/app-server-protocol/src/protocol/v2.rs
#	codex-rs/app-server/tests/suite/v2/thread_fork.rs
This commit is contained in:
Rasmus Rygaard
2026-04-13 15:06:12 -07:00
parent 3e9c9ec684
commit fdba436cc0
11 changed files with 268 additions and 4 deletions
@@ -2708,6 +2708,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -3061,6 +3067,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -12781,6 +12781,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -14085,6 +14091,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -10629,6 +10629,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -11933,6 +11939,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -168,6 +168,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -1081,6 +1081,12 @@
},
"threadId": {
"type": "string"
},
"userInstructions": {
"type": [
"string",
"null"
]
}
},
"required": [
@@ -27,7 +27,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /**
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, userInstructions?: string | null | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/
@@ -36,7 +36,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier
* Override where approval requests are routed for review on this thread
* and subsequent turns.
*/
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /**
approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, userInstructions?: string | null | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /**
* If true, persist additional rollout EventMsg variants required to
* reconstruct a richer thread history on subsequent resume/fork/read.
*/
@@ -2794,6 +2794,14 @@ pub struct ThreadResumeParams {
pub sandbox: Option<SandboxMode>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, serde_json::Value>>,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub user_instructions: Option<Option<String>>,
#[ts(optional = nullable)]
pub base_instructions: Option<String>,
#[ts(optional = nullable)]
@@ -2874,6 +2882,14 @@ pub struct ThreadForkParams {
pub sandbox: Option<SandboxMode>,
#[ts(optional = nullable)]
pub config: Option<HashMap<String, serde_json::Value>>,
#[serde(
default,
deserialize_with = "super::serde_helpers::deserialize_double_option",
serialize_with = "super::serde_helpers::serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
#[ts(optional = nullable)]
pub user_instructions: Option<Option<String>>,
#[ts(optional = nullable)]
pub base_instructions: Option<String>,
#[ts(optional = nullable)]
@@ -8623,6 +8639,46 @@ mod tests {
assert_eq!(serialized_without_override.get("userInstructions"), None);
}
#[test]
fn thread_resume_params_preserve_explicit_null_user_instructions() {
let params: ThreadResumeParams = serde_json::from_value(json!({
"threadId": "thread_123",
"userInstructions": null,
}))
.expect("params should deserialize");
assert_eq!(params.user_instructions, Some(None));
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("userInstructions"),
Some(&serde_json::Value::Null)
);
let serialized_without_override =
serde_json::to_value(ThreadResumeParams::default()).expect("params should serialize");
assert_eq!(serialized_without_override.get("userInstructions"), None);
}
#[test]
fn thread_fork_params_preserve_explicit_null_user_instructions() {
let params: ThreadForkParams = serde_json::from_value(json!({
"threadId": "thread_123",
"userInstructions": null,
}))
.expect("params should deserialize");
assert_eq!(params.user_instructions, Some(None));
let serialized = serde_json::to_value(&params).expect("params should serialize");
assert_eq!(
serialized.get("userInstructions"),
Some(&serde_json::Value::Null)
);
let serialized_without_override =
serde_json::to_value(ThreadForkParams::default()).expect("params should serialize");
assert_eq!(serialized_without_override.get("userInstructions"), None);
}
#[test]
fn turn_start_params_preserve_explicit_null_service_tier() {
let params: TurnStartParams = serde_json::from_value(json!({
@@ -4080,6 +4080,7 @@ impl CodexMessageProcessor {
approvals_reviewer,
sandbox,
config: mut request_overrides,
user_instructions,
base_instructions,
developer_instructions,
personality,
@@ -4113,7 +4114,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
/*user_instructions*/ None,
user_instructions,
base_instructions,
developer_instructions,
personality,
@@ -4603,6 +4604,7 @@ impl CodexMessageProcessor {
approvals_reviewer,
sandbox,
config: cli_overrides,
user_instructions,
base_instructions,
developer_instructions,
ephemeral,
@@ -4684,7 +4686,7 @@ impl CodexMessageProcessor {
approval_policy,
approvals_reviewer,
sandbox,
/*user_instructions*/ None,
user_instructions,
base_instructions,
developer_instructions,
/*personality*/ None,
@@ -9753,6 +9755,7 @@ mod tests {
approvals_reviewer: None,
sandbox: None,
config: None,
user_instructions: None,
base_instructions: None,
developer_instructions: None,
personality: None,
@@ -26,6 +26,8 @@ use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
@@ -183,6 +185,153 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_fork_honors_explicit_null_thread_instructions() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock =
responses::mount_sse_sequence(&server, vec![body.clone(), body.clone(), body]).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
std::fs::write(
codex_home.path().join("AGENTS.md"),
"Config user instructions sentinel",
)?;
let conversation_id = create_fake_rollout(
codex_home.path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved user message",
Some("mock_provider"),
/*git_info*/ None,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let disabled_instruction_config = json!({
"include_permissions_instructions": false,
"include_apps_instructions": false,
"include_environment_context": false,
"features.apps": false,
"features.plugins": false,
"features.codex_hooks": false,
"skills.bundled.enabled": false,
});
let fork_params = [
(
json!({
"threadId": conversation_id.clone(),
"config": disabled_instruction_config.clone(),
}),
/*expect_user_instructions*/ true,
),
(
json!({
"threadId": conversation_id.clone(),
"config": disabled_instruction_config.clone(),
"userInstructions": null,
}),
/*expect_user_instructions*/ false,
),
];
let mut forked_thread_ids = Vec::new();
for (params, _expect_user_instructions) in fork_params {
let fork_id = mcp.send_raw_request("thread/fork", Some(params)).await?;
let fork_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(fork_id)),
)
.await??;
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(fork_resp)?;
forked_thread_ids.push(thread.id.clone());
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![UserInput::Text {
text: "continue".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
let _: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
}
let refork_id = mcp
.send_raw_request(
"thread/fork",
Some(json!({
"threadId": forked_thread_ids[1].clone(),
"config": disabled_instruction_config.clone(),
})),
)
.await?;
let refork_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(refork_id)),
)
.await??;
let ThreadForkResponse { thread, .. } = to_response::<ThreadForkResponse>(refork_resp)?;
let turn_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![UserInput::Text {
text: "continue again".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_id)),
)
.await??;
let _: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
for (request, expect_user_instructions) in requests.into_iter().zip([true, false, false]) {
let user_texts = request.message_input_texts("user");
assert_eq!(
user_texts
.iter()
.any(|text| text.contains("Config user instructions sentinel")),
expect_user_instructions,
"unexpected config user instruction presence: {user_texts:?}"
);
}
Ok(())
}
#[tokio::test]
async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -1810,6 +1810,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
model: Some("gpt-5.2-codex".to_string()),
user_instructions: Some(Some("Resumed user instructions sentinel".to_string())),
personality: Some(Personality::Friendly),
..Default::default()
})
@@ -1860,6 +1861,13 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
instructions_text.contains(CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT),
"expected default base instructions from history, got {instructions_text:?}"
);
let user_texts = request.message_input_texts("user");
assert!(
user_texts
.iter()
.any(|text| text.contains("Resumed user instructions sentinel")),
"expected resumed user instructions in user input, got {user_texts:?}"
);
Ok(())
}