Add ephemeral turn context support

Introduce app-server ephemeralContext, render it as additional_context_for_this_turn fragments, and strip it correctly across contextual-user diffing and compaction flows.

Regenerate app-server schema fixtures and add coverage for model-visible layout, compaction, oversize validation, and resumed/forked context reconstruction.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Charles Cunningham
2026-03-13 11:41:35 -07:00
co-authored by Codex
parent 4bcfdafd01
commit 72d53a2c9b
54 changed files with 1422 additions and 26 deletions
@@ -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"
@@ -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"
@@ -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"
@@ -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, };
@@ -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<UserInput>, /**
* 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<EphemeralContext> | null, /**
* Override the working directory for this turn and subsequent turns.
*/
cwd?: string | null, /**
@@ -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";
@@ -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<EphemeralContext> 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<UserInput>,
/// 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<Vec<EphemeralContext>>,
/// Override the working directory for this turn and subsequent turns.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
@@ -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(&params).expect("params should serialize");
assert_eq!(
serialized.get("ephemeralContext"),
Some(&json!([{
"title": "Context from my editor",
"text": "## Active file: src/main.rs",
}]))
);
}
}
+8
View File
@@ -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",
@@ -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::<usize>()
+ ephemeral_context
.unwrap_or_default()
.iter()
.map(|context| context.title.chars().count() + context.text.chars().count())
.sum::<usize>();
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<String>,
) {
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
if let Err(error) =
Self::validate_v2_input_limit(&params.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(&params.input) {
if let Err(error) = Self::validate_v2_input_limit(&params.input, None) {
self.outgoing.send_error(request_id, error).await;
return;
}
@@ -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,
@@ -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::<ThreadStartResponse>(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::<serde_json::Value>(&requests[0].body)?;
let body_text = body.to_string();
assert!(
body_text.contains("<additional_context_for_this_turn>"),
"expected model request to contain additional_context_for_this_turn wrapper"
);
assert!(
body_text.contains("<title>Context from my editor</title>"),
"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::<ThreadStartResponse>(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::<ThreadStartResponse>(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),
+1
View File
@@ -305,6 +305,7 @@ impl AgentControl {
agent_id,
Op::UserInput {
items,
ephemeral_context: Vec::new(),
final_output_json_schema: None,
},
)
+3
View File
@@ -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,
},
);
+19 -3
View File
@@ -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<String>,
pub(crate) realtime_active: bool,
pub(crate) ephemeral_context: Vec<EphemeralContext>,
pub(crate) config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
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<EphemeralContext>,
) -> ConstraintResult<Arc<TurnContext>> {
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<Option<Value>>,
sandbox_policy_changed: bool,
ephemeral_context: Vec<EphemeralContext>,
) -> Arc<TurnContext> {
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<Session>, 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(),
+1
View File
@@ -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?;
+14 -4
View File
@@ -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);
}
+29
View File
@@ -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: "<additional_context_for_this_turn>\n <title>Context from my editor</title>\n <content>\n## Active file: src/main.rs\n </content>\n</additional_context_for_this_turn>"
.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
+12 -1
View File
@@ -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<UserMessageItem> {
let message = strip_ephemeral_context_prefix(message);
if is_contextual_user_message_content(message) {
return None;
}
+45
View File
@@ -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: "<additional_context_for_this_turn>\n <title>Context from my editor</title>\n <content>\n## Active file: src/main.rs\n </content>\n</additional_context_for_this_turn>".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: "<additional_context_for_this_turn>\n <title>Context from my editor</title>\n <content>\n## Active file: src/main.rs\n </content>\n</additional_context_for_this_turn>".to_string(),
}],
end_turn: None,
phase: None,
};
assert!(parse_turn_item(&item).is_none());
}
#[test]
fn parses_agent_message() {
let item = ResponseItem::Message {
+1
View File
@@ -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?;
+19 -1
View File
@@ -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> {
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<ContextualUserFragmentMarkers> {
+52 -13
View File
@@ -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<BuiltTurnStateFragment>,
) -> Vec<BuiltTurnStateFragment>,
}
impl ModelVisibleFragmentRegistration {
@@ -117,17 +120,19 @@ fn build_registered_turn_state_fragment<F: ModelVisibleContextFragment>(
reference_context_item: Option<&TurnContextItem>,
turn_context: &TurnContext,
params: &TurnContextDiffParams<'_>,
) -> Option<BuiltTurnStateFragment> {
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<BuiltTurnStateFragment> {
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::<SkillsSectionFragment>(),
ModelVisibleFragmentRegistration::of::<ChildAgentsInstructionsFragment>(),
ModelVisibleFragmentRegistration::of::<EnvironmentContext>(),
ModelVisibleFragmentRegistration::of::<EphemeralContext>(),
ModelVisibleFragmentRegistration::of::<SkillInstructions>(),
ModelVisibleFragmentRegistration::of::<PluginInstructions>(),
ModelVisibleFragmentRegistration::of::<UserShellCommandFragment>(),
@@ -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!(
" <title>{}</title>\n <content>\n{}\n </content>",
self.title, self.text
))
}
fn build_many(
turn_context: &TurnContext,
_reference_context_item: Option<&TurnContextItem>,
_params: &TurnContextDiffParams<'_>,
) -> Vec<Self> {
turn_context.ephemeral_context.clone()
}
fn contextual_user_markers() -> Option<ContextualUserFragmentMarkers> {
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<BuiltTurnStateFragment> {
REGISTERED_MODEL_VISIBLE_FRAGMENTS
.iter()
.filter_map(|registration| {
.flat_map(|registration| {
(registration.build_turn_state)(reference_context_item, turn_context, params)
})
.collect()
@@ -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
+5
View File
@@ -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
+29
View File
@@ -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
@@ -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
@@ -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?;
+272
View File
@@ -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<EphemeralContext> {
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
+165
View File
@@ -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<EphemeralContext> {
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?;
@@ -649,6 +649,7 @@ async fn user_turn(conversation: &Arc<CodexThread>, text: &str) {
text: text.into(),
text_elements: Vec::new(),
}],
ephemeral_context: Vec::new(),
final_output_json_schema: None,
})
.await
+1
View File
@@ -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
+9
View File
@@ -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?;
@@ -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?;
+22
View File
@@ -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
@@ -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
@@ -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?;
+2
View File
@@ -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?;
@@ -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?;
@@ -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
@@ -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?;
@@ -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?;
+7
View File
@@ -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?;
+1
View File
@@ -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
+1
View File
@@ -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?;
@@ -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:<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: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
@@ -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
@@ -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
@@ -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</title>\n <content>\n## Act...
03:message/user:first turn with editor context
## Second Request (Refreshed 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</title>\n <content>\n## Act...
03:message/user:first turn with editor context
04:message/assistant:turn one complete
05:message/user:<additional_context_for_this_turn>\n <title>Context from my editor</title>\n <content>\n## Act...
06:message/user:second turn with refreshed editor context
@@ -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
@@ -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
@@ -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?;
@@ -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
+37
View File
@@ -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 = "<user_instructions>";
pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = "</user_instructions>";
pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
pub const ENVIRONMENT_CONTEXT_CLOSE_TAG: &str = "</environment_context>";
pub const EPHEMERAL_CONTEXT_OPEN_TAG: &str = "<additional_context_for_this_turn>";
pub const EPHEMERAL_CONTEXT_CLOSE_TAG: &str = "</additional_context_for_this_turn>";
pub const COLLABORATION_MODE_OPEN_TAG: &str = "<collaboration_mode>";
pub const COLLABORATION_MODE_CLOSE_TAG: &str = "</collaboration_mode>";
pub const REALTIME_CONVERSATION_OPEN_TAG: &str = "<realtime_conversation>";
@@ -209,6 +212,10 @@ pub enum Op {
UserInput {
/// User input items, see `InputItem`
items: Vec<UserInput>,
/// 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<EphemeralContext>,
/// 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<Value>,
@@ -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 {
+8
View File
@@ -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)]