diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 048a1818f..e4c97fbb1 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2274,6 +2274,9 @@ "null" ] }, + "ephemeral": { + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bc6f0c748..c902a8dfc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15137,6 +15137,9 @@ "null" ] }, + "ephemeral": { + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index b67bb447a..fe43f29d9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -12943,6 +12943,9 @@ "null" ] }, + "ephemeral": { + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 6d530e17f..03dfc79ba 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -100,6 +100,9 @@ "null" ] }, + "ephemeral": { + "type": "boolean" + }, "model": { "description": "Configuration overrides for the forked thread, if any.", "type": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index b071bc852..43b0b36ad 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -22,7 +22,7 @@ export type ThreadForkParams = {threadId: string, /** path?: string | null, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | 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. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 1b7c0e758..5c77b71d5 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2434,6 +2434,8 @@ pub struct ThreadForkParams { pub base_instructions: Option, #[ts(optional = nullable)] pub developer_instructions: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ephemeral: bool, /// If true, persist additional rollout EventMsg variants required to /// reconstruct a richer thread history on subsequent resume/fork/read. #[experimental("thread/fork.persistFullHistory")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f34138e57..a680a94a2 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -66,7 +66,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat ## Lifecycle Overview - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. -- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. +- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. - Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). @@ -127,7 +127,7 @@ Example with notification opt-out: - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for the new thread. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -230,10 +230,10 @@ To continue a stored session, call `thread/resume` with the `thread.id` you prev { "id": 11, "result": { "thread": { "id": "thr_123", … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. Pass `ephemeral: true` when the fork should stay in-memory only: ```json -{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123" } } +{ "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } { "id": 12, "result": { "thread": { "id": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 2955b0da6..67d1f18fc 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -3666,9 +3666,9 @@ impl CodexMessageProcessor { thread.id = thread_id.to_string(); thread.path = Some(rollout_path.to_path_buf()); let history_items = thread_history.get_rollout_items(); - if let Err(message) = populate_resume_turns( + if let Err(message) = populate_thread_turns( &mut thread, - ResumeTurnSource::HistoryItems(&history_items), + ThreadTurnSource::HistoryItems(&history_items), None, ) .await @@ -3704,6 +3704,7 @@ impl CodexMessageProcessor { config: cli_overrides, base_instructions, developer_instructions, + ephemeral, persist_extended_history, } = params; @@ -3713,12 +3714,11 @@ impl CodexMessageProcessor { let existing_thread_id = match ThreadId::from_string(&thread_id) { Ok(id) => id, Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; + self.send_invalid_request_error( + request_id, + format!("invalid thread id: {err}"), + ) + .await; return; } }; @@ -3775,7 +3775,7 @@ impl CodexMessageProcessor { } else { Some(cli_overrides) }; - let typesafe_overrides = self.build_thread_config_overrides( + let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, service_tier, @@ -3786,6 +3786,7 @@ impl CodexMessageProcessor { developer_instructions, None, ); + typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); let config = match derive_config_for_cwd( @@ -3799,12 +3800,11 @@ impl CodexMessageProcessor { { Ok(config) => config, Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; + self.send_invalid_request_error( + request_id, + format!("error deriving config: {err}"), + ) + .await; return; } }; @@ -3813,6 +3813,7 @@ impl CodexMessageProcessor { let NewThread { thread_id, + thread: forked_thread, session_configured, .. } = match self @@ -3827,33 +3828,29 @@ impl CodexMessageProcessor { { Ok(thread) => thread, Err(err) => { - let (code, message) = match err { - CodexErr::Io(_) | CodexErr::Json(_) => ( - INVALID_REQUEST_ERROR_CODE, - format!("failed to load rollout `{}`: {err}", rollout_path.display()), - ), - CodexErr::InvalidRequest(message) => (INVALID_REQUEST_ERROR_CODE, message), - _ => (INTERNAL_ERROR_CODE, format!("error forking thread: {err}")), - }; - let error = JSONRPCErrorError { - code, - message, - data: None, - }; - self.outgoing.send_error(request_id, error).await; + match err { + CodexErr::Io(_) | CodexErr::Json(_) => { + self.send_invalid_request_error( + request_id, + format!("failed to load rollout `{}`: {err}", rollout_path.display()), + ) + .await; + } + CodexErr::InvalidRequest(message) => { + self.send_invalid_request_error(request_id, message).await; + } + _ => { + self.send_internal_error( + request_id, + format!("error forking thread: {err}"), + ) + .await; + } + } return; } }; - let SessionConfiguredEvent { rollout_path, .. } = session_configured; - let Some(rollout_path) = rollout_path else { - self.send_internal_error( - request_id, - format!("rollout path missing for thread {thread_id}"), - ) - .await; - return; - }; // Auto-attach a conversation listener when forking a thread. Self::log_listener_attach_result( self.ensure_conversation_listener( @@ -3868,41 +3865,71 @@ impl CodexMessageProcessor { "thread", ); - let mut thread = match read_summary_from_rollout( - rollout_path.as_path(), - fallback_model_provider.as_str(), - ) - .await - { - Ok(summary) => summary_to_thread(summary), - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; + // Persistent forks materialize their own rollout immediately. Ephemeral forks stay + // pathless, so they rebuild their visible history from the copied source rollout instead. + let mut thread = if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() { + match read_summary_from_rollout( + fork_rollout_path.as_path(), + fallback_model_provider.as_str(), + ) + .await + { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load rollout `{}` for thread {thread_id}: {err}", + fork_rollout_path.display() + ), + ) + .await; + return; + } + } + } else { + let config_snapshot = forked_thread.config_snapshot().await; + // forked thread names do not inherit the source thread name + let mut thread = build_thread_from_snapshot(thread_id, &config_snapshot, None); + let history_items = match read_rollout_items_from_rollout(rollout_path.as_path()).await + { + Ok(items) => items, + Err(err) => { + self.send_internal_error( + request_id, + format!( + "failed to load source rollout `{}` for thread {thread_id}: {err}", + rollout_path.display() + ), + ) + .await; + return; + } + }; + thread.preview = preview_from_rollout_items(&history_items); + if let Err(message) = populate_thread_turns( + &mut thread, + ThreadTurnSource::HistoryItems(&history_items), + None, + ) + .await + { + self.send_internal_error(request_id, message).await; return; } + thread }; - // forked thread names do not inherit the source thread name - match read_rollout_items_from_rollout(rollout_path.as_path()).await { - Ok(items) => { - thread.turns = build_turns_from_rollout_items(&items); - } - Err(err) => { - self.send_internal_error( - request_id, - format!( - "failed to load rollout `{}` for thread {thread_id}: {err}", - rollout_path.display() - ), - ) - .await; - return; - } + + if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() + && let Err(message) = populate_thread_turns( + &mut thread, + ThreadTurnSource::RolloutPath(fork_rollout_path.as_path()), + None, + ) + .await + { + self.send_internal_error(request_id, message).await; + return; } self.thread_watch_manager @@ -6990,9 +7017,9 @@ async fn handle_pending_thread_resume_request( let request_id = pending.request_id; let connection_id = request_id.connection_id; let mut thread = pending.thread_summary; - if let Err(message) = populate_resume_turns( + if let Err(message) = populate_thread_turns( &mut thread, - ResumeTurnSource::RolloutPath(pending.rollout_path.as_path()), + ThreadTurnSource::RolloutPath(pending.rollout_path.as_path()), active_turn.as_ref(), ) .await @@ -7054,18 +7081,18 @@ async fn handle_pending_thread_resume_request( .await; } -enum ResumeTurnSource<'a> { +enum ThreadTurnSource<'a> { RolloutPath(&'a Path), HistoryItems(&'a [RolloutItem]), } -async fn populate_resume_turns( +async fn populate_thread_turns( thread: &mut Thread, - turn_source: ResumeTurnSource<'_>, + turn_source: ThreadTurnSource<'_>, active_turn: Option<&Turn>, ) -> std::result::Result<(), String> { let mut turns = match turn_source { - ResumeTurnSource::RolloutPath(rollout_path) => { + ThreadTurnSource::RolloutPath(rollout_path) => { read_rollout_items_from_rollout(rollout_path) .await .map(|items| build_turns_from_rollout_items(&items)) @@ -7077,7 +7104,7 @@ async fn populate_resume_turns( ) })? } - ResumeTurnSource::HistoryItems(items) => build_turns_from_rollout_items(items), + ThreadTurnSource::HistoryItems(items) => build_turns_from_rollout_items(items), }; if let Some(active_turn) = active_turn { merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 1f19ae8b9..62fecd743 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -11,11 +11,15 @@ use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use pretty_assertions::assert_eq; @@ -208,6 +212,169 @@ async fn thread_fork_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_ephemeral_remains_pathless_and_omits_listing() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let preview = "Saved user message"; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + preview, + Some("mock_provider"), + None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: conversation_id.clone(), + ephemeral: true, + ..Default::default() + }) + .await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let fork_result = fork_resp.result.clone(); + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + let fork_thread_id = thread.id.clone(); + + assert!( + thread.ephemeral, + "ephemeral forks should be marked explicitly" + ); + assert_eq!( + thread.path, None, + "ephemeral forks should not expose a path" + ); + assert_eq!(thread.preview, preview); + assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.name, None); + assert_eq!(thread.turns.len(), 1, "expected copied fork history"); + + let turn = &thread.turns[0]; + assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.items.len(), 1, "expected user message item"); + match &turn.items[0] { + ThreadItem::UserMessage { content, .. } => { + assert_eq!( + content, + &vec![UserInput::Text { + text: preview.to_string(), + text_elements: Vec::new(), + }] + ); + } + other => panic!("expected user message item, got {other:?}"), + } + + let thread_json = fork_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(true), + "ephemeral forks should serialize `ephemeral: true`" + ); + + let deadline = tokio::time::Instant::now() + DEFAULT_READ_TIMEOUT; + let notif = loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + let message = timeout(remaining, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notif) = message else { + continue; + }; + if notif.method == "thread/status/changed" { + let status_changed: ThreadStatusChangedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + if status_changed.thread_id == fork_thread_id { + anyhow::bail!( + "thread/fork should introduce the thread without a preceding thread/status/changed" + ); + } + continue; + } + if notif.method == "thread/started" { + break notif; + } + }; + let started_params = notif.params.clone().expect("params must be present"); + let started_thread_json = started_params + .get("thread") + .and_then(Value::as_object) + .expect("thread/started params.thread must be an object"); + assert_eq!( + started_thread_json + .get("ephemeral") + .and_then(Value::as_bool), + Some(true), + "thread/started should serialize `ephemeral: true` for ephemeral forks" + ); + let started: ThreadStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + assert_eq!(started.thread, thread); + + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + sort_key: None, + model_providers: None, + source_kinds: None, + archived: None, + cwd: None, + search_term: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, .. } = to_response::(list_resp)?; + assert!( + data.iter().all(|candidate| candidate.id != fork_thread_id), + "ephemeral forks should not appear in thread/list" + ); + assert!( + data.iter().any(|candidate| candidate.id == conversation_id), + "persistent source thread should remain listed" + ); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: fork_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::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml");