From 8c1e3f3e6470a1b4cb5d8283e561ce55705394c5 Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 27 Feb 2026 17:42:25 -0800 Subject: [PATCH] app-server: Add `ephemeral` field to `Thread` object (#13084) Currently there is no alternative way to know that thread is ephemeral, only client which did create it has the knowledge. --- .../schema/json/ServerNotification.json | 5 ++++ .../codex_app_server_protocol.schemas.json | 5 ++++ .../schema/json/v2/ThreadForkResponse.json | 5 ++++ .../schema/json/v2/ThreadListResponse.json | 5 ++++ .../schema/json/v2/ThreadReadResponse.json | 5 ++++ .../schema/json/v2/ThreadResumeResponse.json | 5 ++++ .../json/v2/ThreadRollbackResponse.json | 5 ++++ .../schema/json/v2/ThreadStartResponse.json | 5 ++++ .../json/v2/ThreadStartedNotification.json | 5 ++++ .../json/v2/ThreadUnarchiveResponse.json | 5 ++++ .../schema/typescript/v2/Thread.ts | 4 +++ .../app-server-protocol/src/protocol/v2.rs | 2 ++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 2 ++ codex-rs/app-server/src/thread_status.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 16 ++++++++++ .../app-server/tests/suite/v2/thread_start.rs | 30 +++++++++++++++++++ codex-rs/core/src/codex.rs | 1 + codex-rs/core/src/codex_thread.rs | 1 + 19 files changed, 108 insertions(+) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 81797e282..7a29ef92b 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1655,6 +1655,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -1724,6 +1728,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", 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 a9fc41aed..374d43de7 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 @@ -12498,6 +12498,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -12567,6 +12571,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index c1dee7a58..fd3887650 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -882,6 +882,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -951,6 +955,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index e9bce99fb..2cf3b940f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -655,6 +655,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -724,6 +728,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index e487ad0d8..b2398692f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -655,6 +655,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -724,6 +728,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index b606e34a2..56e268bb5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -882,6 +882,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -951,6 +955,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index faea61970..4ac54973d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -655,6 +655,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -724,6 +728,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index a1de9f9ef..043354b68 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -882,6 +882,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -951,6 +955,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index f8faff010..c46d4d7bb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -655,6 +655,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -724,6 +728,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index b1be7b2de..eabd61402 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -655,6 +655,10 @@ "description": "Working directory captured for the thread.", "type": "string" }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, "gitInfo": { "anyOf": [ { @@ -724,6 +728,7 @@ "cliVersion", "createdAt", "cwd", + "ephemeral", "id", "modelProvider", "preview", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index ee07081dc..56c568849 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -11,6 +11,10 @@ export type Thread = { id: string, * Usually the first user message in the thread, if available. */ preview: string, +/** + * Whether the thread is ephemeral and should not be materialized on disk. + */ +ephemeral: boolean, /** * Model provider used for this thread (for example, 'openai'). */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index c3d2cef29..a26ca25bd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2517,6 +2517,8 @@ pub struct Thread { pub id: String, /// Usually the first user message in the thread, if available. pub preview: String, + /// Whether the thread is ephemeral and should not be materialized on disk. + pub ephemeral: bool, /// Model provider used for this thread (for example, 'openai'). pub model_provider: String, /// Unix timestamp (in seconds) when the thread was created. diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b7341801d..b43cd99c6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -62,6 +62,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - 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. + 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 and triggers a `turn/started` notification. - 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). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index c4532cafd..674dea42f 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -7869,6 +7869,7 @@ fn build_thread_from_snapshot( Thread { id: thread_id.to_string(), preview: String::new(), + ephemeral: config_snapshot.ephemeral, model_provider: config_snapshot.model_provider_id.clone(), created_at: now, updated_at: now, @@ -7910,6 +7911,7 @@ pub(crate) fn summary_to_thread(summary: ConversationSummary) -> Thread { Thread { id: conversation_id.to_string(), preview, + ephemeral: false, model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index f3a7c1fd8..c5faa42d1 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -733,6 +733,7 @@ mod tests { Thread { id: thread_id.to_string(), preview: String::new(), + ephemeral: false, model_provider: "mock-provider".to_string(), created_at: 0, updated_at: 0, diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 5d2211d3c..196f128c4 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -78,6 +78,7 @@ async fn thread_read_returns_summary_without_turns() -> Result<()> { assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); + assert!(!thread.ephemeral, "stored rollouts should not be ephemeral"); assert!(thread.path.as_ref().expect("thread path").is_absolute()); assert_eq!(thread.cwd, PathBuf::from("/")); assert_eq!(thread.cli_version, "0.0.0"); @@ -278,6 +279,11 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { Some(new_name), "thread/read must serialize `thread.name` on the wire" ); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "thread/read must serialize `thread.ephemeral` on the wire" + ); // List should also surface the name. let list_id = mcp @@ -317,6 +323,11 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { Some(new_name), "thread/list must serialize `thread.name` on the wire" ); + assert_eq!( + listed_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "thread/list must serialize `thread.ephemeral` on the wire" + ); // Resume should also surface the name. let resume_id = mcp @@ -345,6 +356,11 @@ async fn thread_name_set_is_reflected_in_read_list_and_resume() -> Result<()> { Some(new_name), "thread/resume must serialize `thread.name` on the wire" ); + assert_eq!( + resumed_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "thread/resume must serialize `thread.ephemeral` on the wire" + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index c0b84fe42..7871f5835 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -62,6 +62,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { thread.created_at > 0, "created_at should be a positive UNIX timestamp" ); + assert!( + !thread.ephemeral, + "new persistent threads should not be ephemeral" + ); assert_eq!(thread.status, ThreadStatus::Idle); let thread_path = thread.path.clone().expect("thread path should be present"); assert!(thread_path.is_absolute(), "thread path should be absolute"); @@ -80,6 +84,11 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Some(&Value::Null), "new threads should serialize `name: null`" ); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(false), + "new persistent threads should serialize `ephemeral: false`" + ); assert_eq!(thread.name, None); // A corresponding thread/started notification should arrive. @@ -98,6 +107,13 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Some(&Value::Null), "thread/started should serialize `name: null` for new threads" ); + assert_eq!( + started_thread_json + .get("ephemeral") + .and_then(Value::as_bool), + Some(false), + "thread/started should serialize `ephemeral: false` for new persistent threads" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); @@ -196,11 +212,25 @@ async fn thread_start_ephemeral_remains_pathless() -> Result<()> { mcp.read_stream_until_response_message(RequestId::Integer(req_id)), ) .await??; + let resp_result = resp.result.clone(); let ThreadStartResponse { thread, .. } = to_response::(resp)?; + assert!( + thread.ephemeral, + "ephemeral threads should be marked explicitly" + ); assert_eq!( thread.path, None, "ephemeral threads should not expose a path" ); + let thread_json = resp_result + .get("thread") + .and_then(Value::as_object) + .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("ephemeral").and_then(Value::as_bool), + Some(true), + "ephemeral threads should serialize `ephemeral: true`" + ); Ok(()) } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 885554c4c..38dde09a0 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -851,6 +851,7 @@ impl SessionConfiguration { approval_policy: self.approval_policy.value(), sandbox_policy: self.sandbox_policy.get().clone(), cwd: self.cwd.clone(), + ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, session_source: self.session_source.clone(), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 19a8214ee..dc3c4ea75 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -30,6 +30,7 @@ pub struct ThreadConfigSnapshot { pub approval_policy: AskForApproval, pub sandbox_policy: SandboxPolicy, pub cwd: PathBuf, + pub ephemeral: bool, pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource,