Add experimental turn additional context (#24154)

## Summary

Adds experimental `additionalContext` support to `turn/start` and
`turn/steer` so clients can provide ephemeral external context, such as
browser or automation state, without turning that plumbing into a
visible user prompt or triggering user-prompt lifecycle behavior.

## API Shape

The parameter shape is:

```ts
additionalContext?: Record<string, {
  value: string
  kind: "untrusted" | "application"
}> | null
```

Example:

```json
{
  "additionalContext": {
    "browser_info": {
      "value": "Active tab is CI failures.",
      "kind": "untrusted"
    },
    "automation_info": {
      "value": "CI rerun is in progress.",
      "kind": "application"
    }
  }
}
```

The keys are opaque and caller-defined.

## Context Injection

When provided, accepted entries are inserted into model context as
hidden contextual message items, not as visible thread user-message
items.

`kind: "untrusted"` entries are inserted with role `user`:

```text
<external_${key}>${value}</external_${key}>
```

`kind: "application"` entries are inserted with role `developer`:

```text
<${key}>${value}</${key}>
```

Values are not escaped. Each value is truncated to 1k approximate tokens
before wrapping.

For `turn/start`, accepted additional context is inserted before normal
user input. For `turn/steer`, additional context is merged only when the
steer includes non-empty user input; context-only steers still reject as
empty input.

## Dedupe Strategy

`AdditionalContextStore` lives on session state and stores the latest
complete additional-context map.

Each `turn/start` or non-empty `turn/steer` treats its
`additionalContext` as the current complete set of values. Entries are
injected only when the key is new or the exact entry for that key
changed, including `value` or `kind`. After merging, the store is
replaced with the provided map, so omitted keys are removed from the
retained set and can be injected again later if reintroduced.

Omitting `additionalContext`, passing `null`, or passing an empty object
resets the store to empty and injects nothing.

## What Changed

- Threads experimental v2 `additionalContext` through app-server into
core turn start and steer handling.
- Adds separate contextual fragment types for untrusted user-role
context and application developer-role context.
- Uses pending response input items so additional context can be
combined with normal user input without treating it as prompt text.
- Adds integration coverage for start/steer flow, role routing,
dedupe/reset behavior, deletion/re-add behavior, hook-blocked input
behavior, empty context-only steer rejection, external-fragment marker
matching, and truncation.
This commit is contained in:
pakrym-oai
2026-05-26 13:02:34 -07:00
committed by GitHub
parent cd934c8bcb
commit 768848ab6f
108 changed files with 1583 additions and 57 deletions
+3
View File
@@ -444,6 +444,7 @@ async fn send_input_submits_user_message() {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
},
);
@@ -598,6 +599,7 @@ async fn spawn_agent_creates_thread_and_sends_prompt() {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
},
);
@@ -770,6 +772,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
},
);
+1
View File
@@ -192,6 +192,7 @@ pub(crate) async fn run_codex_thread_one_shot(
items: input,
final_output_json_schema,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
+9 -1
View File
@@ -21,6 +21,7 @@ use codex_protocol::models::PermissionProfile;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AdditionalContextEntry;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::Op;
@@ -41,6 +42,7 @@ use codex_thread_store::ThreadStoreError;
use codex_thread_store::ThreadStoreResult;
use codex_utils_absolute_path::AbsolutePathBuf;
use rmcp::model::ReadResourceRequestParams;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
@@ -236,11 +238,17 @@ impl CodexThread {
pub async fn steer_input(
&self,
input: Vec<UserInput>,
additional_context: BTreeMap<String, AdditionalContextEntry>,
expected_turn_id: Option<&str>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
) -> Result<String, SteerInputError> {
self.codex
.steer_input(input, expected_turn_id, responsesapi_client_metadata)
.steer_input(
input,
additional_context,
expected_turn_id,
responsesapi_client_metadata,
)
.await
}
@@ -2,6 +2,7 @@ use codex_protocol::items::HookPromptItem;
use codex_protocol::items::parse_hook_prompt_fragment;
use codex_protocol::models::ContentItem;
use super::AdditionalContextUserFragment;
use super::EnvironmentContext;
use super::FragmentRegistration;
use super::FragmentRegistrationProxy;
@@ -19,6 +20,8 @@ static USER_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy<UserInstruction
FragmentRegistrationProxy::new();
static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy<EnvironmentContext> =
FragmentRegistrationProxy::new();
static ADDITIONAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy<AdditionalContextUserFragment> =
FragmentRegistrationProxy::new();
static SKILL_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy<SkillInstructions> =
FragmentRegistrationProxy::new();
static USER_SHELL_COMMAND_REGISTRATION: FragmentRegistrationProxy<UserShellCommand> =
@@ -42,6 +45,7 @@ static LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION: FragmentRegistrationProxy<
static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[
&USER_INSTRUCTIONS_REGISTRATION,
&ENVIRONMENT_CONTEXT_REGISTRATION,
&ADDITIONAL_CONTEXT_REGISTRATION,
&SKILL_INSTRUCTIONS_REGISTRATION,
&USER_SHELL_COMMAND_REGISTRATION,
&TURN_ABORTED_REGISTRATION,
+14
View File
@@ -1,4 +1,5 @@
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use std::marker::PhantomData;
@@ -81,6 +82,19 @@ pub trait ContextualUserFragment {
phase: None,
}
}
fn into_response_input_item(self) -> ResponseInputItem
where
Self: Sized,
{
ResponseInputItem::Message {
role: Self::role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
}
}
}
fn matches_marked_text(start_marker: &str, end_marker: &str, text: &str) -> bool {
+92
View File
@@ -0,0 +1,92 @@
use super::ContextualUserFragment;
use codex_utils_string::truncate_middle_with_token_budget;
const MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS: usize = 1_000;
const ADDITIONAL_CONTEXT_END_MARKER_SUFFIX: &str = ">";
const ADDITIONAL_CONTEXT_START_MARKER_PREFIX: &str = "<external_";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AdditionalContextUserFragment {
key: String,
value: String,
}
impl AdditionalContextUserFragment {
pub(crate) fn new(key: String, value: String) -> Self {
Self { key, value }
}
}
impl ContextualUserFragment for AdditionalContextUserFragment {
fn role() -> &'static str {
"user"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
(
ADDITIONAL_CONTEXT_START_MARKER_PREFIX,
ADDITIONAL_CONTEXT_END_MARKER_SUFFIX,
)
}
fn matches_text(text: &str) -> bool {
let trimmed = text.trim();
let Some(rest) = trimmed.strip_prefix(ADDITIONAL_CONTEXT_START_MARKER_PREFIX) else {
return false;
};
let Some((key, value_and_close)) = rest.split_once(ADDITIONAL_CONTEXT_END_MARKER_SUFFIX)
else {
return false;
};
value_and_close.ends_with(&format!("</external_{key}>"))
}
fn body(&self) -> String {
additional_context_body(&self.key, &self.value)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AdditionalContextDeveloperFragment {
key: String,
value: String,
}
impl AdditionalContextDeveloperFragment {
pub(crate) fn new(key: String, value: String) -> Self {
Self { key, value }
}
}
impl ContextualUserFragment for AdditionalContextDeveloperFragment {
fn role() -> &'static str {
"developer"
}
fn markers(&self) -> (&'static str, &'static str) {
Self::type_markers()
}
fn type_markers() -> (&'static str, &'static str) {
("", "")
}
fn body(&self) -> String {
additional_context_developer_body(&self.key, &self.value)
}
}
fn additional_context_body(key: &str, value: &str) -> String {
let value = truncate_middle_with_token_budget(value, MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS).0;
format!("{key}>{value}</external_{key}")
}
fn additional_context_developer_body(key: &str, value: &str) -> String {
let value = truncate_middle_with_token_budget(value, MAX_ADDITIONAL_CONTEXT_VALUE_TOKENS).0;
format!("<{key}>{value}</{key}>")
}
-13
View File
@@ -1,8 +1,6 @@
//! Hidden user-context fragment for runtime-owned goal steering prompts.
use super::ContextualUserFragment;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
/// Hidden runtime-owned goal steering context injected into model input.
#[derive(Debug, Clone, PartialEq)]
@@ -17,17 +15,6 @@ impl GoalContext {
prompt: prompt.into(),
}
}
/// Converts the registered fragment into an active-turn injectable item.
pub fn into_response_input_item(self) -> ResponseInputItem {
ResponseInputItem::Message {
role: <Self as ContextualUserFragment>::role().to_string(),
content: vec![ContentItem::InputText {
text: self.render(),
}],
phase: None,
}
}
}
impl ContextualUserFragment for GoalContext {
+3
View File
@@ -8,6 +8,7 @@ mod collaboration_mode_instructions;
mod contextual_user_message;
mod environment_context;
mod fragment;
mod fragments;
mod goal_context;
mod guardian_followup_review_reminder;
mod hook_additional_context;
@@ -40,6 +41,8 @@ pub(crate) use environment_context::EnvironmentContext;
pub use fragment::ContextualUserFragment;
pub(crate) use fragment::FragmentRegistration;
pub(crate) use fragment::FragmentRegistrationProxy;
pub(crate) use fragments::AdditionalContextDeveloperFragment;
pub(crate) use fragments::AdditionalContextUserFragment;
pub use goal_context::GoalContext;
pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder;
pub(crate) use hook_additional_context::HookAdditionalContext;
+1
View File
@@ -5,6 +5,7 @@
//! events, and owns helper hooks used by goal lifecycle behavior.
use crate::StateDbHandle;
use crate::context::ContextualUserFragment;
use crate::context::GoalContext;
use crate::session::TurnInput;
use crate::session::session::Session;
@@ -714,6 +714,7 @@ async fn run_review_on_session(
environments: None,
final_output_json_schema: Some(params.schema.clone()),
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
#[allow(deprecated)]
cwd: Some(params.parent_turn.cwd.to_path_buf()),
+15 -1
View File
@@ -10,6 +10,7 @@ use tracing::debug_span;
use tracing::info_span;
use crate::session::SteerInputError;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::session::SessionSettingsUpdate;
@@ -194,6 +195,7 @@ pub(super) async fn user_input_or_turn_inner(
environments,
final_output_json_schema,
responsesapi_client_metadata,
additional_context,
thread_settings,
} = op
else {
@@ -224,6 +226,7 @@ pub(super) async fn user_input_or_turn_inner(
let accepted_items = match sess
.steer_input(
items.clone(),
additional_context.clone(),
/*expected_turn_id*/ None,
responsesapi_client_metadata.clone(),
)
@@ -246,9 +249,20 @@ pub(super) async fn user_input_or_turn_inner(
)
.await;
let accepted_items = items.clone();
let additional_context_input = {
let mut state = sess.state.lock().await;
state.additional_context.merge(additional_context)
};
let mut task_input = additional_context_input
.into_iter()
.map(TurnInput::ResponseInputItem)
.collect::<Vec<_>>();
if !items.is_empty() {
task_input.push(TurnInput::UserInput(items));
}
sess.spawn_task(
Arc::clone(&current_context),
items,
task_input,
crate::tasks::RegularTask::new(),
)
.await;
+3 -3
View File
@@ -156,13 +156,13 @@ impl InputQueue {
.accept_mailbox_delivery_for_current_turn();
}
pub(super) async fn push_pending_input_and_accept_mailbox_delivery_for_turn_state(
pub(super) async fn extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
&self,
turn_state: &Mutex<TurnState>,
input: TurnInput,
input: Vec<TurnInput>,
) {
let mut turn_state = turn_state.lock().await;
turn_state.pending_input.items.push(input);
turn_state.pending_input.items.extend(input);
turn_state.accept_mailbox_delivery_for_current_turn();
}
+23 -3
View File
@@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::fmt::Debug;
@@ -97,6 +98,7 @@ use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::permissions::FileSystemSandboxPolicy;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AdditionalContextEntry;
use codex_protocol::protocol::FileChange;
use codex_protocol::protocol::HasLegacyEvent;
use codex_protocol::protocol::InterAgentCommunication;
@@ -742,11 +744,17 @@ impl Codex {
pub async fn steer_input(
&self,
input: Vec<UserInput>,
additional_context: BTreeMap<String, AdditionalContextEntry>,
expected_turn_id: Option<&str>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
) -> Result<String, SteerInputError> {
self.session
.steer_input(input, expected_turn_id, responsesapi_client_metadata)
.steer_input(
input,
additional_context,
expected_turn_id,
responsesapi_client_metadata,
)
.await
}
@@ -1086,6 +1094,7 @@ impl Session {
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
},
/*mirror_user_text_to_realtime*/ None,
@@ -3152,6 +3161,7 @@ impl Session {
pub async fn steer_input(
&self,
input: Vec<UserInput>,
additional_context: BTreeMap<String, AdditionalContextEntry>,
expected_turn_id: Option<&str>,
responsesapi_client_metadata: Option<HashMap<String, String>>,
) -> Result<String, SteerInputError> {
@@ -3192,6 +3202,11 @@ impl Session {
return Err(SteerInputError::EmptyInput);
}
let additional_context_input = {
let mut state = self.state.lock().await;
state.additional_context.merge(additional_context)
};
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
active_task
.turn_context
@@ -3199,10 +3214,15 @@ impl Session {
.set_responsesapi_client_metadata(responsesapi_client_metadata);
}
let mut pending_input = additional_context_input
.into_iter()
.map(TurnInput::ResponseInputItem)
.collect::<Vec<_>>();
pending_input.push(TurnInput::UserInput(input));
self.input_queue
.push_pending_input_and_accept_mailbox_delivery_for_turn_state(
.extend_pending_input_and_accept_mailbox_delivery_for_turn_state(
active_turn.turn_state.as_ref(),
TurnInput::UserInput(input),
pending_input,
)
.await;
Ok(active_turn_id.clone())
+2 -2
View File
@@ -132,11 +132,11 @@ pub(super) async fn spawn_review_thread(
};
// Seed the child task with the review prompt as the initial user message.
let input: Vec<UserInput> = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: review_prompt,
// Review prompt is synthesized; no UI element ranges to preserve.
text_elements: Vec::new(),
}];
}])];
let tc = Arc::new(review_turn_context);
tc.turn_metadata_state.spawn_git_enrichment_task();
// TODO(ccunningham): Review turns currently rely on `spawn_task` for TurnComplete but do not
+39 -23
View File
@@ -2287,6 +2287,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
@@ -2333,6 +2334,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: ThreadSettingsOverrides {
approval_policy: Some(AskForApproval::Never),
collaboration_mode: Some(collaboration_mode),
@@ -5430,6 +5432,7 @@ fn op_kind_for_input_and_context_ops() {
items: vec![],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
}
.kind(),
@@ -5460,6 +5463,7 @@ async fn user_turn_updates_approvals_reviewer() {
environments: None,
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
cwd: Some(config.cwd.to_path_buf()),
approval_policy: Some(config.permissions.approval_policy.value()),
@@ -5781,10 +5785,10 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
async {
sess.spawn_task(
Arc::clone(&tc),
vec![UserInput::Text {
vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}],
}])],
TraceCaptureTask {
captured_trace: Arc::clone(&captured_trace),
},
@@ -6592,10 +6596,10 @@ async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_task
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
sess.set_previous_turn_settings(/*previous_turn_settings*/ None)
.await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(
Arc::clone(&tc),
@@ -7861,10 +7865,10 @@ impl SessionTask for GuardianDeniedApprovalTask {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_auto_review_interrupts_after_three_consecutive_denials() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "trigger guardian denials".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(Arc::clone(&tc), input, GuardianDeniedApprovalTask)
.await;
@@ -7892,10 +7896,10 @@ async fn guardian_auto_review_interrupts_after_three_consecutive_denials() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn guardian_helper_review_interrupts_after_three_consecutive_denials() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "keep turn active for helper reviews".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(
Arc::clone(&tc),
input,
@@ -7952,10 +7956,10 @@ async fn guardian_helper_review_interrupts_after_three_consecutive_denials() {
#[test_log::test]
async fn abort_regular_task_emits_turn_aborted_only() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(
Arc::clone(&tc),
input,
@@ -7985,10 +7989,10 @@ async fn abort_regular_task_emits_turn_aborted_only() {
#[tokio::test]
async fn abort_gracefully_emits_turn_aborted_only() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(
Arc::clone(&tc),
input,
@@ -8018,10 +8022,10 @@ async fn abort_gracefully_emits_turn_aborted_only() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(
Arc::clone(&tc),
input,
@@ -8044,6 +8048,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input()
}];
sess.steer_input(
pending_user_input.clone(),
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*responsesapi_client_metadata*/ None,
)
@@ -8140,7 +8145,10 @@ async fn steer_input_requires_active_turn() {
let err = sess
.steer_input(
input, /*expected_turn_id*/ None, /*responsesapi_client_metadata*/ None,
input,
/*additional_context*/ Default::default(),
/*expected_turn_id*/ None,
/*responsesapi_client_metadata*/ None,
)
.await
.expect_err("steering without active turn should fail");
@@ -8151,10 +8159,10 @@ async fn steer_input_requires_active_turn() {
#[tokio::test]
async fn steer_input_enforces_expected_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(
Arc::clone(&tc),
input,
@@ -8172,6 +8180,7 @@ async fn steer_input_enforces_expected_turn_id() {
let err = sess
.steer_input(
steer_input,
/*additional_context*/ Default::default(),
Some("different-turn-id"),
/*responsesapi_client_metadata*/ None,
)
@@ -8196,10 +8205,10 @@ async fn steer_input_rejects_non_regular_turns() {
(TaskKind::Compact, NonSteerableTurnKind::Compact),
] {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
}])];
let turn_context = sess.new_default_turn_with_sub_id("turn".to_string()).await;
sess.spawn_task(
turn_context,
@@ -8218,6 +8227,7 @@ async fn steer_input_rejects_non_regular_turns() {
let err = sess
.steer_input(
steer_input,
/*additional_context*/ Default::default(),
/*expected_turn_id*/ None,
/*responsesapi_client_metadata*/ None,
)
@@ -8233,10 +8243,10 @@ async fn steer_input_rejects_non_regular_turns() {
#[tokio::test]
async fn steer_input_returns_active_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(
Arc::clone(&tc),
input,
@@ -8254,6 +8264,7 @@ async fn steer_input_returns_active_turn_id() {
let turn_id = sess
.steer_input(
steer_input,
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*responsesapi_client_metadata*/ None,
)
@@ -8478,6 +8489,7 @@ async fn active_goal_continuation_runs_again_after_no_tool_turn() -> anyhow::Res
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
@@ -8583,6 +8595,7 @@ async fn pending_request_user_input_does_not_spawn_extra_goal_continuation() ->
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
@@ -9130,6 +9143,7 @@ async fn completed_goal_accounts_current_turn_tokens_before_tool_response() -> a
}],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await?;
@@ -9296,6 +9310,7 @@ async fn steered_input_reopens_mailbox_delivery_for_current_turn() {
text: "follow up".to_string(),
text_elements: Vec::new(),
}],
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*responsesapi_client_metadata*/ None,
)
@@ -9345,6 +9360,7 @@ async fn stale_defer_mailbox_delivery_does_not_override_steered_input() {
text: "follow up".to_string(),
text_elements: Vec::new(),
}],
/*additional_context*/ Default::default(),
Some(&tc.sub_id),
/*responsesapi_client_metadata*/ None,
)
@@ -9426,10 +9442,10 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
let input = vec![TurnInput::UserInput(vec![UserInput::Text {
text: "start review".to_string(),
text_elements: Vec::new(),
}];
}])];
sess.spawn_task(Arc::clone(&tc), input, ReviewTask::new())
.await;
+5 -3
View File
@@ -407,14 +407,16 @@ async fn run_hooks_and_record_inputs(
input: &[TurnInput],
) -> bool {
let mut blocked_input = false;
let mut accepted_input = false;
let mut accepted_user_input = false;
for input_item in input {
let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await;
if hook_outcome.should_stop {
blocked_input = true;
record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await;
} else {
accepted_input = true;
if matches!(input_item, TurnInput::UserInput(items) if !items.is_empty()) {
accepted_user_input = true;
}
record_pending_input(
sess,
turn_context,
@@ -424,7 +426,7 @@ async fn run_hooks_and_record_inputs(
.await;
}
}
blocked_input && !accepted_input
blocked_input && !accepted_user_input
}
#[expect(
@@ -0,0 +1,37 @@
use std::collections::BTreeMap;
use crate::context::AdditionalContextDeveloperFragment;
use crate::context::AdditionalContextUserFragment;
use crate::context::ContextualUserFragment;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::protocol::AdditionalContextEntry;
use codex_protocol::protocol::AdditionalContextKind;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct AdditionalContextStore {
values: BTreeMap<String, AdditionalContextEntry>,
}
impl AdditionalContextStore {
pub(crate) fn merge(
&mut self,
values: BTreeMap<String, AdditionalContextEntry>,
) -> Vec<ResponseInputItem> {
let fragments = values
.iter()
.filter(|(key, value)| self.values.get(*key) != Some(*value))
.map(|(key, entry)| match entry.kind {
AdditionalContextKind::Untrusted => {
AdditionalContextUserFragment::new(key.clone(), entry.value.clone())
.into_response_input_item()
}
AdditionalContextKind::Application => {
AdditionalContextDeveloperFragment::new(key.clone(), entry.value.clone())
.into_response_input_item()
}
})
.collect();
self.values = values;
fragments
}
}
+2
View File
@@ -1,8 +1,10 @@
mod additional_context;
mod auto_compact_window;
mod service;
mod session;
mod turn;
pub(crate) use additional_context::AdditionalContextStore;
pub(crate) use auto_compact_window::AutoCompactWindowSnapshot;
pub(crate) use service::SessionServices;
pub(crate) use session::SessionState;
+3
View File
@@ -6,6 +6,7 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles;
use std::collections::HashSet;
use std::collections::VecDeque;
use super::AdditionalContextStore;
use super::auto_compact_window::AutoCompactWindow;
use super::auto_compact_window::AutoCompactWindowSnapshot;
use crate::context_manager::ContextManager;
@@ -25,6 +26,7 @@ pub(crate) struct SessionState {
pub(crate) latest_rate_limits: Option<RateLimitSnapshot>,
pub(crate) server_reasoning_included: bool,
pub(crate) mcp_dependency_prompted: HashSet<String>,
pub(crate) additional_context: AdditionalContextStore,
/// Settings used by the latest regular user turn, used for turn-to-turn
/// model/realtime handling on subsequent regular turns (including full-context
/// reinjection after resume or `/compact`).
@@ -49,6 +51,7 @@ impl SessionState {
latest_rate_limits: None,
server_reasoning_included: false,
mcp_dependency_prompted: HashSet::new(),
additional_context: AdditionalContextStore::default(),
previous_turn_settings: None,
auto_compact_window: AutoCompactWindow::new(),
startup_prewarm: None,
+3 -8
View File
@@ -50,7 +50,6 @@ use codex_protocol::protocol::TurnAbortReason;
use codex_protocol::protocol::TurnAbortedEvent;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::WarningEvent;
use codex_protocol::user_input::UserInput;
use codex_features::Feature;
use codex_protocol::models::ContentItem;
@@ -303,7 +302,7 @@ impl Session {
pub async fn spawn_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
task: T,
) {
self.abort_all_tasks(TurnAbortReason::Replaced).await;
@@ -314,7 +313,7 @@ impl Session {
pub(crate) async fn start_task<T: SessionTask>(
self: &Arc<Self>,
turn_context: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
task: T,
) {
let task: Arc<dyn AnySessionTask> = Arc::new(task);
@@ -382,11 +381,7 @@ impl Session {
));
let ctx = Arc::clone(&turn_context);
let task_for_run = Arc::clone(&task);
let task_input = if input.is_empty() {
Vec::new()
} else {
vec![TurnInput::UserInput(input)]
};
let task_input = input;
let task_cancellation_token = cancellation_token.child_token();
// Task-owned turn spans keep a core-owned span open for the
// full task lifecycle after the submission dispatch span ends.
@@ -2592,6 +2592,7 @@ async fn send_input_accepts_structured_items() {
],
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
};
let captured = manager