diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 88d0656fb8..a87546dff9 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -513,6 +513,23 @@ ], "type": "object" }, + "EphemeralContext": { + "properties": { + "text": { + "description": "Free-form text payload for additional context sent with one turn.", + "type": "string" + }, + "title": { + "description": "Human-readable title for additional context sent with one turn.", + "type": "string" + } + }, + "required": [ + "text", + "title" + ], + "type": "object" + }, "ExperimentalFeatureListParams": { "properties": { "cursor": { @@ -2941,6 +2958,16 @@ ], "description": "Override the reasoning effort for this turn and subsequent turns." }, + "ephemeralContext": { + "description": "Additional model-visible context for this turn, such as editor or IDE state. This context is not re-injected automatically after compaction, so clients should send a fresh snapshot on each turn instead of expecting it to carry forward automatically.", + "items": { + "$ref": "#/definitions/EphemeralContext" + }, + "type": [ + "array", + "null" + ] + }, "input": { "items": { "$ref": "#/definitions/UserInput" 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 56fb426ec9..73721b199f 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 @@ -6828,6 +6828,23 @@ ], "type": "object" }, + "EphemeralContext": { + "properties": { + "text": { + "description": "Free-form text payload for additional context sent with one turn.", + "type": "string" + }, + "title": { + "description": "Human-readable title for additional context sent with one turn.", + "type": "string" + } + }, + "required": [ + "text", + "title" + ], + "type": "object" + }, "ErrorNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -13206,6 +13223,16 @@ ], "description": "Override the reasoning effort for this turn and subsequent turns." }, + "ephemeralContext": { + "description": "Additional model-visible context for this turn, such as editor or IDE state. This context is not re-injected automatically after compaction, so clients should send a fresh snapshot on each turn instead of expecting it to carry forward automatically.", + "items": { + "$ref": "#/definitions/v2/EphemeralContext" + }, + "type": [ + "array", + "null" + ] + }, "input": { "items": { "$ref": "#/definitions/v2/UserInput" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 0d26176f9d..3839311334 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -90,6 +90,23 @@ ], "type": "object" }, + "EphemeralContext": { + "properties": { + "text": { + "description": "Free-form text payload for additional context sent with one turn.", + "type": "string" + }, + "title": { + "description": "Human-readable title for additional context sent with one turn.", + "type": "string" + } + }, + "required": [ + "text", + "title" + ], + "type": "object" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -520,6 +537,16 @@ ], "description": "Override the reasoning effort for this turn and subsequent turns." }, + "ephemeralContext": { + "description": "Additional model-visible context for this turn, such as editor or IDE state. This context is not re-injected automatically after compaction, so clients should send a fresh snapshot on each turn instead of expecting it to carry forward automatically.", + "items": { + "$ref": "#/definitions/EphemeralContext" + }, + "type": [ + "array", + "null" + ] + }, "input": { "items": { "$ref": "#/definitions/UserInput" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/EphemeralContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/EphemeralContext.ts new file mode 100644 index 0000000000..7861e6660a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/EphemeralContext.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type EphemeralContext = { +/** + * Human-readable title for additional context sent with one turn. + */ +title: string, +/** + * Free-form text payload for additional context sent with one turn. + */ +text: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index b8bf7ea69d..0cb24ba953 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -8,10 +8,17 @@ import type { ReasoningSummary } from "../ReasoningSummary"; import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { AskForApproval } from "./AskForApproval"; +import type { EphemeralContext } from "./EphemeralContext"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { UserInput } from "./UserInput"; export type TurnStartParams = {threadId: string, input: Array, /** + * Additional model-visible context for this turn, such as editor or IDE state. + * This context is not re-injected automatically after compaction, so + * clients should send a fresh snapshot on each turn instead of expecting + * it to carry forward automatically. + */ +ephemeralContext?: Array | null, /** * Override the working directory for this turn and subsequent turns. */ cwd?: string | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 68a8369fed..c952465db4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -76,6 +76,7 @@ export type { DynamicToolCallParams } from "./DynamicToolCallParams"; export type { DynamicToolCallResponse } from "./DynamicToolCallResponse"; export type { DynamicToolCallStatus } from "./DynamicToolCallStatus"; export type { DynamicToolSpec } from "./DynamicToolSpec"; +export type { EphemeralContext } from "./EphemeralContext"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 08c7582b2b..a4f960e469 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -79,6 +79,7 @@ use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; use codex_protocol::user_input::ByteRange as CoreByteRange; +use codex_protocol::user_input::EphemeralContext as CoreEphemeralContext; use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; @@ -3602,6 +3603,27 @@ pub enum TurnStatus { } // Turn APIs +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct EphemeralContext { + /// Human-readable title for additional context sent with one turn. + pub title: String, + /// Free-form text payload for additional context sent with one turn. + pub text: String, +} + +impl From for CoreEphemeralContext { + fn from(value: EphemeralContext) -> Self { + Self { + title: value.title, + text: value.text, + } + } +} + #[derive( Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, )] @@ -3610,6 +3632,12 @@ pub enum TurnStatus { pub struct TurnStartParams { pub thread_id: String, pub input: Vec, + /// Additional model-visible context for this turn, such as editor or IDE state. + /// This context is not re-injected automatically after compaction, so + /// clients should send a fresh snapshot on each turn instead of expecting + /// it to carry forward automatically. + #[ts(optional = nullable)] + pub ephemeral_context: Option>, /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, @@ -7140,6 +7168,7 @@ mod tests { let without_override = TurnStartParams { thread_id: "thread_123".to_string(), input: vec![], + ephemeral_context: None, cwd: None, approval_policy: None, sandbox_policy: None, @@ -7155,4 +7184,35 @@ mod tests { serde_json::to_value(&without_override).expect("params should serialize"); assert_eq!(serialized_without_override.get("serviceTier"), None); } + + #[test] + fn turn_start_params_serialize_ephemeral_context() { + let params = TurnStartParams { + thread_id: "thread_123".to_string(), + input: vec![], + ephemeral_context: Some(vec![EphemeralContext { + title: "Context from my editor".to_string(), + text: "## Active file: src/main.rs".to_string(), + }]), + cwd: None, + approval_policy: None, + sandbox_policy: None, + model: None, + service_tier: None, + effort: None, + summary: None, + output_schema: None, + collaboration_mode: None, + personality: None, + }; + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("ephemeralContext"), + Some(&json!([{ + "title": "Context from my editor", + "text": "## Active file: src/main.rs", + }])) + ); + } } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1681819cab..fc71f5b3d8 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -414,10 +414,18 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. +`ephemeralContext` is separate from `input`. Use it for additional model-visible context for this turn, such as IDE/editor state. This context is not re-injected automatically after compaction, so clients should send a fresh snapshot on each turn instead of expecting it to carry forward automatically. + ```json { "method": "turn/start", "id": 30, "params": { "threadId": "thr_123", "input": [ { "type": "text", "text": "Run tests" } ], + "ephemeralContext": [ + { + "title": "Context from my editor", + "text": "## Active file: src/main.rs" + } + ], // Below are optional config overrides "cwd": "/Users/me/project", "approvalPolicy": "unlessTrusted", diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 5b14ccb4f8..12faf5e792 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -41,6 +41,7 @@ use codex_app_server_protocol::CommandExecWriteParams; use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; +use codex_app_server_protocol::EphemeralContext as V2EphemeralContext; use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; @@ -4743,8 +4744,19 @@ impl CodexMessageProcessor { } } - fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { - let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum(); + fn validate_v2_input_limit( + items: &[V2UserInput], + ephemeral_context: Option<&[V2EphemeralContext]>, + ) -> Result<(), JSONRPCErrorError> { + let actual_chars: usize = items + .iter() + .map(V2UserInput::text_char_count) + .sum::() + + ephemeral_context + .unwrap_or_default() + .iter() + .map(|context| context.title.chars().count() + context.text.chars().count()) + .sum::(); if actual_chars > MAX_USER_INPUT_TEXT_CHARS { return Err(Self::input_too_large_error(actual_chars)); } @@ -5791,7 +5803,9 @@ impl CodexMessageProcessor { params: TurnStartParams, app_server_client_name: Option, ) { - if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + if let Err(error) = + Self::validate_v2_input_limit(¶ms.input, params.ephemeral_context.as_deref()) + { self.outgoing.send_error(request_id, error).await; return; } @@ -5822,6 +5836,12 @@ impl CodexMessageProcessor { .into_iter() .map(V2UserInput::into_core) .collect(); + let ephemeral_context = params + .ephemeral_context + .unwrap_or_default() + .into_iter() + .map(Into::into) + .collect(); let has_any_overrides = params.cwd.is_some() || params.approval_policy.is_some() @@ -5862,6 +5882,7 @@ impl CodexMessageProcessor { thread.as_ref(), Op::UserInput { items: mapped_items, + ephemeral_context, final_output_json_schema: params.output_schema, }, ) @@ -5921,7 +5942,7 @@ impl CodexMessageProcessor { .await; return; } - if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + if let Err(error) = Self::validate_v2_input_limit(¶ms.input, None) { self.outgoing.send_error(request_id, error).await; return; } diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor/tracing_tests.rs index af6fc5a486..154d79ae8d 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor/tracing_tests.rs @@ -504,6 +504,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { text: "hello".to_string(), text_elements: Vec::new(), }], + ephemeral_context: None, cwd: None, approval_policy: None, sandbox_policy: None, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 1b2493b993..a89ca359d8 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -20,6 +20,7 @@ use codex_app_server_protocol::CollabAgentToolCallStatus; use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::EphemeralContext; use codex_app_server_protocol::FileChangeApprovalDecision; use codex_app_server_protocol::FileChangeOutputDeltaNotification; use codex_app_server_protocol::FileChangeRequestApprovalResponse; @@ -239,6 +240,87 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_forwards_ephemeral_context_to_model_input() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: Some(vec![EphemeralContext { + title: "Context from my editor".to_string(), + text: "## Active file: src/main.rs".to_string(), + }]), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + assert_eq!(requests.len(), 1, "expected one model request"); + let body = serde_json::from_slice::(&requests[0].body)?; + let body_text = body.to_string(); + assert!( + body_text.contains(""), + "expected model request to contain additional_context_for_this_turn wrapper" + ); + assert!( + body_text.contains("Context from my editor"), + "expected model request to contain ephemeral context title" + ); + assert!( + body_text.contains("## Active file: src/main.rs"), + "expected model request to contain ephemeral context body" + ); + assert!( + body_text.contains("\"Hello\""), + "expected model request to include the user text input" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; @@ -376,6 +458,148 @@ async fn turn_start_rejects_combined_oversized_text_input() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_rejects_oversized_ephemeral_context_payload() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let title = "ctx".to_string(); + let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS - title.chars().count() + 1); + let actual_chars = title.chars().count() + text.chars().count(); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![], + ephemeral_context: Some(vec![EphemeralContext { title, text }]), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = err.error.data.expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], actual_chars); + + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification for rejected input" + ); + + Ok(()) +} + +#[tokio::test] +async fn turn_start_rejects_combined_oversized_input_and_ephemeral_context() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let input_text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS / 2); + let title = "editor".to_string(); + let ephemeral_text = "y".repeat(MAX_USER_INPUT_TEXT_CHARS / 2 - title.chars().count() + 1); + let actual_chars = + input_text.chars().count() + title.chars().count() + ephemeral_text.chars().count(); + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: input_text, + text_elements: Vec::new(), + }], + ephemeral_context: Some(vec![EphemeralContext { + title, + text: ephemeral_text, + }]), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_PARAMS_ERROR_CODE); + assert_eq!( + err.error.message, + format!("Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters.") + ); + let data = err.error.data.expect("expected structured error data"); + assert_eq!(data["input_error_code"], INPUT_TOO_LARGE_ERROR_CODE); + assert_eq!(data["max_chars"], MAX_USER_INPUT_TEXT_CHARS); + assert_eq!(data["actual_chars"], actual_chars); + + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification for rejected input" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { // Provide a mock server and config so model wiring is valid. @@ -1378,6 +1602,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { text: "first turn".to_string(), text_elements: Vec::new(), }], + ephemeral_context: None, cwd: Some(first_cwd.clone()), approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { @@ -1416,6 +1641,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { text: "second turn".to_string(), text_elements: Vec::new(), }], + ephemeral_context: None, cwd: Some(second_cwd.clone()), approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 8b50ed201c..2588e61cc5 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -305,6 +305,7 @@ impl AgentControl { agent_id, Op::UserInput { items, + ephemeral_context: Vec::new(), final_output_json_schema: None, }, ) diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 75414b81e6..944b4e2608 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -335,6 +335,7 @@ async fn send_input_submits_user_message() { text: "hello from tests".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }, ); @@ -366,6 +367,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() { text: "spawned".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }, ); @@ -442,6 +444,7 @@ async fn spawn_agent_can_fork_parent_thread_history() { text: "child task".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }, ); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2ee6448917..15409efb83 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -323,6 +323,7 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::InitialHistory; +use codex_protocol::user_input::EphemeralContext; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_readiness::Readiness; @@ -773,6 +774,7 @@ pub(crate) struct TurnContext { pub(crate) sub_id: String, pub(crate) trace_id: Option, pub(crate) realtime_active: bool, + pub(crate) ephemeral_context: Vec, pub(crate) config: Arc, pub(crate) auth_manager: Option>, pub(crate) model_info: ModelInfo, @@ -877,6 +879,7 @@ impl TurnContext { sub_id: self.sub_id.clone(), trace_id: self.trace_id.clone(), realtime_active: self.realtime_active, + ephemeral_context: self.ephemeral_context.clone(), config: Arc::new(config), auth_manager: self.auth_manager.clone(), model_info: model_info.clone(), @@ -1308,6 +1311,7 @@ impl Session { sub_id, trace_id: current_span_trace_id(), realtime_active: false, + ephemeral_context: Vec::new(), config: per_turn_config.clone(), auth_manager: auth_manager_for_context, model_info: model_info.clone(), @@ -1942,6 +1946,7 @@ impl Session { text, text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }, ) @@ -2214,6 +2219,7 @@ impl Session { &self, sub_id: String, updates: SessionSettingsUpdate, + ephemeral_context: Vec, ) -> ConstraintResult> { let ( session_configuration, @@ -2274,6 +2280,7 @@ impl Session { session_configuration, updates.final_output_json_schema, sandbox_policy_changed, + ephemeral_context, ) .await) } @@ -2284,6 +2291,7 @@ impl Session { session_configuration: SessionConfiguration, final_output_json_schema: Option>, sandbox_policy_changed: bool, + ephemeral_context: Vec, ) -> Arc { let per_turn_config = Self::build_per_turn_config(&session_configuration); self.services @@ -2342,6 +2350,7 @@ impl Session { skills_outcome, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); + turn_context.ephemeral_context = ephemeral_context; if let Some(final_schema) = final_output_json_schema { turn_context.final_output_json_schema = final_schema; @@ -2499,7 +2508,7 @@ impl Session { let state = self.state.lock().await; state.session_configuration.clone() }; - self.new_turn_from_configuration(sub_id, session_configuration, None, false) + self.new_turn_from_configuration(sub_id, session_configuration, None, false, Vec::new()) .await } @@ -4365,7 +4374,7 @@ mod handlers { } pub async fn user_input_or_turn(sess: &Arc, sub_id: String, op: Op) { - let (items, updates) = match op { + let (items, ephemeral_context, updates) = match op { Op::UserTurn { cwd, approval_policy, @@ -4391,6 +4400,7 @@ mod handlers { }); ( items, + Vec::new(), SessionSettingsUpdate { cwd: Some(cwd), approval_policy: Some(approval_policy), @@ -4407,9 +4417,11 @@ mod handlers { } Op::UserInput { items, + ephemeral_context, final_output_json_schema, } => ( items, + ephemeral_context, SessionSettingsUpdate { final_output_json_schema: Some(final_output_json_schema), ..Default::default() @@ -4418,7 +4430,10 @@ mod handlers { _ => unreachable!(), }; - let Ok(current_context) = sess.new_turn_with_sub_id(sub_id, updates).await else { + let Ok(current_context) = sess + .new_turn_with_sub_id(sub_id, updates, ephemeral_context) + .await + else { // new_turn_with_sub_id already emits the error event. return; }; @@ -5222,6 +5237,7 @@ async fn spawn_review_thread( sub_id: review_turn_id, trace_id: current_span_trace_id(), realtime_active: parent_turn_context.realtime_active, + ephemeral_context: Vec::new(), config: per_turn_config, auth_manager: auth_manager_for_context, model_info: model_info.clone(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 91a6fb2d61..33e5e53dd3 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -148,6 +148,7 @@ pub(crate) async fn run_codex_thread_one_shot( // Send the initial input to kick off the one-shot turn. io.submit(Op::UserInput { items: input, + ephemeral_context: Vec::new(), final_output_json_schema, }) .await?; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 845d159cf0..0a95bd2907 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -761,7 +761,11 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_context_updates_and_set_reference_context_item(&turn_context) .await; - expected.extend(session.build_initial_context(&turn_context).await); + expected.extend( + session + .build_initial_context_without_reference_context_item(&turn_context) + .await, + ); let history_after_seed = session.clone_history().await; assert_eq!(expected, history_after_seed.raw_items()); @@ -926,7 +930,7 @@ async fn record_initial_history_reconstructs_forked_transcript() { let reconstruction_turn = session.new_default_turn().await; expected.extend( session - .build_initial_context(reconstruction_turn.as_ref()) + .build_initial_context_without_reference_context_item(reconstruction_turn.as_ref()) .await, ); let history = session.state.lock().await.clone_history(); @@ -3409,7 +3413,9 @@ async fn record_context_updates_and_set_reference_context_item_injects_full_cont .record_context_updates_and_set_reference_context_item(&turn_context) .await; let history = session.clone_history().await; - let initial_context = session.build_initial_context(&turn_context).await; + let initial_context = session + .build_initial_context_without_reference_context_item(&turn_context) + .await; assert_eq!(history.raw_items().to_vec(), initial_context); let current_context = session.reference_context_item().await; @@ -3453,7 +3459,11 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co let history = session.clone_history().await; let mut expected_history = vec![compacted_summary]; - expected_history.extend(session.build_initial_context(&turn_context).await); + expected_history.extend( + session + .build_initial_context_without_reference_context_item(&turn_context) + .await, + ); assert_eq!(history.raw_items().to_vec(), expected_history); } diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 92e889d647..6f1380420b 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -121,6 +121,35 @@ do things assert_eq!(vec!["real user message".to_string()], collected); } +#[test] +fn collect_user_messages_filters_ephemeral_context_entries() { + let items = vec![ + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n Context from my editor\n \n## Active file: src/main.rs\n \n" + .to_string(), + }], + end_turn: None, + phase: None, + }, + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "real user message".to_string(), + }], + end_turn: None, + phase: None, + }, + ]; + + let collected = collect_user_messages(&items); + + assert_eq!(vec!["real user message".to_string()], collected); +} + #[test] fn build_token_limited_compacted_history_truncates_overlong_user_messages() { // Use a small truncation limit so the test remains fast while still validating diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 1c4a7cf5c1..672d296d83 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -19,13 +19,24 @@ use tracing::warn; use uuid::Uuid; use crate::model_visible_fragments::is_contextual_user_fragment; +use crate::model_visible_fragments::is_ephemeral_context_fragment; use crate::web_search::web_search_action_detail; +fn strip_ephemeral_context_prefix(message: &[ContentItem]) -> &[ContentItem] { + let prefix_len = message + .iter() + .take_while(|content_item| is_ephemeral_context_fragment(content_item)) + .count(); + &message[prefix_len..] +} + pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool { - message.iter().any(is_contextual_user_fragment) + let stripped = strip_ephemeral_context_prefix(message); + stripped.is_empty() || stripped.iter().any(is_contextual_user_fragment) } fn parse_user_message(message: &[ContentItem]) -> Option { + let message = strip_ephemeral_context_prefix(message); if is_contextual_user_message_content(message) { return None; } diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 88b0cee372..4e82c1be55 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -218,6 +218,51 @@ fn skips_user_instructions_and_env() { } } +#[test] +fn strips_ephemeral_context_prefix_from_user_message() { + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "\n Context from my editor\n \n## Active file: src/main.rs\n \n".to_string(), + }, + ContentItem::InputText { + text: "Explain this function.".to_string(), + }, + ], + end_turn: None, + phase: None, + }; + + let turn_item = parse_turn_item(&item).expect("expected user message turn item"); + let TurnItem::UserMessage(user_message) = turn_item else { + panic!("expected TurnItem::UserMessage"); + }; + assert_eq!( + user_message.content, + vec![UserInput::Text { + text: "Explain this function.".to_string(), + text_elements: Vec::new(), + }] + ); +} + +#[test] +fn drops_user_message_when_only_ephemeral_context_prefix_is_present() { + let item = ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: "\n Context from my editor\n \n## Active file: src/main.rs\n \n".to_string(), + }], + end_turn: None, + phase: None, + }; + + assert!(parse_turn_item(&item).is_none()); +} + #[test] fn parses_agent_message() { let item = ResponseItem::Message { diff --git a/codex-rs/core/src/guardian.rs b/codex-rs/core/src/guardian.rs index 8db5af402b..87d5ba6c8e 100644 --- a/codex-rs/core/src/guardian.rs +++ b/codex-rs/core/src/guardian.rs @@ -628,6 +628,7 @@ async fn run_guardian_subagent( codex .submit(Op::UserInput { items: prompt_items, + ephemeral_context: Vec::new(), final_output_json_schema: Some(schema), }) .await?; diff --git a/codex-rs/core/src/model_visible_context.rs b/codex-rs/core/src/model_visible_context.rs index 8047412711..8ad69a888a 100644 --- a/codex-rs/core/src/model_visible_context.rs +++ b/codex-rs/core/src/model_visible_context.rs @@ -19,7 +19,9 @@ //! - `ContextualUserContextRole` for contextual user-role state that must be //! parsed as context rather than literal user intent //! - If the fragment is durable turn/session state that should rebuild across -//! resume, compaction, backtracking, or fork, implement `build(...)`. +//! resume, compaction, backtracking, or fork, implement `build(...)` for the +//! common zero-or-one case, or override `build_many(...)` when a fragment +//! source needs to emit multiple content items from one turn-state source. //! `reference_context_item` is the baseline already represented in //! model-visible history; compare against it to avoid duplicates, and use //! `TurnContextDiffParams` for other runtime/session inputs such as @@ -187,6 +189,22 @@ pub(crate) trait ModelVisibleContextFragment: Sized { None } + /// Build zero or more fragments from the current turn state. + /// + /// Most fragments should implement `build(...)` and use this default, + /// which lifts the common zero-or-one case into a vector. Override this + /// only when one turn-state source intentionally renders multiple + /// model-visible content items. + fn build_many( + turn_context: &TurnContext, + reference_context_item: Option<&TurnContextItem>, + params: &TurnContextDiffParams<'_>, + ) -> Vec { + Self::build(turn_context, reference_context_item, params) + .into_iter() + .collect() + } + /// Stable markers used to recognize contextual-user fragments in persisted /// history. Developer fragments should keep the default `None`. fn contextual_user_markers() -> Option { diff --git a/codex-rs/core/src/model_visible_fragments.rs b/codex-rs/core/src/model_visible_fragments.rs index 18e26c0b26..2d88f56f9c 100644 --- a/codex-rs/core/src/model_visible_fragments.rs +++ b/codex-rs/core/src/model_visible_fragments.rs @@ -73,10 +73,13 @@ use codex_protocol::models::developer_realtime_start_text_with_instructions; use codex_protocol::protocol::AgentStatus; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; +use codex_protocol::protocol::EPHEMERAL_CONTEXT_CLOSE_TAG; +use codex_protocol::protocol::EPHEMERAL_CONTEXT_OPEN_TAG; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; use codex_protocol::protocol::USER_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::USER_INSTRUCTIONS_OPEN_TAG; +use codex_protocol::user_input::EphemeralContext; use serde::Deserialize; use serde::Serialize; use std::path::PathBuf; @@ -94,7 +97,7 @@ struct ModelVisibleFragmentRegistration { Option<&TurnContextItem>, &TurnContext, &TurnContextDiffParams<'_>, - ) -> Option, + ) -> Vec, } impl ModelVisibleFragmentRegistration { @@ -117,17 +120,19 @@ fn build_registered_turn_state_fragment( reference_context_item: Option<&TurnContextItem>, turn_context: &TurnContext, params: &TurnContextDiffParams<'_>, -) -> Option { - let fragment = F::build(turn_context, reference_context_item, params)?; - match F::Role::MESSAGE_ROLE { - MessageRole::Developer => Some(BuiltTurnStateFragment::Developer( - DeveloperTextFragment::new(fragment.render_text()), - )), - MessageRole::User => Some(BuiltTurnStateFragment::ContextualUser( - ContextualUserTextFragment::new(fragment.render_text()), - )), - MessageRole::Assistant | MessageRole::System => None, - } +) -> Vec { + F::build_many(turn_context, reference_context_item, params) + .into_iter() + .filter_map(|fragment| match F::Role::MESSAGE_ROLE { + MessageRole::Developer => Some(BuiltTurnStateFragment::Developer( + DeveloperTextFragment::new(fragment.render_text()), + )), + MessageRole::User => Some(BuiltTurnStateFragment::ContextualUser( + ContextualUserTextFragment::new(fragment.render_text()), + )), + MessageRole::Assistant | MessageRole::System => None, + }) + .collect() } /// Canonical ordered registry for all current model-visible fragments. @@ -146,6 +151,7 @@ const REGISTERED_MODEL_VISIBLE_FRAGMENTS: &[ModelVisibleFragmentRegistration] = ModelVisibleFragmentRegistration::of::(), ModelVisibleFragmentRegistration::of::(), ModelVisibleFragmentRegistration::of::(), + ModelVisibleFragmentRegistration::of::(), ModelVisibleFragmentRegistration::of::(), ModelVisibleFragmentRegistration::of::(), ModelVisibleFragmentRegistration::of::(), @@ -842,6 +848,32 @@ impl ModelVisibleContextFragment for EnvironmentContext { } } +impl ModelVisibleContextFragment for EphemeralContext { + type Role = ContextualUserContextRole; + + fn render_text(&self) -> String { + Self::wrap_contextual_user_body(format!( + " {}\n \n{}\n ", + self.title, self.text + )) + } + + fn build_many( + turn_context: &TurnContext, + _reference_context_item: Option<&TurnContextItem>, + _params: &TurnContextDiffParams<'_>, + ) -> Vec { + turn_context.ephemeral_context.clone() + } + + fn contextual_user_markers() -> Option { + Some(ContextualUserFragmentMarkers::new( + EPHEMERAL_CONTEXT_OPEN_TAG, + EPHEMERAL_CONTEXT_CLOSE_TAG, + )) + } +} + // --------------------------------------------------------------------------- // Contextual-user runtime fragments // --------------------------------------------------------------------------- @@ -1012,6 +1044,13 @@ pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool { || is_legacy_contextual_user_fragment(text) } +pub(crate) fn is_ephemeral_context_fragment(content_item: &ContentItem) -> bool { + let ContentItem::InputText { text } = content_item else { + return false; + }; + EphemeralContext::matches_contextual_user_text(text) +} + pub(crate) fn build_turn_state_fragments( reference_context_item: Option<&TurnContextItem>, turn_context: &TurnContext, @@ -1019,7 +1058,7 @@ pub(crate) fn build_turn_state_fragments( ) -> Vec { REGISTERED_MODEL_VISIBLE_FRAGMENTS .iter() - .filter_map(|registration| { + .flat_map(|registration| { (registration.build_turn_state)(reference_context_item, turn_context, params) }) .collect() diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 548a2bc843..457ba0a878 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -535,6 +535,7 @@ async fn send_input_accepts_structured_items() { text_elements: Vec::new(), }, ], + ephemeral_context: Vec::new(), final_output_json_schema: None, }; let captured = manager diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index af3c70b74e..5ab2607b67 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -50,6 +50,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { text: "start sleep".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -104,6 +105,7 @@ async fn interrupt_tool_records_history_entries() { text: "start history recording".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -122,6 +124,7 @@ async fn interrupt_tool_records_history_entries() { text: "follow up".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -202,6 +205,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text: "start interrupt marker".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -220,6 +224,7 @@ async fn interrupt_persists_turn_aborted_marker_in_next_request() { text: "follow up".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 9ff0a67b0f..516a00da8c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -276,6 +276,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -634,6 +635,7 @@ async fn includes_conversation_id_and_model_headers_in_request() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -682,6 +684,7 @@ async fn includes_base_instructions_override_in_request() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -733,6 +736,7 @@ async fn chatgpt_auth_sends_correct_request() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -834,6 +838,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -870,6 +875,7 @@ async fn includes_user_instructions_message_in_request() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -958,6 +964,7 @@ async fn includes_apps_guidance_as_developer_message_for_chatgpt_auth() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1049,6 +1056,7 @@ async fn omits_apps_guidance_for_api_key_auth_even_when_feature_enabled() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1118,6 +1126,7 @@ async fn skills_append_to_instructions() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1175,6 +1184,7 @@ async fn includes_configured_effort_in_request() -> anyhow::Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1217,6 +1227,7 @@ async fn includes_no_effort_in_request() -> anyhow::Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1257,6 +1268,7 @@ async fn includes_default_reasoning_effort_in_request_when_defined_by_model_info text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1368,6 +1380,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1482,6 +1495,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1538,6 +1552,7 @@ async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Res text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1574,6 +1589,7 @@ async fn includes_default_verbosity_in_request() -> anyhow::Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1619,6 +1635,7 @@ async fn configured_verbosity_not_sent_for_models_without_support() -> anyhow::R text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1663,6 +1680,7 @@ async fn configured_verbosity_is_sent() -> anyhow::Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1712,6 +1730,7 @@ async fn includes_developer_instructions_message_in_request() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1993,6 +2012,7 @@ async fn token_count_includes_rate_limits_snapshot() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2158,6 +2178,7 @@ async fn usage_limit_error_emits_rate_limit_event() -> anyhow::Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2232,6 +2253,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text: "seed turn".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2244,6 +2266,7 @@ async fn context_window_error_sets_total_tokens_to_model_window() -> anyhow::Res text: "trigger context window".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2326,6 +2349,7 @@ async fn incomplete_response_emits_content_filter_error_message() -> anyhow::Res text: "trigger incomplete".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2423,6 +2447,7 @@ async fn azure_overrides_assign_properties_used_for_responses_url() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2507,6 +2532,7 @@ async fn env_var_overrides_loaded_auth() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2568,6 +2594,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text: "U1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2581,6 +2608,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text: "U2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2594,6 +2622,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { text: "U3".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 2c7b4d48e1..892ed90438 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -836,6 +836,7 @@ async fn responses_websocket_usage_limit_error_emits_rate_limit_event() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -921,6 +922,7 @@ async fn responses_websocket_invalid_request_error_with_status_is_forwarded() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 7eec64b721..5bfa1ac3d1 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -76,6 +76,7 @@ async fn no_collaboration_instructions_by_default() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -125,6 +126,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -221,6 +223,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -341,6 +344,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -367,6 +371,7 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()> text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -422,6 +427,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -448,6 +454,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> { text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -505,6 +512,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -534,6 +542,7 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -592,6 +601,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -621,6 +631,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged() text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -683,6 +694,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -696,6 +708,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> { text: "after resume".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -751,6 +764,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 43e41fd3e0..0b4aee0648 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::WarningEvent; +use codex_protocol::user_input::EphemeralContext; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -81,6 +82,13 @@ fn set_test_compact_prompt(config: &mut Config) { config.compact_prompt = Some(SUMMARIZATION_PROMPT.to_string()); } +fn editor_context(path: &str) -> Vec { + vec![EphemeralContext { + title: "Context from my editor".to_string(), + text: format!("## Active file: {path}"), + }] +} + fn body_contains_text(body: &str, text: &str) -> bool { body.contains(&json_fragment(text)) } @@ -239,6 +247,7 @@ async fn summarize_context_three_requests_and_instructions() { text: "hello world".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -261,6 +270,7 @@ async fn summarize_context_three_requests_and_instructions() { text: THIRD_USER_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -437,6 +447,7 @@ async fn manual_compact_uses_custom_prompt() { text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -581,6 +592,7 @@ async fn manual_compact_emits_context_compaction_items() { text: "manual compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -744,6 +756,7 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() { text: user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1248,6 +1261,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1261,6 +1275,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1274,6 +1289,7 @@ async fn auto_compact_runs_after_token_limit_hit() { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1442,6 +1458,7 @@ async fn auto_compact_emits_context_compaction_items() { text: user.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1520,6 +1537,7 @@ async fn auto_compact_starts_after_turn_started() { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1532,6 +1550,7 @@ async fn auto_compact_starts_after_turn_started() { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1544,6 +1563,7 @@ async fn auto_compact_starts_after_turn_started() { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2031,6 +2051,7 @@ async fn auto_compact_persists_rollout_entries() { text: FIRST_AUTO_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2043,6 +2064,7 @@ async fn auto_compact_persists_rollout_entries() { text: SECOND_AUTO_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2055,6 +2077,7 @@ async fn auto_compact_persists_rollout_entries() { text: POST_AUTO_USER_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2141,6 +2164,7 @@ async fn manual_compact_retries_after_context_window_error() { text: "first turn".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2252,6 +2276,7 @@ async fn manual_compact_non_context_failure_retries_then_emits_task_error() { text: "first turn".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2344,6 +2369,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text: first_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2359,6 +2385,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text: second_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2374,6 +2401,7 @@ async fn manual_compact_twice_preserves_latest_user_messages() { text: final_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2535,6 +2563,7 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ text: user.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2637,6 +2666,7 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { text: FUNCTION_CALL_LIMIT_MSG.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2696,6 +2726,125 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_mid_turn_compaction_replaces_ephemeral_context() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let context_window = 100; + let limit = context_window * 90 / 100; + let over_limit_tokens = context_window * 95 / 100 + 1; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 60), + ]), + sse(vec![ + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r2", over_limit_tokens), + ]), + sse(vec![ + ev_assistant_message("m3", &auto_summary(AUTO_SUMMARY_TEXT)), + ev_completed_with_tokens("r3", 10), + ]), + sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 10), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(context_window); + config.model_auto_compact_token_limit = Some(limit); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "SETUP_USER".to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: editor_context("src/old.rs"), + final_output_json_schema: None, + }) + .await + .expect("submit setup user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: FUNCTION_CALL_LIMIT_MSG.to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: editor_context("src/new.rs"), + final_output_json_schema: None, + }) + .await + .expect("submit active user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!( + requests.len(), + 4, + "expected setup turn, active turn, compact, and continuation requests" + ); + + insta::assert_snapshot!( + "mid_turn_compaction_replaces_ephemeral_context_shapes", + format_labeled_requests_snapshot( + "Mid-turn continuation compaction keeps both prior and active ephemeral_context in the compact request, then reinjects only the active turn ephemeral_context into the continuation request.", + &[ + ("Local Compaction Request", &requests[2]), + ("Local Post-Compaction History Layout", &requests[3]), + ] + ) + ); + + let compact_user_texts = requests[2].message_input_texts("user"); + assert!( + compact_user_texts + .iter() + .any(|text| text.contains("src/old.rs")), + "expected compact request to include earlier turn ephemeral context from history" + ); + assert!( + compact_user_texts + .iter() + .any(|text| text.contains("src/new.rs")), + "expected compact request to include the active turn ephemeral context" + ); + + let follow_up_user_texts = requests[3].message_input_texts("user"); + assert!( + follow_up_user_texts + .iter() + .any(|text| text.contains("src/new.rs")), + "expected continuation request to reinject the active turn ephemeral context" + ); + assert!( + !follow_up_user_texts + .iter() + .any(|text| text.contains("src/old.rs")), + "did not expect continuation request to retain the compacted-away earlier ephemeral context" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_clamps_config_limit_to_context_window() { skip_if_no_network!(); @@ -2831,6 +2980,7 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text: user.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -2948,6 +3098,7 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text: user.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -3007,6 +3158,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess text: user.to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -3041,6 +3193,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess text_elements: Vec::new(), }, ], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -3081,6 +3234,122 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_pre_turn_compaction_replaces_ephemeral_context() { + skip_if_no_network!(); + + let server = start_mock_server().await; + + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", 60), + ]), + sse(vec![ + ev_assistant_message("m2", "SECOND_REPLY"), + ev_completed_with_tokens("r2", 500), + ]), + sse(vec![ + ev_assistant_message("m3", "PRE_TURN_SUMMARY"), + ev_completed_with_tokens("r3", 100), + ]), + sse(vec![ + ev_assistant_message("m4", FINAL_REPLY), + ev_completed_with_tokens("r4", 80), + ]), + ], + ) + .await; + + let model_provider = non_openai_model_provider(&server); + let codex = test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_auto_compact_token_limit = Some(200); + }) + .build(&server) + .await + .expect("build codex") + .codex; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_ONE".to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: editor_context("src/old.rs"), + final_output_json_schema: None, + }) + .await + .expect("submit first user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: Vec::new(), + final_output_json_schema: None, + }) + .await + .expect("submit second user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_THREE".to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: editor_context("src/new.rs"), + final_output_json_schema: None, + }) + .await + .expect("submit third user input"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 4, "expected user, user, compact, follow-up"); + + insta::assert_snapshot!( + "pre_turn_compaction_replaces_ephemeral_context_shapes", + format_labeled_requests_snapshot( + "Pre-turn auto-compaction keeps prior turn ephemeral_context in the compact request, but the follow-up request carries only the fresh turn ephemeral_context.", + &[ + ("Local Compaction Request", &requests[2]), + ("Local Post-Compaction History Layout", &requests[3]), + ] + ) + ); + + let compact_user_texts = requests[2].message_input_texts("user"); + assert!( + compact_user_texts + .iter() + .any(|text| text.contains("src/old.rs")), + "expected compact request to include the previous turn ephemeral context" + ); + let follow_up_user_texts = requests[3].message_input_texts("user"); + assert!( + follow_up_user_texts + .iter() + .any(|text| text.contains("src/new.rs")), + "expected follow-up request to include the incoming turn ephemeral context" + ); + assert!( + !follow_up_user_texts + .iter() + .any(|text| text.contains("src/old.rs")), + "did not expect follow-up request to retain the compacted-away ephemeral context" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] // TODO(ccunningham): Update once pre-turn compaction context-overflow handling includes incoming // user input and emits richer oversized-input messaging. @@ -3251,6 +3520,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -3263,6 +3533,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -3333,6 +3604,7 @@ async fn snapshot_request_shape_manual_compact_without_previous_user_messages() text: "AFTER_MANUAL_EMPTY_COMPACT".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 2ac45321cf..a880bbbc5a 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; +use codex_protocol::user_input::EphemeralContext; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -59,6 +60,13 @@ fn summary_with_prefix(summary: &str) -> String { format!("{SUMMARY_PREFIX}\n{summary}") } +fn editor_context(path: &str) -> Vec { + vec![EphemeralContext { + title: "Context from my editor".to_string(), + text: format!("## Active file: {path}"), + }] +} + fn context_snapshot_options() -> ContextSnapshotOptions { ContextSnapshotOptions::default() .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 64 }) @@ -233,6 +241,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text: "hello remote compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -247,6 +256,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { text: "after compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -377,6 +387,7 @@ async fn remote_compact_runs_automatically() -> Result<()> { text: "hello remote compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -451,6 +462,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text: first_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -462,6 +474,7 @@ async fn remote_compact_trims_function_call_history_to_fit_context_window() -> R text: second_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -571,6 +584,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text: first_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -582,6 +596,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text: second_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -599,6 +614,7 @@ async fn auto_remote_compact_trims_function_call_history_to_fit_context_window() text: "turn that triggers auto compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -696,6 +712,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text: "turn that exceeds token threshold".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -707,6 +724,7 @@ async fn auto_remote_compact_failure_stops_agent_loop() -> Result<()> { text: "turn that triggers auto compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -798,6 +816,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text: first_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -812,6 +831,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text: second_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -897,6 +917,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text: first_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -911,6 +932,7 @@ async fn remote_compact_trim_estimate_uses_session_base_instructions() -> Result text: second_user_message.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -979,6 +1001,7 @@ async fn remote_manual_compact_emits_context_compaction_items() -> Result<()> { text: "manual remote compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1057,6 +1080,7 @@ async fn remote_manual_compact_failure_emits_task_error_event() -> Result<()> { text: "manual remote compact".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1139,6 +1163,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text: "needs compaction".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1280,6 +1305,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text: "start remote compact flow".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1295,6 +1321,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text: "after compact in same session".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1317,6 +1344,7 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text: "after resume".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1411,6 +1439,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text: "start remote compact flow".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1425,6 +1454,7 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text: "after compact in same session".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1494,6 +1524,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1505,6 +1536,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_sta text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1569,6 +1601,7 @@ async fn remote_request_uses_custom_experimental_realtime_start_instructions() - text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1627,6 +1660,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1640,6 +1674,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_restates_realtime_end text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1712,6 +1747,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1726,6 +1762,7 @@ async fn snapshot_request_shape_remote_manual_compact_restates_realtime_start() text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1806,6 +1843,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text: "SETUP_USER".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1819,6 +1857,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_does_not_restate_real text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1907,6 +1946,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1934,6 +1974,7 @@ async fn snapshot_request_shape_remote_compact_resume_restates_realtime_end() -> text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2026,6 +2067,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_including_incoming_us text: user.to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2110,6 +2152,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text: "BEFORE_SWITCH_USER".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2135,6 +2178,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_strips_incoming_model text: "AFTER_SWITCH_USER".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2251,6 +2295,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2262,6 +2307,7 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2344,6 +2390,7 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2372,6 +2419,120 @@ async fn snapshot_request_shape_remote_mid_turn_continuation_compaction() -> Res Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_request_shape_remote_mid_turn_compaction_replaces_ephemeral_context() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let harness = TestCodexHarness::with_builder( + test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config.model_auto_compact_token_limit = Some(200); + }), + ) + .await?; + let codex = harness.test().codex.clone(); + + let responses_mock = responses::mount_sse_sequence( + harness.server(), + vec![ + responses::sse(vec![ + responses::ev_assistant_message("m1", "REMOTE_SETUP_REPLY"), + responses::ev_completed_with_tokens("r1", 60), + ]), + responses::sse(vec![ + responses::ev_function_call("call-remote-mid-turn", DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens("r2", 500), + ]), + responses::sse(vec![ + responses::ev_assistant_message("m3", "REMOTE_MID_TURN_FINAL_REPLY"), + responses::ev_completed_with_tokens("r3", 80), + ]), + ], + ) + .await; + + let compact_mock = responses::mount_compact_user_history_with_summary_once( + harness.server(), + &summary_with_prefix("REMOTE_MID_TURN_SUMMARY"), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "SETUP_USER".to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: editor_context("src/old.rs"), + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "USER_TWO".to_string(), + text_elements: Vec::new(), + }], + ephemeral_context: editor_context("src/new.rs"), + final_output_json_schema: None, + }) + .await?; + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + assert_eq!(compact_mock.requests().len(), 1); + let requests = responses_mock.requests(); + assert_eq!( + requests.len(), + 3, + "expected setup turn, active turn, and continuation requests" + ); + + let compact_request = compact_mock.single_request(); + let post_compact_request = &requests[2]; + insta::assert_snapshot!( + "remote_mid_turn_compaction_replaces_ephemeral_context_shapes", + format_labeled_requests_snapshot( + "Remote mid-turn continuation compaction keeps both prior and active ephemeral_context in the compact request, then reinjects only the active turn ephemeral_context into the continuation request.", + &[ + ("Remote Compaction Request", &compact_request), + ( + "Remote Post-Compaction History Layout", + post_compact_request + ), + ] + ) + ); + + assert!( + compact_request.body_contains_text("src/old.rs"), + "expected remote compact request to include earlier turn ephemeral context from history" + ); + assert!( + compact_request.body_contains_text("src/new.rs"), + "expected remote compact request to include the active turn ephemeral context" + ); + + let follow_up_user_texts = post_compact_request.message_input_texts("user"); + assert!( + follow_up_user_texts + .iter() + .any(|text| text.contains("src/new.rs")), + "expected remote continuation request to reinject the active turn ephemeral context" + ); + assert!( + !follow_up_user_texts + .iter() + .any(|text| text.contains("src/old.rs")), + "did not expect remote continuation request to retain the compacted-away earlier ephemeral context" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinjects_context() -> Result<()> { @@ -2419,6 +2580,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2502,6 +2664,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2516,6 +2679,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec text: "USER_TWO".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -2595,6 +2759,7 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess text: "USER_ONE".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 079c49797f..d818a43dc5 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -649,6 +649,7 @@ async fn user_turn(conversation: &Arc, text: &str) { text: text.into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index 96fef440c8..e1ddf7198a 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -51,6 +51,7 @@ async fn fork_thread_twice_drops_to_first_message() { text: text.to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 113a946019..19c75cc69d 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -59,6 +59,7 @@ async fn user_message_item_is_emitted() -> anyhow::Result<()> { codex .submit(Op::UserInput { items: vec![expected_input.clone()], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -115,6 +116,7 @@ async fn assistant_message_item_is_emitted() -> anyhow::Result<()> { text: "please summarize results".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -173,6 +175,7 @@ async fn reasoning_item_is_emitted() -> anyhow::Result<()> { text: "explain your reasoning".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -232,6 +235,7 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> { text: "find the weather".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -287,6 +291,7 @@ async fn image_generation_call_event_is_emitted() -> anyhow::Result<()> { text: "generate a tiny blue square".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -340,6 +345,7 @@ async fn image_generation_call_event_is_emitted_when_image_save_fails() -> anyho text: "generate an image".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -393,6 +399,7 @@ async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> { text: "please stream text".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1035,6 +1042,7 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { text: "reason through it".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -1094,6 +1102,7 @@ async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { text: "show raw reasoning".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/model_visible_layout.rs b/codex-rs/core/tests/suite/model_visible_layout.rs index e4b56e369f..43f0ca4f59 100644 --- a/codex-rs/core/tests/suite/model_visible_layout.rs +++ b/codex-rs/core/tests/suite/model_visible_layout.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::user_input::EphemeralContext; use codex_protocol::user_input::UserInput; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; @@ -149,6 +150,85 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn snapshot_model_visible_layout_ephemeral_context() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "turn one complete"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "turn two complete"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + + let test = test_codex() + .with_model("gpt-5.2-codex") + .build(&server) + .await?; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "first turn with editor context".into(), + text_elements: Vec::new(), + }], + ephemeral_context: vec![EphemeralContext { + title: "Context from my editor".to_string(), + text: "## Active file: src/one.rs".to_string(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "second turn with refreshed editor context".into(), + text_elements: Vec::new(), + }], + ephemeral_context: vec![EphemeralContext { + title: "Context from my editor".to_string(), + text: "## Active file: src/two.rs".to_string(), + }], + final_output_json_schema: None, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2, "expected two requests"); + insta::assert_snapshot!( + "model_visible_layout_ephemeral_context", + format_labeled_requests_snapshot( + "Turns resend fresh ephemeral editor context while keeping it outside durable turn-state diffs.", + &[ + ("First Request (With Editor Context)", &requests[0]), + ("Second Request (Refreshed Editor Context)", &requests[1]), + ] + ) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_model_visible_layout_cwd_change_refreshes_agents() -> Result<()> { skip_if_no_network!(Ok(())); @@ -290,6 +370,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul text: "seed resume history".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -388,6 +469,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text: "seed resume history".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -432,6 +514,7 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() - text: "first resumed turn after model override".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index c96988d18b..d6d8dcc995 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -106,6 +106,7 @@ async fn responses_api_emits_api_request_event() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -149,6 +150,7 @@ async fn process_sse_emits_tracing_for_output_item() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -192,6 +194,7 @@ async fn process_sse_emits_failed_event_on_parse_error() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -236,6 +239,7 @@ async fn process_sse_records_failed_event_when_stream_closes_without_completed() text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -300,6 +304,7 @@ async fn process_sse_failed_event_records_response_error_message() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -362,6 +367,7 @@ async fn process_sse_failed_event_logs_parse_error() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -411,6 +417,7 @@ async fn process_sse_failed_event_logs_missing_error() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -469,6 +476,7 @@ async fn process_sse_failed_event_logs_response_completed_parse_error() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -521,6 +529,7 @@ async fn process_sse_emits_completed_telemetry() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -593,6 +602,7 @@ async fn handle_responses_span_records_response_kind_and_tool_name() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -677,6 +687,7 @@ async fn record_responses_sets_span_fields_for_response_events() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -761,6 +772,7 @@ async fn handle_response_item_records_tool_result_for_custom_tool_call() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -834,6 +846,7 @@ async fn handle_response_item_records_tool_result_for_function_call() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -917,6 +930,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_missing_ids() text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -984,6 +998,7 @@ async fn handle_response_item_records_tool_result_for_local_shell_call() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1092,6 +1107,7 @@ async fn handle_container_exec_autoapprove_from_config_records_tool_decision() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1143,6 +1159,7 @@ async fn handle_container_exec_user_approved_records_tool_decision() { text: "approved".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1209,6 +1226,7 @@ async fn handle_container_exec_user_approved_for_session_records_tool_decision() text: "persist".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1275,6 +1293,7 @@ async fn handle_sandbox_error_user_approves_retry_records_tool_decision() { text: "retry".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1341,6 +1360,7 @@ async fn handle_container_exec_user_denies_records_tool_decision() { text: "deny".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1407,6 +1427,7 @@ async fn handle_sandbox_error_user_approves_for_session_records_tool_decision() text: "persist".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -1474,6 +1495,7 @@ async fn handle_sandbox_error_user_denies_records_tool_decision() { text: "deny".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 30b0d9d50b..8c5dee27eb 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -103,6 +103,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text: "first prompt".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -119,6 +120,7 @@ async fn injected_user_input_triggers_follow_up_request_with_deltas() { text: "second prompt".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index a2428090a8..5515390d4c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -66,6 +66,7 @@ async fn permissions_message_sent_once_on_start() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -191,6 +192,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -217,6 +219,7 @@ async fn permissions_message_added_on_override_change() -> Result<()> { text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -264,6 +267,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -275,6 +279,7 @@ async fn permissions_message_not_added_when_no_change() -> Result<()> { text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -333,6 +338,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -361,6 +367,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -374,6 +381,7 @@ async fn resume_replays_permissions_messages() -> Result<()> { text: "after resume".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -433,6 +441,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -461,6 +470,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -482,6 +492,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text: "after resume".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -510,6 +521,7 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { text: "after fork".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -564,6 +576,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index f16f379fc3..b84675b7c8 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -210,6 +210,7 @@ async fn plugin_instructions_are_split_from_agents_instructions() -> Result<()> text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -313,6 +314,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> { name: "sample".into(), path: format!("plugin://{SAMPLE_PLUGIN_CONFIG_NAME}"), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 82396efbe1..a1043b4edc 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -160,6 +160,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -171,6 +172,7 @@ async fn prompt_tools_are_consistent_across_requests() -> anyhow::Result<()> { text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -251,6 +253,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -262,6 +265,7 @@ async fn gpt_5_tools_without_apply_patch_append_apply_patch_instructions() -> an text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -324,6 +328,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -335,6 +340,7 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -416,6 +422,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -451,6 +458,7 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an text: "hello 2".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -532,6 +540,7 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul text: "first message".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -683,6 +692,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res text: "hello 1".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/quota_exceeded.rs b/codex-rs/core/tests/suite/quota_exceeded.rs index 466680e6a8..7d7559834e 100644 --- a/codex-rs/core/tests/suite/quota_exceeded.rs +++ b/codex-rs/core/tests/suite/quota_exceeded.rs @@ -45,6 +45,7 @@ async fn quota_exceeded_emits_single_error_event() -> Result<()> { text: "quota?".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 0d49f8c8d5..8a810ec632 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -1938,6 +1938,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { text: "first prompt".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/request_compression.rs b/codex-rs/core/tests/suite/request_compression.rs index 7f8b996c08..efa9cd2fbd 100644 --- a/codex-rs/core/tests/suite/request_compression.rs +++ b/codex-rs/core/tests/suite/request_compression.rs @@ -44,6 +44,7 @@ async fn request_body_is_zstd_compressed_for_codex_backend_when_enabled() -> any text: "compress me".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -91,6 +92,7 @@ async fn request_body_is_not_compressed_for_api_key_auth_even_when_enabled() -> text: "do not compress".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/resume.rs b/codex-rs/core/tests/suite/resume.rs index b5889c9aad..457ea8d0d0 100644 --- a/codex-rs/core/tests/suite/resume.rs +++ b/codex-rs/core/tests/suite/resume.rs @@ -90,6 +90,7 @@ async fn resume_includes_initial_messages_from_rollout_events() -> Result<()> { text: "Record some messages".into(), text_elements: text_elements.clone(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -175,6 +176,7 @@ async fn resume_includes_initial_messages_from_reasoning_events() -> Result<()> text: "Record reasoning messages".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -264,6 +266,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text: "Record initial instructions".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -304,6 +307,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text: "Resume with different model".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -319,6 +323,7 @@ async fn resume_switches_models_preserves_base_instructions() -> Result<()> { text: "Second turn after resume".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -389,6 +394,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text: "Record initial instructions".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; @@ -431,6 +437,7 @@ async fn resume_model_switch_is_not_duplicated_after_pre_turn_override() -> Resu text: "first turn after override".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 3a18cf157b..716e3502f9 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -716,6 +716,7 @@ async fn review_history_surfaces_in_parent_session() { text: followup.clone(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index e7f0a60cb7..0ca1aed899 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -381,6 +381,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - text: "Find the calendar create tool".to_string(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_replaces_ephemeral_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_replaces_ephemeral_context_shapes.snap new file mode 100644 index 0000000000..bea758420a --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__mid_turn_compaction_replaces_ephemeral_context_shapes.snap @@ -0,0 +1,29 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Mid-turn continuation compaction keeps both prior and active ephemeral_context in the compact request, then reinjects only the active turn ephemeral_context into the continuation request.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" +--- +Scenario: Mid-turn continuation compaction keeps both prior and active ephemeral_context in the compact request, then reinjects only the active turn ephemeral_context into the continuation request. + +## Local Compaction Request +00:message/developer: +01:message/user[2]: + [01] + [02] > +02:message/user:\n Context from my edi... +03:message/user:SETUP_USER +04:message/assistant:FIRST_REPLY +05:message/user:<additional_context_for_this_turn>\n <title>Context from my edi... +06:message/user:function call limit push +07:function_call/test_tool +08:function_call_output:unsupported call: test_tool +09:message/user:<SUMMARIZATION_PROMPT> + +## Local Post-Compaction History Layout +00:message/user:SETUP_USER +01:message/developer:<PERMISSIONS_INSTRUCTIONS> +02:message/user[3]: + [01] <AGENTS_MD> + [02] <ENVIRONMENT_CONTEXT:cwd=<CWD>> + [03] <additional_context_for_this_turn>\n <title>Context from my edi... +03:message/user:function call limit push +04:message/user:<COMPACTION_SUMMARY>\nAUTO_SUMMARY diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_replaces_ephemeral_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_replaces_ephemeral_context_shapes.snap new file mode 100644 index 0000000000..580e8443f1 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact__pre_turn_compaction_replaces_ephemeral_context_shapes.snap @@ -0,0 +1,28 @@ +--- +source: core/tests/suite/compact.rs +expression: "format_labeled_requests_snapshot(\"Pre-turn auto-compaction keeps prior turn ephemeral_context in the compact request, but the follow-up request carries only the fresh turn ephemeral_context.\",\n&[(\"Local Compaction Request\", &requests[2]),\n(\"Local Post-Compaction History Layout\", &requests[3]),])" +--- +Scenario: Pre-turn auto-compaction keeps prior turn ephemeral_context in the compact request, but the follow-up request carries only the fresh turn ephemeral_context. + +## Local Compaction Request +00:message/developer:<PERMISSIONS_INSTRUCTIONS> +01:message/user[2]: + [01] <AGENTS_MD> + [02] <ENVIRONMENT_CONTEXT:cwd=<CWD>> +02:message/user:<additional_context_for_this_turn>\n <title>Context from my edi... +03:message/user:USER_ONE +04:message/assistant:FIRST_REPLY +05:message/user:USER_TWO +06:message/assistant:SECOND_REPLY +07:message/user:<SUMMARIZATION_PROMPT> + +## Local Post-Compaction History Layout +00:message/user:USER_ONE +01:message/user:USER_TWO +02:message/user:<COMPACTION_SUMMARY>\nPRE_TURN_SUMMARY +03:message/developer:<PERMISSIONS_INSTRUCTIONS> +04:message/user[3]: + [01] <AGENTS_MD> + [02] <ENVIRONMENT_CONTEXT:cwd=<CWD>> + [03] <additional_context_for_this_turn>\n <title>Context from my edi... +05:message/user:USER_THREE diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_replaces_ephemeral_context_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_replaces_ephemeral_context_shapes.snap new file mode 100644 index 0000000000..7246172803 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_mid_turn_compaction_replaces_ephemeral_context_shapes.snap @@ -0,0 +1,28 @@ +--- +source: core/tests/suite/compact_remote.rs +expression: "format_labeled_requests_snapshot(\"Remote mid-turn continuation compaction keeps both prior and active ephemeral_context in the compact request, then reinjects only the active turn ephemeral_context into the continuation request.\",\n&[(\"Remote Compaction Request\", &compact_request),\n(\"Remote Post-Compaction History Layout\", post_compact_request),])" +--- +Scenario: Remote mid-turn continuation compaction keeps both prior and active ephemeral_context in the compact request, then reinjects only the active turn ephemeral_context into the continuation request. + +## Remote Compaction Request +00:message/developer:<PERMISSIONS_INSTRUCTIONS> +01:message/user[2]: + [01] <AGENTS_MD> + [02] <ENVIRONMENT_CONTEXT:cwd=<CWD>> +02:message/user:<additional_context_for_this_turn>\n <title>Context from my edi... +03:message/user:SETUP_USER +04:message/assistant:REMOTE_SETUP_REPLY +05:message/user:<additional_context_for_this_turn>\n <title>Context from my edi... +06:message/user:USER_TWO +07:function_call/test_tool +08:function_call_output:unsupported call: test_tool + +## Remote Post-Compaction History Layout +00:message/user:SETUP_USER +01:message/developer:<PERMISSIONS_INSTRUCTIONS> +02:message/user[3]: + [01] <AGENTS_MD> + [02] <ENVIRONMENT_CONTEXT:cwd=<CWD>> + [03] <additional_context_for_this_turn>\n <title>Context from my edi... +03:message/user:USER_TWO +04:compaction:encrypted=true diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_ephemeral_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_ephemeral_context.snap new file mode 100644 index 0000000000..3b559869db --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__model_visible_layout__model_visible_layout_ephemeral_context.snap @@ -0,0 +1,24 @@ +--- +source: core/tests/suite/model_visible_layout.rs +expression: "format_labeled_requests_snapshot(\"Turns resend fresh ephemeral editor context while keeping it outside durable turn-state diffs.\",\n&[(\"First Request (With Editor Context)\", &requests[0]),\n(\"Second Request (Refreshed Editor Context)\", &requests[1]),])" +--- +Scenario: Turns resend fresh ephemeral editor context while keeping it outside durable turn-state diffs. + +## First Request (With Editor Context) +00:message/developer:<PERMISSIONS_INSTRUCTIONS> +01:message/user[2]: + [01] <AGENTS_MD> + [02] <ENVIRONMENT_CONTEXT:cwd=<CWD>> +02:message/user:<additional_context_for_this_turn>\n <title>Context from my editor\n \n## Act... +03:message/user:first turn with editor context + +## Second Request (Refreshed Editor Context) +00:message/developer: +01:message/user[2]: + [01] + [02] > +02:message/user:\n Context from my editor\n \n## Act... +03:message/user:first turn with editor context +04:message/assistant:turn one complete +05:message/user:\n Context from my editor\n \n## Act... +06:message/user:second turn with refreshed editor context diff --git a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs index a8d1b37950..8a5111f6ae 100644 --- a/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs +++ b/codex-rs/core/tests/suite/stream_error_allows_next_turn.rs @@ -95,6 +95,7 @@ async fn continue_after_stream_error() { text: "first message".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await @@ -114,6 +115,7 @@ async fn continue_after_stream_error() { text: "follow up".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/stream_no_completed.rs b/codex-rs/core/tests/suite/stream_no_completed.rs index e6fc7ee8cb..105c35f457 100644 --- a/codex-rs/core/tests/suite/stream_no_completed.rs +++ b/codex-rs/core/tests/suite/stream_no_completed.rs @@ -79,6 +79,7 @@ async fn retries_on_early_close() { text: "hello".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/core/tests/suite/user_notification.rs b/codex-rs/core/tests/suite/user_notification.rs index a2303c0ecf..8cb8fde3dd 100644 --- a/codex-rs/core/tests/suite/user_notification.rs +++ b/codex-rs/core/tests/suite/user_notification.rs @@ -61,6 +61,7 @@ mv "${tmp_path}" "${payload_path}""#, text: "hello world".into(), text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await?; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index df38a1c0c8..8823238937 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -113,6 +113,7 @@ pub async fn run_codex_tool_session( // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }, trace: None, @@ -160,6 +161,7 @@ pub async fn run_codex_tool_session_reply( // MCP tool prompts are plain text with no UI element ranges. text_elements: Vec::new(), }], + ephemeral_context: Vec::new(), final_output_json_schema: None, }) .await diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 76d08b8cd7..bf51d23571 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -44,6 +44,7 @@ use crate::plan_tool::UpdatePlanArgs; use crate::request_permissions::RequestPermissionsEvent; use crate::request_permissions::RequestPermissionsResponse; use crate::request_user_input::RequestUserInputResponse; +use crate::user_input::EphemeralContext; use crate::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use schemars::JsonSchema; @@ -80,6 +81,8 @@ pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = ""; +pub const EPHEMERAL_CONTEXT_OPEN_TAG: &str = ""; +pub const EPHEMERAL_CONTEXT_CLOSE_TAG: &str = ""; pub const COLLABORATION_MODE_OPEN_TAG: &str = ""; pub const COLLABORATION_MODE_CLOSE_TAG: &str = ""; pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; @@ -209,6 +212,10 @@ pub enum Op { UserInput { /// User input items, see `InputItem` items: Vec, + /// Turn-scoped context that is visible to the model but is not part of + /// the durable turn baseline. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + ephemeral_context: Vec, /// Optional JSON Schema used to constrain the final assistant message for this turn. #[serde(skip_serializing_if = "Option::is_none")] final_output_json_schema: Option, @@ -4053,6 +4060,7 @@ mod tests { fn user_input_serialization_omits_final_output_json_schema_when_none() -> Result<()> { let op = Op::UserInput { items: Vec::new(), + ephemeral_context: Vec::new(), final_output_json_schema: None, }; @@ -4070,6 +4078,7 @@ mod tests { op, Op::UserInput { items: Vec::new(), + ephemeral_context: Vec::new(), final_output_json_schema: None, } ); @@ -4089,6 +4098,7 @@ mod tests { }); let op = Op::UserInput { items: Vec::new(), + ephemeral_context: Vec::new(), final_output_json_schema: Some(schema.clone()), }; @@ -4105,6 +4115,33 @@ mod tests { Ok(()) } + #[test] + fn user_input_serialization_includes_ephemeral_context_when_present() -> Result<()> { + let op = Op::UserInput { + items: Vec::new(), + ephemeral_context: vec![EphemeralContext { + title: "Context from my editor".to_string(), + text: "## Active file: src/main.rs".to_string(), + }], + final_output_json_schema: None, + }; + + let json_op = serde_json::to_value(op)?; + assert_eq!( + json_op, + json!({ + "type": "user_input", + "items": [], + "ephemeral_context": [{ + "title": "Context from my editor", + "text": "## Active file: src/main.rs", + }], + }) + ); + + Ok(()) + } + #[test] fn user_input_text_serializes_empty_text_elements() -> Result<()> { let input = UserInput::Text { diff --git a/codex-rs/protocol/src/user_input.rs b/codex-rs/protocol/src/user_input.rs index 4ed112df8d..3585238d3c 100644 --- a/codex-rs/protocol/src/user_input.rs +++ b/codex-rs/protocol/src/user_input.rs @@ -6,6 +6,14 @@ use ts_rs::TS; /// Conservative cap so one user message cannot monopolize a large context window. pub const MAX_USER_INPUT_TEXT_CHARS: usize = 1 << 20; +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] +pub struct EphemeralContext { + /// Human-readable title for additional context sent with one turn. + pub title: String, + /// Free-form text payload for additional context sent with one turn. + pub text: String, +} + /// User input #[non_exhaustive] #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, TS, JsonSchema)]