mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
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:
co-authored by
Codex
parent
4bcfdafd01
commit
72d53a2c9b
@@ -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(¶ms.input) {
|
||||
if let Err(error) =
|
||||
Self::validate_v2_input_limit(¶ms.input, params.ephemeral_context.as_deref())
|
||||
{
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
@@ -5822,6 +5836,12 @@ impl CodexMessageProcessor {
|
||||
.into_iter()
|
||||
.map(V2UserInput::into_core)
|
||||
.collect();
|
||||
let ephemeral_context = params
|
||||
.ephemeral_context
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
|
||||
let has_any_overrides = params.cwd.is_some()
|
||||
|| params.approval_policy.is_some()
|
||||
@@ -5862,6 +5882,7 @@ impl CodexMessageProcessor {
|
||||
thread.as_ref(),
|
||||
Op::UserInput {
|
||||
items: mapped_items,
|
||||
ephemeral_context,
|
||||
final_output_json_schema: params.output_schema,
|
||||
},
|
||||
)
|
||||
@@ -5921,7 +5942,7 @@ impl CodexMessageProcessor {
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
if let Err(error) = Self::validate_v2_input_limit(¶ms.input) {
|
||||
if let Err(error) = Self::validate_v2_input_limit(¶ms.input, None) {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user