mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Merge branch 'main' into dev/mzeng/hot_reload_config
This commit is contained in:
@@ -61,6 +61,7 @@ env-flags = { workspace = true }
|
||||
eventsource-stream = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
iana-time-zone = { workspace = true }
|
||||
indexmap = { workspace = true }
|
||||
keyring = { workspace = true, features = ["crypto-rust"] }
|
||||
libc = { workspace = true }
|
||||
|
||||
@@ -409,9 +409,6 @@
|
||||
"shell_zsh_fork": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_approval": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_env_var_dependency_prompt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -616,6 +613,10 @@
|
||||
"additionalProperties": false,
|
||||
"description": "Memories settings loaded from config.toml.",
|
||||
"properties": {
|
||||
"generate_memories": {
|
||||
"description": "When `false`, newly created threads are stored with `memory_mode = \"disabled\"` in the state DB.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"max_raw_memories_for_global": {
|
||||
"description": "Maximum number of recent raw memories retained for global consolidation.",
|
||||
"format": "uint",
|
||||
@@ -633,6 +634,11 @@
|
||||
"minimum": 0.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"max_unused_days": {
|
||||
"description": "Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"min_rollout_idle_hours": {
|
||||
"description": "Minimum idle time between last thread activity and memory creation (hours). > 12h recommended.",
|
||||
"format": "int64",
|
||||
@@ -645,6 +651,10 @@
|
||||
"phase_2_model": {
|
||||
"description": "Model used for memory consolidation.",
|
||||
"type": "string"
|
||||
},
|
||||
"use_memories": {
|
||||
"description": "When `false`, skip injecting memory usage instructions into developer prompts.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -1144,6 +1154,10 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"oauth_resource": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"required": {
|
||||
"default": null,
|
||||
"type": "boolean"
|
||||
@@ -1177,6 +1191,18 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"RealtimeAudioToml": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"microphone": {
|
||||
"type": "string"
|
||||
},
|
||||
"speaker": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ReasoningEffort": {
|
||||
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
|
||||
"enum": [
|
||||
@@ -1538,6 +1564,15 @@
|
||||
"default": null,
|
||||
"description": "Settings for app-specific controls."
|
||||
},
|
||||
"audio": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RealtimeAudioToml"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Machine-local realtime audio device preferences used by realtime voice."
|
||||
},
|
||||
"background_terminal_max_timeout": {
|
||||
"description": "Maximum poll window for background terminal output (`write_stdin`), in milliseconds. Default: `300000` (5 minutes).",
|
||||
"format": "uint64",
|
||||
@@ -1705,9 +1740,6 @@
|
||||
"shell_zsh_fork": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_approval": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skill_env_var_dependency_prompt": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1,87 +1,100 @@
|
||||
Ash
|
||||
Elm
|
||||
Yew
|
||||
Fir
|
||||
Oak
|
||||
Pine
|
||||
Spruce
|
||||
Cedar
|
||||
Birch
|
||||
Maple
|
||||
Beech
|
||||
Alder
|
||||
Willow
|
||||
Poplar
|
||||
Aspen
|
||||
Larch
|
||||
Juniper
|
||||
Cypress
|
||||
Hemlock
|
||||
Sequoia
|
||||
Baobab
|
||||
Acacia
|
||||
Banyan
|
||||
Teak
|
||||
Olive
|
||||
Cherry
|
||||
Apple
|
||||
Pear
|
||||
Plum
|
||||
Peach
|
||||
Mango
|
||||
Guava
|
||||
Fig
|
||||
Date
|
||||
Palm
|
||||
Holly
|
||||
Ivy
|
||||
Fern
|
||||
Moss
|
||||
Reed
|
||||
Cane
|
||||
Rush
|
||||
Sage
|
||||
Mint
|
||||
Thyme
|
||||
Basil
|
||||
Dill
|
||||
Fennel
|
||||
Parsley
|
||||
Laurel
|
||||
Myrtle
|
||||
Lotus
|
||||
Tulip
|
||||
Rose
|
||||
Daisy
|
||||
Lily
|
||||
Iris
|
||||
Poppy
|
||||
Aster
|
||||
Peony
|
||||
Camellia
|
||||
Azalea
|
||||
Begonia
|
||||
Petunia
|
||||
Verbena
|
||||
Marigold
|
||||
Daffodil
|
||||
Orchid
|
||||
Violet
|
||||
Clover
|
||||
Bamboo
|
||||
Eucalypt
|
||||
Oleander
|
||||
Yucca
|
||||
Agave
|
||||
Aloe
|
||||
Cactus
|
||||
Sedum
|
||||
Boxwood
|
||||
Hawthorn
|
||||
Redwood
|
||||
Ironwood
|
||||
Sandal
|
||||
Kapok
|
||||
Mahonia
|
||||
Spirea
|
||||
Wisteria
|
||||
Euclid
|
||||
Archimedes
|
||||
Ptolemy
|
||||
Hypatia
|
||||
Avicenna
|
||||
Averroes
|
||||
Aquinas
|
||||
Copernicus
|
||||
Kepler
|
||||
Galileo
|
||||
Bacon
|
||||
Descartes
|
||||
Pascal
|
||||
Fermat
|
||||
Huygens
|
||||
Leibniz
|
||||
Newton
|
||||
Halley
|
||||
Euler
|
||||
Lagrange
|
||||
Laplace
|
||||
Volta
|
||||
Gauss
|
||||
Ampere
|
||||
Faraday
|
||||
Darwin
|
||||
Lovelace
|
||||
Boole
|
||||
Pasteur
|
||||
Maxwell
|
||||
Mendel
|
||||
Curie
|
||||
Planck
|
||||
Tesla
|
||||
Poincare
|
||||
Noether
|
||||
Hilbert
|
||||
Einstein
|
||||
Raman
|
||||
Bohr
|
||||
Turing
|
||||
Hubble
|
||||
Feynman
|
||||
Franklin
|
||||
McClintock
|
||||
Meitner
|
||||
Herschel
|
||||
Linnaeus
|
||||
Wegener
|
||||
Chandrasekhar
|
||||
Sagan
|
||||
Goodall
|
||||
Carson
|
||||
Carver
|
||||
Socrates
|
||||
Plato
|
||||
Aristotle
|
||||
Epicurus
|
||||
Cicero
|
||||
Confucius
|
||||
Mencius
|
||||
Zeno
|
||||
Locke
|
||||
Hume
|
||||
Kant
|
||||
Hegel
|
||||
Kierkegaard
|
||||
Mill
|
||||
Nietzsche
|
||||
Peirce
|
||||
James
|
||||
Dewey
|
||||
Russell
|
||||
Popper
|
||||
Sartre
|
||||
Beauvoir
|
||||
Arendt
|
||||
Rawls
|
||||
Singer
|
||||
Anscombe
|
||||
Parfit
|
||||
Kuhn
|
||||
Boyle
|
||||
Hooke
|
||||
Harvey
|
||||
Dalton
|
||||
Ohm
|
||||
Helmholtz
|
||||
Gibbs
|
||||
Lorentz
|
||||
Schrodinger
|
||||
Heisenberg
|
||||
Pauli
|
||||
Dirac
|
||||
Bernoulli
|
||||
Godel
|
||||
Nash
|
||||
Banach
|
||||
Ramanujan
|
||||
Erdos
|
||||
@@ -4,11 +4,17 @@ use crate::agent::status::is_final;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::find_thread_path_by_id_str;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::session_prefix::format_subagent_context_line;
|
||||
use crate::session_prefix::format_subagent_notification_message;
|
||||
use crate::state_db;
|
||||
use crate::thread_manager::ThreadManagerState;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
@@ -18,6 +24,12 @@ use std::sync::Weak;
|
||||
use tokio::sync::watch;
|
||||
|
||||
const AGENT_NAMES: &str = include_str!("agent_names.txt");
|
||||
const FORKED_SPAWN_AGENT_OUTPUT_MESSAGE: &str = "You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct SpawnAgentOptions {
|
||||
pub(crate) fork_parent_spawn_call_id: Option<String>,
|
||||
}
|
||||
|
||||
fn agent_nickname_list() -> Vec<&'static str> {
|
||||
AGENT_NAMES
|
||||
@@ -57,6 +69,17 @@ impl AgentControl {
|
||||
config: crate::config::Config,
|
||||
items: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
) -> CodexResult<ThreadId> {
|
||||
self.spawn_agent_with_options(config, items, session_source, SpawnAgentOptions::default())
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn spawn_agent_with_options(
|
||||
&self,
|
||||
config: crate::config::Config,
|
||||
items: Vec<UserInput>,
|
||||
session_source: Option<SessionSource>,
|
||||
options: SpawnAgentOptions,
|
||||
) -> CodexResult<ThreadId> {
|
||||
let state = self.upgrade()?;
|
||||
let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?;
|
||||
@@ -82,9 +105,75 @@ impl AgentControl {
|
||||
// The same `AgentControl` is sent to spawn the thread.
|
||||
let new_thread = match session_source {
|
||||
Some(session_source) => {
|
||||
state
|
||||
.spawn_new_thread_with_source(config, self.clone(), session_source, false, None)
|
||||
.await?
|
||||
if let Some(call_id) = options.fork_parent_spawn_call_id.as_ref() {
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
..
|
||||
}) = session_source.clone()
|
||||
else {
|
||||
return Err(CodexErr::Fatal(
|
||||
"spawn_agent fork requires a thread-spawn session source".to_string(),
|
||||
));
|
||||
};
|
||||
let parent_thread = state.get_thread(parent_thread_id).await.ok();
|
||||
if let Some(parent_thread) = parent_thread.as_ref() {
|
||||
// `record_conversation_items` only queues rollout writes asynchronously.
|
||||
// Flush/materialize the live parent before snapshotting JSONL for a fork.
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread.codex.session.flush_rollout().await;
|
||||
}
|
||||
let rollout_path = parent_thread
|
||||
.as_ref()
|
||||
.and_then(|parent_thread| parent_thread.rollout_path())
|
||||
.or(find_thread_path_by_id_str(
|
||||
config.codex_home.as_path(),
|
||||
&parent_thread_id.to_string(),
|
||||
)
|
||||
.await?)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::Fatal(format!(
|
||||
"parent thread rollout unavailable for fork: {parent_thread_id}"
|
||||
))
|
||||
})?;
|
||||
let mut forked_rollout_items =
|
||||
RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await?
|
||||
.get_rollout_items();
|
||||
let mut output = FunctionCallOutputPayload::from_text(
|
||||
FORKED_SPAWN_AGENT_OUTPUT_MESSAGE.to_string(),
|
||||
);
|
||||
output.success = Some(true);
|
||||
forked_rollout_items.push(RolloutItem::ResponseItem(
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output,
|
||||
},
|
||||
));
|
||||
let initial_history = InitialHistory::Forked(forked_rollout_items);
|
||||
state
|
||||
.fork_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
self.clone(),
|
||||
session_source,
|
||||
false,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
state
|
||||
.spawn_new_thread_with_source(
|
||||
config,
|
||||
self.clone(),
|
||||
session_source,
|
||||
false,
|
||||
None,
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
None => state.spawn_new_thread(config, self.clone()).await?,
|
||||
};
|
||||
@@ -255,6 +344,40 @@ impl AgentControl {
|
||||
thread.total_token_usage().await
|
||||
}
|
||||
|
||||
pub(crate) async fn format_environment_context_subagents(
|
||||
&self,
|
||||
parent_thread_id: ThreadId,
|
||||
) -> String {
|
||||
let Ok(state) = self.upgrade() else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
let mut agents = Vec::new();
|
||||
for thread_id in state.list_thread_ids().await {
|
||||
let Ok(thread) = state.get_thread(thread_id).await else {
|
||||
continue;
|
||||
};
|
||||
let snapshot = thread.config_snapshot().await;
|
||||
let SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: agent_parent_thread_id,
|
||||
agent_nickname,
|
||||
..
|
||||
}) = snapshot.session_source
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if agent_parent_thread_id != parent_thread_id {
|
||||
continue;
|
||||
}
|
||||
agents.push(format_subagent_context_line(
|
||||
&thread_id.to_string(),
|
||||
agent_nickname.as_deref(),
|
||||
));
|
||||
}
|
||||
agents.sort();
|
||||
agents.join("\n")
|
||||
}
|
||||
|
||||
/// Starts a detached watcher for sub-agents spawned from another thread.
|
||||
///
|
||||
/// This is only enabled for `SubAgentSource::ThreadSpawn`, where a parent thread exists and
|
||||
@@ -319,8 +442,8 @@ mod tests {
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use crate::features::Feature;
|
||||
use crate::session_prefix::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::models::ContentItem;
|
||||
@@ -421,6 +544,21 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns true when any message item contains `needle` in a text span.
|
||||
fn history_contains_text(history_items: &[ResponseItem], needle: &str) -> bool {
|
||||
history_items.iter().any(|item| {
|
||||
let ResponseItem::Message { content, .. } = item else {
|
||||
return false;
|
||||
};
|
||||
content.iter().any(|content_item| match content_item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
|
||||
text.contains(needle)
|
||||
}
|
||||
ContentItem::InputImage { .. } => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async fn wait_for_subagent_notification(parent_thread: &Arc<CodexThread>) -> bool {
|
||||
let wait = async {
|
||||
loop {
|
||||
@@ -673,6 +811,242 @@ mod tests {
|
||||
assert_eq!(captured, Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_can_fork_parent_thread_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
parent_thread
|
||||
.inject_user_message_without_turn("parent seed context".to_string())
|
||||
.await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-history".to_string();
|
||||
let parent_spawn_call = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "spawn_agent".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: parent_spawn_call_id.clone(),
|
||||
};
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[parent_spawn_call])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread.codex.session.flush_rollout().await;
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_options(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should succeed");
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
assert_ne!(child_thread_id, parent_thread_id);
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
assert!(history_contains_text(
|
||||
history.raw_items(),
|
||||
"parent seed context"
|
||||
));
|
||||
|
||||
let expected = (
|
||||
child_thread_id,
|
||||
Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "child task".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
},
|
||||
);
|
||||
let captured = harness
|
||||
.manager
|
||||
.captured_ops()
|
||||
.into_iter()
|
||||
.find(|entry| *entry == expected);
|
||||
assert_eq!(captured, Some(expected));
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_injects_output_for_parent_spawn_call() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-1".to_string();
|
||||
let parent_spawn_call = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "spawn_agent".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: parent_spawn_call_id.clone(),
|
||||
};
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[parent_spawn_call])
|
||||
.await;
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.ensure_rollout_materialized()
|
||||
.await;
|
||||
parent_thread.codex.session.flush_rollout().await;
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_options(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should succeed");
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
let injected_output = history.raw_items().iter().find_map(|item| match item {
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
if call_id == &parent_spawn_call_id =>
|
||||
{
|
||||
Some(output)
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let injected_output =
|
||||
injected_output.expect("forked child should contain synthetic tool output");
|
||||
assert_eq!(
|
||||
injected_output.text_content(),
|
||||
Some(FORKED_SPAWN_AGENT_OUTPUT_MESSAGE)
|
||||
);
|
||||
assert_eq!(injected_output.success, Some(true));
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_fork_flushes_parent_rollout_before_loading_history() {
|
||||
let harness = AgentControlHarness::new().await;
|
||||
let (parent_thread_id, parent_thread) = harness.start_thread().await;
|
||||
let turn_context = parent_thread.codex.session.new_default_turn().await;
|
||||
let parent_spawn_call_id = "spawn-call-unflushed".to_string();
|
||||
let parent_spawn_call = ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "spawn_agent".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: parent_spawn_call_id.clone(),
|
||||
};
|
||||
parent_thread
|
||||
.codex
|
||||
.session
|
||||
.record_conversation_items(turn_context.as_ref(), &[parent_spawn_call])
|
||||
.await;
|
||||
|
||||
let child_thread_id = harness
|
||||
.control
|
||||
.spawn_agent_with_options(
|
||||
harness.config.clone(),
|
||||
text_input("child task"),
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id,
|
||||
depth: 1,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: Some(parent_spawn_call_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("forked spawn should flush parent rollout before loading history");
|
||||
|
||||
let child_thread = harness
|
||||
.manager
|
||||
.get_thread(child_thread_id)
|
||||
.await
|
||||
.expect("child thread should be registered");
|
||||
let history = child_thread.codex.session.clone_history().await;
|
||||
|
||||
let mut parent_call_index = None;
|
||||
let mut injected_output_index = None;
|
||||
for (idx, item) in history.raw_items().iter().enumerate() {
|
||||
match item {
|
||||
ResponseItem::FunctionCall { call_id, .. } if call_id == &parent_spawn_call_id => {
|
||||
parent_call_index = Some(idx);
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { call_id, .. }
|
||||
if call_id == &parent_spawn_call_id =>
|
||||
{
|
||||
injected_output_index = Some(idx);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let parent_call_index =
|
||||
parent_call_index.expect("forked child should include the parent spawn_agent call");
|
||||
let injected_output_index = injected_output_index
|
||||
.expect("forked child should include synthetic output for the parent spawn_agent call");
|
||||
assert!(parent_call_index < injected_output_index);
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should submit");
|
||||
let _ = parent_thread
|
||||
.submit(Op::Shutdown {})
|
||||
.await
|
||||
.expect("parent shutdown should submit");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_respects_max_threads_limit() {
|
||||
let max_threads = 1usize;
|
||||
|
||||
@@ -187,19 +187,24 @@ Rules:
|
||||
config_file: None,
|
||||
}
|
||||
),
|
||||
(
|
||||
"awaiter".to_string(),
|
||||
AgentRoleConfig {
|
||||
description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that might take some time.
|
||||
This includes, but not only:
|
||||
* testing
|
||||
* monitoring of a long running process
|
||||
* explicit ask to wait for something
|
||||
|
||||
When YOU wait for the `awaiter` agent to be done, use the largest possible timeout."#.to_string()),
|
||||
config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()),
|
||||
}
|
||||
)
|
||||
// Awaiter is temp removed
|
||||
// (
|
||||
// "awaiter".to_string(),
|
||||
// AgentRoleConfig {
|
||||
// description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that will take some very long time.
|
||||
// This includes, but not only:
|
||||
// * testing
|
||||
// * monitoring of a long running process
|
||||
// * explicit ask to wait for something
|
||||
//
|
||||
// Rules:
|
||||
// - When an awaiter is running, you can work on something else. If you need to wait for its completion, use the largest possible timeout.
|
||||
// - Be patient with the `awaiter`.
|
||||
// - Do not use an awaiter for every compilation/test if it won't take time. Only use if for long running commands.
|
||||
// - Close the awaiter when you're done with it."#.to_string()),
|
||||
// config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()),
|
||||
// }
|
||||
// )
|
||||
])
|
||||
});
|
||||
&CONFIG
|
||||
|
||||
@@ -84,19 +84,13 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) {
|
||||
shell_call_ids.insert(call_id.clone());
|
||||
}
|
||||
}
|
||||
ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
if shell_call_ids.remove(call_id)
|
||||
&& let Some(structured) = parse_structured_shell_output(output)
|
||||
{
|
||||
*output = structured
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCall { name, call_id, .. }
|
||||
if is_shell_tool_name(name) || name == "apply_patch" =>
|
||||
{
|
||||
shell_call_ids.insert(call_id.clone());
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { call_id, output } => {
|
||||
ResponseItem::FunctionCallOutput { call_id, output }
|
||||
| ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
if shell_call_ids.remove(call_id)
|
||||
&& let Some(structured) = output
|
||||
.text_content()
|
||||
@@ -240,6 +234,7 @@ mod tests {
|
||||
use codex_api::common::OpenAiVerbosity;
|
||||
use codex_api::common::TextControls;
|
||||
use codex_api::create_text_param_for_request;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
@@ -343,4 +338,62 @@ mod tests {
|
||||
let v = serde_json::to_value(&req).expect("json");
|
||||
assert!(v.get("text").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
|
||||
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
|
||||
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
|
||||
let mut items = vec![
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "shell".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
|
||||
},
|
||||
ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-2".to_string(),
|
||||
name: "apply_patch".to_string(),
|
||||
input: "*** Begin Patch".to_string(),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-2".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
|
||||
},
|
||||
];
|
||||
|
||||
reserialize_shell_outputs(&mut items);
|
||||
|
||||
assert_eq!(
|
||||
items,
|
||||
vec![
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "shell".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
|
||||
},
|
||||
ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "call-2".to_string(),
|
||||
name: "apply_patch".to_string(),
|
||||
input: "*** Begin Patch".to_string(),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-2".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+255
-149
@@ -53,6 +53,8 @@ use crate::util::error_or_panic;
|
||||
use crate::ws_version_from_features;
|
||||
use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
use codex_hooks::HookEvent;
|
||||
use codex_hooks::HookEventAfterAgent;
|
||||
use codex_hooks::HookPayload;
|
||||
@@ -94,7 +96,6 @@ use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_protocol::skill_approval::SkillApprovalResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_utils_stream_parser::AssistantTextChunk;
|
||||
@@ -252,6 +253,7 @@ use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
|
||||
use crate::tools::js_repl::JsReplHandle;
|
||||
use crate::tools::js_repl::resolve_compatible_node;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::network_approval::build_blocked_request_observer;
|
||||
use crate::tools::network_approval::build_network_policy_decider;
|
||||
@@ -342,6 +344,18 @@ impl Codex {
|
||||
config.features.disable(Feature::Collab);
|
||||
}
|
||||
|
||||
if config.features.enabled(Feature::JsRepl)
|
||||
&& let Err(err) = resolve_compatible_node(config.js_repl_node_path.as_deref()).await
|
||||
{
|
||||
let message = format!(
|
||||
"Disabled `js_repl` for this session because the configured Node runtime is unavailable or incompatible. {err}"
|
||||
);
|
||||
warn!("{message}");
|
||||
config.features.disable(Feature::JsRepl);
|
||||
config.features.disable(Feature::JsReplToolsOnly);
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
|
||||
let allowed_skills_for_implicit_invocation =
|
||||
loaded_skills.allowed_skills_for_implicit_invocation();
|
||||
let user_instructions =
|
||||
@@ -429,6 +443,7 @@ impl Codex {
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name,
|
||||
app_server_client_name: None,
|
||||
session_source,
|
||||
dynamic_tools,
|
||||
persist_extended_history,
|
||||
@@ -516,6 +531,18 @@ impl Codex {
|
||||
self.session.steer_input(input, expected_turn_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn set_app_server_client_name(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
app_server_client_name,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn agent_status(&self) -> AgentStatus {
|
||||
self.agent_status.borrow().clone()
|
||||
}
|
||||
@@ -583,6 +610,9 @@ pub(crate) struct TurnContext {
|
||||
/// the model as well as sandbox policies are resolved against this path
|
||||
/// instead of `std::env::current_dir()`.
|
||||
pub(crate) cwd: PathBuf,
|
||||
pub(crate) current_date: Option<String>,
|
||||
pub(crate) timezone: Option<String>,
|
||||
pub(crate) app_server_client_name: Option<String>,
|
||||
pub(crate) developer_instructions: Option<String>,
|
||||
pub(crate) compact_prompt: Option<String>,
|
||||
pub(crate) user_instructions: Option<String>,
|
||||
@@ -667,6 +697,9 @@ impl TurnContext {
|
||||
reasoning_summary: self.reasoning_summary,
|
||||
session_source: self.session_source.clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
current_date: self.current_date.clone(),
|
||||
timezone: self.timezone.clone(),
|
||||
app_server_client_name: self.app_server_client_name.clone(),
|
||||
developer_instructions: self.developer_instructions.clone(),
|
||||
compact_prompt: self.compact_prompt.clone(),
|
||||
user_instructions: self.user_instructions.clone(),
|
||||
@@ -707,6 +740,8 @@ impl TurnContext {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
cwd: self.cwd.clone(),
|
||||
current_date: self.current_date.clone(),
|
||||
timezone: self.timezone.clone(),
|
||||
approval_policy: self.approval_policy.value(),
|
||||
sandbox_policy: self.sandbox_policy.get().clone(),
|
||||
network: self.turn_context_network_item(),
|
||||
@@ -736,13 +771,23 @@ impl TurnContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn local_time_context() -> (String, String) {
|
||||
match iana_time_zone::get_timezone() {
|
||||
Ok(timezone) => (Local::now().format("%Y-%m-%d").to_string(), timezone),
|
||||
Err(_) => (
|
||||
Utc::now().format("%Y-%m-%d").to_string(),
|
||||
"Etc/UTC".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct SessionConfiguration {
|
||||
/// Provider identifier ("openai", "openrouter", ...).
|
||||
provider: ModelProviderInfo,
|
||||
|
||||
collaboration_mode: CollaborationMode,
|
||||
model_reasoning_summary: ReasoningSummaryConfig,
|
||||
model_reasoning_summary: Option<ReasoningSummaryConfig>,
|
||||
|
||||
/// Developer instructions that supplement the base instructions.
|
||||
developer_instructions: Option<String>,
|
||||
@@ -782,6 +827,7 @@ pub(crate) struct SessionConfiguration {
|
||||
original_config_do_not_use: Arc<Config>,
|
||||
/// Optional service name tag for session metrics.
|
||||
metrics_service_name: Option<String>,
|
||||
app_server_client_name: Option<String>,
|
||||
/// Source of the session (cli, vscode, exec, mcp, ...)
|
||||
session_source: SessionSource,
|
||||
dynamic_tools: Vec<DynamicToolSpec>,
|
||||
@@ -812,7 +858,7 @@ impl SessionConfiguration {
|
||||
next_configuration.collaboration_mode = collaboration_mode;
|
||||
}
|
||||
if let Some(summary) = updates.reasoning_summary {
|
||||
next_configuration.model_reasoning_summary = summary;
|
||||
next_configuration.model_reasoning_summary = Some(summary);
|
||||
}
|
||||
if let Some(personality) = updates.personality {
|
||||
next_configuration.personality = Some(personality);
|
||||
@@ -829,6 +875,9 @@ impl SessionConfiguration {
|
||||
if let Some(cwd) = updates.cwd.clone() {
|
||||
next_configuration.cwd = cwd;
|
||||
}
|
||||
if let Some(app_server_client_name) = updates.app_server_client_name.clone() {
|
||||
next_configuration.app_server_client_name = Some(app_server_client_name);
|
||||
}
|
||||
Ok(next_configuration)
|
||||
}
|
||||
}
|
||||
@@ -843,6 +892,7 @@ pub(crate) struct SessionSettingsUpdate {
|
||||
pub(crate) reasoning_summary: Option<ReasoningSummaryConfig>,
|
||||
pub(crate) final_output_json_schema: Option<Option<Value>>,
|
||||
pub(crate) personality: Option<Personality>,
|
||||
pub(crate) app_server_client_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -973,7 +1023,9 @@ impl Session {
|
||||
skills_outcome: Arc<SkillLoadOutcome>,
|
||||
) -> TurnContext {
|
||||
let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort();
|
||||
let reasoning_summary = session_configuration.model_reasoning_summary;
|
||||
let reasoning_summary = session_configuration
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(model_info.default_reasoning_summary);
|
||||
let otel_manager = otel_manager.clone().with_model(
|
||||
session_configuration.collaboration_mode.model(),
|
||||
model_info.slug.as_str(),
|
||||
@@ -1003,6 +1055,7 @@ impl Session {
|
||||
.features
|
||||
.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
));
|
||||
let (current_date, timezone) = local_time_context();
|
||||
TurnContext {
|
||||
sub_id,
|
||||
config: per_turn_config.clone(),
|
||||
@@ -1014,6 +1067,9 @@ impl Session {
|
||||
reasoning_summary,
|
||||
session_source,
|
||||
cwd,
|
||||
current_date: Some(current_date),
|
||||
timezone: Some(timezone),
|
||||
app_server_client_name: session_configuration.app_server_client_name.clone(),
|
||||
developer_instructions: session_configuration.developer_instructions.clone(),
|
||||
compact_prompt: session_configuration.compact_prompt.clone(),
|
||||
user_instructions: session_configuration.user_instructions.clone(),
|
||||
@@ -1112,7 +1168,7 @@ impl Session {
|
||||
//
|
||||
// - initialize RolloutRecorder with new or resumed session info
|
||||
// - perform default shell discovery
|
||||
// - load history metadata
|
||||
// - load history metadata (skipped for subagents)
|
||||
let rollout_fut = async {
|
||||
if config.ephemeral {
|
||||
Ok::<_, anyhow::Error>((None, None))
|
||||
@@ -1129,7 +1185,16 @@ impl Session {
|
||||
}
|
||||
};
|
||||
|
||||
let history_meta_fut = crate::message_history::history_metadata(&config);
|
||||
let history_meta_fut = async {
|
||||
if matches!(
|
||||
session_configuration.session_source,
|
||||
SessionSource::SubAgent(_)
|
||||
) {
|
||||
(0, 0)
|
||||
} else {
|
||||
crate::message_history::history_metadata(&config).await
|
||||
}
|
||||
};
|
||||
let auth_manager_clone = Arc::clone(&auth_manager);
|
||||
let config_for_mcp = Arc::clone(&config);
|
||||
let auth_and_mcp_fut = async move {
|
||||
@@ -1250,7 +1315,9 @@ impl Session {
|
||||
otel_manager.conversation_starts(
|
||||
config.model_provider.name.as_str(),
|
||||
session_configuration.collaboration_mode.reasoning_effort(),
|
||||
config.model_reasoning_summary,
|
||||
config
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(ReasoningSummaryConfig::Auto),
|
||||
config.model_context_window,
|
||||
config.model_auto_compact_token_limit,
|
||||
config.permissions.approval_policy.value(),
|
||||
@@ -1657,7 +1724,10 @@ impl Session {
|
||||
match conversation_history {
|
||||
InitialHistory::New => {
|
||||
// Build and record initial items (user instructions + environment context)
|
||||
let items = self.build_initial_context(&turn_context).await;
|
||||
// TODO(ccunningham): Defer initial context insertion until the first real turn
|
||||
// starts so it reflects the actual first-turn settings (permissions, etc.) and
|
||||
// we do not emit model-visible "diff" updates before the first user message.
|
||||
let items = self.build_initial_context(&turn_context, None).await;
|
||||
self.record_conversation_items(&turn_context, &items).await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
@@ -1761,7 +1831,7 @@ impl Session {
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context).await;
|
||||
let initial_context = self.build_initial_context(&turn_context, None).await;
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
{
|
||||
@@ -2590,9 +2660,13 @@ impl Session {
|
||||
|
||||
/// Emit an exec approval request event and await the user's decision.
|
||||
///
|
||||
/// The request is keyed by `call_id` + `approval_id` so matching responses are delivered
|
||||
/// to the correct in-flight turn. If the task is aborted, this returns the
|
||||
/// default `ReviewDecision` (`Denied`).
|
||||
/// The request is keyed by `call_id` + `approval_id` so matching responses
|
||||
/// are delivered to the correct in-flight turn. If the task is aborted,
|
||||
/// this returns the default `ReviewDecision` (`Denied`).
|
||||
///
|
||||
/// Note that if `available_decisions` is `None`, then the other fields will
|
||||
/// be used to derive the available decisions via
|
||||
/// [ExecApprovalRequestEvent::default_available_decisions].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn request_command_approval(
|
||||
&self,
|
||||
@@ -2605,6 +2679,7 @@ impl Session {
|
||||
network_approval_context: Option<NetworkApprovalContext>,
|
||||
proposed_execpolicy_amendment: Option<ExecPolicyAmendment>,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
available_decisions: Option<Vec<ReviewDecision>>,
|
||||
) -> ReviewDecision {
|
||||
// command-level approvals use `call_id`.
|
||||
// `approval_id` is only present for subcommand callbacks (execve intercept)
|
||||
@@ -2638,6 +2713,14 @@ impl Session {
|
||||
},
|
||||
]
|
||||
});
|
||||
let available_decisions = available_decisions.unwrap_or_else(|| {
|
||||
ExecApprovalRequestEvent::default_available_decisions(
|
||||
network_approval_context.as_ref(),
|
||||
proposed_execpolicy_amendment.as_ref(),
|
||||
proposed_network_policy_amendments.as_deref(),
|
||||
additional_permissions.as_ref(),
|
||||
)
|
||||
});
|
||||
let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
|
||||
call_id,
|
||||
approval_id,
|
||||
@@ -2649,6 +2732,7 @@ impl Session {
|
||||
proposed_execpolicy_amendment,
|
||||
proposed_network_policy_amendments,
|
||||
additional_permissions,
|
||||
available_decisions: Some(available_decisions),
|
||||
parsed_cmd,
|
||||
});
|
||||
self.send_event(turn_context, event).await;
|
||||
@@ -2723,40 +2807,6 @@ impl Session {
|
||||
rx_response.await.ok()
|
||||
}
|
||||
|
||||
pub async fn request_skill_approval(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
item_id: String,
|
||||
skill_name: String,
|
||||
) -> Option<SkillApprovalResponse> {
|
||||
let (tx_response, rx_response) = oneshot::channel();
|
||||
let prev_entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.insert_pending_skill_approval(item_id.clone(), tx_response)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
if prev_entry.is_some() {
|
||||
warn!("Overwriting existing pending skill approval for item_id: {item_id}");
|
||||
}
|
||||
|
||||
self.send_event(
|
||||
turn_context,
|
||||
EventMsg::SkillRequestApproval(
|
||||
codex_protocol::skill_approval::SkillRequestApprovalEvent {
|
||||
item_id,
|
||||
skill_name,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await;
|
||||
rx_response.await.ok()
|
||||
}
|
||||
|
||||
pub async fn notify_user_input_response(
|
||||
&self,
|
||||
sub_id: &str,
|
||||
@@ -2803,31 +2853,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn notify_skill_approval_response(
|
||||
&self,
|
||||
item_id: &str,
|
||||
response: SkillApprovalResponse,
|
||||
) {
|
||||
let entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
match active.as_mut() {
|
||||
Some(at) => {
|
||||
let mut ts = at.turn_state.lock().await;
|
||||
ts.remove_pending_skill_approval(item_id)
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
};
|
||||
match entry {
|
||||
Some(tx_response) => {
|
||||
tx_response.send(response).ok();
|
||||
}
|
||||
None => {
|
||||
warn!("No pending skill approval found for item_id: {item_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn notify_approval(&self, approval_id: &str, decision: ReviewDecision) {
|
||||
let entry = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
@@ -2895,7 +2920,7 @@ impl Session {
|
||||
} else {
|
||||
let user_messages = collect_user_messages(history.raw_items());
|
||||
let rebuilt = compact::build_compacted_history(
|
||||
self.build_initial_context(turn_context).await,
|
||||
self.build_initial_context(turn_context, None).await,
|
||||
&user_messages,
|
||||
&compacted.message,
|
||||
);
|
||||
@@ -3023,10 +3048,20 @@ impl Session {
|
||||
pub(crate) async fn build_initial_context(
|
||||
&self,
|
||||
turn_context: &TurnContext,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut items = Vec::<ResponseItem>::with_capacity(4);
|
||||
let mut developer_sections = Vec::<String>::with_capacity(8);
|
||||
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
|
||||
let shell = self.user_shell();
|
||||
items.push(
|
||||
if let Some(model_switch_message) =
|
||||
crate::context_manager::updates::build_model_instructions_update_item(
|
||||
previous_user_turn_model,
|
||||
turn_context,
|
||||
)
|
||||
{
|
||||
developer_sections.push(model_switch_message.into_text());
|
||||
}
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::from_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
turn_context.approval_policy.value(),
|
||||
@@ -3034,17 +3069,18 @@ impl Session {
|
||||
&turn_context.cwd,
|
||||
turn_context.features.enabled(Feature::RequestPermissions),
|
||||
)
|
||||
.into(),
|
||||
.into_text(),
|
||||
);
|
||||
if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() {
|
||||
items.push(DeveloperInstructions::new(developer_instructions.to_string()).into());
|
||||
developer_sections.push(developer_instructions.to_string());
|
||||
}
|
||||
// Add developer instructions for memories.
|
||||
if let Some(memory_prompt) =
|
||||
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
|
||||
&& turn_context.features.enabled(Feature::MemoryTool)
|
||||
if turn_context.features.enabled(Feature::MemoryTool)
|
||||
&& turn_context.config.memories.use_memories
|
||||
&& let Some(memory_prompt) =
|
||||
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
|
||||
{
|
||||
items.push(DeveloperInstructions::new(memory_prompt).into());
|
||||
developer_sections.push(memory_prompt);
|
||||
}
|
||||
// Add developer instructions from collaboration_mode if they exist and are non-empty
|
||||
let (collaboration_mode, base_instructions) = {
|
||||
@@ -3057,7 +3093,7 @@ impl Session {
|
||||
if let Some(collab_instructions) =
|
||||
DeveloperInstructions::from_collaboration_mode(&collaboration_mode)
|
||||
{
|
||||
items.push(collab_instructions.into());
|
||||
developer_sections.push(collab_instructions.into_text());
|
||||
}
|
||||
if self.features.enabled(Feature::Personality)
|
||||
&& let Some(personality) = turn_context.personality
|
||||
@@ -3072,34 +3108,53 @@ impl Session {
|
||||
personality,
|
||||
)
|
||||
{
|
||||
items.push(
|
||||
DeveloperInstructions::personality_spec_message(personality_message).into(),
|
||||
developer_sections.push(
|
||||
DeveloperInstructions::personality_spec_message(personality_message)
|
||||
.into_text(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if turn_context.features.enabled(Feature::Apps) {
|
||||
items.push(DeveloperInstructions::new(render_apps_section()).into());
|
||||
developer_sections.push(render_apps_section());
|
||||
}
|
||||
if turn_context.features.enabled(Feature::CodexGitCommit)
|
||||
&& let Some(commit_message_instruction) = commit_message_trailer_instruction(
|
||||
turn_context.config.commit_attribution.as_deref(),
|
||||
)
|
||||
{
|
||||
items.push(DeveloperInstructions::new(commit_message_instruction).into());
|
||||
developer_sections.push(commit_message_instruction);
|
||||
}
|
||||
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
|
||||
items.push(
|
||||
contextual_user_sections.push(
|
||||
UserInstructions {
|
||||
text: user_instructions.to_string(),
|
||||
directory: turn_context.cwd.to_string_lossy().into_owned(),
|
||||
}
|
||||
.into(),
|
||||
.serialize_to_text(),
|
||||
);
|
||||
}
|
||||
items.push(ResponseItem::from(EnvironmentContext::from_turn_context(
|
||||
turn_context,
|
||||
shell.as_ref(),
|
||||
)));
|
||||
let subagents = self
|
||||
.services
|
||||
.agent_control
|
||||
.format_environment_context_subagents(self.conversation_id)
|
||||
.await;
|
||||
contextual_user_sections.push(
|
||||
EnvironmentContext::from_turn_context(turn_context, shell.as_ref())
|
||||
.with_subagents(subagents)
|
||||
.serialize_to_xml(),
|
||||
);
|
||||
|
||||
let mut items = Vec::with_capacity(2);
|
||||
if let Some(developer_message) =
|
||||
crate::context_manager::updates::build_developer_update_item(developer_sections)
|
||||
{
|
||||
items.push(developer_message);
|
||||
}
|
||||
if let Some(contextual_user_message) =
|
||||
crate::context_manager::updates::build_contextual_user_message(contextual_user_sections)
|
||||
{
|
||||
items.push(contextual_user_message);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
@@ -3144,22 +3199,8 @@ impl Session {
|
||||
let reference_context_item = self.reference_context_item().await;
|
||||
let should_inject_full_context = reference_context_item.is_none();
|
||||
let context_items = if should_inject_full_context {
|
||||
let mut initial_context = self.build_initial_context(turn_context).await;
|
||||
// Full reinjection bypasses the settings-diff path, so add the model-switch
|
||||
// instruction explicitly when needed. Keep it before the rest of full context so
|
||||
// model-specific guidance is read first.
|
||||
if let Some(model_switch_item) =
|
||||
crate::context_manager::updates::build_model_instructions_update_item(
|
||||
previous_user_turn_model,
|
||||
turn_context,
|
||||
)
|
||||
{
|
||||
// TODO(ccunningham): When a model switch changes the effective personality
|
||||
// instructions, inject the updated personality spec alongside <model_switch>
|
||||
// here so resume/model-switch paths can avoid forcing full reinjection.
|
||||
initial_context.insert(0, model_switch_item);
|
||||
}
|
||||
initial_context
|
||||
self.build_initial_context(turn_context, previous_user_turn_model)
|
||||
.await
|
||||
} else {
|
||||
// Steady-state path: append only context diffs to minimize token overhead.
|
||||
self.build_settings_update_items(
|
||||
@@ -3549,9 +3590,9 @@ impl Session {
|
||||
use_linux_sandbox_bwrap: turn_context.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
};
|
||||
{
|
||||
let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
cancel_guard.cancel();
|
||||
*cancel_guard = CancellationToken::new();
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
*guard = CancellationToken::new();
|
||||
}
|
||||
let (refreshed_manager, cancel_token) = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
@@ -3565,11 +3606,11 @@ impl Session {
|
||||
)
|
||||
.await;
|
||||
{
|
||||
let mut cancel_guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
if cancel_guard.is_cancelled() {
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
if guard.is_cancelled() {
|
||||
cancel_token.cancel();
|
||||
}
|
||||
*cancel_guard = cancel_token;
|
||||
*guard = cancel_token;
|
||||
}
|
||||
|
||||
let mut manager = self.services.mcp_connection_manager.write().await;
|
||||
@@ -3731,9 +3772,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
|
||||
Op::DynamicToolResponse { id, response } => {
|
||||
handlers::dynamic_tool_response(&sess, id, response).await;
|
||||
}
|
||||
Op::SkillApproval { id, response } => {
|
||||
handlers::skill_approval_response(&sess, id, response).await;
|
||||
}
|
||||
Op::AddToHistory { text } => {
|
||||
handlers::add_to_history(&sess, &config, text).await;
|
||||
}
|
||||
@@ -3857,7 +3895,6 @@ mod handlers {
|
||||
use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::WarningEvent;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_protocol::skill_approval::SkillApprovalResponse;
|
||||
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
@@ -3930,9 +3967,10 @@ mod handlers {
|
||||
sandbox_policy: Some(sandbox_policy),
|
||||
windows_sandbox_level: None,
|
||||
collaboration_mode,
|
||||
reasoning_summary: Some(summary),
|
||||
reasoning_summary: summary,
|
||||
final_output_json_schema: Some(final_output_json_schema),
|
||||
personality,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -4098,14 +4136,6 @@ mod handlers {
|
||||
sess.notify_dynamic_tool_response(&id, response).await;
|
||||
}
|
||||
|
||||
pub async fn skill_approval_response(
|
||||
sess: &Arc<Session>,
|
||||
id: String,
|
||||
response: SkillApprovalResponse,
|
||||
) {
|
||||
sess.notify_skill_approval_response(&id, response).await;
|
||||
}
|
||||
|
||||
pub async fn add_to_history(sess: &Arc<Session>, config: &Arc<Config>, text: String) {
|
||||
let id = sess.conversation_id;
|
||||
let config = Arc::clone(config);
|
||||
@@ -4660,7 +4690,9 @@ async fn spawn_review_thread(
|
||||
let provider_for_context = provider.clone();
|
||||
let otel_manager_for_context = otel_manager.clone();
|
||||
let reasoning_effort = per_turn_config.model_reasoning_effort;
|
||||
let reasoning_summary = per_turn_config.model_reasoning_summary;
|
||||
let reasoning_summary = per_turn_config
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(model_info.default_reasoning_summary);
|
||||
let session_source = parent_turn_context.session_source.clone();
|
||||
|
||||
let per_turn_config = Arc::new(per_turn_config);
|
||||
@@ -4688,6 +4720,9 @@ async fn spawn_review_thread(
|
||||
tools_config,
|
||||
features: parent_turn_context.features.clone(),
|
||||
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
|
||||
current_date: parent_turn_context.current_date.clone(),
|
||||
timezone: parent_turn_context.timezone.clone(),
|
||||
app_server_client_name: parent_turn_context.app_server_client_name.clone(),
|
||||
developer_instructions: None,
|
||||
user_instructions: None,
|
||||
compact_prompt: parent_turn_context.compact_prompt.clone(),
|
||||
@@ -4819,15 +4854,6 @@ pub(crate) async fn run_turn(
|
||||
collaboration_mode_kind: turn_context.collaboration_mode.mode,
|
||||
});
|
||||
sess.send_event(&turn_context, event).await;
|
||||
if turn_context.config.features.enabled(Feature::SkillApproval) {
|
||||
let _ = sess
|
||||
.request_skill_approval(
|
||||
turn_context.as_ref(),
|
||||
turn_context.sub_id.clone(),
|
||||
"test-skill".to_string(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
// TODO(ccunningham): Pre-turn compaction runs before context updates and the
|
||||
// new user message are recorded. Estimate pending incoming items (context
|
||||
// diffs/full reinjection + user input) and trigger compaction preemptively
|
||||
@@ -5065,6 +5091,7 @@ pub(crate) async fn run_turn(
|
||||
&sess,
|
||||
&turn_context,
|
||||
InitialContextInjection::BeforeLastUserMessage,
|
||||
previous_model.as_deref(),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
@@ -5081,6 +5108,7 @@ pub(crate) async fn run_turn(
|
||||
.dispatch(HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::AfterAgent {
|
||||
event: HookEventAfterAgent {
|
||||
@@ -5188,7 +5216,13 @@ async fn run_pre_sampling_compact(
|
||||
.unwrap_or(i64::MAX);
|
||||
// Compact if the total usage tokens are greater than the auto compact limit
|
||||
if total_usage_tokens >= auto_compact_limit {
|
||||
run_auto_compact(sess, turn_context, InitialContextInjection::DoNotInject).await?;
|
||||
run_auto_compact(
|
||||
sess,
|
||||
turn_context,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -5231,6 +5265,7 @@ async fn maybe_run_previous_model_inline_compact(
|
||||
sess,
|
||||
&previous_model_turn_context,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
return Ok(true);
|
||||
@@ -5242,12 +5277,14 @@ async fn run_auto_compact(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
if should_use_remote_compact_task(&turn_context.provider) {
|
||||
run_inline_remote_auto_compact_task(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(turn_context),
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
@@ -5255,6 +5292,7 @@ async fn run_auto_compact(
|
||||
Arc::clone(sess),
|
||||
Arc::clone(turn_context),
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -5856,7 +5894,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::DynamicToolCallRequest(_)
|
||||
| EventMsg::DynamicToolCallResponse(_)
|
||||
| EventMsg::SkillRequestApproval(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::DeprecationNotice(_)
|
||||
@@ -7270,6 +7307,8 @@ mod tests {
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
@@ -7307,6 +7346,8 @@ mod tests {
|
||||
let mut previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
@@ -7493,7 +7534,7 @@ mod tests {
|
||||
session
|
||||
.record_context_updates_and_set_reference_context_item(&turn_context, None)
|
||||
.await;
|
||||
expected.extend(session.build_initial_context(&turn_context).await);
|
||||
expected.extend(session.build_initial_context(&turn_context, None).await);
|
||||
let history_after_seed = session.clone_history().await;
|
||||
assert_eq!(expected, history_after_seed.raw_items());
|
||||
|
||||
@@ -7501,7 +7542,10 @@ mod tests {
|
||||
.record_context_updates_and_set_reference_context_item(&turn_context, None)
|
||||
.await;
|
||||
let history_after_second_seed = session.clone_history().await;
|
||||
assert_eq!(expected, history_after_second_seed.raw_items());
|
||||
assert_eq!(
|
||||
history_after_seed.raw_items(),
|
||||
history_after_second_seed.raw_items()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -7655,7 +7699,7 @@ mod tests {
|
||||
let reconstruction_turn = session.new_default_turn().await;
|
||||
expected.extend(
|
||||
session
|
||||
.build_initial_context(reconstruction_turn.as_ref())
|
||||
.build_initial_context(reconstruction_turn.as_ref(), None)
|
||||
.await,
|
||||
);
|
||||
let history = session.state.lock().await.clone_history();
|
||||
@@ -7669,6 +7713,8 @@ mod tests {
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
@@ -7698,7 +7744,7 @@ mod tests {
|
||||
async fn thread_rollback_drops_last_turn_from_history() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -7769,7 +7815,7 @@ mod tests {
|
||||
async fn thread_rollback_clears_history_when_num_turns_exceeds_existing_turns() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -7797,7 +7843,7 @@ mod tests {
|
||||
async fn thread_rollback_fails_when_turn_in_progress() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -7818,7 +7864,7 @@ mod tests {
|
||||
async fn thread_rollback_fails_when_num_turns_is_zero() {
|
||||
let (sess, tc, rx) = make_session_and_context_with_rx().await;
|
||||
|
||||
let initial_context = sess.build_initial_context(tc.as_ref()).await;
|
||||
let initial_context = sess.build_initial_context(tc.as_ref(), None).await;
|
||||
sess.record_into_history(&initial_context, tc.as_ref())
|
||||
.await;
|
||||
|
||||
@@ -7872,6 +7918,7 @@ mod tests {
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -7964,6 +8011,7 @@ mod tests {
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -8275,6 +8323,7 @@ mod tests {
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -8328,6 +8377,7 @@ mod tests {
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -8409,6 +8459,7 @@ mod tests {
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
@@ -8568,6 +8619,7 @@ mod tests {
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools,
|
||||
persist_extended_history: false,
|
||||
@@ -8834,6 +8886,42 @@ mod tests {
|
||||
assert!(environment_update.contains("<denied>blocked.example.com</denied>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_settings_update_items_emits_environment_item_for_time_changes() {
|
||||
let (session, previous_context) = make_session_and_context().await;
|
||||
let previous_context = Arc::new(previous_context);
|
||||
let mut current_context = previous_context
|
||||
.with_model(
|
||||
previous_context.model_info.slug.clone(),
|
||||
&session.services.models_manager,
|
||||
)
|
||||
.await;
|
||||
current_context.current_date = Some("2026-02-27".to_string());
|
||||
current_context.timezone = Some("Europe/Berlin".to_string());
|
||||
|
||||
let reference_context_item = previous_context.to_turn_context_item();
|
||||
let update_items = session.build_settings_update_items(
|
||||
Some(&reference_context_item),
|
||||
None,
|
||||
¤t_context,
|
||||
);
|
||||
|
||||
let environment_update = update_items
|
||||
.iter()
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "user" => {
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
return None;
|
||||
};
|
||||
text.contains("<environment_context>").then_some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("environment update item should be emitted");
|
||||
assert!(environment_update.contains("<current_date>2026-02-27</current_date>"));
|
||||
assert!(environment_update.contains("<timezone>Europe/Berlin</timezone>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing()
|
||||
{
|
||||
@@ -8842,7 +8930,7 @@ mod tests {
|
||||
.record_context_updates_and_set_reference_context_item(&turn_context, None)
|
||||
.await;
|
||||
let history = session.clone_history().await;
|
||||
let initial_context = session.build_initial_context(&turn_context).await;
|
||||
let initial_context = session.build_initial_context(&turn_context, None).await;
|
||||
assert_eq!(history.raw_items().to_vec(), initial_context);
|
||||
|
||||
let current_context = session.reference_context_item().await;
|
||||
@@ -8886,10 +8974,28 @@ mod tests {
|
||||
|
||||
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(&turn_context, None).await);
|
||||
assert_eq!(history.raw_items().to_vec(), expected_history);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_initial_context_prepends_model_switch_message() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
let initial_context = session
|
||||
.build_initial_context(&turn_context, Some("previous-regular-model"))
|
||||
.await;
|
||||
|
||||
let ResponseItem::Message { role, content, .. } = &initial_context[0] else {
|
||||
panic!("expected developer message");
|
||||
};
|
||||
assert_eq!(role, "developer");
|
||||
let [ContentItem::InputText { text }, ..] = content.as_slice() else {
|
||||
panic!("expected developer text");
|
||||
};
|
||||
assert!(text.contains("<model_switch>"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_user_shell_command_does_not_set_reference_context_item() {
|
||||
let (session, _turn_context, rx) = make_session_and_context_with_rx().await;
|
||||
@@ -9211,7 +9317,7 @@ mod tests {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
text.contains(crate::session_prefix::TURN_ABORTED_OPEN_TAG)
|
||||
text.contains(crate::contextual_user_message::TURN_ABORTED_OPEN_TAG)
|
||||
})
|
||||
}),
|
||||
"expected a model-visible turn aborted marker in history after interrupt"
|
||||
@@ -9285,7 +9391,7 @@ mod tests {
|
||||
// personality_spec) matches reconstruction.
|
||||
let reconstruction_turn = session.new_default_turn().await;
|
||||
let mut initial_context = session
|
||||
.build_initial_context(reconstruction_turn.as_ref())
|
||||
.build_initial_context(reconstruction_turn.as_ref(), None)
|
||||
.await;
|
||||
// Ensure personality_spec is present when Personality is enabled, so expected matches
|
||||
// what reconstruction produces (build_initial_context may omit it when baked into model).
|
||||
|
||||
@@ -322,6 +322,7 @@ async fn handle_exec_approval(
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
additional_permissions,
|
||||
available_decisions,
|
||||
..
|
||||
} = event;
|
||||
// Race approval with cancellation and timeout to avoid hangs.
|
||||
@@ -335,6 +336,7 @@ async fn handle_exec_approval(
|
||||
network_approval_context,
|
||||
proposed_execpolicy_amendment,
|
||||
additional_permissions,
|
||||
available_decisions,
|
||||
);
|
||||
let decision = await_approval_with_cancel(
|
||||
approval_fut,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::SteerInputError;
|
||||
use crate::config::ConstraintResult;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::features::Feature;
|
||||
use crate::file_watcher::WatchRegistration;
|
||||
@@ -67,6 +68,15 @@ impl CodexThread {
|
||||
self.codex.steer_input(input, expected_turn_id).await
|
||||
}
|
||||
|
||||
pub async fn set_app_server_client_name(
|
||||
&self,
|
||||
app_server_client_name: Option<String>,
|
||||
) -> ConstraintResult<()> {
|
||||
self.codex
|
||||
.set_app_server_client_name(app_server_client_name)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Use sparingly: this is intended to be removed soon.
|
||||
pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> {
|
||||
self.codex.submit_with_id(sub).await
|
||||
|
||||
+63
-155
@@ -7,7 +7,6 @@ use crate::client_common::ResponseEvent;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::codex::get_last_assistant_message_from_turn;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::CompactedItem;
|
||||
@@ -51,47 +50,11 @@ pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bo
|
||||
provider.is_openai()
|
||||
}
|
||||
|
||||
fn is_model_switch_developer_message(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, content, .. } if role == "developer" => {
|
||||
matches!(
|
||||
content.as_slice(),
|
||||
[ContentItem::InputText { text }] if text.starts_with("<model_switch>\n")
|
||||
)
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn extract_trailing_model_switch_update_for_compaction_request(
|
||||
history: &mut ContextManager,
|
||||
) -> Option<ResponseItem> {
|
||||
let history_items = history.raw_items();
|
||||
let last_user_turn_boundary_index = history_items
|
||||
.iter()
|
||||
.rposition(crate::context_manager::is_user_turn_boundary);
|
||||
let model_switch_index = history_items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.rev()
|
||||
.find_map(|(i, item)| {
|
||||
let is_trailing = last_user_turn_boundary_index.is_none_or(|boundary| i > boundary);
|
||||
if is_trailing && is_model_switch_developer_message(item) {
|
||||
Some(i)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})?;
|
||||
let mut replacement = history_items.to_vec();
|
||||
let model_switch_item = replacement.remove(model_switch_index);
|
||||
history.replace(replacement);
|
||||
Some(model_switch_item)
|
||||
}
|
||||
|
||||
pub(crate) async fn run_inline_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
let prompt = turn_context.compact_prompt().to_string();
|
||||
let input = vec![UserInput::Text {
|
||||
@@ -100,7 +63,14 @@ pub(crate) async fn run_inline_auto_compact_task(
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
|
||||
run_compact_task_inner(sess, turn_context, input, initial_context_injection).await?;
|
||||
run_compact_task_inner(
|
||||
sess,
|
||||
turn_context,
|
||||
input,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -120,6 +90,7 @@ pub(crate) async fn run_compact_task(
|
||||
turn_context,
|
||||
input,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -129,6 +100,7 @@ async fn run_compact_task_inner(
|
||||
turn_context: Arc<TurnContext>,
|
||||
input: Vec<UserInput>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
|
||||
sess.emit_turn_item_started(&turn_context, &compaction_item)
|
||||
@@ -136,10 +108,6 @@ async fn run_compact_task_inner(
|
||||
let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input);
|
||||
|
||||
let mut history = sess.clone_history().await;
|
||||
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
|
||||
// tail of history (between turns), exclude it from the compaction request payload.
|
||||
let stripped_model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
history.record_items(
|
||||
&[initial_input_for_turn.into()],
|
||||
turn_context.truncation_policy,
|
||||
@@ -240,15 +208,12 @@ async fn run_compact_task_inner(
|
||||
initial_context_injection,
|
||||
InitialContextInjection::BeforeLastUserMessage
|
||||
) {
|
||||
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
|
||||
let initial_context = sess
|
||||
.build_initial_context(turn_context.as_ref(), previous_user_turn_model)
|
||||
.await;
|
||||
new_history =
|
||||
insert_initial_context_before_last_real_user_or_summary(new_history, initial_context);
|
||||
}
|
||||
// Reattach the stripped model-switch update only after successful compaction so the model
|
||||
// still sees the switch instructions on the next real sampling request.
|
||||
if let Some(model_switch_item) = stripped_model_switch_item {
|
||||
new_history.push(model_switch_item);
|
||||
}
|
||||
let ghost_snapshots: Vec<ResponseItem> = history_items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. }))
|
||||
@@ -491,14 +456,18 @@ mod tests {
|
||||
|
||||
async fn process_compacted_history_with_test_session(
|
||||
compacted_history: Vec<ResponseItem>,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> (Vec<ResponseItem>, Vec<ResponseItem>) {
|
||||
let (session, turn_context) = crate::codex::make_session_and_context().await;
|
||||
let initial_context = session.build_initial_context(&turn_context).await;
|
||||
let initial_context = session
|
||||
.build_initial_context(&turn_context, previous_user_turn_model)
|
||||
.await;
|
||||
let refreshed = crate::compact_remote::process_compacted_history(
|
||||
&session,
|
||||
&turn_context,
|
||||
compacted_history,
|
||||
InitialContextInjection::BeforeLastUserMessage,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await;
|
||||
(refreshed, initial_context)
|
||||
@@ -534,107 +503,6 @@ mod tests {
|
||||
assert_eq!(None, joined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_trailing_model_switch_update_for_compaction_request_removes_trailing_item() {
|
||||
let mut history = ContextManager::new();
|
||||
history.replace(vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "USER_MESSAGE".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "ASSISTANT_REPLY".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<model_switch>\nNEW_MODEL_INSTRUCTIONS".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
|
||||
assert_eq!(history.raw_items().len(), 2);
|
||||
assert!(model_switch_item.is_some());
|
||||
assert!(
|
||||
history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.all(|item| !is_model_switch_developer_message(item))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_trailing_model_switch_update_for_compaction_request_keeps_historical_item() {
|
||||
let mut history = ContextManager::new();
|
||||
history.replace(vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "FIRST_USER_MESSAGE".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "developer".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<model_switch>\nOLDER_MODEL_INSTRUCTIONS".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentItem::OutputText {
|
||||
text: "ASSISTANT_REPLY".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "SECOND_USER_MESSAGE".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
|
||||
assert_eq!(history.raw_items().len(), 4);
|
||||
assert!(model_switch_item.is_none());
|
||||
assert!(
|
||||
history
|
||||
.raw_items()
|
||||
.iter()
|
||||
.any(is_model_switch_developer_message)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_user_messages_extracts_user_text_only() {
|
||||
let items = vec![
|
||||
@@ -802,7 +670,7 @@ do things
|
||||
},
|
||||
];
|
||||
let (refreshed, mut expected) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
@@ -827,7 +695,7 @@ do things
|
||||
phase: None,
|
||||
}];
|
||||
let (refreshed, mut expected) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
@@ -903,7 +771,7 @@ keep me updated
|
||||
},
|
||||
];
|
||||
let (refreshed, mut expected) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
@@ -949,7 +817,7 @@ keep me updated
|
||||
];
|
||||
|
||||
let (refreshed, initial_context) =
|
||||
process_compacted_history_with_test_session(compacted_history).await;
|
||||
process_compacted_history_with_test_session(compacted_history, None).await;
|
||||
let mut expected = vec![
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
@@ -983,6 +851,46 @@ keep me updated
|
||||
assert_eq!(refreshed, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_compacted_history_reinjects_model_switch_message() {
|
||||
let compacted_history = vec![ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "summary".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}];
|
||||
|
||||
let (refreshed, initial_context) = process_compacted_history_with_test_session(
|
||||
compacted_history,
|
||||
Some("previous-regular-model"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let ResponseItem::Message { role, content, .. } = &initial_context[0] else {
|
||||
panic!("expected developer message");
|
||||
};
|
||||
assert_eq!(role, "developer");
|
||||
let [ContentItem::InputText { text }, ..] = content.as_slice() else {
|
||||
panic!("expected developer text");
|
||||
};
|
||||
assert!(text.contains("<model_switch>"));
|
||||
|
||||
let mut expected = initial_context;
|
||||
expected.push(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "summary".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
});
|
||||
assert_eq!(refreshed, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() {
|
||||
let compacted_history = vec![
|
||||
|
||||
@@ -4,7 +4,6 @@ use crate::Prompt;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::compact::InitialContextInjection;
|
||||
use crate::compact::extract_trailing_model_switch_update_for_compaction_request;
|
||||
use crate::compact::insert_initial_context_before_last_real_user_or_summary;
|
||||
use crate::context_manager::ContextManager;
|
||||
use crate::context_manager::TotalTokenUsageBreakdown;
|
||||
@@ -28,8 +27,15 @@ pub(crate) async fn run_inline_remote_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
run_remote_compact_task_inner(&sess, &turn_context, initial_context_injection).await?;
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&turn_context,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -44,16 +50,28 @@ pub(crate) async fn run_remote_compact_task(
|
||||
});
|
||||
sess.send_event(&turn_context, start_event).await;
|
||||
|
||||
run_remote_compact_task_inner(&sess, &turn_context, InitialContextInjection::DoNotInject).await
|
||||
run_remote_compact_task_inner(
|
||||
&sess,
|
||||
&turn_context,
|
||||
InitialContextInjection::DoNotInject,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn run_remote_compact_task_inner(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
if let Err(err) =
|
||||
run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await
|
||||
if let Err(err) = run_remote_compact_task_inner_impl(
|
||||
sess,
|
||||
turn_context,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let event = EventMsg::Error(
|
||||
err.to_error_event(Some("Error running remote compact task".to_string())),
|
||||
@@ -68,15 +86,12 @@ async fn run_remote_compact_task_inner_impl(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> CodexResult<()> {
|
||||
let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new());
|
||||
sess.emit_turn_item_started(turn_context, &compaction_item)
|
||||
.await;
|
||||
let mut history = sess.clone_history().await;
|
||||
// Keep compaction prompts in-distribution: if a model-switch update was injected at the
|
||||
// tail of history (between turns), exclude it from the compaction request payload.
|
||||
let stripped_model_switch_item =
|
||||
extract_trailing_model_switch_update_for_compaction_request(&mut history);
|
||||
let base_instructions = sess.get_base_instructions().await;
|
||||
let deleted_items = trim_function_call_history_to_fit_context_window(
|
||||
&mut history,
|
||||
@@ -90,7 +105,6 @@ async fn run_remote_compact_task_inner_impl(
|
||||
"trimmed history items before remote compaction"
|
||||
);
|
||||
}
|
||||
|
||||
// Required to keep `/undo` available after compaction
|
||||
let ghost_snapshots: Vec<ResponseItem> = history
|
||||
.raw_items()
|
||||
@@ -134,13 +148,9 @@ async fn run_remote_compact_task_inner_impl(
|
||||
turn_context.as_ref(),
|
||||
new_history,
|
||||
initial_context_injection,
|
||||
previous_user_turn_model,
|
||||
)
|
||||
.await;
|
||||
// Reattach the stripped model-switch update only after successful compaction so the model
|
||||
// still sees the switch instructions on the next real sampling request.
|
||||
if let Some(model_switch_item) = stripped_model_switch_item {
|
||||
new_history.push(model_switch_item);
|
||||
}
|
||||
|
||||
if !ghost_snapshots.is_empty() {
|
||||
new_history.extend(ghost_snapshots);
|
||||
@@ -170,6 +180,7 @@ pub(crate) async fn process_compacted_history(
|
||||
turn_context: &TurnContext,
|
||||
mut compacted_history: Vec<ResponseItem>,
|
||||
initial_context_injection: InitialContextInjection,
|
||||
previous_user_turn_model: Option<&str>,
|
||||
) -> Vec<ResponseItem> {
|
||||
// Mid-turn compaction is the only path that must inject initial context above the last user
|
||||
// message in the replacement history. Pre-turn compaction instead injects context after the
|
||||
@@ -178,7 +189,8 @@ pub(crate) async fn process_compacted_history(
|
||||
initial_context_injection,
|
||||
InitialContextInjection::BeforeLastUserMessage
|
||||
) {
|
||||
sess.build_initial_context(turn_context).await
|
||||
sess.build_initial_context(turn_context, previous_user_turn_model)
|
||||
.await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -205,10 +217,12 @@ pub(crate) async fn process_compacted_history(
|
||||
fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, .. } if role == "developer" => false,
|
||||
ResponseItem::Message { role, .. } if role == "user" => matches!(
|
||||
crate::event_mapping::parse_turn_item(item),
|
||||
Some(TurnItem::UserMessage(_))
|
||||
),
|
||||
ResponseItem::Message { role, .. } if role == "user" => {
|
||||
matches!(
|
||||
crate::event_mapping::parse_turn_item(item),
|
||||
Some(TurnItem::UserMessage(_))
|
||||
)
|
||||
}
|
||||
ResponseItem::Message { role, .. } if role == "assistant" => true,
|
||||
ResponseItem::Message { .. } => false,
|
||||
ResponseItem::Compaction { .. } => true,
|
||||
|
||||
@@ -195,6 +195,11 @@ mod document_helpers {
|
||||
{
|
||||
entry["scopes"] = array_from_iter(scopes.iter().cloned());
|
||||
}
|
||||
if let Some(resource) = &config.oauth_resource
|
||||
&& !resource.is_empty()
|
||||
{
|
||||
entry["oauth_resource"] = value(resource.clone());
|
||||
}
|
||||
|
||||
entry
|
||||
}
|
||||
@@ -839,6 +844,30 @@ impl ConfigEditsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_realtime_microphone(mut self, microphone: Option<&str>) -> Self {
|
||||
let segments = vec!["audio".to_string(), "microphone".to_string()];
|
||||
match microphone {
|
||||
Some(microphone) => self.edits.push(ConfigEdit::SetPath {
|
||||
segments,
|
||||
value: value(microphone),
|
||||
}),
|
||||
None => self.edits.push(ConfigEdit::ClearPath { segments }),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_realtime_speaker(mut self, speaker: Option<&str>) -> Self {
|
||||
let segments = vec!["audio".to_string(), "speaker".to_string()];
|
||||
match speaker {
|
||||
Some(speaker) => self.edits.push(ConfigEdit::SetPath {
|
||||
segments,
|
||||
value: value(speaker),
|
||||
}),
|
||||
None => self.edits.push(ConfigEdit::ClearPath { segments }),
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn clear_legacy_windows_sandbox_keys(mut self) -> Self {
|
||||
for key in [
|
||||
"experimental_windows_sandbox",
|
||||
@@ -1441,6 +1470,7 @@ gpt-5 = "gpt-5.1"
|
||||
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1465,6 +1495,7 @@ gpt-5 = "gpt-5.1"
|
||||
enabled_tools: None,
|
||||
disabled_tools: Some(vec!["forbidden".to_string()]),
|
||||
scopes: None,
|
||||
oauth_resource: Some("https://resource.example.com".to_string()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1483,6 +1514,7 @@ bearer_token_env_var = \"TOKEN\"
|
||||
enabled = false
|
||||
startup_timeout_sec = 5.0
|
||||
disabled_tools = [\"forbidden\"]
|
||||
oauth_resource = \"https://resource.example.com\"
|
||||
|
||||
[mcp_servers.http.http_headers]
|
||||
Z-Header = \"z\"
|
||||
@@ -1532,6 +1564,7 @@ foo = { command = "cmd" }
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1578,6 +1611,7 @@ foo = { command = "cmd" } # keep me
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1623,6 +1657,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1669,6 +1704,7 @@ foo = { command = "cmd" }
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1804,6 +1840,50 @@ model_reasoning_effort = "high"
|
||||
assert_eq!(notice, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_builder_set_realtime_audio_persists_and_clears() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.set_realtime_microphone(Some("USB Mic"))
|
||||
.set_realtime_speaker(Some("Desk Speakers"))
|
||||
.apply_blocking()
|
||||
.expect("persist realtime audio");
|
||||
|
||||
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let config: TomlValue = toml::from_str(&raw).expect("parse config");
|
||||
let realtime_audio = config
|
||||
.get("audio")
|
||||
.and_then(TomlValue::as_table)
|
||||
.expect("audio table should exist");
|
||||
assert_eq!(
|
||||
realtime_audio.get("microphone").and_then(TomlValue::as_str),
|
||||
Some("USB Mic")
|
||||
);
|
||||
assert_eq!(
|
||||
realtime_audio.get("speaker").and_then(TomlValue::as_str),
|
||||
Some("Desk Speakers")
|
||||
);
|
||||
|
||||
ConfigEditsBuilder::new(codex_home)
|
||||
.set_realtime_microphone(None)
|
||||
.apply_blocking()
|
||||
.expect("clear realtime microphone");
|
||||
|
||||
let raw = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let config: TomlValue = toml::from_str(&raw).expect("parse config");
|
||||
let realtime_audio = config
|
||||
.get("audio")
|
||||
.and_then(TomlValue::as_table)
|
||||
.expect("audio table should exist");
|
||||
assert_eq!(realtime_audio.get("microphone"), None);
|
||||
assert_eq!(
|
||||
realtime_audio.get("speaker").and_then(TomlValue::as_str),
|
||||
Some("Desk Speakers")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_mcp_servers_blocking_clears_table_when_empty() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
|
||||
+141
-13
@@ -49,8 +49,6 @@ use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS;
|
||||
use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
|
||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
@@ -66,6 +64,7 @@ use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
@@ -82,8 +81,6 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
#[cfg(test)]
|
||||
use tempfile::tempdir;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
type MacOsSeatbeltProfileExtensions = ();
|
||||
|
||||
use crate::config::permissions::network_proxy_config_from_permissions;
|
||||
use crate::config::profile::ConfigProfile;
|
||||
@@ -412,9 +409,9 @@ pub struct Config {
|
||||
/// global default").
|
||||
pub plan_mode_reasoning_effort: Option<ReasoningEffort>,
|
||||
|
||||
/// If not "none", the value to use for `reasoning.summary` when making a
|
||||
/// request using the Responses API.
|
||||
pub model_reasoning_summary: ReasoningSummary,
|
||||
/// Optional value to use for `reasoning.summary` when making a request
|
||||
/// using the Responses API. When unset, the model catalog default is used.
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
|
||||
/// Optional override to force-enable reasoning summaries for the configured model.
|
||||
pub model_supports_reasoning_summaries: Option<bool>,
|
||||
@@ -429,6 +426,9 @@ pub struct Config {
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: String,
|
||||
|
||||
/// Machine-local realtime audio device preferences used by realtime voice.
|
||||
pub realtime_audio: RealtimeAudioConfig,
|
||||
|
||||
/// Experimental / do not use. Overrides only the realtime conversation
|
||||
/// websocket transport base URL (the `Op::RealtimeConversation` `/ws`
|
||||
/// connection) without changing normal provider HTTP requests.
|
||||
@@ -1178,6 +1178,10 @@ pub struct ConfigToml {
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
|
||||
/// Machine-local realtime audio device preferences used by realtime voice.
|
||||
#[serde(default)]
|
||||
pub audio: Option<RealtimeAudioToml>,
|
||||
|
||||
/// Experimental / do not use. Overrides only the realtime conversation
|
||||
/// websocket transport base URL (the `Op::RealtimeConversation` `/ws`
|
||||
/// connection) without changing normal provider HTTP requests.
|
||||
@@ -1309,6 +1313,19 @@ impl ProjectConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RealtimeAudioConfig {
|
||||
pub microphone: Option<String>,
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct RealtimeAudioToml {
|
||||
pub microphone: Option<String>,
|
||||
pub speaker: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ToolsToml {
|
||||
@@ -2141,8 +2158,7 @@ impl Config {
|
||||
.or(cfg.plan_mode_reasoning_effort),
|
||||
model_reasoning_summary: config_profile
|
||||
.model_reasoning_summary
|
||||
.or(cfg.model_reasoning_summary)
|
||||
.unwrap_or_default(),
|
||||
.or(cfg.model_reasoning_summary),
|
||||
model_supports_reasoning_summaries: cfg.model_supports_reasoning_summaries,
|
||||
model_catalog,
|
||||
model_verbosity: config_profile.model_verbosity.or(cfg.model_verbosity),
|
||||
@@ -2150,6 +2166,12 @@ impl Config {
|
||||
.chatgpt_base_url
|
||||
.or(cfg.chatgpt_base_url)
|
||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||
realtime_audio: cfg
|
||||
.audio
|
||||
.map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig {
|
||||
microphone: audio.microphone,
|
||||
speaker: audio.speaker,
|
||||
}),
|
||||
experimental_realtime_ws_base_url: cfg.experimental_realtime_ws_base_url,
|
||||
experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt,
|
||||
forced_chatgpt_workspace_id,
|
||||
@@ -2411,6 +2433,7 @@ mod tests {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2430,6 +2453,7 @@ mod tests {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2466,7 +2490,10 @@ persistence = "none"
|
||||
|
||||
let memories = r#"
|
||||
[memories]
|
||||
generate_memories = false
|
||||
use_memories = false
|
||||
max_raw_memories_for_global = 512
|
||||
max_unused_days = 21
|
||||
max_rollout_age_days = 42
|
||||
max_rollouts_per_startup = 9
|
||||
min_rollout_idle_hours = 24
|
||||
@@ -2477,7 +2504,10 @@ phase_2_model = "gpt-5"
|
||||
toml::from_str::<ConfigToml>(memories).expect("TOML deserialization should succeed");
|
||||
assert_eq!(
|
||||
Some(MemoriesToml {
|
||||
generate_memories: Some(false),
|
||||
use_memories: Some(false),
|
||||
max_raw_memories_for_global: Some(512),
|
||||
max_unused_days: Some(21),
|
||||
max_rollout_age_days: Some(42),
|
||||
max_rollouts_per_startup: Some(9),
|
||||
min_rollout_idle_hours: Some(24),
|
||||
@@ -2496,7 +2526,10 @@ phase_2_model = "gpt-5"
|
||||
assert_eq!(
|
||||
config.memories,
|
||||
MemoriesConfig {
|
||||
generate_memories: false,
|
||||
use_memories: false,
|
||||
max_raw_memories_for_global: 512,
|
||||
max_unused_days: 21,
|
||||
max_rollout_age_days: 42,
|
||||
max_rollouts_per_startup: 9,
|
||||
min_rollout_idle_hours: 24,
|
||||
@@ -3464,6 +3497,7 @@ profile = "project"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3621,6 +3655,7 @@ bearer_token = "secret"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -3692,6 +3727,7 @@ ZIG_VAR = "3"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -3743,6 +3779,7 @@ ZIG_VAR = "3"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -3792,6 +3829,7 @@ ZIG_VAR = "3"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -3857,6 +3895,7 @@ startup_timeout_sec = 2.0
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
apply_blocking(
|
||||
@@ -3934,6 +3973,7 @@ X-Auth = "DOCS_AUTH"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -3964,6 +4004,7 @@ X-Auth = "DOCS_AUTH"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
apply_blocking(
|
||||
@@ -4032,6 +4073,7 @@ url = "https://example.com/mcp"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
),
|
||||
(
|
||||
@@ -4052,6 +4094,7 @@ url = "https://example.com/mcp"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
),
|
||||
]);
|
||||
@@ -4135,6 +4178,7 @@ url = "https://example.com/mcp"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -4180,6 +4224,7 @@ url = "https://example.com/mcp"
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -4225,6 +4270,7 @@ url = "https://example.com/mcp"
|
||||
enabled_tools: Some(vec!["allowed".to_string()]),
|
||||
disabled_tools: Some(vec!["blocked".to_string()]),
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -4253,6 +4299,51 @@ url = "https://example.com/mcp"
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let servers = BTreeMap::from([(
|
||||
"docs".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::StreamableHttp {
|
||||
url: "https://example.com/mcp".to_string(),
|
||||
bearer_token_env_var: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
enabled: true,
|
||||
required: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: Some("https://resource.example.com".to_string()),
|
||||
},
|
||||
)]);
|
||||
|
||||
apply_blocking(
|
||||
codex_home.path(),
|
||||
None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
|
||||
)?;
|
||||
|
||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let serialized = std::fs::read_to_string(&config_path)?;
|
||||
assert!(serialized.contains(r#"oauth_resource = "https://resource.example.com""#));
|
||||
|
||||
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||
let docs = loaded.get("docs").expect("docs entry");
|
||||
assert_eq!(
|
||||
docs.oauth_resource.as_deref(),
|
||||
Some("https://resource.example.com")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_model_updates_defaults() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -4761,12 +4852,13 @@ model_verbosity = "high"
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
plan_mode_reasoning_effort: None,
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_catalog: None,
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_ws_base_url: None,
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
base_instructions: None,
|
||||
@@ -4887,12 +4979,13 @@ model_verbosity = "high"
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
plan_mode_reasoning_effort: None,
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_reasoning_summary: None,
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_catalog: None,
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_ws_base_url: None,
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
base_instructions: None,
|
||||
@@ -5011,12 +5104,13 @@ model_verbosity = "high"
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: None,
|
||||
plan_mode_reasoning_effort: None,
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_reasoning_summary: None,
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_catalog: None,
|
||||
model_verbosity: None,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_ws_base_url: None,
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
base_instructions: None,
|
||||
@@ -5121,12 +5215,13 @@ model_verbosity = "high"
|
||||
show_raw_agent_reasoning: false,
|
||||
model_reasoning_effort: Some(ReasoningEffort::High),
|
||||
plan_mode_reasoning_effort: None,
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
model_supports_reasoning_summaries: None,
|
||||
model_catalog: None,
|
||||
model_verbosity: Some(Verbosity::High),
|
||||
personality: Some(Personality::Pragmatic),
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
realtime_audio: RealtimeAudioConfig::default(),
|
||||
experimental_realtime_ws_base_url: None,
|
||||
experimental_realtime_ws_backend_prompt: None,
|
||||
base_instructions: None,
|
||||
@@ -5971,6 +6066,39 @@ experimental_realtime_ws_backend_prompt = "prompt from config"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realtime_audio_loads_from_config_toml() -> std::io::Result<()> {
|
||||
let cfg: ConfigToml = toml::from_str(
|
||||
r#"
|
||||
[audio]
|
||||
microphone = "USB Mic"
|
||||
speaker = "Desk Speakers"
|
||||
"#,
|
||||
)
|
||||
.expect("TOML deserialization should succeed");
|
||||
|
||||
let realtime_audio = cfg
|
||||
.audio
|
||||
.as_ref()
|
||||
.expect("realtime audio config should be present");
|
||||
assert_eq!(realtime_audio.microphone.as_deref(), Some("USB Mic"));
|
||||
assert_eq!(realtime_audio.speaker.as_deref(), Some("Desk Speakers"));
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
cfg,
|
||||
ConfigOverrides::default(),
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(config.realtime_audio.microphone.as_deref(), Some("USB Mic"));
|
||||
assert_eq!(
|
||||
config.realtime_audio.speaker.as_deref(),
|
||||
Some("Desk Speakers")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -27,6 +27,7 @@ pub const DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP: usize = 16;
|
||||
pub const DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS: i64 = 30;
|
||||
pub const DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS: i64 = 6;
|
||||
pub const DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL: usize = 1_024;
|
||||
pub const DEFAULT_MEMORIES_MAX_UNUSED_DAYS: i64 = 30;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
@@ -98,6 +99,10 @@ pub struct McpServerConfig {
|
||||
/// Optional OAuth scopes to request during MCP login.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
|
||||
/// Optional OAuth resource parameter to include during MCP login (RFC 8707).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub oauth_resource: Option<String>,
|
||||
}
|
||||
|
||||
// Raw MCP config shape used for deserialization and JSON Schema generation.
|
||||
@@ -142,6 +147,8 @@ pub(crate) struct RawMcpServerConfig {
|
||||
pub disabled_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub scopes: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub oauth_resource: Option<String>,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
@@ -165,6 +172,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
let enabled_tools = raw.enabled_tools.clone();
|
||||
let disabled_tools = raw.disabled_tools.clone();
|
||||
let scopes = raw.scopes.clone();
|
||||
let oauth_resource = raw.oauth_resource.clone();
|
||||
|
||||
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
|
||||
where
|
||||
@@ -188,6 +196,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?;
|
||||
throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?;
|
||||
throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?;
|
||||
throw_if_set("stdio", "oauth_resource", raw.oauth_resource.as_ref())?;
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args: raw.args.clone().unwrap_or_default(),
|
||||
@@ -221,6 +230,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
|
||||
enabled_tools,
|
||||
disabled_tools,
|
||||
scopes,
|
||||
oauth_resource,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -361,8 +371,14 @@ pub struct FeedbackConfigToml {
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct MemoriesToml {
|
||||
/// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB.
|
||||
pub generate_memories: Option<bool>,
|
||||
/// When `false`, skip injecting memory usage instructions into developer prompts.
|
||||
pub use_memories: Option<bool>,
|
||||
/// Maximum number of recent raw memories retained for global consolidation.
|
||||
pub max_raw_memories_for_global: Option<usize>,
|
||||
/// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.
|
||||
pub max_unused_days: Option<i64>,
|
||||
/// Maximum age of the threads used for memories.
|
||||
pub max_rollout_age_days: Option<i64>,
|
||||
/// Maximum number of rollout candidates processed per pass.
|
||||
@@ -378,7 +394,10 @@ pub struct MemoriesToml {
|
||||
/// Effective memories settings after defaults are applied.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemoriesConfig {
|
||||
pub generate_memories: bool,
|
||||
pub use_memories: bool,
|
||||
pub max_raw_memories_for_global: usize,
|
||||
pub max_unused_days: i64,
|
||||
pub max_rollout_age_days: i64,
|
||||
pub max_rollouts_per_startup: usize,
|
||||
pub min_rollout_idle_hours: i64,
|
||||
@@ -389,7 +408,10 @@ pub struct MemoriesConfig {
|
||||
impl Default for MemoriesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
generate_memories: true,
|
||||
use_memories: true,
|
||||
max_raw_memories_for_global: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
|
||||
max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS,
|
||||
max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS,
|
||||
max_rollouts_per_startup: DEFAULT_MEMORIES_MAX_ROLLOUTS_PER_STARTUP,
|
||||
min_rollout_idle_hours: DEFAULT_MEMORIES_MIN_ROLLOUT_IDLE_HOURS,
|
||||
@@ -403,10 +425,16 @@ impl From<MemoriesToml> for MemoriesConfig {
|
||||
fn from(toml: MemoriesToml) -> Self {
|
||||
let defaults = Self::default();
|
||||
Self {
|
||||
generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories),
|
||||
use_memories: toml.use_memories.unwrap_or(defaults.use_memories),
|
||||
max_raw_memories_for_global: toml
|
||||
.max_raw_memories_for_global
|
||||
.unwrap_or(defaults.max_raw_memories_for_global)
|
||||
.min(4096),
|
||||
max_unused_days: toml
|
||||
.max_unused_days
|
||||
.unwrap_or(defaults.max_unused_days)
|
||||
.clamp(0, 365),
|
||||
max_rollout_age_days: toml
|
||||
.max_rollout_age_days
|
||||
.unwrap_or(defaults.max_rollout_age_days)
|
||||
@@ -1084,6 +1112,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_streamable_http_server_config_with_oauth_resource() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
url = "https://example.com/mcp"
|
||||
oauth_resource = "https://api.example.com"
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize http config with oauth_resource");
|
||||
|
||||
assert_eq!(
|
||||
cfg.oauth_resource,
|
||||
Some("https://api.example.com".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_server_config_with_tool_filters() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
@@ -1138,6 +1182,20 @@ mod tests {
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject env_http_headers for stdio transport");
|
||||
|
||||
let err = toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
command = "echo"
|
||||
oauth_resource = "https://api.example.com"
|
||||
"#,
|
||||
)
|
||||
.expect_err("should reject oauth_resource for stdio transport");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("oauth_resource is not supported for stdio"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::context_manager::normalize;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::session_prefix::is_session_prefix;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count_i64;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
use crate::truncate::truncate_text;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
@@ -347,32 +344,21 @@ impl ContextManager {
|
||||
let policy_with_serialization_budget = policy * 1.2;
|
||||
match item {
|
||||
ResponseItem::FunctionCallOutput { call_id, output } => {
|
||||
let body = match &output.body {
|
||||
FunctionCallOutputBody::Text(content) => FunctionCallOutputBody::Text(
|
||||
truncate_text(content, policy_with_serialization_budget),
|
||||
),
|
||||
FunctionCallOutputBody::ContentItems(items) => {
|
||||
FunctionCallOutputBody::ContentItems(
|
||||
truncate_function_output_items_with_policy(
|
||||
items,
|
||||
policy_with_serialization_budget,
|
||||
),
|
||||
)
|
||||
}
|
||||
};
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body,
|
||||
success: output.success,
|
||||
},
|
||||
output: truncate_function_output_payload(
|
||||
output,
|
||||
policy_with_serialization_budget,
|
||||
),
|
||||
}
|
||||
}
|
||||
ResponseItem::CustomToolCallOutput { call_id, output } => {
|
||||
let truncated = truncate_text(output, policy_with_serialization_budget);
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: truncated,
|
||||
output: truncate_function_output_payload(
|
||||
output,
|
||||
policy_with_serialization_budget,
|
||||
),
|
||||
}
|
||||
}
|
||||
ResponseItem::Message { .. }
|
||||
@@ -388,6 +374,25 @@ impl ContextManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_function_output_payload(
|
||||
output: &FunctionCallOutputPayload,
|
||||
policy: TruncationPolicy,
|
||||
) -> FunctionCallOutputPayload {
|
||||
let body = match &output.body {
|
||||
FunctionCallOutputBody::Text(content) => {
|
||||
FunctionCallOutputBody::Text(truncate_text(content, policy))
|
||||
}
|
||||
FunctionCallOutputBody::ContentItems(items) => FunctionCallOutputBody::ContentItems(
|
||||
truncate_function_output_items_with_policy(items, policy),
|
||||
),
|
||||
};
|
||||
|
||||
FunctionCallOutputPayload {
|
||||
body,
|
||||
success: output.success,
|
||||
}
|
||||
}
|
||||
|
||||
/// API messages include every non-system item (user/assistant messages, reasoning,
|
||||
/// tool calls, tool outputs, shell calls, and web-search calls).
|
||||
fn is_api_message(message: &ResponseItem) -> bool {
|
||||
@@ -511,7 +516,8 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
|
||||
}
|
||||
}
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. } => {
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let FunctionCallOutputBody::ContentItems(items) = &output.body {
|
||||
for content_item in items {
|
||||
if let FunctionCallOutputContentItem::InputImage { image_url } = content_item {
|
||||
@@ -554,33 +560,7 @@ pub(crate) fn is_user_turn_boundary(item: &ResponseItem) -> bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
if role != "user" {
|
||||
return false;
|
||||
}
|
||||
|
||||
if UserInstructions::is_user_instructions(content)
|
||||
|| SkillInstructions::is_skill_instructions(content)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for content_item in content {
|
||||
match content_item {
|
||||
ContentItem::InputText { text } => {
|
||||
if is_session_prefix(text) || is_user_shell_command_text(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ContentItem::OutputText { text } => {
|
||||
if is_session_prefix(text) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
ContentItem::InputImage { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
role == "user" && !is_contextual_user_message_content(content)
|
||||
}
|
||||
|
||||
fn user_message_positions(items: &[ResponseItem]) -> Vec<usize> {
|
||||
|
||||
@@ -67,7 +67,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
|
||||
fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem {
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: output.to_string(),
|
||||
output: FunctionCallOutputPayload::from_text(output.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -279,6 +279,24 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "tool-1".to_string(),
|
||||
name: "js_repl".to_string(),
|
||||
input: "view_image".to_string(),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "js repl result".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/js-repl-result.png".to_string(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
];
|
||||
let history = create_history_with_items(items);
|
||||
let text_only_modalities = vec![InputModality::Text];
|
||||
@@ -321,6 +339,25 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
},
|
||||
]),
|
||||
},
|
||||
ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "tool-1".to_string(),
|
||||
name: "js_repl".to_string(),
|
||||
input: "view_image".to_string(),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "js repl result".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "image content omitted because you do not support image input"
|
||||
.to_string(),
|
||||
},
|
||||
]),
|
||||
},
|
||||
];
|
||||
assert_eq!(stripped, expected);
|
||||
|
||||
@@ -563,7 +600,6 @@ fn drop_last_n_user_turns_preserves_prefix() {
|
||||
fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
let items = vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -586,7 +622,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let expected_prefix_and_first_turn = vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -608,7 +643,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let expected_prefix_only = vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -623,7 +657,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let mut history = create_history_with_items(vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -644,7 +677,6 @@ fn drop_last_n_user_turns_ignores_session_prefix_user_messages() {
|
||||
|
||||
let mut history = create_history_with_items(vec![
|
||||
user_input_text_msg("<environment_context>ctx</environment_context>"),
|
||||
user_input_text_msg("<user_instructions>do the thing</user_instructions>"),
|
||||
user_input_text_msg(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
),
|
||||
@@ -676,7 +708,7 @@ fn remove_first_item_handles_custom_tool_pair() {
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-1".to_string(),
|
||||
output: "ok".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
},
|
||||
];
|
||||
let mut h = create_history_with_items(items);
|
||||
@@ -755,7 +787,7 @@ fn record_items_truncates_custom_tool_call_output_content() {
|
||||
let long_output = line.repeat(2_500);
|
||||
let item = ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-200".to_string(),
|
||||
output: long_output.clone(),
|
||||
output: FunctionCallOutputPayload::from_text(long_output.clone()),
|
||||
};
|
||||
|
||||
history.record_items([&item], policy);
|
||||
@@ -763,7 +795,8 @@ fn record_items_truncates_custom_tool_call_output_content() {
|
||||
assert_eq!(history.items.len(), 1);
|
||||
match &history.items[0] {
|
||||
ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
assert_ne!(output, &long_output);
|
||||
let output = output.text_content().unwrap_or_default();
|
||||
assert_ne!(output, long_output);
|
||||
assert!(
|
||||
output.contains("tokens truncated"),
|
||||
"expected token-based truncation marker, got {output}"
|
||||
@@ -954,7 +987,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() {
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "tool-x".to_string(),
|
||||
output: "aborted".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
]
|
||||
);
|
||||
@@ -1021,7 +1054,7 @@ fn normalize_removes_orphan_function_call_output() {
|
||||
fn normalize_removes_orphan_custom_tool_call_output() {
|
||||
let items = vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id: "orphan-2".to_string(),
|
||||
output: "ok".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
}];
|
||||
let mut h = create_history_with_items(items);
|
||||
|
||||
@@ -1094,7 +1127,7 @@ fn normalize_mixed_inserts_and_removals() {
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "t1".to_string(),
|
||||
output: "aborted".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
ResponseItem::LocalShellCall {
|
||||
id: None,
|
||||
@@ -1196,7 +1229,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() {
|
||||
fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() {
|
||||
let items = vec![ResponseItem::CustomToolCallOutput {
|
||||
call_id: "orphan-2".to_string(),
|
||||
output: "ok".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
}];
|
||||
let mut h = create_history_with_items(items);
|
||||
h.normalize_history(&default_input_modalities());
|
||||
@@ -1299,6 +1332,28 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() {
|
||||
assert!(estimated < raw_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() {
|
||||
let payload = "C".repeat(50_000);
|
||||
let image_url = format!("data:image/png;base64,{payload}");
|
||||
let item = ResponseItem::CustomToolCallOutput {
|
||||
call_id: "call-js-repl".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "Screenshot captured".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage { image_url },
|
||||
]),
|
||||
};
|
||||
|
||||
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
|
||||
let estimated = estimate_response_item_model_visible_bytes(&item);
|
||||
let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE;
|
||||
|
||||
assert_eq!(estimated, expected);
|
||||
assert!(estimated < raw_len);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_base64_image_urls_are_unchanged() {
|
||||
let message_item = ResponseItem::Message {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -35,10 +34,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
idx,
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text("aborted".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -59,7 +55,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
idx,
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: "aborted".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -82,10 +78,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
|
||||
idx,
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: call_id.clone(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text("aborted".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -245,7 +238,8 @@ pub(crate) fn strip_images_when_unsupported(
|
||||
}
|
||||
*content = normalized_content;
|
||||
}
|
||||
ResponseItem::FunctionCallOutput { output, .. } => {
|
||||
ResponseItem::FunctionCallOutput { output, .. }
|
||||
| ResponseItem::CustomToolCallOutput { output, .. } => {
|
||||
if let Some(content_items) = output.content_items_mut() {
|
||||
let mut normalized_content_items = Vec::with_capacity(content_items.len());
|
||||
for content_item in content_items.iter() {
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::features::Feature;
|
||||
use crate::shell::Shell;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
@@ -30,7 +31,7 @@ fn build_permissions_update_item(
|
||||
previous: Option<&TurnContextItem>,
|
||||
next: &TurnContext,
|
||||
exec_policy: &Policy,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
let prev = previous?;
|
||||
if prev.sandbox_policy == *next.sandbox_policy.get()
|
||||
&& prev.approval_policy == next.approval_policy.value()
|
||||
@@ -38,27 +39,26 @@ fn build_permissions_update_item(
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
DeveloperInstructions::from_policy(
|
||||
next.sandbox_policy.get(),
|
||||
next.approval_policy.value(),
|
||||
exec_policy,
|
||||
&next.cwd,
|
||||
next.features.enabled(Feature::RequestPermissions),
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
Some(DeveloperInstructions::from_policy(
|
||||
next.sandbox_policy.get(),
|
||||
next.approval_policy.value(),
|
||||
exec_policy,
|
||||
&next.cwd,
|
||||
next.features.enabled(Feature::RequestPermissions),
|
||||
))
|
||||
}
|
||||
|
||||
fn build_collaboration_mode_update_item(
|
||||
previous: Option<&TurnContextItem>,
|
||||
next: &TurnContext,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
let prev = previous?;
|
||||
if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) {
|
||||
// If the next mode has empty developer instructions, this returns None and we emit no
|
||||
// update, so prior collaboration instructions remain in the prompt history.
|
||||
Some(DeveloperInstructions::from_collaboration_mode(&next.collaboration_mode)?.into())
|
||||
Some(DeveloperInstructions::from_collaboration_mode(
|
||||
&next.collaboration_mode,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -68,7 +68,7 @@ fn build_personality_update_item(
|
||||
previous: Option<&TurnContextItem>,
|
||||
next: &TurnContext,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
if !personality_feature_enabled {
|
||||
return None;
|
||||
}
|
||||
@@ -82,8 +82,7 @@ fn build_personality_update_item(
|
||||
{
|
||||
let model_info = &next.model_info;
|
||||
let personality_message = personality_message_for(model_info, personality);
|
||||
personality_message
|
||||
.map(|message| DeveloperInstructions::personality_spec_message(message).into())
|
||||
personality_message.map(DeveloperInstructions::personality_spec_message)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -103,7 +102,7 @@ pub(crate) fn personality_message_for(
|
||||
pub(crate) fn build_model_instructions_update_item(
|
||||
previous_user_turn_model: Option<&str>,
|
||||
next: &TurnContext,
|
||||
) -> Option<ResponseItem> {
|
||||
) -> Option<DeveloperInstructions> {
|
||||
let previous_model = previous_user_turn_model?;
|
||||
if previous_model == next.model_info.slug {
|
||||
return None;
|
||||
@@ -114,7 +113,36 @@ pub(crate) fn build_model_instructions_update_item(
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(DeveloperInstructions::model_switch_message(model_instructions).into())
|
||||
Some(DeveloperInstructions::model_switch_message(
|
||||
model_instructions,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn build_developer_update_item(text_sections: Vec<String>) -> Option<ResponseItem> {
|
||||
build_text_message("developer", text_sections)
|
||||
}
|
||||
|
||||
pub(crate) fn build_contextual_user_message(text_sections: Vec<String>) -> Option<ResponseItem> {
|
||||
build_text_message("user", text_sections)
|
||||
}
|
||||
|
||||
fn build_text_message(role: &str, text_sections: Vec<String>) -> Option<ResponseItem> {
|
||||
if text_sections.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let content = text_sections
|
||||
.into_iter()
|
||||
.map(|text| ContentItem::InputText { text })
|
||||
.collect();
|
||||
|
||||
Some(ResponseItem::Message {
|
||||
id: None,
|
||||
role: role.to_string(),
|
||||
content,
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_settings_update_items(
|
||||
@@ -125,29 +153,26 @@ pub(crate) fn build_settings_update_items(
|
||||
exec_policy: &Policy,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
let mut update_items = Vec::new();
|
||||
let contextual_user_message = build_environment_update_item(previous, next, shell);
|
||||
let developer_update_sections = [
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
// any other context diffs on this turn.
|
||||
build_model_instructions_update_item(previous_user_turn_model, next),
|
||||
build_permissions_update_item(previous, next, exec_policy),
|
||||
build_collaboration_mode_update_item(previous, next),
|
||||
build_personality_update_item(previous, next, personality_feature_enabled),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(DeveloperInstructions::into_text)
|
||||
.collect();
|
||||
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
// any other context diffs on this turn.
|
||||
if let Some(model_instructions_item) =
|
||||
build_model_instructions_update_item(previous_user_turn_model, next)
|
||||
{
|
||||
update_items.push(model_instructions_item);
|
||||
let mut items = Vec::with_capacity(2);
|
||||
if let Some(developer_message) = build_developer_update_item(developer_update_sections) {
|
||||
items.push(developer_message);
|
||||
}
|
||||
if let Some(env_item) = build_environment_update_item(previous, next, shell) {
|
||||
update_items.push(env_item);
|
||||
if let Some(contextual_user_message) = contextual_user_message {
|
||||
items.push(contextual_user_message);
|
||||
}
|
||||
if let Some(permissions_item) = build_permissions_update_item(previous, next, exec_policy) {
|
||||
update_items.push(permissions_item);
|
||||
}
|
||||
if let Some(collaboration_mode_item) = build_collaboration_mode_update_item(previous, next) {
|
||||
update_items.push(collaboration_mode_item);
|
||||
}
|
||||
if let Some(personality_item) =
|
||||
build_personality_update_item(previous, next, personality_feature_enabled)
|
||||
{
|
||||
update_items.push(personality_item);
|
||||
}
|
||||
|
||||
update_items
|
||||
items
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
|
||||
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
|
||||
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
|
||||
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
|
||||
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
|
||||
pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = "<user_shell_command>";
|
||||
pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = "</user_shell_command>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = "</turn_aborted>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ContextualUserFragmentDefinition {
|
||||
start_marker: &'static str,
|
||||
end_marker: &'static str,
|
||||
}
|
||||
|
||||
impl ContextualUserFragmentDefinition {
|
||||
pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
|
||||
Self {
|
||||
start_marker,
|
||||
end_marker,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches_text(&self, text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..self.start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn start_marker(&self) -> &'static str {
|
||||
self.start_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn end_marker(&self) -> &'static str {
|
||||
self.end_marker
|
||||
}
|
||||
|
||||
pub(crate) fn wrap(&self, body: String) -> String {
|
||||
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
|
||||
}
|
||||
|
||||
pub(crate) fn into_message(self, text: String) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
ENVIRONMENT_CONTEXT_OPEN_TAG,
|
||||
ENVIRONMENT_CONTEXT_CLOSE_TAG,
|
||||
);
|
||||
pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
|
||||
pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
USER_SHELL_COMMAND_OPEN_TAG,
|
||||
USER_SHELL_COMMAND_CLOSE_TAG,
|
||||
);
|
||||
pub(crate) const TURN_ABORTED_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(TURN_ABORTED_OPEN_TAG, TURN_ABORTED_CLOSE_TAG);
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
SUBAGENT_NOTIFICATION_OPEN_TAG,
|
||||
SUBAGENT_NOTIFICATION_CLOSE_TAG,
|
||||
);
|
||||
|
||||
const CONTEXTUAL_USER_FRAGMENTS: &[ContextualUserFragmentDefinition] = &[
|
||||
AGENTS_MD_FRAGMENT,
|
||||
ENVIRONMENT_CONTEXT_FRAGMENT,
|
||||
SKILL_FRAGMENT,
|
||||
USER_SHELL_COMMAND_FRAGMENT,
|
||||
TURN_ABORTED_FRAGMENT,
|
||||
SUBAGENT_NOTIFICATION_FRAGMENT,
|
||||
];
|
||||
|
||||
pub(crate) fn is_contextual_user_fragment(content_item: &ContentItem) -> bool {
|
||||
let ContentItem::InputText { text } = content_item else {
|
||||
return false;
|
||||
};
|
||||
CONTEXTUAL_USER_FRAGMENTS
|
||||
.iter()
|
||||
.any(|definition| definition.matches_text(text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
||||
text: "<environment_context>\n<cwd>/tmp</cwd>\n</environment_context>".to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_agents_instructions_fragment() {
|
||||
assert!(is_contextual_user_fragment(&ContentItem::InputText {
|
||||
text: "# AGENTS.md instructions for /tmp\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_subagent_notification_fragment_case_insensitively() {
|
||||
assert!(
|
||||
SUBAGENT_NOTIFICATION_FRAGMENT
|
||||
.matches_text("<SUBAGENT_NOTIFICATION>{}</subagent_notification>")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_regular_user_text() {
|
||||
assert!(!is_contextual_user_fragment(&ContentItem::InputText {
|
||||
text: "hello".to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
use crate::codex::TurnContext;
|
||||
use crate::contextual_user_message::ENVIRONMENT_CONTEXT_FRAGMENT;
|
||||
use crate::shell::Shell;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::TurnContextNetworkItem;
|
||||
use serde::Deserialize;
|
||||
@@ -15,7 +13,10 @@ use std::path::PathBuf;
|
||||
pub(crate) struct EnvironmentContext {
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub shell: Shell,
|
||||
pub current_date: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub network: Option<NetworkContext>,
|
||||
pub subagents: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
@@ -25,11 +26,21 @@ pub(crate) struct NetworkContext {
|
||||
}
|
||||
|
||||
impl EnvironmentContext {
|
||||
pub fn new(cwd: Option<PathBuf>, shell: Shell, network: Option<NetworkContext>) -> Self {
|
||||
pub fn new(
|
||||
cwd: Option<PathBuf>,
|
||||
shell: Shell,
|
||||
current_date: Option<String>,
|
||||
timezone: Option<String>,
|
||||
network: Option<NetworkContext>,
|
||||
subagents: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
cwd,
|
||||
shell,
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
subagents,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,11 +50,17 @@ impl EnvironmentContext {
|
||||
pub fn equals_except_shell(&self, other: &EnvironmentContext) -> bool {
|
||||
let EnvironmentContext {
|
||||
cwd,
|
||||
current_date,
|
||||
timezone,
|
||||
network,
|
||||
// should compare all fields except shell
|
||||
subagents,
|
||||
shell: _,
|
||||
} = other;
|
||||
self.cwd == *cwd && self.network == *network
|
||||
self.cwd == *cwd
|
||||
&& self.current_date == *current_date
|
||||
&& self.timezone == *timezone
|
||||
&& self.network == *network
|
||||
&& self.subagents == *subagents
|
||||
}
|
||||
|
||||
pub fn diff_from_turn_context_item(
|
||||
@@ -58,19 +75,24 @@ impl EnvironmentContext {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let current_date = after.current_date.clone();
|
||||
let timezone = after.timezone.clone();
|
||||
let network = if before_network != after_network {
|
||||
after_network
|
||||
} else {
|
||||
before_network
|
||||
};
|
||||
EnvironmentContext::new(cwd, shell.clone(), network)
|
||||
EnvironmentContext::new(cwd, shell.clone(), current_date, timezone, network, None)
|
||||
}
|
||||
|
||||
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
|
||||
Self::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
shell.clone(),
|
||||
turn_context.current_date.clone(),
|
||||
turn_context.timezone.clone(),
|
||||
Self::network_from_turn_context(turn_context),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,10 +100,20 @@ impl EnvironmentContext {
|
||||
Self::new(
|
||||
Some(turn_context_item.cwd.clone()),
|
||||
shell.clone(),
|
||||
turn_context_item.current_date.clone(),
|
||||
turn_context_item.timezone.clone(),
|
||||
Self::network_from_turn_context_item(turn_context_item),
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn with_subagents(mut self, subagents: String) -> Self {
|
||||
if !subagents.is_empty() {
|
||||
self.subagents = Some(subagents);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn network_from_turn_context(turn_context: &TurnContext) -> Option<NetworkContext> {
|
||||
let network = turn_context
|
||||
.config
|
||||
@@ -122,13 +154,19 @@ impl EnvironmentContext {
|
||||
/// </environment_context>
|
||||
/// ```
|
||||
pub fn serialize_to_xml(self) -> String {
|
||||
let mut lines = vec![ENVIRONMENT_CONTEXT_OPEN_TAG.to_string()];
|
||||
let mut lines = Vec::new();
|
||||
if let Some(cwd) = self.cwd {
|
||||
lines.push(format!(" <cwd>{}</cwd>", cwd.to_string_lossy()));
|
||||
}
|
||||
|
||||
let shell_name = self.shell.name();
|
||||
lines.push(format!(" <shell>{shell_name}</shell>"));
|
||||
if let Some(current_date) = self.current_date {
|
||||
lines.push(format!(" <current_date>{current_date}</current_date>"));
|
||||
}
|
||||
if let Some(timezone) = self.timezone {
|
||||
lines.push(format!(" <timezone>{timezone}</timezone>"));
|
||||
}
|
||||
match self.network {
|
||||
Some(ref network) => {
|
||||
lines.push(" <network enabled=\"true\">".to_string());
|
||||
@@ -145,22 +183,18 @@ impl EnvironmentContext {
|
||||
// lines.push(" <network enabled=\"false\" />".to_string());
|
||||
}
|
||||
}
|
||||
lines.push(ENVIRONMENT_CONTEXT_CLOSE_TAG.to_string());
|
||||
lines.join("\n")
|
||||
if let Some(subagents) = self.subagents {
|
||||
lines.push(" <subagents>".to_string());
|
||||
lines.extend(subagents.lines().map(|line| format!(" {line}")));
|
||||
lines.push(" </subagents>".to_string());
|
||||
}
|
||||
ENVIRONMENT_CONTEXT_FRAGMENT.wrap(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EnvironmentContext> for ResponseItem {
|
||||
fn from(ec: EnvironmentContext) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: ec.serialize_to_xml(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
ENVIRONMENT_CONTEXT_FRAGMENT.into_message(ec.serialize_to_xml())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,12 +217,21 @@ mod tests {
|
||||
#[test]
|
||||
fn serialize_workspace_write_environment_context() {
|
||||
let cwd = test_path_buf("/repo");
|
||||
let context = EnvironmentContext::new(Some(cwd.clone()), fake_shell(), None);
|
||||
let context = EnvironmentContext::new(
|
||||
Some(cwd.clone()),
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{cwd}</cwd>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
</environment_context>"#,
|
||||
cwd = cwd.display(),
|
||||
);
|
||||
@@ -202,13 +245,21 @@ mod tests {
|
||||
allowed_domains: vec!["api.example.com".to_string(), "*.openai.com".to_string()],
|
||||
denied_domains: vec!["blocked.example.com".to_string()],
|
||||
};
|
||||
let context =
|
||||
EnvironmentContext::new(Some(test_path_buf("/repo")), fake_shell(), Some(network));
|
||||
let context = EnvironmentContext::new(
|
||||
Some(test_path_buf("/repo")),
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
Some(network),
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
<network enabled="true">
|
||||
<allowed>api.example.com</allowed>
|
||||
<allowed>*.openai.com</allowed>
|
||||
@@ -223,10 +274,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_read_only_environment_context() {
|
||||
let context = EnvironmentContext::new(None, fake_shell(), None);
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
</environment_context>"#;
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
@@ -234,10 +294,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_external_sandbox_environment_context() {
|
||||
let context = EnvironmentContext::new(None, fake_shell(), None);
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
</environment_context>"#;
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
@@ -245,10 +314,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_external_sandbox_with_restricted_network_environment_context() {
|
||||
let context = EnvironmentContext::new(None, fake_shell(), None);
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
</environment_context>"#;
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
@@ -256,10 +334,19 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn serialize_full_access_environment_context() {
|
||||
let context = EnvironmentContext::new(None, fake_shell(), None);
|
||||
let context = EnvironmentContext::new(
|
||||
None,
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let expected = r#"<environment_context>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
</environment_context>"#;
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
@@ -267,23 +354,65 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_cwd() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_ignores_sandbox_policy() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo")), fake_shell(), None);
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equals_except_shell_compares_cwd_differences() {
|
||||
let context1 = EnvironmentContext::new(Some(PathBuf::from("/repo1")), fake_shell(), None);
|
||||
let context2 = EnvironmentContext::new(Some(PathBuf::from("/repo2")), fake_shell(), None);
|
||||
let context1 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo1")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo2")),
|
||||
fake_shell(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(!context1.equals_except_shell(&context2));
|
||||
}
|
||||
@@ -298,6 +427,9 @@ mod tests {
|
||||
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let context2 = EnvironmentContext::new(
|
||||
Some(PathBuf::from("/repo")),
|
||||
@@ -307,8 +439,39 @@ mod tests {
|
||||
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
|
||||
},
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(context1.equals_except_shell(&context2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_environment_context_with_subagents() {
|
||||
let context = EnvironmentContext::new(
|
||||
Some(test_path_buf("/repo")),
|
||||
fake_shell(),
|
||||
Some("2026-02-26".to_string()),
|
||||
Some("America/Los_Angeles".to_string()),
|
||||
None,
|
||||
Some("- agent-1: atlas\n- agent-2".to_string()),
|
||||
);
|
||||
|
||||
let expected = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<shell>bash</shell>
|
||||
<current_date>2026-02-26</current_date>
|
||||
<timezone>America/Los_Angeles</timezone>
|
||||
<subagents>
|
||||
- agent-1: atlas
|
||||
- agent-2
|
||||
</subagents>
|
||||
</environment_context>"#,
|
||||
test_path_buf("/repo").display()
|
||||
);
|
||||
|
||||
assert_eq!(context.serialize_to_xml(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,16 +18,15 @@ use codex_protocol::user_input::UserInput;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::session_prefix::is_session_prefix;
|
||||
use crate::user_shell_command::is_user_shell_command_text;
|
||||
use crate::contextual_user_message::is_contextual_user_fragment;
|
||||
use crate::web_search::web_search_action_detail;
|
||||
|
||||
pub(crate) fn is_contextual_user_message_content(message: &[ContentItem]) -> bool {
|
||||
message.iter().any(is_contextual_user_fragment)
|
||||
}
|
||||
|
||||
fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
if UserInstructions::is_user_instructions(message)
|
||||
|| SkillInstructions::is_skill_instructions(message)
|
||||
{
|
||||
if is_contextual_user_message_content(message) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -44,9 +43,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if is_session_prefix(text) || is_user_shell_command_text(text) {
|
||||
return None;
|
||||
}
|
||||
content.push(UserInput::Text {
|
||||
text: text.clone(),
|
||||
// Model input content does not carry UI element ranges.
|
||||
@@ -59,9 +55,6 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
});
|
||||
}
|
||||
ContentItem::OutputText { text } => {
|
||||
if is_session_prefix(text) {
|
||||
return None;
|
||||
}
|
||||
warn!("Output text in user message: {}", text);
|
||||
}
|
||||
}
|
||||
@@ -299,7 +292,7 @@ mod tests {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: "<user_instructions>test_text</user_instructions>".to_string(),
|
||||
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -341,6 +334,22 @@ mod tests {
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: "<environment_context>ctx</environment_context>".to_string(),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text:
|
||||
"# AGENTS.md instructions for dir\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
},
|
||||
];
|
||||
|
||||
for item in items {
|
||||
|
||||
@@ -219,6 +219,8 @@ pub async fn process_exec_tool_call(
|
||||
enforce_managed_network,
|
||||
network: network.as_ref(),
|
||||
sandbox_policy_cwd: sandbox_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level,
|
||||
|
||||
@@ -133,8 +133,6 @@ pub enum Feature {
|
||||
SkillMcpDependencyInstall,
|
||||
/// Prompt for missing skill env var dependencies.
|
||||
SkillEnvVarDependencyPrompt,
|
||||
/// Emit skill approval test prompts/events.
|
||||
SkillApproval,
|
||||
/// Steer feature flag - when enabled, Enter submits immediately instead of queuing.
|
||||
/// Kept for config backward compatibility; behavior is always steer-enabled.
|
||||
Steer,
|
||||
@@ -465,7 +463,7 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
key: "js_repl",
|
||||
stage: Stage::Experimental {
|
||||
name: "JavaScript REPL",
|
||||
menu_description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities. Requires Node >= v24.13.1 installed.",
|
||||
menu_description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities. Requires Node >= v22.22.0 installed.",
|
||||
announcement: "NEW: JavaScript REPL is now available in /experimental. Enable it, then start a new chat or restart Codex to use it.",
|
||||
},
|
||||
default_enabled: false,
|
||||
@@ -630,12 +628,6 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::SkillApproval,
|
||||
key: "skill_approval",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Steer,
|
||||
key: "steer",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
mod user_instructions;
|
||||
|
||||
pub(crate) use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_OPEN_TAG_LEGACY;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
pub const USER_INSTRUCTIONS_OPEN_TAG_LEGACY: &str = "<user_instructions>";
|
||||
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
|
||||
use crate::contextual_user_message::SKILL_FRAGMENT;
|
||||
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
|
||||
pub const SKILL_INSTRUCTIONS_PREFIX: &str = "<skill";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
@@ -16,31 +16,20 @@ pub(crate) struct UserInstructions {
|
||||
}
|
||||
|
||||
impl UserInstructions {
|
||||
pub fn is_user_instructions(message: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = message {
|
||||
text.starts_with(USER_INSTRUCTIONS_PREFIX)
|
||||
|| text.starts_with(USER_INSTRUCTIONS_OPEN_TAG_LEGACY)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
pub(crate) fn serialize_to_text(&self) -> String {
|
||||
format!(
|
||||
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
|
||||
prefix = AGENTS_MD_FRAGMENT.start_marker(),
|
||||
directory = self.directory,
|
||||
contents = self.text,
|
||||
suffix = AGENTS_MD_FRAGMENT.end_marker(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserInstructions> for ResponseItem {
|
||||
fn from(ui: UserInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!(
|
||||
"{USER_INSTRUCTIONS_PREFIX}{directory}\n\n<INSTRUCTIONS>\n{contents}\n</INSTRUCTIONS>",
|
||||
directory = ui.directory,
|
||||
contents = ui.text
|
||||
),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,36 +41,21 @@ pub(crate) struct SkillInstructions {
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
impl SkillInstructions {
|
||||
pub fn is_skill_instructions(message: &[ContentItem]) -> bool {
|
||||
if let [ContentItem::InputText { text }] = message {
|
||||
text.starts_with(SKILL_INSTRUCTIONS_PREFIX)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
impl SkillInstructions {}
|
||||
|
||||
impl From<SkillInstructions> for ResponseItem {
|
||||
fn from(si: SkillInstructions) -> Self {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format!(
|
||||
"<skill>\n<name>{}</name>\n<path>{}</path>\n{}\n</skill>",
|
||||
si.name, si.path, si.contents
|
||||
),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!(
|
||||
"<name>{}</name>\n<path>{}</path>\n{}",
|
||||
si.name, si.path, si.contents
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
@@ -110,21 +84,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_user_instructions() {
|
||||
assert!(UserInstructions::is_user_instructions(
|
||||
&[ContentItem::InputText {
|
||||
text: "# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>".to_string(),
|
||||
}]
|
||||
assert!(AGENTS_MD_FRAGMENT.matches_text(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>"
|
||||
));
|
||||
assert!(UserInstructions::is_user_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "<user_instructions>test_text</user_instructions>".to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(!UserInstructions::is_user_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "test_text".to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(!AGENTS_MD_FRAGMENT.matches_text("test_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -154,16 +117,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_skill_instructions() {
|
||||
assert!(SkillInstructions::is_skill_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
|
||||
.to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(!SkillInstructions::is_skill_instructions(&[
|
||||
ContentItem::InputText {
|
||||
text: "regular text".to_string(),
|
||||
}
|
||||
]));
|
||||
assert!(SKILL_FRAGMENT.matches_text(
|
||||
"<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
|
||||
));
|
||||
assert!(!SKILL_FRAGMENT.matches_text("regular text"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub mod config;
|
||||
pub mod config_loader;
|
||||
pub mod connectors;
|
||||
mod context_manager;
|
||||
mod contextual_user_message;
|
||||
pub mod custom_prompts;
|
||||
pub mod env;
|
||||
mod environment_context;
|
||||
|
||||
@@ -139,6 +139,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -241,6 +241,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
|
||||
oauth_config.http_headers,
|
||||
oauth_config.env_http_headers,
|
||||
&[],
|
||||
server_config.oauth_resource.as_deref(),
|
||||
config.mcp_oauth_callback_port,
|
||||
config.mcp_oauth_callback_url.as_deref(),
|
||||
)
|
||||
@@ -387,6 +388,7 @@ fn mcp_dependency_to_server_config(
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,6 +413,7 @@ fn mcp_dependency_to_server_config(
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -468,6 +471,7 @@ mod tests {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
@@ -516,6 +520,7 @@ mod tests {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
)]);
|
||||
|
||||
|
||||
@@ -2115,6 +2115,7 @@ mod tests {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
@@ -2162,6 +2163,7 @@ mod tests {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
auth_status: McpAuthStatus::Unsupported,
|
||||
};
|
||||
|
||||
@@ -59,7 +59,14 @@ Phase 2 consolidates the latest stage-1 outputs into the filesystem memory artif
|
||||
What it does:
|
||||
|
||||
- claims a single global phase-2 job (so only one consolidation runs at a time)
|
||||
- loads a bounded set of the most recent stage-1 outputs from the state DB (the per-rollout memories produced by Phase 1, used as the consolidation input set)
|
||||
- loads a bounded set of stage-1 outputs from the state DB using phase-2
|
||||
selection rules:
|
||||
- ignores memories whose `last_usage` falls outside the configured
|
||||
`max_unused_days` window
|
||||
- for memories with no `last_usage`, falls back to `generated_at` so fresh
|
||||
never-used memories can still be selected
|
||||
- ranks eligible memories by `usage_count` first, then by the most recent
|
||||
`last_usage` / `generated_at`
|
||||
- computes a completion watermark from the claimed watermark + newest input timestamps
|
||||
- syncs local memory artifacts under the memories root:
|
||||
- `raw_memories.md` (merged raw memories, latest first)
|
||||
@@ -70,11 +77,31 @@ What it does:
|
||||
If there is input, it then:
|
||||
|
||||
- spawns an internal consolidation sub-agent
|
||||
- builds the Phase 2 prompt with a diff of the current Phase 1 input
|
||||
selection versus the last successful Phase 2 selection (`added`,
|
||||
`retained`, `removed`)
|
||||
- runs it with no approvals, no network, and local write access only
|
||||
- disables collab for that agent (to prevent recursive delegation)
|
||||
- watches the agent status and heartbeats the global job lease while it runs
|
||||
- marks the phase-2 job success/failure in the state DB when the agent finishes
|
||||
|
||||
Selection diff behavior:
|
||||
|
||||
- successful Phase 2 runs mark the exact stage-1 snapshots they consumed with
|
||||
`selected_for_phase2 = 1` and persist the matching
|
||||
`selected_for_phase2_source_updated_at`
|
||||
- Phase 1 upserts preserve the previous `selected_for_phase2` baseline until
|
||||
the next successful Phase 2 run rewrites it
|
||||
- the next Phase 2 run compares the current top-N stage-1 inputs against that
|
||||
prior snapshot selection to label inputs as `added` or `retained`; a
|
||||
refreshed thread stays `added` until Phase 2 successfully selects its newer
|
||||
snapshot
|
||||
- rows that were previously selected but still exist outside the current top-N
|
||||
selection are surfaced as `removed`
|
||||
- before the agent starts, local `rollout_summaries/` and `raw_memories.md`
|
||||
keep the union of the current selection and the previous successful
|
||||
selection, so removed-thread evidence stays available during forgetting
|
||||
|
||||
Watermark behavior:
|
||||
|
||||
- The global phase-2 job claim includes an input watermark representing the latest input timestamp known when the job was claimed.
|
||||
|
||||
@@ -8,6 +8,7 @@ use crate::memories::metrics;
|
||||
use crate::memories::phase_two;
|
||||
use crate::memories::prompts::build_consolidation_prompt;
|
||||
use crate::memories::storage::rebuild_raw_memories_file_from_memories;
|
||||
use crate::memories::storage::rollout_summary_file_stem;
|
||||
use crate::memories::storage::sync_rollout_summaries_from_memories;
|
||||
use codex_config::Constrained;
|
||||
use codex_protocol::ThreadId;
|
||||
@@ -17,8 +18,10 @@ use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::SubAgentSource;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_state::Stage1Output;
|
||||
use codex_state::StateRuntime;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
@@ -50,6 +53,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
};
|
||||
let root = memory_root(&config.codex_home);
|
||||
let max_raw_memories = config.memories.max_raw_memories_for_global;
|
||||
let max_unused_days = config.memories.max_unused_days;
|
||||
|
||||
// 1. Claim the job.
|
||||
let claim = match job::claim(session, db).await {
|
||||
@@ -73,21 +77,27 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
};
|
||||
|
||||
// 3. Query the memories
|
||||
let raw_memories = match db.list_stage1_outputs_for_global(max_raw_memories).await {
|
||||
Ok(memories) => memories,
|
||||
let selection = match db
|
||||
.get_phase2_input_selection(max_raw_memories, max_unused_days)
|
||||
.await
|
||||
{
|
||||
Ok(selection) => selection,
|
||||
Err(err) => {
|
||||
tracing::error!("failed to list stage1 outputs from global: {}", err);
|
||||
job::failed(session, db, &claim, "failed_load_stage1_outputs").await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let raw_memories = selection.selected.to_vec();
|
||||
let artifact_memories = artifact_memories_for_phase2(&selection);
|
||||
let new_watermark = get_watermark(claim.watermark, &raw_memories);
|
||||
|
||||
// 4. Update the file system by syncing the raw memories with the one extracted from DB at
|
||||
// step 3
|
||||
// [`rollout_summaries/`]
|
||||
if let Err(err) =
|
||||
sync_rollout_summaries_from_memories(&root, &raw_memories, max_raw_memories).await
|
||||
sync_rollout_summaries_from_memories(&root, &artifact_memories, artifact_memories.len())
|
||||
.await
|
||||
{
|
||||
tracing::error!("failed syncing local memory artifacts for global consolidation: {err}");
|
||||
job::failed(session, db, &claim, "failed_sync_artifacts").await;
|
||||
@@ -95,7 +105,8 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
}
|
||||
// [`raw_memories.md`]
|
||||
if let Err(err) =
|
||||
rebuild_raw_memories_file_from_memories(&root, &raw_memories, max_raw_memories).await
|
||||
rebuild_raw_memories_file_from_memories(&root, &artifact_memories, artifact_memories.len())
|
||||
.await
|
||||
{
|
||||
tracing::error!("failed syncing local memory artifacts for global consolidation: {err}");
|
||||
job::failed(session, db, &claim, "failed_rebuild_raw_memories").await;
|
||||
@@ -103,12 +114,20 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
}
|
||||
if raw_memories.is_empty() {
|
||||
// We check only after sync of the file system.
|
||||
job::succeed(session, db, &claim, new_watermark, "succeeded_no_input").await;
|
||||
job::succeed(
|
||||
session,
|
||||
db,
|
||||
&claim,
|
||||
new_watermark,
|
||||
&[],
|
||||
"succeeded_no_input",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
// 5. Spawn the agent
|
||||
let prompt = agent::get_prompt(config);
|
||||
let prompt = agent::get_prompt(config, &selection);
|
||||
let source = SessionSource::SubAgent(SubAgentSource::MemoryConsolidation);
|
||||
let thread_id = match session
|
||||
.services
|
||||
@@ -129,6 +148,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
session,
|
||||
claim,
|
||||
new_watermark,
|
||||
raw_memories.clone(),
|
||||
thread_id,
|
||||
phase_two_e2e_timer,
|
||||
);
|
||||
@@ -140,6 +160,22 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
emit_metrics(session, counters);
|
||||
}
|
||||
|
||||
fn artifact_memories_for_phase2(
|
||||
selection: &codex_state::Phase2InputSelection,
|
||||
) -> Vec<Stage1Output> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut memories = selection.selected.clone();
|
||||
for memory in &selection.selected {
|
||||
seen.insert(rollout_summary_file_stem(memory));
|
||||
}
|
||||
for memory in &selection.previous_selected {
|
||||
if seen.insert(rollout_summary_file_stem(memory)) {
|
||||
memories.push(memory.clone());
|
||||
}
|
||||
}
|
||||
memories
|
||||
}
|
||||
|
||||
mod job {
|
||||
use super::*;
|
||||
|
||||
@@ -205,6 +241,7 @@ mod job {
|
||||
db: &StateRuntime,
|
||||
claim: &Claim,
|
||||
completion_watermark: i64,
|
||||
selected_outputs: &[codex_state::Stage1Output],
|
||||
reason: &'static str,
|
||||
) {
|
||||
session.services.otel_manager.counter(
|
||||
@@ -213,7 +250,7 @@ mod job {
|
||||
&[("status", reason)],
|
||||
);
|
||||
let _ = db
|
||||
.mark_global_phase2_job_succeeded(&claim.token, completion_watermark)
|
||||
.mark_global_phase2_job_succeeded(&claim.token, completion_watermark, selected_outputs)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
@@ -266,9 +303,12 @@ mod agent {
|
||||
Some(agent_config)
|
||||
}
|
||||
|
||||
pub(super) fn get_prompt(config: Arc<Config>) -> Vec<UserInput> {
|
||||
pub(super) fn get_prompt(
|
||||
config: Arc<Config>,
|
||||
selection: &codex_state::Phase2InputSelection,
|
||||
) -> Vec<UserInput> {
|
||||
let root = memory_root(&config.codex_home);
|
||||
let prompt = build_consolidation_prompt(&root);
|
||||
let prompt = build_consolidation_prompt(&root, selection);
|
||||
vec![UserInput::Text {
|
||||
text: prompt,
|
||||
text_elements: vec![],
|
||||
@@ -280,6 +320,7 @@ mod agent {
|
||||
session: &Arc<Session>,
|
||||
claim: Claim,
|
||||
new_watermark: i64,
|
||||
selected_outputs: Vec<codex_state::Stage1Output>,
|
||||
thread_id: ThreadId,
|
||||
phase_two_e2e_timer: Option<codex_otel::Timer>,
|
||||
) {
|
||||
@@ -316,7 +357,15 @@ mod agent {
|
||||
if let Some(token_usage) = agent_control.get_total_token_usage(thread_id).await {
|
||||
emit_token_usage_metrics(&session, &token_usage);
|
||||
}
|
||||
job::succeed(&session, &db, &claim, new_watermark, "succeeded").await;
|
||||
job::succeed(
|
||||
&session,
|
||||
&db,
|
||||
&claim,
|
||||
new_watermark,
|
||||
&selected_outputs,
|
||||
"succeeded",
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
job::failed(&session, &db, &claim, "failed_agent").await;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::phase_one;
|
||||
use crate::memories::storage::rollout_summary_file_stem_from_parts;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use askama::Template;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_state::Phase2InputSelection;
|
||||
use codex_state::Stage1Output;
|
||||
use codex_state::Stage1OutputRef;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::warn;
|
||||
@@ -12,6 +16,7 @@ use tracing::warn;
|
||||
#[template(path = "memories/consolidation.md", escape = "none")]
|
||||
struct ConsolidationPromptTemplate<'a> {
|
||||
memory_root: &'a str,
|
||||
phase2_input_selection: &'a str,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -30,17 +35,91 @@ struct MemoryToolDeveloperInstructionsTemplate<'a> {
|
||||
}
|
||||
|
||||
/// Builds the consolidation subagent prompt for a specific memory root.
|
||||
pub(super) fn build_consolidation_prompt(memory_root: &Path) -> String {
|
||||
pub(super) fn build_consolidation_prompt(
|
||||
memory_root: &Path,
|
||||
selection: &Phase2InputSelection,
|
||||
) -> String {
|
||||
let memory_root = memory_root.display().to_string();
|
||||
let phase2_input_selection = render_phase2_input_selection(selection);
|
||||
let template = ConsolidationPromptTemplate {
|
||||
memory_root: &memory_root,
|
||||
phase2_input_selection: &phase2_input_selection,
|
||||
};
|
||||
template.render().unwrap_or_else(|err| {
|
||||
warn!("failed to render memories consolidation prompt template: {err}");
|
||||
format!("## Memory Phase 2 (Consolidation)\nConsolidate Codex memories in: {memory_root}")
|
||||
format!(
|
||||
"## Memory Phase 2 (Consolidation)\nConsolidate Codex memories in: {memory_root}\n\n{phase2_input_selection}"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn render_phase2_input_selection(selection: &Phase2InputSelection) -> String {
|
||||
let retained = selection.retained_thread_ids.len();
|
||||
let added = selection.selected.len().saturating_sub(retained);
|
||||
let selected = if selection.selected.is_empty() {
|
||||
"- none".to_string()
|
||||
} else {
|
||||
selection
|
||||
.selected
|
||||
.iter()
|
||||
.map(|item| {
|
||||
render_selected_input_line(
|
||||
item,
|
||||
selection.retained_thread_ids.contains(&item.thread_id),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let removed = if selection.removed.is_empty() {
|
||||
"- none".to_string()
|
||||
} else {
|
||||
selection
|
||||
.removed
|
||||
.iter()
|
||||
.map(render_removed_input_line)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
format!(
|
||||
"- selected inputs this run: {}\n- newly added since the last successful Phase 2 run: {added}\n- retained from the last successful Phase 2 run: {retained}\n- removed from the last successful Phase 2 run: {}\n\nCurrent selected Phase 1 inputs:\n{selected}\n\nRemoved from the last successful Phase 2 selection:\n{removed}\n",
|
||||
selection.selected.len(),
|
||||
selection.removed.len(),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_selected_input_line(item: &Stage1Output, retained: bool) -> String {
|
||||
let status = if retained { "retained" } else { "added" };
|
||||
let rollout_summary_file = format!(
|
||||
"rollout_summaries/{}.md",
|
||||
rollout_summary_file_stem_from_parts(
|
||||
item.thread_id,
|
||||
item.source_updated_at,
|
||||
item.rollout_slug.as_deref(),
|
||||
)
|
||||
);
|
||||
format!(
|
||||
"- [{status}] thread_id={}, rollout_summary_file={rollout_summary_file}",
|
||||
item.thread_id
|
||||
)
|
||||
}
|
||||
|
||||
fn render_removed_input_line(item: &Stage1OutputRef) -> String {
|
||||
let rollout_summary_file = format!(
|
||||
"rollout_summaries/{}.md",
|
||||
rollout_summary_file_stem_from_parts(
|
||||
item.thread_id,
|
||||
item.source_updated_at,
|
||||
item.rollout_slug.as_deref(),
|
||||
)
|
||||
);
|
||||
format!(
|
||||
"- thread_id={}, rollout_summary_file={rollout_summary_file}",
|
||||
item.thread_id
|
||||
)
|
||||
}
|
||||
|
||||
/// Builds the stage-1 user message containing rollout metadata and content.
|
||||
///
|
||||
/// Large rollout payloads are truncated to 70% of the active model's effective
|
||||
|
||||
@@ -82,6 +82,8 @@ async fn rebuild_raw_memories_file(
|
||||
)
|
||||
.map_err(raw_memories_format_error)?;
|
||||
writeln!(body, "cwd: {}", memory.cwd.display()).map_err(raw_memories_format_error)?;
|
||||
writeln!(body, "rollout_path: {}", memory.rollout_path.display())
|
||||
.map_err(raw_memories_format_error)?;
|
||||
let rollout_summary_file = format!("{}.md", rollout_summary_file_stem(memory));
|
||||
writeln!(body, "rollout_summary_file: {rollout_summary_file}")
|
||||
.map_err(raw_memories_format_error)?;
|
||||
@@ -138,7 +140,12 @@ async fn write_rollout_summary_for_thread(
|
||||
memory.source_updated_at.to_rfc3339()
|
||||
)
|
||||
.map_err(rollout_summary_format_error)?;
|
||||
writeln!(body, "rollout_path: {}", memory.rollout_path.display())
|
||||
.map_err(rollout_summary_format_error)?;
|
||||
writeln!(body, "cwd: {}", memory.cwd.display()).map_err(rollout_summary_format_error)?;
|
||||
if let Some(git_branch) = memory.git_branch.as_deref() {
|
||||
writeln!(body, "git_branch: {git_branch}").map_err(rollout_summary_format_error)?;
|
||||
}
|
||||
writeln!(body).map_err(rollout_summary_format_error)?;
|
||||
body.push_str(&memory.rollout_summary);
|
||||
body.push('\n');
|
||||
@@ -267,7 +274,9 @@ mod tests {
|
||||
raw_memory: "raw memory".to_string(),
|
||||
rollout_summary: "summary".to_string(),
|
||||
rollout_slug: rollout_slug.map(ToString::to_string),
|
||||
rollout_path: PathBuf::from("/tmp/rollout.jsonl"),
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
git_branch: None,
|
||||
generated_at: Utc.timestamp_opt(124, 0).single().expect("timestamp"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,9 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
|
||||
raw_memory: "raw memory".to_string(),
|
||||
rollout_summary: "short summary".to_string(),
|
||||
rollout_slug: None,
|
||||
rollout_path: PathBuf::from("/tmp/rollout-100.jsonl"),
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
git_branch: None,
|
||||
generated_at: Utc.timestamp_opt(101, 0).single().expect("timestamp"),
|
||||
}];
|
||||
|
||||
@@ -135,6 +137,7 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
|
||||
assert!(raw_memories.contains("raw memory"));
|
||||
assert!(raw_memories.contains(&keep_id));
|
||||
assert!(raw_memories.contains("cwd: /tmp/workspace"));
|
||||
assert!(raw_memories.contains("rollout_path: /tmp/rollout-100.jsonl"));
|
||||
assert!(raw_memories.contains(&format!(
|
||||
"rollout_summary_file: {canonical_rollout_summary_file}"
|
||||
)));
|
||||
@@ -150,6 +153,10 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
|
||||
.find("cwd: /tmp/workspace")
|
||||
.map(|offset| thread_pos + offset)
|
||||
.expect("cwd should exist after thread header");
|
||||
let rollout_path_pos = raw_memories[thread_pos..]
|
||||
.find("rollout_path: /tmp/rollout-100.jsonl")
|
||||
.map(|offset| thread_pos + offset)
|
||||
.expect("rollout_path should exist after thread header");
|
||||
let file_pos = raw_memories[thread_pos..]
|
||||
.find(&format!(
|
||||
"rollout_summary_file: {canonical_rollout_summary_file}"
|
||||
@@ -158,7 +165,8 @@ async fn sync_rollout_summaries_and_raw_memories_file_keeps_latest_memories_only
|
||||
.expect("rollout_summary_file should exist after thread header");
|
||||
assert!(thread_pos < updated_pos);
|
||||
assert!(updated_pos < cwd_pos);
|
||||
assert!(cwd_pos < file_pos);
|
||||
assert!(cwd_pos < rollout_path_pos);
|
||||
assert!(rollout_path_pos < file_pos);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -184,7 +192,9 @@ async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename(
|
||||
raw_memory: "raw memory".to_string(),
|
||||
rollout_summary: "short summary".to_string(),
|
||||
rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()),
|
||||
rollout_path: PathBuf::from("/tmp/rollout-200.jsonl"),
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
git_branch: Some("feature/memory-branch".to_string()),
|
||||
generated_at: Utc.timestamp_opt(201, 0).single().expect("timestamp"),
|
||||
}];
|
||||
|
||||
@@ -239,6 +249,8 @@ async fn sync_rollout_summaries_uses_timestamp_hash_and_sanitized_slug_filename(
|
||||
.await
|
||||
.expect("read rollout summary");
|
||||
assert!(summary.contains(&format!("thread_id: {thread_id}")));
|
||||
assert!(summary.contains("rollout_path: /tmp/rollout-200.jsonl"));
|
||||
assert!(summary.contains("git_branch: feature/memory-branch"));
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&stale_unslugged_path)
|
||||
.await
|
||||
@@ -283,7 +295,9 @@ task_outcome: success
|
||||
.to_string(),
|
||||
rollout_summary: "short summary".to_string(),
|
||||
rollout_slug: Some("Unsafe Slug/With Spaces & Symbols + EXTRA_LONG_12345".to_string()),
|
||||
rollout_path: PathBuf::from("/tmp/rollout-200.jsonl"),
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
git_branch: None,
|
||||
generated_at: Utc.timestamp_opt(201, 0).single().expect("timestamp"),
|
||||
}];
|
||||
|
||||
@@ -316,6 +330,12 @@ task_outcome: success
|
||||
let raw_memories = tokio::fs::read_to_string(raw_memories_file(&root))
|
||||
.await
|
||||
.expect("read raw memories");
|
||||
let summary = tokio::fs::read_to_string(
|
||||
rollout_summaries_dir(&root).join(canonical_rollout_summary_file),
|
||||
)
|
||||
.await
|
||||
.expect("read rollout summary");
|
||||
assert!(summary.contains("rollout_path: /tmp/rollout-200.jsonl"));
|
||||
assert!(raw_memories.contains(&format!(
|
||||
"rollout_summary_file: {canonical_rollout_summary_file}"
|
||||
)));
|
||||
@@ -360,7 +380,9 @@ mod phase2 {
|
||||
raw_memory: "raw memory".to_string(),
|
||||
rollout_summary: "rollout summary".to_string(),
|
||||
rollout_slug: None,
|
||||
rollout_path: PathBuf::from("/tmp/rollout-summary.jsonl"),
|
||||
cwd: PathBuf::from("/tmp/workspace"),
|
||||
git_branch: None,
|
||||
generated_at: chrono::DateTime::<Utc>::from_timestamp(source_updated_at + 1, 0)
|
||||
.expect("valid generated_at timestamp"),
|
||||
}
|
||||
@@ -542,7 +564,7 @@ mod phase2 {
|
||||
#[tokio::test]
|
||||
async fn dispatch_reclaims_stale_global_lock_and_starts_consolidation() {
|
||||
let harness = DispatchHarness::new().await;
|
||||
harness.seed_stage1_output(100).await;
|
||||
harness.seed_stage1_output(Utc::now().timestamp()).await;
|
||||
|
||||
let stale_claim = harness
|
||||
.state_db
|
||||
@@ -556,12 +578,18 @@ mod phase2 {
|
||||
|
||||
phase2::run(&harness.session, Arc::clone(&harness.config)).await;
|
||||
|
||||
let running_claim = harness
|
||||
let post_dispatch_claim = harness
|
||||
.state_db
|
||||
.try_claim_global_phase2_job(ThreadId::new(), 3_600)
|
||||
.await
|
||||
.expect("claim while running");
|
||||
pretty_assertions::assert_eq!(running_claim, Phase2JobClaimOutcome::SkippedRunning);
|
||||
.expect("claim after stale lock dispatch");
|
||||
assert!(
|
||||
matches!(
|
||||
post_dispatch_claim,
|
||||
Phase2JobClaimOutcome::SkippedRunning | Phase2JobClaimOutcome::SkippedNotDirty
|
||||
),
|
||||
"stale-lock dispatch should either keep the reclaimed job running or finish it before re-claim"
|
||||
);
|
||||
|
||||
let user_input_ops = harness.user_input_ops_count();
|
||||
pretty_assertions::assert_eq!(user_input_ops, 1);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelInstructionsVariables;
|
||||
@@ -68,10 +69,12 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo {
|
||||
visibility: ModelVisibility::None,
|
||||
supported_in_api: true,
|
||||
priority: 99,
|
||||
availability_nux: None,
|
||||
upgrade: None,
|
||||
base_instructions: BASE_INSTRUCTIONS.to_string(),
|
||||
model_messages: local_personality_messages_for_slug(slug),
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
apply_patch_tool_type: None,
|
||||
|
||||
@@ -31,6 +31,7 @@ use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
const AUDIO_IN_QUEUE_CAPACITY: usize = 256;
|
||||
@@ -184,6 +185,7 @@ pub(crate) async fn handle_start(
|
||||
let requested_session_id = params
|
||||
.session_id
|
||||
.or_else(|| Some(sess.conversation_id.to_string()));
|
||||
info!("starting realtime conversation");
|
||||
let events_rx = match sess
|
||||
.conversation
|
||||
.start(api_provider, None, prompt, requested_session_id.clone())
|
||||
@@ -191,11 +193,14 @@ pub(crate) async fn handle_start(
|
||||
{
|
||||
Ok(events_rx) => events_rx,
|
||||
Err(err) => {
|
||||
error!("failed to start realtime conversation: {err}");
|
||||
send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::Other).await;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
info!("realtime conversation started");
|
||||
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id.clone(),
|
||||
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
|
||||
@@ -211,6 +216,7 @@ pub(crate) async fn handle_start(
|
||||
msg,
|
||||
};
|
||||
while let Ok(event) = events_rx.recv().await {
|
||||
debug!(conversation_id = %sess_clone.conversation_id, "received realtime conversation event");
|
||||
let maybe_routed_text = match &event {
|
||||
RealtimeEvent::ConversationItemAdded(item) => {
|
||||
realtime_text_from_conversation_item(item)
|
||||
@@ -231,6 +237,7 @@ pub(crate) async fn handle_start(
|
||||
.await;
|
||||
}
|
||||
if let Some(()) = sess_clone.conversation.running_state().await {
|
||||
info!("realtime conversation transport closed");
|
||||
sess_clone
|
||||
.send_event_raw(ev(EventMsg::RealtimeConversationClosed(
|
||||
RealtimeConversationClosedEvent {
|
||||
@@ -250,6 +257,7 @@ pub(crate) async fn handle_audio(
|
||||
params: ConversationAudioParams,
|
||||
) {
|
||||
if let Err(err) = sess.conversation.audio_in(params.frame).await {
|
||||
error!("failed to append realtime audio: {err}");
|
||||
send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::BadRequest).await;
|
||||
}
|
||||
}
|
||||
@@ -284,6 +292,7 @@ pub(crate) async fn handle_text(
|
||||
debug!(text = %params.text, "[realtime-text] appending realtime conversation text input");
|
||||
|
||||
if let Err(err) = sess.conversation.text_in(params.text).await {
|
||||
error!("failed to append realtime text: {err}");
|
||||
send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::BadRequest).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +141,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::SkillRequestApproval(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
|
||||
@@ -326,7 +326,9 @@ impl RolloutRecorder {
|
||||
else {
|
||||
break;
|
||||
};
|
||||
if let Some(path) = select_resume_path_from_db_page(&db_page, filter_cwd) {
|
||||
if let Some(path) =
|
||||
select_resume_path_from_db_page(&db_page, filter_cwd, default_provider).await
|
||||
{
|
||||
return Ok(Some(path));
|
||||
}
|
||||
db_cursor = db_page.next_anchor.map(Into::into);
|
||||
@@ -348,7 +350,7 @@ impl RolloutRecorder {
|
||||
default_provider,
|
||||
)
|
||||
.await?;
|
||||
if let Some(path) = select_resume_path(&page, filter_cwd) {
|
||||
if let Some(path) = select_resume_path(&page, filter_cwd, default_provider).await {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
@@ -458,6 +460,7 @@ impl RolloutRecorder {
|
||||
state_db_ctx.clone(),
|
||||
state_builder,
|
||||
config.model_provider_id.clone(),
|
||||
config.memories.generate_memories,
|
||||
));
|
||||
|
||||
Ok(Self {
|
||||
@@ -709,6 +712,7 @@ async fn rollout_writer(
|
||||
state_db_ctx: Option<StateDbHandle>,
|
||||
mut state_builder: Option<ThreadMetadataBuilder>,
|
||||
default_provider: String,
|
||||
generate_memories: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let mut writer = file.map(|file| JsonlWriter { file });
|
||||
let mut buffered_items = Vec::<RolloutItem>::new();
|
||||
@@ -729,6 +733,7 @@ async fn rollout_writer(
|
||||
state_db_ctx.as_deref(),
|
||||
&mut state_builder,
|
||||
default_provider.as_str(),
|
||||
generate_memories,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -782,6 +787,7 @@ async fn rollout_writer(
|
||||
state_db_ctx.as_deref(),
|
||||
&mut state_builder,
|
||||
default_provider.as_str(),
|
||||
generate_memories,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
@@ -829,6 +835,7 @@ async fn rollout_writer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn write_session_meta(
|
||||
mut writer: Option<&mut JsonlWriter>,
|
||||
session_meta: SessionMeta,
|
||||
@@ -837,6 +844,7 @@ async fn write_session_meta(
|
||||
state_db_ctx: Option<&StateRuntime>,
|
||||
state_builder: &mut Option<ThreadMetadataBuilder>,
|
||||
default_provider: &str,
|
||||
generate_memories: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let git_info = collect_git_info(cwd).await;
|
||||
let session_meta_line = SessionMetaLine {
|
||||
@@ -858,6 +866,7 @@ async fn write_session_meta(
|
||||
state_builder.as_ref(),
|
||||
std::slice::from_ref(&rollout_item),
|
||||
None,
|
||||
(!generate_memories).then_some("disabled"),
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
@@ -886,6 +895,7 @@ async fn write_and_reconcile_items(
|
||||
state_builder.as_ref(),
|
||||
items,
|
||||
"rollout_writer",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
@@ -961,35 +971,79 @@ impl From<codex_state::ThreadsPage> for ThreadsPage {
|
||||
}
|
||||
}
|
||||
|
||||
fn select_resume_path(page: &ThreadsPage, filter_cwd: Option<&Path>) -> Option<PathBuf> {
|
||||
async fn select_resume_path(
|
||||
page: &ThreadsPage,
|
||||
filter_cwd: Option<&Path>,
|
||||
default_provider: &str,
|
||||
) -> Option<PathBuf> {
|
||||
match filter_cwd {
|
||||
Some(cwd) => page.items.iter().find_map(|item| {
|
||||
if item
|
||||
.cwd
|
||||
.as_ref()
|
||||
.is_some_and(|session_cwd| cwd_matches(session_cwd, cwd))
|
||||
{
|
||||
Some(item.path.clone())
|
||||
} else {
|
||||
None
|
||||
Some(cwd) => {
|
||||
for item in &page.items {
|
||||
if resume_candidate_matches_cwd(
|
||||
item.path.as_path(),
|
||||
item.cwd.as_deref(),
|
||||
cwd,
|
||||
default_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Some(item.path.clone());
|
||||
}
|
||||
}
|
||||
}),
|
||||
None
|
||||
}
|
||||
None => page.items.first().map(|item| item.path.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn select_resume_path_from_db_page(
|
||||
async fn resume_candidate_matches_cwd(
|
||||
rollout_path: &Path,
|
||||
cached_cwd: Option<&Path>,
|
||||
cwd: &Path,
|
||||
default_provider: &str,
|
||||
) -> bool {
|
||||
if cached_cwd.is_some_and(|session_cwd| cwd_matches(session_cwd, cwd)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if let Ok((items, _, _)) = RolloutRecorder::load_rollout_items(rollout_path).await
|
||||
&& let Some(latest_turn_context_cwd) = items.iter().rev().find_map(|item| match item {
|
||||
RolloutItem::TurnContext(turn_context) => Some(turn_context.cwd.as_path()),
|
||||
RolloutItem::SessionMeta(_)
|
||||
| RolloutItem::ResponseItem(_)
|
||||
| RolloutItem::Compacted(_)
|
||||
| RolloutItem::EventMsg(_) => None,
|
||||
})
|
||||
{
|
||||
return cwd_matches(latest_turn_context_cwd, cwd);
|
||||
}
|
||||
|
||||
metadata::extract_metadata_from_rollout(rollout_path, default_provider, None)
|
||||
.await
|
||||
.is_ok_and(|outcome| cwd_matches(outcome.metadata.cwd.as_path(), cwd))
|
||||
}
|
||||
|
||||
async fn select_resume_path_from_db_page(
|
||||
page: &codex_state::ThreadsPage,
|
||||
filter_cwd: Option<&Path>,
|
||||
default_provider: &str,
|
||||
) -> Option<PathBuf> {
|
||||
match filter_cwd {
|
||||
Some(cwd) => page.items.iter().find_map(|item| {
|
||||
if cwd_matches(item.cwd.as_path(), cwd) {
|
||||
Some(item.rollout_path.clone())
|
||||
} else {
|
||||
None
|
||||
Some(cwd) => {
|
||||
for item in &page.items {
|
||||
if resume_candidate_matches_cwd(
|
||||
item.rollout_path.as_path(),
|
||||
Some(item.cwd.as_path()),
|
||||
cwd,
|
||||
default_provider,
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Some(item.rollout_path.clone());
|
||||
}
|
||||
}
|
||||
}),
|
||||
None
|
||||
}
|
||||
None => page.items.first().map(|item| item.rollout_path.clone()),
|
||||
}
|
||||
}
|
||||
@@ -1010,8 +1064,12 @@ mod tests {
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::features::Feature;
|
||||
use chrono::TimeZone;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::File;
|
||||
@@ -1320,4 +1378,49 @@ mod tests {
|
||||
assert_eq!(repaired_path, Some(real_path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let stale_cwd = home.path().join("stale");
|
||||
let latest_cwd = home.path().join("latest");
|
||||
fs::create_dir_all(&stale_cwd)?;
|
||||
fs::create_dir_all(&latest_cwd)?;
|
||||
|
||||
let path = write_session_file(home.path(), "2025-01-03T13-00-00", Uuid::from_u128(9012))?;
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let turn_context = RolloutLine {
|
||||
timestamp: "2025-01-03T13:00:01Z".to_string(),
|
||||
item: RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
cwd: latest_cwd.clone(),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
model: "test-model".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
effort: None,
|
||||
summary: ReasoningSummaryConfig::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
}),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&turn_context)?)?;
|
||||
|
||||
assert!(
|
||||
resume_candidate_matches_cwd(
|
||||
path.as_path(),
|
||||
Some(stale_cwd.as_path()),
|
||||
latest_cwd.as_path(),
|
||||
"test-provider",
|
||||
)
|
||||
.await
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ignores_session_prefix_messages_when_truncating_rollout_from_start() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let mut items = session.build_initial_context(&turn_context).await;
|
||||
let mut items = session.build_initial_context(&turn_context, None).await;
|
||||
items.push(user_msg("feature request"));
|
||||
items.push(assistant_msg("ack"));
|
||||
items.push(user_msg("second question"));
|
||||
|
||||
@@ -17,7 +17,7 @@ use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::create_seatbelt_command_args;
|
||||
use crate::seatbelt::create_seatbelt_command_args_with_extensions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
|
||||
@@ -25,6 +25,8 @@ use crate::tools::sandboxing::SandboxablePreference;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
pub use codex_protocol::models::SandboxPermissions;
|
||||
use codex_protocol::protocol::ReadOnlyAccess;
|
||||
@@ -73,6 +75,8 @@ pub(crate) struct SandboxTransformRequest<'a> {
|
||||
// to make shared ownership explicit across runtime/sandbox plumbing.
|
||||
pub network: Option<&'a NetworkProxy>,
|
||||
pub sandbox_policy_cwd: &'a Path,
|
||||
#[cfg(target_os = "macos")]
|
||||
pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
|
||||
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
|
||||
pub use_linux_sandbox_bwrap: bool,
|
||||
pub windows_sandbox_level: WindowsSandboxLevel,
|
||||
@@ -342,6 +346,8 @@ impl SandboxManager {
|
||||
enforce_managed_network,
|
||||
network,
|
||||
sandbox_policy_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level,
|
||||
@@ -370,12 +376,13 @@ impl SandboxManager {
|
||||
SandboxType::MacosSeatbelt => {
|
||||
let mut seatbelt_env = HashMap::new();
|
||||
seatbelt_env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
let mut args = create_seatbelt_command_args(
|
||||
let mut args = create_seatbelt_command_args_with_extensions(
|
||||
command.clone(),
|
||||
&effective_policy,
|
||||
sandbox_policy_cwd,
|
||||
enforce_managed_network,
|
||||
network,
|
||||
macos_seatbelt_profile_extensions,
|
||||
);
|
||||
let mut full_command = Vec::with_capacity(1 + args.len());
|
||||
full_command.push(MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string());
|
||||
|
||||
@@ -3,34 +3,9 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum MacOsPreferencesPermission {
|
||||
// IMPORTANT: ReadOnly needs to be the default because it's the security-sensitive default.
|
||||
// it's important for allowing cf prefs to work.
|
||||
#[default]
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
None,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum MacOsAutomationPermission {
|
||||
#[default]
|
||||
None,
|
||||
All,
|
||||
BundleIds(Vec<String>),
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct MacOsSeatbeltProfileExtensions {
|
||||
pub macos_preferences: MacOsPreferencesPermission,
|
||||
pub macos_automation: MacOsAutomationPermission,
|
||||
pub macos_accessibility: bool,
|
||||
pub macos_calendar: bool,
|
||||
}
|
||||
pub use codex_protocol::models::MacOsAutomationPermission;
|
||||
pub use codex_protocol::models::MacOsPreferencesPermission;
|
||||
pub use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub(crate) struct SeatbeltExtensionPolicy {
|
||||
@@ -38,25 +13,26 @@ pub(crate) struct SeatbeltExtensionPolicy {
|
||||
pub(crate) dir_params: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
impl MacOsSeatbeltProfileExtensions {
|
||||
pub fn normalized(&self) -> Self {
|
||||
let mut normalized = self.clone();
|
||||
if let MacOsAutomationPermission::BundleIds(bundle_ids) = &self.macos_automation {
|
||||
let bundle_ids = normalize_bundle_ids(bundle_ids);
|
||||
normalized.macos_automation = if bundle_ids.is_empty() {
|
||||
MacOsAutomationPermission::None
|
||||
} else {
|
||||
MacOsAutomationPermission::BundleIds(bundle_ids)
|
||||
};
|
||||
}
|
||||
normalized
|
||||
fn normalized_extensions(
|
||||
extensions: &MacOsSeatbeltProfileExtensions,
|
||||
) -> MacOsSeatbeltProfileExtensions {
|
||||
let mut normalized = extensions.clone();
|
||||
if let MacOsAutomationPermission::BundleIds(bundle_ids) = &extensions.macos_automation {
|
||||
let bundle_ids = normalize_bundle_ids(bundle_ids);
|
||||
normalized.macos_automation = if bundle_ids.is_empty() {
|
||||
MacOsAutomationPermission::None
|
||||
} else {
|
||||
MacOsAutomationPermission::BundleIds(bundle_ids)
|
||||
};
|
||||
}
|
||||
|
||||
normalized
|
||||
}
|
||||
|
||||
pub(crate) fn build_seatbelt_extensions(
|
||||
extensions: &MacOsSeatbeltProfileExtensions,
|
||||
) -> SeatbeltExtensionPolicy {
|
||||
let extensions = extensions.normalized();
|
||||
let extensions = normalized_extensions(extensions);
|
||||
let mut clauses = Vec::new();
|
||||
|
||||
match extensions.macos_preferences {
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
|
||||
/// Helpers for identifying model-visible "session prefix" messages.
|
||||
///
|
||||
/// A session prefix is a user-role message that carries configuration or state needed by
|
||||
/// follow-up turns (e.g. `<environment_context>`, `<turn_aborted>`). These items are persisted in
|
||||
/// history so the model can see them, but they are not user intent and must not create user-turn
|
||||
/// boundaries.
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = "<environment_context>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
|
||||
|
||||
fn starts_with_ascii_case_insensitive(text: &str, prefix: &str) -> bool {
|
||||
text.get(..prefix.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(prefix))
|
||||
}
|
||||
|
||||
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
|
||||
pub(crate) fn is_session_prefix(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
starts_with_ascii_case_insensitive(trimmed, ENVIRONMENT_CONTEXT_OPEN_TAG)
|
||||
|| starts_with_ascii_case_insensitive(trimmed, TURN_ABORTED_OPEN_TAG)
|
||||
|| starts_with_ascii_case_insensitive(trimmed, SUBAGENT_NOTIFICATION_OPEN_TAG)
|
||||
}
|
||||
/// Helpers for model-visible session state markers that are stored in user-role
|
||||
/// messages but are not user intent.
|
||||
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_FRAGMENT;
|
||||
|
||||
pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &AgentStatus) -> String {
|
||||
let payload_json = serde_json::json!({
|
||||
@@ -30,19 +10,12 @@ pub(crate) fn format_subagent_notification_message(agent_id: &str, status: &Agen
|
||||
"status": status,
|
||||
})
|
||||
.to_string();
|
||||
format!("{SUBAGENT_NOTIFICATION_OPEN_TAG}\n{payload_json}\n{SUBAGENT_NOTIFICATION_CLOSE_TAG}")
|
||||
SUBAGENT_NOTIFICATION_FRAGMENT.wrap(payload_json)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn is_session_prefix_is_case_insensitive() {
|
||||
assert_eq!(
|
||||
is_session_prefix("<SUBAGENT_NOTIFICATION>{}</subagent_notification>"),
|
||||
true
|
||||
);
|
||||
pub(crate) fn format_subagent_context_line(agent_id: &str, agent_nickname: Option<&str>) -> String {
|
||||
match agent_nickname.filter(|nickname| !nickname.is_empty()) {
|
||||
Some(agent_nickname) => format!("- {agent_id}: {agent_nickname}"),
|
||||
None => format!("- {agent_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,12 @@ impl SkillsManager {
|
||||
}),
|
||||
);
|
||||
let mut outcome = load_skills_from_roots(roots);
|
||||
if !extra_user_roots.is_empty() {
|
||||
// When extra user roots are provided, skip system skills before caching the result.
|
||||
outcome
|
||||
.skills
|
||||
.retain(|skill| skill.scope != SkillScope::System);
|
||||
}
|
||||
outcome.disabled_paths = disabled_paths_from_stack(&config_layer_stack);
|
||||
let (by_scripts_dir, by_doc_path) =
|
||||
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
|
||||
|
||||
@@ -8,6 +8,7 @@ use codex_protocol::models::MacOsAutomationValue;
|
||||
use codex_protocol::models::MacOsPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::models::MacOsPreferencesValue;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
@@ -20,10 +21,6 @@ use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt_permissions::MacOsSeatbeltProfileExtensions;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
type MacOsSeatbeltProfileExtensions = ();
|
||||
|
||||
pub(crate) fn compile_permission_profile(
|
||||
skill_dir: &Path,
|
||||
|
||||
@@ -11,7 +11,6 @@ use tokio_util::task::AbortOnDropHandle;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_protocol::skill_approval::SkillApprovalResponse;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::codex::TurnContext;
|
||||
@@ -72,7 +71,6 @@ impl ActiveTurn {
|
||||
pub(crate) struct TurnState {
|
||||
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
|
||||
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
|
||||
pending_skill_approvals: HashMap<String, oneshot::Sender<SkillApprovalResponse>>,
|
||||
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
|
||||
pending_input: Vec<ResponseInputItem>,
|
||||
}
|
||||
@@ -96,7 +94,6 @@ impl TurnState {
|
||||
pub(crate) fn clear_pending(&mut self) {
|
||||
self.pending_approvals.clear();
|
||||
self.pending_user_input.clear();
|
||||
self.pending_skill_approvals.clear();
|
||||
self.pending_dynamic_tools.clear();
|
||||
self.pending_input.clear();
|
||||
}
|
||||
@@ -124,21 +121,6 @@ impl TurnState {
|
||||
self.pending_dynamic_tools.insert(key, tx)
|
||||
}
|
||||
|
||||
pub(crate) fn insert_pending_skill_approval(
|
||||
&mut self,
|
||||
key: String,
|
||||
tx: oneshot::Sender<SkillApprovalResponse>,
|
||||
) -> Option<oneshot::Sender<SkillApprovalResponse>> {
|
||||
self.pending_skill_approvals.insert(key, tx)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_skill_approval(
|
||||
&mut self,
|
||||
key: &str,
|
||||
) -> Option<oneshot::Sender<SkillApprovalResponse>> {
|
||||
self.pending_skill_approvals.remove(key)
|
||||
}
|
||||
|
||||
pub(crate) fn remove_pending_dynamic_tool(
|
||||
&mut self,
|
||||
key: &str,
|
||||
|
||||
@@ -345,6 +345,7 @@ pub async fn reconcile_rollout(
|
||||
builder: Option<&ThreadMetadataBuilder>,
|
||||
items: &[RolloutItem],
|
||||
archived_only: Option<bool>,
|
||||
new_thread_memory_mode: Option<&str>,
|
||||
) {
|
||||
let Some(ctx) = context else {
|
||||
return;
|
||||
@@ -357,6 +358,7 @@ pub async fn reconcile_rollout(
|
||||
builder,
|
||||
items,
|
||||
"reconcile_rollout",
|
||||
new_thread_memory_mode,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
@@ -467,6 +469,7 @@ pub async fn read_repair_rollout_path(
|
||||
None,
|
||||
&[],
|
||||
archived_only,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -479,6 +482,7 @@ pub async fn apply_rollout_items(
|
||||
builder: Option<&ThreadMetadataBuilder>,
|
||||
items: &[RolloutItem],
|
||||
stage: &str,
|
||||
new_thread_memory_mode: Option<&str>,
|
||||
) {
|
||||
let Some(ctx) = context else {
|
||||
return;
|
||||
@@ -499,7 +503,10 @@ pub async fn apply_rollout_items(
|
||||
};
|
||||
builder.rollout_path = rollout_path.to_path_buf();
|
||||
builder.cwd = normalize_cwd_for_state_db(&builder.cwd);
|
||||
if let Err(err) = ctx.apply_rollout_items(&builder, items, None).await {
|
||||
if let Err(err) = ctx
|
||||
.apply_rollout_items(&builder, items, None, new_thread_memory_mode)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"state db apply_rollout_items failed during {stage} for {}: {err}",
|
||||
rollout_path.display()
|
||||
|
||||
@@ -21,12 +21,12 @@ use tracing::warn;
|
||||
use crate::AuthManager;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::contextual_user_message::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnAbortReason;
|
||||
use crate::protocol::TurnAbortedEvent;
|
||||
use crate::protocol::TurnCompleteEvent;
|
||||
use crate::session_prefix::TURN_ABORTED_OPEN_TAG;
|
||||
use crate::state::ActiveTurn;
|
||||
use crate::state::RunningTask;
|
||||
use crate::state::TaskKind;
|
||||
|
||||
@@ -252,7 +252,7 @@ impl ThreadManager {
|
||||
}
|
||||
|
||||
pub async fn list_thread_ids(&self) -> Vec<ThreadId> {
|
||||
self.state.threads.read().await.keys().copied().collect()
|
||||
self.state.list_thread_ids().await
|
||||
}
|
||||
|
||||
pub async fn refresh_mcp_servers(&self, refresh_config: McpServerRefreshConfig) {
|
||||
@@ -428,6 +428,10 @@ impl ThreadManager {
|
||||
}
|
||||
|
||||
impl ThreadManagerState {
|
||||
pub(crate) async fn list_thread_ids(&self) -> Vec<ThreadId> {
|
||||
self.threads.read().await.keys().copied().collect()
|
||||
}
|
||||
|
||||
/// Fetch a thread by ID or return ThreadNotFound.
|
||||
pub(crate) async fn get_thread(&self, thread_id: ThreadId) -> CodexResult<Arc<CodexThread>> {
|
||||
let threads = self.threads.read().await;
|
||||
@@ -511,6 +515,27 @@ impl ThreadManagerState {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn fork_thread_with_source(
|
||||
&self,
|
||||
config: Config,
|
||||
initial_history: InitialHistory,
|
||||
agent_control: AgentControl,
|
||||
session_source: SessionSource,
|
||||
persist_extended_history: bool,
|
||||
) -> CodexResult<NewThread> {
|
||||
self.spawn_thread_with_source(
|
||||
config,
|
||||
initial_history,
|
||||
Arc::clone(&self.auth_manager),
|
||||
agent_control,
|
||||
session_source,
|
||||
Vec::new(),
|
||||
persist_extended_history,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Spawn a new thread with optional history and register it with the manager.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn spawn_thread(
|
||||
@@ -706,7 +731,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn ignores_session_prefix_messages_when_truncating() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let mut items = session.build_initial_context(&turn_context).await;
|
||||
let mut items = session.build_initial_context(&turn_context, None).await;
|
||||
items.push(user_msg("feature request"));
|
||||
items.push(assistant_msg("ack"));
|
||||
items.push(user_msg("second question"));
|
||||
|
||||
@@ -95,15 +95,12 @@ impl ToolOutput {
|
||||
match self {
|
||||
ToolOutput::Function { body, success } => {
|
||||
// `custom_tool_call` is the Responses API item type for freeform
|
||||
// tools (`ToolSpec::Freeform`, e.g. freeform `apply_patch`).
|
||||
// Those payloads must round-trip as `custom_tool_call_output`
|
||||
// with plain string output.
|
||||
// tools (`ToolSpec::Freeform`, e.g. freeform `apply_patch` or
|
||||
// `js_repl`).
|
||||
if matches!(payload, ToolPayload::Custom { .. }) {
|
||||
// Freeform/custom tools (`custom_tool_call`) use the custom
|
||||
// output wire shape and remain string-only.
|
||||
return ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: body.to_text().unwrap_or_default(),
|
||||
output: FunctionCallOutputPayload { body, success },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,7 +180,9 @@ mod tests {
|
||||
match response {
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
assert_eq!(call_id, "call-42");
|
||||
assert_eq!(output, "patched");
|
||||
assert_eq!(output.text_content(), Some("patched"));
|
||||
assert!(output.content_items().is_none());
|
||||
assert_eq!(output.success, Some(true));
|
||||
}
|
||||
other => panic!("expected CustomToolCallOutput, got {other:?}"),
|
||||
}
|
||||
@@ -234,8 +233,21 @@ mod tests {
|
||||
|
||||
match response {
|
||||
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
|
||||
let expected = vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "line 1".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,AAA".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "line 2".to_string(),
|
||||
},
|
||||
];
|
||||
assert_eq!(call_id, "call-99");
|
||||
assert_eq!(output, "line 1\nline 2");
|
||||
assert_eq!(output.content_items(), Some(expected.as_slice()));
|
||||
assert_eq!(output.body.to_text().as_deref(), Some("line 1\nline 2"));
|
||||
assert_eq!(output.success, Some(true));
|
||||
}
|
||||
other => panic!("expected CustomToolCallOutput, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -155,9 +155,13 @@ impl ToolHandler for JsReplHandler {
|
||||
};
|
||||
|
||||
let content = result.output;
|
||||
let items = vec![FunctionCallOutputContentItem::InputText {
|
||||
text: content.clone(),
|
||||
}];
|
||||
let mut items = Vec::with_capacity(result.content_items.len() + 1);
|
||||
if !content.is_empty() {
|
||||
items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: content.clone(),
|
||||
});
|
||||
}
|
||||
items.extend(result.content_items);
|
||||
|
||||
emit_js_repl_exec_end(
|
||||
session.as_ref(),
|
||||
@@ -170,7 +174,11 @@ impl ToolHandler for JsReplHandler {
|
||||
.await;
|
||||
|
||||
Ok(ToolOutput::Function {
|
||||
body: FunctionCallOutputBody::ContentItems(items),
|
||||
body: if items.is_empty() {
|
||||
FunctionCallOutputBody::Text(content)
|
||||
} else {
|
||||
FunctionCallOutputBody::ContentItems(items)
|
||||
},
|
||||
success: Some(true),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ impl ToolHandler for MultiAgentHandler {
|
||||
|
||||
mod spawn {
|
||||
use super::*;
|
||||
use crate::agent::control::SpawnAgentOptions;
|
||||
use crate::agent::role::DEFAULT_ROLE_NAME;
|
||||
use crate::agent::role::apply_role_to_config;
|
||||
|
||||
@@ -103,6 +104,8 @@ mod spawn {
|
||||
message: Option<String>,
|
||||
items: Option<Vec<UserInput>>,
|
||||
agent_type: Option<String>,
|
||||
#[serde(default)]
|
||||
fork_context: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -155,7 +158,7 @@ mod spawn {
|
||||
let result = session
|
||||
.services
|
||||
.agent_control
|
||||
.spawn_agent(
|
||||
.spawn_agent_with_options(
|
||||
config,
|
||||
input_items,
|
||||
Some(thread_spawn_source(
|
||||
@@ -163,6 +166,9 @@ mod spawn {
|
||||
child_depth,
|
||||
role_name,
|
||||
)),
|
||||
SpawnAgentOptions {
|
||||
fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(collab_spawn_error);
|
||||
@@ -914,7 +920,7 @@ fn build_agent_shared_config(turn: &TurnContext) -> Result<Config, FunctionCallE
|
||||
config.model = Some(turn.model_info.slug.clone());
|
||||
config.model_provider = turn.provider.clone();
|
||||
config.model_reasoning_effort = turn.reasoning_effort;
|
||||
config.model_reasoning_summary = turn.reasoning_summary;
|
||||
config.model_reasoning_summary = Some(turn.reasoning_summary);
|
||||
config.developer_instructions = turn.developer_instructions.clone();
|
||||
config.compact_prompt = turn.compact_prompt.clone();
|
||||
apply_spawn_agent_runtime_overrides(&mut config, turn)?;
|
||||
@@ -1222,7 +1228,7 @@ mod tests {
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "await this command",
|
||||
"agent_type": "awaiter"
|
||||
"agent_type": "explorer"
|
||||
})),
|
||||
);
|
||||
let output = MultiAgentHandler
|
||||
@@ -2046,7 +2052,7 @@ mod tests {
|
||||
expected.model = Some(turn.model_info.slug.clone());
|
||||
expected.model_provider = turn.provider.clone();
|
||||
expected.model_reasoning_effort = turn.reasoning_effort;
|
||||
expected.model_reasoning_summary = turn.reasoning_summary;
|
||||
expected.model_reasoning_summary = Some(turn.reasoning_summary);
|
||||
expected.developer_instructions = turn.developer_instructions.clone();
|
||||
expected.compact_prompt = turn.compact_prompt.clone();
|
||||
expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
@@ -2098,7 +2104,7 @@ mod tests {
|
||||
expected.model = Some(turn.model_info.slug.clone());
|
||||
expected.model_provider = turn.provider.clone();
|
||||
expected.model_reasoning_effort = turn.reasoning_effort;
|
||||
expected.model_reasoning_summary = turn.reasoning_summary;
|
||||
expected.model_reasoning_summary = Some(turn.reasoning_summary);
|
||||
expected.developer_instructions = turn.developer_instructions.clone();
|
||||
expected.compact_prompt = turn.compact_prompt.clone();
|
||||
expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone();
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::time::Duration;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
@@ -24,6 +25,8 @@ use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::OnceCell;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
use tracing::info;
|
||||
use tracing::trace;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -39,6 +42,8 @@ use crate::sandboxing::SandboxPermissions;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
|
||||
pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:";
|
||||
const KERNEL_SOURCE: &str = include_str!("kernel.js");
|
||||
@@ -51,6 +56,7 @@ const JS_REPL_STDERR_TAIL_SEPARATOR: &str = " | ";
|
||||
const JS_REPL_EXEC_ID_LOG_LIMIT: usize = 8;
|
||||
const JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES: usize = 1_024;
|
||||
const JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES: usize = 256;
|
||||
const JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES: usize = 512;
|
||||
|
||||
/// Per-task js_repl handle stored on the turn context.
|
||||
pub(crate) struct JsReplHandle {
|
||||
@@ -98,6 +104,7 @@ pub struct JsReplArgs {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JsExecResult {
|
||||
pub output: String,
|
||||
pub content_items: Vec<FunctionCallOutputContentItem>,
|
||||
}
|
||||
|
||||
struct KernelState {
|
||||
@@ -119,10 +126,37 @@ struct ExecContext {
|
||||
#[derive(Default)]
|
||||
struct ExecToolCalls {
|
||||
in_flight: usize,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
notify: Arc<Notify>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
enum JsReplToolCallPayloadKind {
|
||||
MessageContent,
|
||||
FunctionText,
|
||||
FunctionContentItems,
|
||||
CustomText,
|
||||
CustomContentItems,
|
||||
McpResult,
|
||||
McpErrorResult,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
struct JsReplToolCallResponseSummary {
|
||||
response_type: Option<String>,
|
||||
payload_kind: Option<JsReplToolCallPayloadKind>,
|
||||
payload_text_preview: Option<String>,
|
||||
payload_text_length: Option<usize>,
|
||||
payload_item_count: Option<usize>,
|
||||
text_item_count: Option<usize>,
|
||||
image_item_count: Option<usize>,
|
||||
structured_content_present: Option<bool>,
|
||||
result_is_error: Option<bool>,
|
||||
}
|
||||
|
||||
enum KernelStreamEnd {
|
||||
Shutdown,
|
||||
StdoutEof,
|
||||
@@ -338,6 +372,21 @@ impl JsReplManager {
|
||||
Some(state.cancel.clone())
|
||||
}
|
||||
|
||||
async fn record_exec_tool_call_content_items(
|
||||
exec_tool_calls: &Arc<Mutex<HashMap<String, ExecToolCalls>>>,
|
||||
exec_id: &str,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
) {
|
||||
if content_items.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut calls = exec_tool_calls.lock().await;
|
||||
if let Some(state) = calls.get_mut(exec_id) {
|
||||
state.content_items.extend(content_items);
|
||||
}
|
||||
}
|
||||
|
||||
async fn finish_exec_tool_call(
|
||||
exec_tool_calls: &Arc<Mutex<HashMap<String, ExecToolCalls>>>,
|
||||
exec_id: &str,
|
||||
@@ -404,6 +453,205 @@ impl JsReplManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn log_tool_call_response(
|
||||
req: &RunToolRequest,
|
||||
ok: bool,
|
||||
summary: &JsReplToolCallResponseSummary,
|
||||
response: Option<&JsonValue>,
|
||||
error: Option<&str>,
|
||||
) {
|
||||
info!(
|
||||
exec_id = %req.exec_id,
|
||||
tool_call_id = %req.id,
|
||||
tool_name = %req.tool_name,
|
||||
ok,
|
||||
summary = ?summary,
|
||||
"js_repl nested tool call completed"
|
||||
);
|
||||
if let Some(response) = response {
|
||||
trace!(
|
||||
exec_id = %req.exec_id,
|
||||
tool_call_id = %req.id,
|
||||
tool_name = %req.tool_name,
|
||||
response_json = %response,
|
||||
"js_repl nested tool call raw response"
|
||||
);
|
||||
}
|
||||
if let Some(error) = error {
|
||||
trace!(
|
||||
exec_id = %req.exec_id,
|
||||
tool_call_id = %req.id,
|
||||
tool_name = %req.tool_name,
|
||||
error = %error,
|
||||
"js_repl nested tool call raw error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_text_payload(
|
||||
response_type: Option<&str>,
|
||||
payload_kind: JsReplToolCallPayloadKind,
|
||||
text: &str,
|
||||
) -> JsReplToolCallResponseSummary {
|
||||
JsReplToolCallResponseSummary {
|
||||
response_type: response_type.map(str::to_owned),
|
||||
payload_kind: Some(payload_kind),
|
||||
payload_text_preview: (!text.is_empty()).then(|| {
|
||||
truncate_text(
|
||||
text,
|
||||
TruncationPolicy::Bytes(JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES),
|
||||
)
|
||||
}),
|
||||
payload_text_length: Some(text.len()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_function_output_payload(
|
||||
response_type: &str,
|
||||
payload_kind: JsReplToolCallPayloadKind,
|
||||
output: &FunctionCallOutputPayload,
|
||||
) -> JsReplToolCallResponseSummary {
|
||||
let (payload_item_count, text_item_count, image_item_count) =
|
||||
if let Some(items) = output.content_items() {
|
||||
let text_item_count = items
|
||||
.iter()
|
||||
.filter(|item| matches!(item, FunctionCallOutputContentItem::InputText { .. }))
|
||||
.count();
|
||||
let image_item_count = items.len().saturating_sub(text_item_count);
|
||||
(
|
||||
Some(items.len()),
|
||||
Some(text_item_count),
|
||||
Some(image_item_count),
|
||||
)
|
||||
} else {
|
||||
(None, None, None)
|
||||
};
|
||||
let payload_text = output.body.to_text();
|
||||
JsReplToolCallResponseSummary {
|
||||
response_type: Some(response_type.to_string()),
|
||||
payload_kind: Some(payload_kind),
|
||||
payload_text_preview: payload_text.as_deref().and_then(|text| {
|
||||
(!text.is_empty()).then(|| {
|
||||
truncate_text(
|
||||
text,
|
||||
TruncationPolicy::Bytes(JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES),
|
||||
)
|
||||
})
|
||||
}),
|
||||
payload_text_length: payload_text.as_ref().map(String::len),
|
||||
payload_item_count,
|
||||
text_item_count,
|
||||
image_item_count,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_message_payload(content: &[ContentItem]) -> JsReplToolCallResponseSummary {
|
||||
let text_item_count = content
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item,
|
||||
ContentItem::InputText { .. } | ContentItem::OutputText { .. }
|
||||
)
|
||||
})
|
||||
.count();
|
||||
let image_item_count = content.len().saturating_sub(text_item_count);
|
||||
let payload_text = content
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
ContentItem::InputText { text } | ContentItem::OutputText { text }
|
||||
if !text.trim().is_empty() =>
|
||||
{
|
||||
Some(text.as_str())
|
||||
}
|
||||
ContentItem::InputText { .. }
|
||||
| ContentItem::InputImage { .. }
|
||||
| ContentItem::OutputText { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let payload_text = if payload_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(payload_text.join("\n"))
|
||||
};
|
||||
JsReplToolCallResponseSummary {
|
||||
response_type: Some("message".to_string()),
|
||||
payload_kind: Some(JsReplToolCallPayloadKind::MessageContent),
|
||||
payload_text_preview: payload_text.as_deref().and_then(|text| {
|
||||
(!text.is_empty()).then(|| {
|
||||
truncate_text(
|
||||
text,
|
||||
TruncationPolicy::Bytes(JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES),
|
||||
)
|
||||
})
|
||||
}),
|
||||
payload_text_length: payload_text.as_ref().map(String::len),
|
||||
payload_item_count: Some(content.len()),
|
||||
text_item_count: Some(text_item_count),
|
||||
image_item_count: Some(image_item_count),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_tool_call_response(response: &ResponseInputItem) -> JsReplToolCallResponseSummary {
|
||||
match response {
|
||||
ResponseInputItem::Message { content, .. } => Self::summarize_message_payload(content),
|
||||
ResponseInputItem::FunctionCallOutput { output, .. } => {
|
||||
let payload_kind = if output.content_items().is_some() {
|
||||
JsReplToolCallPayloadKind::FunctionContentItems
|
||||
} else {
|
||||
JsReplToolCallPayloadKind::FunctionText
|
||||
};
|
||||
Self::summarize_function_output_payload(
|
||||
"function_call_output",
|
||||
payload_kind,
|
||||
output,
|
||||
)
|
||||
}
|
||||
ResponseInputItem::CustomToolCallOutput { output, .. } => {
|
||||
let payload_kind = if output.content_items().is_some() {
|
||||
JsReplToolCallPayloadKind::CustomContentItems
|
||||
} else {
|
||||
JsReplToolCallPayloadKind::CustomText
|
||||
};
|
||||
Self::summarize_function_output_payload(
|
||||
"custom_tool_call_output",
|
||||
payload_kind,
|
||||
output,
|
||||
)
|
||||
}
|
||||
ResponseInputItem::McpToolCallOutput { result, .. } => match result {
|
||||
Ok(result) => {
|
||||
let output = FunctionCallOutputPayload::from(result);
|
||||
let mut summary = Self::summarize_function_output_payload(
|
||||
"mcp_tool_call_output",
|
||||
JsReplToolCallPayloadKind::McpResult,
|
||||
&output,
|
||||
);
|
||||
summary.payload_item_count = Some(result.content.len());
|
||||
summary.structured_content_present = Some(result.structured_content.is_some());
|
||||
summary.result_is_error = Some(result.is_error.unwrap_or(false));
|
||||
summary
|
||||
}
|
||||
Err(error) => {
|
||||
let mut summary = Self::summarize_text_payload(
|
||||
Some("mcp_tool_call_output"),
|
||||
JsReplToolCallPayloadKind::McpErrorResult,
|
||||
error,
|
||||
);
|
||||
summary.result_is_error = Some(true);
|
||||
summary
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_tool_call_error(error: &str) -> JsReplToolCallResponseSummary {
|
||||
Self::summarize_text_payload(None, JsReplToolCallPayloadKind::Error, error)
|
||||
}
|
||||
|
||||
pub async fn reset(&self) -> Result<(), FunctionCallError> {
|
||||
let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| {
|
||||
FunctionCallError::RespondToModel("js_repl execution unavailable".to_string())
|
||||
@@ -546,7 +794,13 @@ impl JsReplManager {
|
||||
};
|
||||
|
||||
match response {
|
||||
ExecResultMessage::Ok { output } => Ok(JsExecResult { output }),
|
||||
ExecResultMessage::Ok { content_items } => {
|
||||
let (output, content_items) = split_exec_result_content_items(content_items);
|
||||
Ok(JsExecResult {
|
||||
output,
|
||||
content_items,
|
||||
})
|
||||
}
|
||||
ExecResultMessage::Err { message } => Err(FunctionCallError::RespondToModel(message)),
|
||||
}
|
||||
}
|
||||
@@ -556,10 +810,7 @@ impl JsReplManager {
|
||||
turn: Arc<TurnContext>,
|
||||
thread_id: Option<ThreadId>,
|
||||
) -> Result<KernelState, String> {
|
||||
let node_path = resolve_node(self.node_path.as_deref()).ok_or_else(|| {
|
||||
"Node runtime not found; install Node or set CODEX_JS_REPL_NODE_PATH".to_string()
|
||||
})?;
|
||||
ensure_node_version(&node_path).await?;
|
||||
let node_path = resolve_compatible_node(self.node_path.as_deref()).await?;
|
||||
|
||||
let kernel_path = self
|
||||
.write_kernel_script()
|
||||
@@ -616,6 +867,8 @@ impl JsReplManager {
|
||||
enforce_managed_network: has_managed_network_requirements,
|
||||
network: None,
|
||||
sandbox_policy_cwd: &turn.cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap: turn
|
||||
.features
|
||||
@@ -851,10 +1104,22 @@ impl JsReplManager {
|
||||
error,
|
||||
} => {
|
||||
JsReplManager::wait_for_exec_tool_calls_map(&exec_tool_calls, &id).await;
|
||||
let content_items = {
|
||||
let calls = exec_tool_calls.lock().await;
|
||||
calls
|
||||
.get(&id)
|
||||
.map(|state| state.content_items.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut pending = pending_execs.lock().await;
|
||||
if let Some(tx) = pending.remove(&id) {
|
||||
let payload = if ok {
|
||||
ExecResultMessage::Ok { output }
|
||||
ExecResultMessage::Ok {
|
||||
content_items: build_exec_result_content_items(
|
||||
output,
|
||||
content_items,
|
||||
),
|
||||
}
|
||||
} else {
|
||||
ExecResultMessage::Err {
|
||||
message: error
|
||||
@@ -911,7 +1176,11 @@ impl JsReplManager {
|
||||
response: None,
|
||||
error: Some("js_repl execution reset".to_string()),
|
||||
},
|
||||
result = JsReplManager::run_tool_request(ctx, req) => result,
|
||||
result = JsReplManager::run_tool_request(
|
||||
ctx,
|
||||
req,
|
||||
Arc::clone(&exec_tool_calls_for_task),
|
||||
) => result,
|
||||
}
|
||||
}
|
||||
None => RunToolResult {
|
||||
@@ -1005,13 +1274,20 @@ impl JsReplManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_tool_request(exec: ExecContext, req: RunToolRequest) -> RunToolResult {
|
||||
async fn run_tool_request(
|
||||
exec: ExecContext,
|
||||
req: RunToolRequest,
|
||||
exec_tool_calls: Arc<Mutex<HashMap<String, ExecToolCalls>>>,
|
||||
) -> RunToolResult {
|
||||
if is_js_repl_internal_tool(&req.tool_name) {
|
||||
let error = "js_repl cannot invoke itself".to_string();
|
||||
let summary = Self::summarize_tool_call_error(&error);
|
||||
Self::log_tool_call_response(&req, false, &summary, None, Some(&error));
|
||||
return RunToolResult {
|
||||
id: req.id,
|
||||
ok: false,
|
||||
response: None,
|
||||
error: Some("js_repl cannot invoke itself".to_string()),
|
||||
error: Some(error),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1075,62 +1351,50 @@ impl JsReplManager {
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if let ResponseInputItem::FunctionCallOutput { output, .. } = &response
|
||||
&& let Some(items) = output.content_items()
|
||||
{
|
||||
let mut has_image = false;
|
||||
let mut content = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
FunctionCallOutputContentItem::InputText { text } => {
|
||||
content.push(ContentItem::InputText { text: text.clone() });
|
||||
}
|
||||
FunctionCallOutputContentItem::InputImage { image_url } => {
|
||||
has_image = true;
|
||||
content.push(ContentItem::InputImage {
|
||||
image_url: image_url.clone(),
|
||||
});
|
||||
}
|
||||
if let Some(items) = response_content_items(&response) {
|
||||
Self::record_exec_tool_call_content_items(
|
||||
&exec_tool_calls,
|
||||
&req.exec_id,
|
||||
items,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let summary = Self::summarize_tool_call_response(&response);
|
||||
match serde_json::to_value(response) {
|
||||
Ok(value) => {
|
||||
Self::log_tool_call_response(&req, true, &summary, Some(&value), None);
|
||||
RunToolResult {
|
||||
id: req.id,
|
||||
ok: true,
|
||||
response: Some(value),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
if has_image
|
||||
&& session
|
||||
.inject_response_items(vec![ResponseInputItem::Message {
|
||||
role: "user".to_string(),
|
||||
content,
|
||||
}])
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
warn!(
|
||||
tool_name = %tool_name,
|
||||
"js_repl tool call returned image content but there was no active turn to attach it to"
|
||||
);
|
||||
Err(err) => {
|
||||
let error = format!("failed to serialize tool output: {err}");
|
||||
let summary = Self::summarize_tool_call_error(&error);
|
||||
Self::log_tool_call_response(&req, false, &summary, None, Some(&error));
|
||||
RunToolResult {
|
||||
id: req.id,
|
||||
ok: false,
|
||||
response: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match serde_json::to_value(response) {
|
||||
Ok(value) => RunToolResult {
|
||||
id: req.id,
|
||||
ok: true,
|
||||
response: Some(value),
|
||||
error: None,
|
||||
},
|
||||
Err(err) => RunToolResult {
|
||||
id: req.id,
|
||||
ok: false,
|
||||
response: None,
|
||||
error: Some(format!("failed to serialize tool output: {err}")),
|
||||
},
|
||||
}
|
||||
Err(err) => {
|
||||
let error = err.to_string();
|
||||
let summary = Self::summarize_tool_call_error(&error);
|
||||
Self::log_tool_call_response(&req, false, &summary, None, Some(&error));
|
||||
RunToolResult {
|
||||
id: req.id,
|
||||
ok: false,
|
||||
response: None,
|
||||
error: Some(error),
|
||||
}
|
||||
}
|
||||
Err(err) => RunToolResult {
|
||||
id: req.id,
|
||||
ok: false,
|
||||
response: None,
|
||||
error: Some(err.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1168,6 +1432,50 @@ impl JsReplManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn response_content_items(
|
||||
response: &ResponseInputItem,
|
||||
) -> Option<Vec<FunctionCallOutputContentItem>> {
|
||||
match response {
|
||||
ResponseInputItem::FunctionCallOutput { output, .. }
|
||||
| ResponseInputItem::CustomToolCallOutput { output, .. } => output
|
||||
.content_items()
|
||||
.map(<[FunctionCallOutputContentItem]>::to_vec),
|
||||
ResponseInputItem::McpToolCallOutput { result, .. } => match result {
|
||||
Ok(result) => FunctionCallOutputPayload::from(result)
|
||||
.content_items()
|
||||
.map(<[FunctionCallOutputContentItem]>::to_vec),
|
||||
Err(_) => None,
|
||||
},
|
||||
ResponseInputItem::Message { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exec_result_content_items(
|
||||
output: String,
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
) -> Vec<FunctionCallOutputContentItem> {
|
||||
let mut all_content_items = Vec::with_capacity(content_items.len() + 1);
|
||||
all_content_items.push(FunctionCallOutputContentItem::InputText { text: output });
|
||||
all_content_items.extend(content_items);
|
||||
all_content_items
|
||||
}
|
||||
|
||||
fn split_exec_result_content_items(
|
||||
mut content_items: Vec<FunctionCallOutputContentItem>,
|
||||
) -> (String, Vec<FunctionCallOutputContentItem>) {
|
||||
match content_items.first() {
|
||||
Some(FunctionCallOutputContentItem::InputText { .. }) => {
|
||||
let FunctionCallOutputContentItem::InputText { text } = content_items.remove(0) else {
|
||||
unreachable!("first content item should be input_text");
|
||||
};
|
||||
(text, content_items)
|
||||
}
|
||||
Some(FunctionCallOutputContentItem::InputImage { .. }) | None => {
|
||||
(String::new(), content_items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_freeform_tool(specs: &[ToolSpec], name: &str) -> bool {
|
||||
specs
|
||||
.iter()
|
||||
@@ -1223,8 +1531,12 @@ struct RunToolResult {
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ExecResultMessage {
|
||||
Ok { output: String },
|
||||
Err { message: String },
|
||||
Ok {
|
||||
content_items: Vec<FunctionCallOutputContentItem>,
|
||||
},
|
||||
Err {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
@@ -1321,6 +1633,14 @@ async fn ensure_node_version(node_path: &Path) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_compatible_node(config_path: Option<&Path>) -> Result<PathBuf, String> {
|
||||
let node_path = resolve_node(config_path).ok_or_else(|| {
|
||||
"Node runtime not found; install Node or set CODEX_JS_REPL_NODE_PATH".to_string()
|
||||
})?;
|
||||
ensure_node_version(&node_path).await?;
|
||||
Ok(node_path)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_node(config_path: Option<&Path>) -> Option<PathBuf> {
|
||||
if let Some(path) = std::env::var_os("CODEX_JS_REPL_NODE_PATH") {
|
||||
let p = PathBuf::from(path);
|
||||
@@ -1354,7 +1674,8 @@ mod tests {
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -1572,6 +1893,84 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tool_call_response_for_multimodal_function_output() {
|
||||
let response = ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abcd".to_string(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
let actual = JsReplManager::summarize_tool_call_response(&response);
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
JsReplToolCallResponseSummary {
|
||||
response_type: Some("function_call_output".to_string()),
|
||||
payload_kind: Some(JsReplToolCallPayloadKind::FunctionContentItems),
|
||||
payload_text_preview: None,
|
||||
payload_text_length: None,
|
||||
payload_item_count: Some(1),
|
||||
text_item_count: Some(0),
|
||||
image_item_count: Some(1),
|
||||
structured_content_present: None,
|
||||
result_is_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tool_call_response_for_multimodal_custom_output() {
|
||||
let response = ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,abcd".to_string(),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
let actual = JsReplManager::summarize_tool_call_response(&response);
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
JsReplToolCallResponseSummary {
|
||||
response_type: Some("custom_tool_call_output".to_string()),
|
||||
payload_kind: Some(JsReplToolCallPayloadKind::CustomContentItems),
|
||||
payload_text_preview: None,
|
||||
payload_text_length: None,
|
||||
payload_item_count: Some(1),
|
||||
text_item_count: Some(0),
|
||||
image_item_count: Some(1),
|
||||
structured_content_present: None,
|
||||
result_is_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_tool_call_error_marks_error_payload() {
|
||||
let actual = JsReplManager::summarize_tool_call_error("tool failed");
|
||||
|
||||
assert_eq!(
|
||||
actual,
|
||||
JsReplToolCallResponseSummary {
|
||||
response_type: None,
|
||||
payload_kind: Some(JsReplToolCallPayloadKind::Error),
|
||||
payload_text_preview: Some("tool failed".to_string()),
|
||||
payload_text_length: Some("tool failed".len()),
|
||||
payload_item_count: None,
|
||||
text_item_count: None,
|
||||
image_item_count: None,
|
||||
structured_content_present: None,
|
||||
result_is_error: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reset_clears_inflight_exec_tool_calls_without_waiting() {
|
||||
let manager = JsReplManager::new(None, Vec::new())
|
||||
@@ -2012,20 +2411,22 @@ console.log(out.output?.body?.text ?? "");
|
||||
)
|
||||
.await?;
|
||||
assert!(result.output.contains("function_call_output"));
|
||||
|
||||
let pending_input = session.get_pending_input().await;
|
||||
let [ResponseInputItem::Message { role, content }] = pending_input.as_slice() else {
|
||||
panic!(
|
||||
"view_image should inject exactly one pending input message, got {pending_input:?}"
|
||||
);
|
||||
};
|
||||
assert_eq!(role, "user");
|
||||
let [ContentItem::InputImage { image_url }] = content.as_slice() else {
|
||||
panic!(
|
||||
"view_image should inject exactly one input_image content item, got {content:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
result.content_items.as_slice(),
|
||||
[FunctionCallOutputContentItem::InputImage {
|
||||
image_url:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
|
||||
.to_string(),
|
||||
}]
|
||||
.as_slice()
|
||||
);
|
||||
let [FunctionCallOutputContentItem::InputImage { image_url }] =
|
||||
result.content_items.as_slice()
|
||||
else {
|
||||
panic!("view_image should return exactly one input_image content item");
|
||||
};
|
||||
assert!(image_url.starts_with("data:image/png;base64,"));
|
||||
assert!(session.get_pending_input().await.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -2106,22 +2507,18 @@ console.log(out.type);
|
||||
response_watcher_result?;
|
||||
let result = result?;
|
||||
assert!(result.output.contains("function_call_output"));
|
||||
|
||||
let pending_input = session.get_pending_input().await;
|
||||
assert_eq!(
|
||||
pending_input,
|
||||
vec![ResponseInputItem::Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentItem::InputText {
|
||||
text: "inline image note".to_string(),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: image_url.to_string(),
|
||||
},
|
||||
],
|
||||
}]
|
||||
result.content_items,
|
||||
vec![
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "inline image note".to_string(),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: image_url.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(session.get_pending_input().await.is_empty());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -331,6 +331,7 @@ impl NetworkApprovalService {
|
||||
protocol,
|
||||
};
|
||||
|
||||
let available_decisions = None;
|
||||
let approval_decision = session
|
||||
.request_command_approval(
|
||||
turn_context.as_ref(),
|
||||
@@ -342,6 +343,7 @@ impl NetworkApprovalService {
|
||||
Some(network_approval_context.clone()),
|
||||
None,
|
||||
None,
|
||||
available_decisions,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -117,7 +117,10 @@ impl ToolCallRuntime {
|
||||
match &call.payload {
|
||||
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
|
||||
call_id: call.call_id.clone(),
|
||||
output: Self::abort_message(call, secs),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(Self::abort_message(call, secs)),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
|
||||
call_id: call.call_id.clone(),
|
||||
|
||||
@@ -379,6 +379,7 @@ async fn dispatch_after_tool_use_hook(
|
||||
.dispatch(HookPayload {
|
||||
session_id: session.conversation_id,
|
||||
cwd: turn.cwd.clone(),
|
||||
client: turn.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::AfterToolUse {
|
||||
event: HookEventAfterToolUse {
|
||||
|
||||
@@ -197,7 +197,10 @@ impl ToolRouter {
|
||||
if payload_outputs_custom {
|
||||
ResponseInputItem::CustomToolCallOutput {
|
||||
call_id,
|
||||
output: message,
|
||||
output: codex_protocol::models::FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(message),
|
||||
success: Some(false),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
|
||||
@@ -150,6 +150,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
let call_id = ctx.call_id.to_string();
|
||||
Box::pin(async move {
|
||||
with_cached_approval(&session.services, "shell", keys, move || async move {
|
||||
let available_decisions = None;
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
@@ -163,6 +164,7 @@ impl Approvable<ShellRequest> for ShellRuntime {
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
req.additional_permissions.clone(),
|
||||
available_decisions,
|
||||
)
|
||||
.await
|
||||
})
|
||||
|
||||
@@ -12,12 +12,14 @@ use crate::skills::SkillMetadata;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::runtimes::build_command_spec;
|
||||
use crate::tools::sandboxing::SandboxAttempt;
|
||||
use crate::tools::sandboxing::SandboxablePreference;
|
||||
use crate::tools::sandboxing::ToolCtx;
|
||||
use crate::tools::sandboxing::ToolError;
|
||||
use codex_execpolicy::Decision;
|
||||
use codex_execpolicy::Policy;
|
||||
use codex_execpolicy::RuleMatch;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::NetworkPolicyRuleAction;
|
||||
@@ -26,11 +28,15 @@ use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_shell_command::bash::parse_shell_lc_plain_commands;
|
||||
use codex_shell_command::bash::parse_shell_lc_single_command_prefix;
|
||||
use codex_shell_escalation::EscalateAction;
|
||||
use codex_shell_escalation::EscalateServer;
|
||||
use codex_shell_escalation::EscalationDecision;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::EscalationPolicy;
|
||||
use codex_shell_escalation::ExecParams;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use codex_shell_escalation::Permissions as EscalatedPermissions;
|
||||
use codex_shell_escalation::PreparedExec;
|
||||
use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_shell_escalation::Stopwatch;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -105,6 +111,15 @@ pub(super) async fn try_run_zsh_fork(
|
||||
sandbox_permissions,
|
||||
justification,
|
||||
arg0,
|
||||
sandbox_policy_cwd: ctx.turn.cwd.clone(),
|
||||
macos_seatbelt_profile_extensions: ctx
|
||||
.turn
|
||||
.config
|
||||
.permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
|
||||
use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
};
|
||||
let main_execve_wrapper_exe = ctx
|
||||
.session
|
||||
@@ -136,6 +151,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
approval_policy: ctx.turn.approval_policy.value(),
|
||||
sandbox_policy: attempt.policy.clone(),
|
||||
sandbox_permissions: req.sandbox_permissions,
|
||||
prompt_permissions: req.additional_permissions.clone(),
|
||||
stopwatch: stopwatch.clone(),
|
||||
};
|
||||
|
||||
@@ -146,7 +162,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
);
|
||||
|
||||
let exec_result = escalate_server
|
||||
.exec(exec_params, cancel_token, &command_executor)
|
||||
.exec(exec_params, cancel_token, Arc::new(command_executor))
|
||||
.await
|
||||
.map_err(|err| ToolError::Rejected(err.to_string()))?;
|
||||
|
||||
@@ -161,6 +177,7 @@ struct CoreShellActionProvider {
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
prompt_permissions: Option<PermissionProfile>,
|
||||
stopwatch: Stopwatch,
|
||||
}
|
||||
|
||||
@@ -182,6 +199,55 @@ impl CoreShellActionProvider {
|
||||
})
|
||||
}
|
||||
|
||||
fn shell_request_escalation_execution(
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
additional_permissions: Option<&PermissionProfile>,
|
||||
macos_seatbelt_profile_extensions: Option<&MacOsSeatbeltProfileExtensions>,
|
||||
) -> EscalationExecution {
|
||||
match sandbox_permissions {
|
||||
SandboxPermissions::UseDefault => EscalationExecution::TurnDefault,
|
||||
SandboxPermissions::RequireEscalated => EscalationExecution::Unsandboxed,
|
||||
SandboxPermissions::WithAdditionalPermissions => additional_permissions
|
||||
.map(|_| {
|
||||
// Shell request additional permissions were already normalized and
|
||||
// merged into the first-attempt sandbox policy.
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
macos_seatbelt_profile_extensions: macos_seatbelt_profile_extensions
|
||||
.cloned(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.unwrap_or(EscalationExecution::TurnDefault),
|
||||
}
|
||||
}
|
||||
|
||||
fn skill_escalation_execution(skill: &SkillMetadata) -> EscalationExecution {
|
||||
skill
|
||||
.permissions
|
||||
.as_ref()
|
||||
.map(|permissions| {
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy: permissions.sandbox_policy.get().clone(),
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
},
|
||||
))
|
||||
})
|
||||
.or_else(|| {
|
||||
skill
|
||||
.permission_profile
|
||||
.clone()
|
||||
.map(EscalationPermissions::PermissionProfile)
|
||||
.map(EscalationExecution::Permissions)
|
||||
})
|
||||
.unwrap_or(EscalationExecution::TurnDefault)
|
||||
}
|
||||
|
||||
async fn prompt(
|
||||
&self,
|
||||
program: &AbsolutePathBuf,
|
||||
@@ -189,6 +255,7 @@ impl CoreShellActionProvider {
|
||||
workdir: &AbsolutePathBuf,
|
||||
stopwatch: &Stopwatch,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
decision_source: &DecisionSource,
|
||||
) -> anyhow::Result<ReviewDecision> {
|
||||
let command = join_program_and_argv(program, argv);
|
||||
let workdir = workdir.to_path_buf();
|
||||
@@ -198,6 +265,20 @@ impl CoreShellActionProvider {
|
||||
let approval_id = Some(Uuid::new_v4().to_string());
|
||||
Ok(stopwatch
|
||||
.pause_for(async move {
|
||||
let available_decisions = vec![
|
||||
Some(ReviewDecision::Approved),
|
||||
// Currently, ApprovedForSession is only honored for skills,
|
||||
// so only offer it for skill script approvals.
|
||||
if matches!(decision_source, DecisionSource::SkillScript { .. }) {
|
||||
Some(ReviewDecision::ApprovedForSession)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
Some(ReviewDecision::Abort),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect();
|
||||
session
|
||||
.request_command_approval(
|
||||
&turn,
|
||||
@@ -209,6 +290,7 @@ impl CoreShellActionProvider {
|
||||
None,
|
||||
None,
|
||||
additional_permissions,
|
||||
Some(available_decisions),
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -249,22 +331,21 @@ impl CoreShellActionProvider {
|
||||
program: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
workdir: &AbsolutePathBuf,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
prompt_permissions: Option<PermissionProfile>,
|
||||
escalation_execution: EscalationExecution,
|
||||
decision_source: DecisionSource,
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
) -> anyhow::Result<EscalationDecision> {
|
||||
let action = match decision {
|
||||
Decision::Forbidden => EscalateAction::Deny {
|
||||
reason: Some("Execution forbidden by policy".to_string()),
|
||||
},
|
||||
Decision::Forbidden => {
|
||||
EscalationDecision::deny(Some("Execution forbidden by policy".to_string()))
|
||||
}
|
||||
Decision::Prompt => {
|
||||
if matches!(
|
||||
self.approval_policy,
|
||||
AskForApproval::Never
|
||||
| AskForApproval::Reject(RejectConfig { rules: true, .. })
|
||||
) {
|
||||
EscalateAction::Deny {
|
||||
reason: Some("Execution forbidden by policy".to_string()),
|
||||
}
|
||||
EscalationDecision::deny(Some("Execution forbidden by policy".to_string()))
|
||||
} else {
|
||||
match self
|
||||
.prompt(
|
||||
@@ -272,16 +353,17 @@ impl CoreShellActionProvider {
|
||||
argv,
|
||||
workdir,
|
||||
&self.stopwatch,
|
||||
additional_permissions,
|
||||
prompt_permissions,
|
||||
&decision_source,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ReviewDecision::Approved
|
||||
| ReviewDecision::ApprovedExecpolicyAmendment { .. } => {
|
||||
if needs_escalation {
|
||||
EscalateAction::Escalate
|
||||
EscalationDecision::escalate(escalation_execution.clone())
|
||||
} else {
|
||||
EscalateAction::Run
|
||||
EscalationDecision::run()
|
||||
}
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => {
|
||||
@@ -306,9 +388,9 @@ impl CoreShellActionProvider {
|
||||
}
|
||||
|
||||
if needs_escalation {
|
||||
EscalateAction::Escalate
|
||||
EscalationDecision::escalate(escalation_execution.clone())
|
||||
} else {
|
||||
EscalateAction::Run
|
||||
EscalationDecision::run()
|
||||
}
|
||||
}
|
||||
ReviewDecision::NetworkPolicyAmendment {
|
||||
@@ -316,29 +398,29 @@ impl CoreShellActionProvider {
|
||||
} => match network_policy_amendment.action {
|
||||
NetworkPolicyRuleAction::Allow => {
|
||||
if needs_escalation {
|
||||
EscalateAction::Escalate
|
||||
EscalationDecision::escalate(escalation_execution.clone())
|
||||
} else {
|
||||
EscalateAction::Run
|
||||
EscalationDecision::run()
|
||||
}
|
||||
}
|
||||
NetworkPolicyRuleAction::Deny => EscalateAction::Deny {
|
||||
reason: Some("User denied execution".to_string()),
|
||||
},
|
||||
},
|
||||
ReviewDecision::Denied => EscalateAction::Deny {
|
||||
reason: Some("User denied execution".to_string()),
|
||||
},
|
||||
ReviewDecision::Abort => EscalateAction::Deny {
|
||||
reason: Some("User cancelled execution".to_string()),
|
||||
NetworkPolicyRuleAction::Deny => {
|
||||
EscalationDecision::deny(Some("User denied execution".to_string()))
|
||||
}
|
||||
},
|
||||
ReviewDecision::Denied => {
|
||||
EscalationDecision::deny(Some("User denied execution".to_string()))
|
||||
}
|
||||
ReviewDecision::Abort => {
|
||||
EscalationDecision::deny(Some("User cancelled execution".to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Decision::Allow => {
|
||||
if needs_escalation {
|
||||
EscalateAction::Escalate
|
||||
EscalationDecision::escalate(escalation_execution)
|
||||
} else {
|
||||
EscalateAction::Run
|
||||
EscalationDecision::run()
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -356,7 +438,7 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
program: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
workdir: &AbsolutePathBuf,
|
||||
) -> anyhow::Result<EscalateAction> {
|
||||
) -> anyhow::Result<EscalationDecision> {
|
||||
tracing::debug!(
|
||||
"Determining escalation action for command {program:?} with args {argv:?} in {workdir:?}"
|
||||
);
|
||||
@@ -377,15 +459,13 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
tracing::debug!(
|
||||
"Found session approval for {program:?}, allowing execution without further checks"
|
||||
);
|
||||
// TODO(mbolin): We need to include the permissions with the
|
||||
// escalation decision so it can be run with the appropriate
|
||||
// permissions.
|
||||
let _permissions = approval
|
||||
let execution = approval
|
||||
.skill
|
||||
.as_ref()
|
||||
.and_then(|s| s.permission_profile.clone());
|
||||
.map(Self::skill_escalation_execution)
|
||||
.unwrap_or(EscalationExecution::TurnDefault);
|
||||
|
||||
return Ok(EscalateAction::Escalate);
|
||||
return Ok(EscalationDecision::escalate(execution));
|
||||
}
|
||||
|
||||
// In the usual case, the execve wrapper reports the command being
|
||||
@@ -407,6 +487,7 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
argv,
|
||||
workdir,
|
||||
skill.permission_profile.clone(),
|
||||
Self::skill_escalation_execution(&skill),
|
||||
decision_source,
|
||||
)
|
||||
.await;
|
||||
@@ -447,13 +528,24 @@ impl EscalationPolicy for CoreShellActionProvider {
|
||||
} else {
|
||||
DecisionSource::UnmatchedCommandFallback
|
||||
};
|
||||
let escalation_execution = Self::shell_request_escalation_execution(
|
||||
self.sandbox_permissions,
|
||||
&self.sandbox_policy,
|
||||
self.prompt_permissions.as_ref(),
|
||||
self.turn
|
||||
.config
|
||||
.permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.as_ref(),
|
||||
);
|
||||
self.process_decision(
|
||||
evaluation.decision,
|
||||
needs_escalation,
|
||||
program,
|
||||
argv,
|
||||
workdir,
|
||||
None,
|
||||
self.prompt_permissions.clone(),
|
||||
escalation_execution,
|
||||
decision_source,
|
||||
)
|
||||
.await
|
||||
@@ -471,6 +563,10 @@ struct CoreShellCommandExecutor {
|
||||
sandbox_permissions: SandboxPermissions,
|
||||
justification: Option<String>,
|
||||
arg0: Option<String>,
|
||||
sandbox_policy_cwd: PathBuf,
|
||||
macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -516,6 +612,126 @@ impl ShellCommandExecutor for CoreShellCommandExecutor {
|
||||
timed_out: result.timed_out,
|
||||
})
|
||||
}
|
||||
|
||||
async fn prepare_escalated_exec(
|
||||
&self,
|
||||
program: &AbsolutePathBuf,
|
||||
argv: &[String],
|
||||
workdir: &AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
execution: EscalationExecution,
|
||||
) -> anyhow::Result<PreparedExec> {
|
||||
let command = join_program_and_argv(program, argv);
|
||||
let Some(first_arg) = argv.first() else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"intercepted exec request must contain argv[0]"
|
||||
));
|
||||
};
|
||||
|
||||
let prepared = match execution {
|
||||
EscalationExecution::Unsandboxed => PreparedExec {
|
||||
command,
|
||||
cwd: workdir.to_path_buf(),
|
||||
env,
|
||||
arg0: Some(first_arg.clone()),
|
||||
},
|
||||
EscalationExecution::TurnDefault => self.prepare_sandboxed_exec(
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&self.sandbox_policy,
|
||||
None,
|
||||
self.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)?,
|
||||
EscalationExecution::Permissions(EscalationPermissions::PermissionProfile(
|
||||
permission_profile,
|
||||
)) => self.prepare_sandboxed_exec(
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&self.sandbox_policy,
|
||||
Some(permission_profile),
|
||||
None,
|
||||
)?,
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(permissions)) => {
|
||||
self.prepare_sandboxed_exec(
|
||||
command,
|
||||
workdir,
|
||||
env,
|
||||
&permissions.sandbox_policy,
|
||||
None,
|
||||
permissions.macos_seatbelt_profile_extensions.as_ref(),
|
||||
)?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(prepared)
|
||||
}
|
||||
}
|
||||
|
||||
impl CoreShellCommandExecutor {
|
||||
fn prepare_sandboxed_exec(
|
||||
&self,
|
||||
command: Vec<String>,
|
||||
workdir: &AbsolutePathBuf,
|
||||
env: HashMap<String, String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
additional_permissions: Option<PermissionProfile>,
|
||||
#[cfg(target_os = "macos")] macos_seatbelt_profile_extensions: Option<
|
||||
&MacOsSeatbeltProfileExtensions,
|
||||
>,
|
||||
#[cfg(not(target_os = "macos"))] _macos_seatbelt_profile_extensions: Option<
|
||||
&MacOsSeatbeltProfileExtensions,
|
||||
>,
|
||||
) -> anyhow::Result<PreparedExec> {
|
||||
let (program, args) = command
|
||||
.split_first()
|
||||
.ok_or_else(|| anyhow::anyhow!("prepared command must not be empty"))?;
|
||||
let sandbox_manager = crate::sandboxing::SandboxManager::new();
|
||||
let sandbox = sandbox_manager.select_initial(
|
||||
sandbox_policy,
|
||||
SandboxablePreference::Auto,
|
||||
self.windows_sandbox_level,
|
||||
self.network.is_some(),
|
||||
);
|
||||
let mut exec_request =
|
||||
sandbox_manager.transform(crate::sandboxing::SandboxTransformRequest {
|
||||
spec: crate::sandboxing::CommandSpec {
|
||||
program: program.clone(),
|
||||
args: args.to_vec(),
|
||||
cwd: workdir.to_path_buf(),
|
||||
env,
|
||||
expiration: ExecExpiration::DefaultTimeout,
|
||||
sandbox_permissions: if additional_permissions.is_some() {
|
||||
SandboxPermissions::WithAdditionalPermissions
|
||||
} else {
|
||||
SandboxPermissions::UseDefault
|
||||
},
|
||||
additional_permissions,
|
||||
justification: self.justification.clone(),
|
||||
},
|
||||
policy: sandbox_policy,
|
||||
sandbox,
|
||||
enforce_managed_network: self.network.is_some(),
|
||||
network: self.network.as_ref(),
|
||||
sandbox_policy_cwd: &self.sandbox_policy_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
})?;
|
||||
if let Some(network) = exec_request.network.as_ref() {
|
||||
network.apply_to_env(&mut exec_request.env);
|
||||
}
|
||||
|
||||
Ok(PreparedExec {
|
||||
command: exec_request.command,
|
||||
cwd: exec_request.cwd,
|
||||
env: exec_request.env,
|
||||
arg0: exec_request.arg0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
@@ -583,122 +799,5 @@ fn join_program_and_argv(program: &AbsolutePathBuf, argv: &[String]) -> Vec<Stri
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ParsedShellCommand;
|
||||
use super::extract_shell_script;
|
||||
use super::join_program_and_argv;
|
||||
use super::map_exec_result;
|
||||
use crate::exec::SandboxType;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_preserves_login_flag() {
|
||||
assert_eq!(
|
||||
extract_shell_script(&["/bin/zsh".into(), "-lc".into(), "echo hi".into()]).unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hi".to_string(),
|
||||
login: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
extract_shell_script(&["/bin/zsh".into(), "-c".into(), "echo hi".into()]).unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hi".to_string(),
|
||||
login: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_supports_wrapped_command_prefixes() {
|
||||
assert_eq!(
|
||||
extract_shell_script(&[
|
||||
"/usr/bin/env".into(),
|
||||
"CODEX_EXECVE_WRAPPER=1".into(),
|
||||
"/bin/zsh".into(),
|
||||
"-lc".into(),
|
||||
"echo hello".into()
|
||||
])
|
||||
.unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hello".to_string(),
|
||||
login: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_shell_script(&[
|
||||
"sandbox-exec".into(),
|
||||
"-p".into(),
|
||||
"sandbox_policy".into(),
|
||||
"/bin/zsh".into(),
|
||||
"-c".into(),
|
||||
"pwd".into(),
|
||||
])
|
||||
.unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "pwd".to_string(),
|
||||
login: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_rejects_unsupported_shell_invocation() {
|
||||
let err = extract_shell_script(&[
|
||||
"sandbox-exec".into(),
|
||||
"-fc".into(),
|
||||
"echo not supported".into(),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, super::ToolError::Rejected(_)));
|
||||
assert_eq!(
|
||||
match err {
|
||||
super::ToolError::Rejected(reason) => reason,
|
||||
_ => "".to_string(),
|
||||
},
|
||||
"unexpected shell command format for zsh-fork execution"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_program_and_argv_replaces_original_argv_zero() {
|
||||
assert_eq!(
|
||||
join_program_and_argv(
|
||||
&AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(),
|
||||
&["./tool".into(), "--flag".into(), "value".into()],
|
||||
),
|
||||
vec!["/tmp/tool", "--flag", "value"]
|
||||
);
|
||||
assert_eq!(
|
||||
join_program_and_argv(
|
||||
&AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(),
|
||||
&["./tool".into()]
|
||||
),
|
||||
vec!["/tmp/tool"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_exec_result_preserves_stdout_and_stderr() {
|
||||
let out = map_exec_result(
|
||||
SandboxType::None,
|
||||
ExecResult {
|
||||
exit_code: 0,
|
||||
stdout: "out".to_string(),
|
||||
stderr: "err".to_string(),
|
||||
output: "outerr".to_string(),
|
||||
duration: Duration::from_millis(1),
|
||||
timed_out: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.stdout.text, "out");
|
||||
assert_eq!(out.stderr.text, "err");
|
||||
assert_eq!(out.aggregated_output.text, "outerr");
|
||||
}
|
||||
}
|
||||
#[path = "unix_escalation_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
use super::CoreShellActionProvider;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::CoreShellCommandExecutor;
|
||||
use super::ParsedShellCommand;
|
||||
use super::extract_shell_script;
|
||||
use super::join_program_and_argv;
|
||||
use super::map_exec_result;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::Constrained;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::Permissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::config::types::ShellEnvironmentPolicy;
|
||||
use crate::exec::SandboxType;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::ReadOnlyAccess;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsPreferencesPermission;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_shell_escalation::EscalationExecution;
|
||||
use codex_shell_escalation::EscalationPermissions;
|
||||
use codex_shell_escalation::ExecResult;
|
||||
use codex_shell_escalation::Permissions as EscalatedPermissions;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_shell_escalation::ShellCommandExecutor;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_preserves_login_flag() {
|
||||
assert_eq!(
|
||||
extract_shell_script(&["/bin/zsh".into(), "-lc".into(), "echo hi".into()]).unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hi".to_string(),
|
||||
login: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
extract_shell_script(&["/bin/zsh".into(), "-c".into(), "echo hi".into()]).unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hi".to_string(),
|
||||
login: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_supports_wrapped_command_prefixes() {
|
||||
assert_eq!(
|
||||
extract_shell_script(&[
|
||||
"/usr/bin/env".into(),
|
||||
"CODEX_EXECVE_WRAPPER=1".into(),
|
||||
"/bin/zsh".into(),
|
||||
"-lc".into(),
|
||||
"echo hello".into()
|
||||
])
|
||||
.unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "echo hello".to_string(),
|
||||
login: true,
|
||||
}
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
extract_shell_script(&[
|
||||
"sandbox-exec".into(),
|
||||
"-p".into(),
|
||||
"sandbox_policy".into(),
|
||||
"/bin/zsh".into(),
|
||||
"-c".into(),
|
||||
"pwd".into(),
|
||||
])
|
||||
.unwrap(),
|
||||
ParsedShellCommand {
|
||||
script: "pwd".to_string(),
|
||||
login: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_shell_script_rejects_unsupported_shell_invocation() {
|
||||
let err = extract_shell_script(&[
|
||||
"sandbox-exec".into(),
|
||||
"-fc".into(),
|
||||
"echo not supported".into(),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, super::ToolError::Rejected(_)));
|
||||
assert_eq!(
|
||||
match err {
|
||||
super::ToolError::Rejected(reason) => reason,
|
||||
_ => "".to_string(),
|
||||
},
|
||||
"unexpected shell command format for zsh-fork execution"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_program_and_argv_replaces_original_argv_zero() {
|
||||
assert_eq!(
|
||||
join_program_and_argv(
|
||||
&AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(),
|
||||
&["./tool".into(), "--flag".into(), "value".into()],
|
||||
),
|
||||
vec!["/tmp/tool", "--flag", "value"]
|
||||
);
|
||||
assert_eq!(
|
||||
join_program_and_argv(
|
||||
&AbsolutePathBuf::from_absolute_path("/tmp/tool").unwrap(),
|
||||
&["./tool".into()]
|
||||
),
|
||||
vec!["/tmp/tool"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_exec_result_preserves_stdout_and_stderr() {
|
||||
let out = map_exec_result(
|
||||
SandboxType::None,
|
||||
ExecResult {
|
||||
exit_code: 0,
|
||||
stdout: "out".to_string(),
|
||||
stderr: "err".to_string(),
|
||||
output: "outerr".to_string(),
|
||||
duration: Duration::from_millis(1),
|
||||
timed_out: false,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.stdout.text, "out");
|
||||
assert_eq!(out.stderr.text, "err");
|
||||
assert_eq!(out.aggregated_output.text, "outerr");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_request_escalation_execution_is_explicit() {
|
||||
let requested_permissions = PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![PathBuf::from("./output")]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![AbsolutePathBuf::from_absolute_path("/tmp/original/output").unwrap()],
|
||||
read_only_access: ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
let macos_seatbelt_profile_extensions = MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::shell_request_escalation_execution(
|
||||
crate::sandboxing::SandboxPermissions::UseDefault,
|
||||
&sandbox_policy,
|
||||
None,
|
||||
Some(&macos_seatbelt_profile_extensions),
|
||||
),
|
||||
EscalationExecution::TurnDefault,
|
||||
);
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::shell_request_escalation_execution(
|
||||
crate::sandboxing::SandboxPermissions::RequireEscalated,
|
||||
&sandbox_policy,
|
||||
None,
|
||||
Some(&macos_seatbelt_profile_extensions),
|
||||
),
|
||||
EscalationExecution::Unsandboxed,
|
||||
);
|
||||
assert_eq!(
|
||||
CoreShellActionProvider::shell_request_escalation_execution(
|
||||
crate::sandboxing::SandboxPermissions::WithAdditionalPermissions,
|
||||
&sandbox_policy,
|
||||
Some(&requested_permissions),
|
||||
Some(&macos_seatbelt_profile_extensions),
|
||||
),
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy,
|
||||
macos_seatbelt_profile_extensions: Some(macos_seatbelt_profile_extensions),
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
..Default::default()
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
|
||||
&["echo".to_string(), "ok".to_string()],
|
||||
&cwd,
|
||||
HashMap::new(),
|
||||
EscalationExecution::TurnDefault,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.command.first().map(String::as_str),
|
||||
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
);
|
||||
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
|
||||
assert!(
|
||||
prepared
|
||||
.command
|
||||
.get(2)
|
||||
.is_some_and(|policy| policy.contains("(allow user-preference-write)")),
|
||||
"expected seatbelt policy to include macOS extension profile: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() {
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(std::env::temp_dir()).unwrap();
|
||||
let executor = CoreShellCommandExecutor {
|
||||
command: vec!["echo".to_string(), "ok".to_string()],
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: HashMap::new(),
|
||||
network: None,
|
||||
sandbox: SandboxType::None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
sandbox_policy_cwd: cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
};
|
||||
|
||||
let permissions = Permissions {
|
||||
approval_policy: Constrained::allow_any(AskForApproval::Never),
|
||||
sandbox_policy: Constrained::allow_any(SandboxPolicy::new_read_only_policy()),
|
||||
network: None,
|
||||
allow_login_shell: true,
|
||||
shell_environment_policy: ShellEnvironmentPolicy::default(),
|
||||
windows_sandbox_mode: None,
|
||||
macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions {
|
||||
macos_preferences: MacOsPreferencesPermission::ReadWrite,
|
||||
..Default::default()
|
||||
}),
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
.prepare_escalated_exec(
|
||||
&AbsolutePathBuf::from_absolute_path("/bin/echo").unwrap(),
|
||||
&["echo".to_string(), "ok".to_string()],
|
||||
&cwd,
|
||||
HashMap::new(),
|
||||
EscalationExecution::Permissions(EscalationPermissions::Permissions(
|
||||
EscalatedPermissions {
|
||||
sandbox_policy: permissions.sandbox_policy.get().clone(),
|
||||
macos_seatbelt_profile_extensions: permissions
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
},
|
||||
)),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
prepared.command.first().map(String::as_str),
|
||||
Some(MACOS_PATH_TO_SEATBELT_EXECUTABLE)
|
||||
);
|
||||
assert_eq!(prepared.command.get(1).map(String::as_str), Some("-p"));
|
||||
assert!(
|
||||
prepared
|
||||
.command
|
||||
.get(2)
|
||||
.is_some_and(|policy| policy.contains("(allow user-preference-write)")),
|
||||
"expected seatbelt policy to include macOS extension profile: {:?}",
|
||||
prepared.command
|
||||
);
|
||||
}
|
||||
@@ -111,6 +111,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
.or_else(|| req.justification.clone());
|
||||
Box::pin(async move {
|
||||
with_cached_approval(&session.services, "unified_exec", keys, || async move {
|
||||
let available_decisions = None;
|
||||
session
|
||||
.request_command_approval(
|
||||
turn,
|
||||
@@ -124,6 +125,7 @@ impl Approvable<UnifiedExecRequest> for UnifiedExecRuntime<'_> {
|
||||
.proposed_execpolicy_amendment()
|
||||
.cloned(),
|
||||
req.additional_permissions.clone(),
|
||||
available_decisions,
|
||||
)
|
||||
.await
|
||||
})
|
||||
|
||||
@@ -340,6 +340,8 @@ impl<'a> SandboxAttempt<'a> {
|
||||
enforce_managed_network: self.enforce_managed_network,
|
||||
network,
|
||||
sandbox_policy_cwd: self.sandbox_cwd,
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
|
||||
@@ -633,6 +633,15 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
)),
|
||||
},
|
||||
),
|
||||
(
|
||||
"fork_context".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some(
|
||||
"When true, fork the current thread history into the new agent before sending the initial prompt. This must be used when you want the new agent to have exactly the same context as you."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::codex::TurnContext;
|
||||
use crate::contextual_user_message::USER_SHELL_COMMAND_FRAGMENT;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::tools::format_exec_output_str;
|
||||
|
||||
pub const USER_SHELL_COMMAND_OPEN: &str = "<user_shell_command>";
|
||||
pub const USER_SHELL_COMMAND_CLOSE: &str = "</user_shell_command>";
|
||||
|
||||
pub fn is_user_shell_command_text(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let lowered = trimmed.to_ascii_lowercase();
|
||||
lowered.starts_with(USER_SHELL_COMMAND_OPEN)
|
||||
}
|
||||
|
||||
fn format_duration_line(duration: Duration) -> String {
|
||||
let duration_seconds = duration.as_secs_f64();
|
||||
format!("Duration: {duration_seconds:.4} seconds")
|
||||
@@ -48,7 +39,7 @@ pub fn format_user_shell_command_record(
|
||||
turn_context: &TurnContext,
|
||||
) -> String {
|
||||
let body = format_user_shell_command_body(command, exec_output, turn_context);
|
||||
format!("{USER_SHELL_COMMAND_OPEN}\n{body}\n{USER_SHELL_COMMAND_CLOSE}")
|
||||
USER_SHELL_COMMAND_FRAGMENT.wrap(body)
|
||||
}
|
||||
|
||||
pub fn user_shell_command_record_item(
|
||||
@@ -56,15 +47,11 @@ pub fn user_shell_command_record_item(
|
||||
exec_output: &ExecToolCallOutput,
|
||||
turn_context: &TurnContext,
|
||||
) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText {
|
||||
text: format_user_shell_command_record(command, exec_output, turn_context),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
USER_SHELL_COMMAND_FRAGMENT.into_message(format_user_shell_command_record(
|
||||
command,
|
||||
exec_output,
|
||||
turn_context,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -72,14 +59,16 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::codex::make_session_and_context;
|
||||
use crate::exec::StreamOutput;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn detects_user_shell_command_text_variants() {
|
||||
assert!(is_user_shell_command_text(
|
||||
"<user_shell_command>\necho hi\n</user_shell_command>"
|
||||
));
|
||||
assert!(!is_user_shell_command_text("echo hi"));
|
||||
assert!(
|
||||
USER_SHELL_COMMAND_FRAGMENT
|
||||
.matches_text("<user_shell_command>\necho hi\n</user_shell_command>")
|
||||
);
|
||||
assert!(!USER_SHELL_COMMAND_FRAGMENT.matches_text("echo hi"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -106,7 +106,7 @@ Under `{{ memory_root }}/`:
|
||||
context.
|
||||
- source of rollout-level metadata needed for MEMORY.md `### rollout_summary_files`
|
||||
annotations;
|
||||
you should be able to find `cwd` and `updated_at` there.
|
||||
you should be able to find `cwd`, `rollout_path`, and `updated_at` there.
|
||||
- `MEMORY.md`
|
||||
- merged memories; produce a lightly clustered version if applicable
|
||||
- `rollout_summaries/*.md`
|
||||
@@ -121,6 +121,27 @@ Mode selection:
|
||||
- INCREMENTAL UPDATE: existing artifacts already exist and `raw_memories.md`
|
||||
mostly contains new additions.
|
||||
|
||||
Incremental thread diff snapshot (computed before the current artifact sync rewrites local files):
|
||||
|
||||
**Diff since last consolidation:**
|
||||
{{ phase2_input_selection }}
|
||||
|
||||
Incremental update and forgetting mechanism:
|
||||
- Use the diff provided
|
||||
- Do not open raw sessions / original rollout transcripts.
|
||||
- For each added thread id, search it in `raw_memories.md`, read that raw-memory section, and
|
||||
read the corresponding `rollout_summaries/*.md` file only when needed for stronger evidence,
|
||||
task placement, or conflict resolution.
|
||||
- For each removed thread id, search it in `MEMORY.md` and delete only the memory supported by
|
||||
that thread. Use `thread_id=<thread_id>` in `### rollout_summary_files` when available; if not,
|
||||
fall back to rollout summary filenames plus the corresponding `rollout_summaries/*.md` files.
|
||||
- If a `MEMORY.md` block contains both removed and undeleted threads, do not delete the whole
|
||||
block. Remove only the removed thread's references and thread-local learnings, preserve shared
|
||||
or still-supported content, and split or rewrite the block only if needed to keep the undeleted
|
||||
threads intact.
|
||||
- After `MEMORY.md` cleanup is done, revisit `memory_summary.md` and remove or rewrite stale
|
||||
summary/index content that was only supported by removed thread ids.
|
||||
|
||||
Outputs:
|
||||
Under `{{ memory_root }}/`:
|
||||
A) `MEMORY.md`
|
||||
@@ -171,7 +192,7 @@ Required task-oriented body shape (strict):
|
||||
## Task 1: <task description, outcome>
|
||||
|
||||
### rollout_summary_files
|
||||
- <rollout_summaries/file1.md> (cwd=<path>, updated_at=<timestamp>, thread_id=<thread_id>, <optional status/usefulness note>)
|
||||
- <rollout_summaries/file1.md> (cwd=<path>, rollout_path=<path>, updated_at=<timestamp>, thread_id=<thread_id>, <optional status/usefulness note>)
|
||||
|
||||
### keywords
|
||||
|
||||
@@ -236,7 +257,8 @@ Schema rules (strict):
|
||||
- Every `## Task <n>` section must include `### rollout_summary_files`, `### keywords`,
|
||||
and `### learnings`.
|
||||
- `### rollout_summary_files` must be task-local (not a block-wide catch-all list).
|
||||
- Each rollout annotation must include `cwd=<path>` and `updated_at=<timestamp>`.
|
||||
- Each rollout annotation must include `cwd=<path>`, `rollout_path=<path>`, and
|
||||
`updated_at=<timestamp>`.
|
||||
If missing from a rollout summary, recover them from `raw_memories.md`.
|
||||
- Major learnings should be traceable to rollout summaries listed in the same task section.
|
||||
- Order rollout references by freshness and practical usefulness.
|
||||
@@ -497,27 +519,42 @@ WORKFLOW
|
||||
conflicting task families until MEMORY blocks are richer and more useful than raw memories
|
||||
|
||||
3) INCREMENTAL UPDATE behavior:
|
||||
- Treat `raw_memories.md` as the primary source of NEW signal.
|
||||
- Read existing memory files first for continuity.
|
||||
- Read existing `MEMORY.md` and `memory_summary.md` first for continuity and to locate
|
||||
existing references that may need surgical cleanup.
|
||||
- Use the injected thread-diff snapshot as the first routing pass:
|
||||
- added thread ids = ingestion queue
|
||||
- removed thread ids = forgetting / stale-cleanup queue
|
||||
- Build an index of rollout references already present in existing `MEMORY.md` before
|
||||
scanning raw memories so you can route net-new evidence into the right blocks.
|
||||
- Compute net-new candidates from the raw-memory inventory (threads / rollout summaries /
|
||||
updated evidence not already represented in `MEMORY.md`).
|
||||
- Work in this order:
|
||||
1. For newly added thread ids, search them in `raw_memories.md`, read those sections, and
|
||||
open the corresponding `rollout_summaries/*.md` files when necessary.
|
||||
2. Route the new signal into existing `MEMORY.md` blocks or create new ones when needed.
|
||||
3. For removed thread ids, search `MEMORY.md` and surgically delete or rewrite only the
|
||||
unsupported thread-local memory.
|
||||
4. If a block mixes removed and undeleted threads, preserve the undeleted-thread content;
|
||||
split or rewrite the block if that is the cleanest way to delete only the removed part.
|
||||
5. After `MEMORY.md` is correct, revisit `memory_summary.md` and remove or rewrite stale
|
||||
summary/index content that no longer has undeleted support.
|
||||
- Integrate new signal into existing artifacts by:
|
||||
- scanning new raw memories in recency order and identifying which existing blocks they should update
|
||||
- scanning the newly added raw-memory entries in recency order and identifying which existing blocks they should update
|
||||
- updating existing knowledge with better/newer evidence
|
||||
- updating stale or contradicting guidance
|
||||
- pruning or downgrading memory whose only provenance comes from removed thread ids
|
||||
- expanding terse old blocks when new summaries/raw memories make the task family clearer
|
||||
- doing light clustering and merging if needed
|
||||
- refreshing `MEMORY.md` top-of-file ordering so recent high-utility task families stay easy to find
|
||||
- rebuilding the `memory_summary.md` recent active window (last 3 memory days) from current `updated_at` coverage
|
||||
- updating existing skills or adding new skills only when there is clear new reusable procedure
|
||||
- update `memory_summary.md` last to reflect the final state of the memory folder
|
||||
- updating `memory_summary.md` last to reflect the final state of the memory folder
|
||||
- Minimize churn in incremental mode: if an existing `MEMORY.md` block or `## What's in Memory`
|
||||
topic still reflects the current evidence and points to the same task family / retrieval
|
||||
target, keep its wording, label, and relative order mostly stable. Rewrite/reorder/rename/
|
||||
split/merge only when fixing a real problem (staleness, ambiguity, schema drift, wrong
|
||||
boundaries) or when meaningful new evidence materially improves retrieval clarity/searchability.
|
||||
- Spend most of your deep-dive budget on newly added thread ids and on mixed blocks touched by
|
||||
removed thread ids. Do not re-read unchanged older threads unless you need them for
|
||||
conflict resolution, clustering, or provenance repair.
|
||||
|
||||
4) Evidence deep-dive rule (both modes):
|
||||
- `raw_memories.md` is the routing layer, not always the final authority for detail.
|
||||
@@ -528,6 +565,9 @@ WORKFLOW
|
||||
- When a task family is important, ambiguous, or duplicated across multiple rollouts,
|
||||
open the relevant `rollout_summaries/*.md` files and extract richer procedural detail,
|
||||
validation signals, and user feedback before finalizing `MEMORY.md`.
|
||||
- When deleting stale memory from a mixed block, use the relevant rollout summaries to decide
|
||||
which details are uniquely supported by removed threads versus still supported by undeleted
|
||||
threads.
|
||||
- Use `updated_at` and validation strength together to resolve stale/conflicting notes.
|
||||
|
||||
5) For both modes, update `MEMORY.md` after skill updates:
|
||||
@@ -541,6 +581,8 @@ WORKFLOW
|
||||
7) Final pass:
|
||||
- remove duplication in memory_summary, skills/, and MEMORY.md
|
||||
- remove stale or low-signal blocks that are less likely to be useful in the future
|
||||
- remove or rewrite blocks/task sections whose supporting rollout references point only to
|
||||
removed thread ids or missing rollout summary files
|
||||
- run a global rollout-reference audit on final `MEMORY.md` and fix accidental duplicate
|
||||
entries / redundant repetition, while preserving intentional multi-task or multi-block
|
||||
reuse when it adds distinct task-local value
|
||||
@@ -559,16 +601,3 @@ WORKFLOW
|
||||
|
||||
You should dive deep and make sure you didn't miss any important information that might
|
||||
be useful for future agents; do not be superficial.
|
||||
|
||||
============================================================
|
||||
SEARCH / REVIEW COMMANDS (RG-FIRST)
|
||||
============================================================
|
||||
|
||||
Use `rg` for fast retrieval while consolidating:
|
||||
|
||||
- Search durable notes:
|
||||
`rg -n -i "<pattern>" "{{ memory_root }}/MEMORY.md"`
|
||||
- Search across memory tree:
|
||||
`rg -n -i "<pattern>" "{{ memory_root }}" | head -n 100`
|
||||
- Locate rollout summary files:
|
||||
`rg --files "{{ memory_root }}/rollout_summaries" | head -n 400`
|
||||
|
||||
@@ -25,7 +25,10 @@ Memory layout (general -> specific):
|
||||
- scripts/ (optional helper scripts)
|
||||
- examples/ (optional example outputs)
|
||||
- templates/ (optional templates)
|
||||
- {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets)
|
||||
- {{ base_path }}/rollout_summaries/ (per-rollout recaps + evidence snippets)
|
||||
- The paths of these entries can be found in {{ base_path }}/MEMORY.md or {{ base_path }}/rollout_summaries/ as `rollout_path`
|
||||
- These files are append-only `jsonl`: `session_meta.payload.id` identifies the session, `turn_context` marks turn boundaries, `event_msg` is the lightweight status stream, and `response_item` contains actual messages, tool calls, and tool outputs.
|
||||
- For efficient lookup, prefer matching the filename suffix or `session_meta.payload.id`; avoid broad full-content scans unless needed.
|
||||
|
||||
Quick memory pass (when applicable):
|
||||
|
||||
@@ -34,7 +37,8 @@ Quick memory pass (when applicable):
|
||||
3. Only if MEMORY.md directly points to rollout summaries/skills, open the 1-2
|
||||
most relevant files under {{ base_path }}/rollout_summaries/ or
|
||||
{{ base_path }}/skills/.
|
||||
4. If there are no relevant hits, stop memory lookup and continue normally.
|
||||
4. If above are not clear and you need exact commands, error text, or precise evidence, search over `rollout_path` for more evidence.
|
||||
5. If there are no relevant hits, stop memory lookup and continue normally.
|
||||
|
||||
Quick-pass budget:
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshot
|
||||
match item_type {
|
||||
"message" => {
|
||||
let role = item.get("role").and_then(Value::as_str).unwrap_or("unknown");
|
||||
let text = item
|
||||
let rendered_parts = item
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.map(|content| {
|
||||
@@ -93,11 +93,27 @@ pub fn format_response_items_snapshot(items: &[Value], options: &ContextSnapshot
|
||||
}
|
||||
})
|
||||
.collect::<Vec<String>>()
|
||||
.join(" | ")
|
||||
})
|
||||
.filter(|text| !text.is_empty())
|
||||
.unwrap_or_else(|| "<NO_TEXT>".to_string());
|
||||
format!("{idx:02}:message/{role}:{text}")
|
||||
.unwrap_or_default();
|
||||
let role = if rendered_parts.len() > 1 {
|
||||
format!("{role}[{}]", rendered_parts.len())
|
||||
} else {
|
||||
role.to_string()
|
||||
};
|
||||
if rendered_parts.is_empty() {
|
||||
return format!("{idx:02}:message/{role}:<NO_TEXT>");
|
||||
}
|
||||
if rendered_parts.len() == 1 {
|
||||
return format!("{idx:02}:message/{role}:{}", rendered_parts[0]);
|
||||
}
|
||||
|
||||
let parts = rendered_parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(part_idx, part)| format!(" [{:02}] {part}", part_idx + 1))
|
||||
.collect::<Vec<String>>()
|
||||
.join("\n");
|
||||
format!("{idx:02}:message/{role}:\n{parts}")
|
||||
}
|
||||
"function_call" => {
|
||||
let name = item.get("name").and_then(Value::as_str).unwrap_or("unknown");
|
||||
@@ -222,15 +238,34 @@ fn canonicalize_snapshot_text(text: &str) -> String {
|
||||
return "<AGENTS_MD>".to_string();
|
||||
}
|
||||
if text.starts_with("<environment_context>") {
|
||||
let subagent_count = text
|
||||
.split_once("<subagents>")
|
||||
.and_then(|(_, rest)| rest.split_once("</subagents>"))
|
||||
.map(|(subagents, _)| {
|
||||
subagents
|
||||
.lines()
|
||||
.filter(|line| line.trim_start().starts_with("- "))
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0);
|
||||
let subagents_suffix = if subagent_count > 0 {
|
||||
format!(":subagents={subagent_count}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let (Some(cwd_start), Some(cwd_end)) = (text.find("<cwd>"), text.find("</cwd>")) {
|
||||
let cwd = &text[cwd_start + "<cwd>".len()..cwd_end];
|
||||
return if cwd.ends_with("PRETURN_CONTEXT_DIFF_CWD") {
|
||||
"<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD>".to_string()
|
||||
format!("<ENVIRONMENT_CONTEXT:cwd=PRETURN_CONTEXT_DIFF_CWD{subagents_suffix}>")
|
||||
} else {
|
||||
"<ENVIRONMENT_CONTEXT:cwd=<CWD>>".to_string()
|
||||
format!("<ENVIRONMENT_CONTEXT:cwd=<CWD>{subagents_suffix}>")
|
||||
};
|
||||
}
|
||||
return "<ENVIRONMENT_CONTEXT>".to_string();
|
||||
return if subagent_count > 0 {
|
||||
format!("<ENVIRONMENT_CONTEXT{subagents_suffix}>")
|
||||
} else {
|
||||
"<ENVIRONMENT_CONTEXT>".to_string()
|
||||
};
|
||||
}
|
||||
if text.starts_with("You are performing a CONTEXT CHECKPOINT COMPACTION.") {
|
||||
return "<SUMMARIZATION_PROMPT>".to_string();
|
||||
@@ -258,7 +293,7 @@ mod tests {
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "# AGENTS.md instructions for /tmp/example"
|
||||
"text": "# AGENTS.md instructions for /tmp/example\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
||||
}]
|
||||
})];
|
||||
|
||||
@@ -269,7 +304,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"00:message/user:# AGENTS.md instructions for /tmp/example"
|
||||
r"00:message/user:# AGENTS.md instructions for /tmp/example\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -280,7 +315,7 @@ mod tests {
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "# AGENTS.md instructions for /tmp/example"
|
||||
"text": "# AGENTS.md instructions for /tmp/example\n\n<INSTRUCTIONS>\nbody\n</INSTRUCTIONS>"
|
||||
}]
|
||||
})];
|
||||
|
||||
@@ -292,6 +327,28 @@ mod tests {
|
||||
assert_eq!(rendered, "00:message/user:<AGENTS_MD>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redacted_text_mode_normalizes_environment_context_with_subagents() {
|
||||
let items = vec![json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "<environment_context>\n <cwd>/tmp/example</cwd>\n <shell>bash</shell>\n <subagents>\n - agent-1: atlas\n - agent-2\n </subagents>\n</environment_context>"
|
||||
}]
|
||||
})];
|
||||
|
||||
let rendered = format_response_items_snapshot(
|
||||
&items,
|
||||
&ContextSnapshotOptions::default().render_mode(ContextSnapshotRenderMode::RedactedText),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"00:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>:subagents=2>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_only_message_is_rendered_as_non_text_span() {
|
||||
let items = vec![json!({
|
||||
@@ -333,7 +390,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
rendered,
|
||||
"00:message/user:<image> | <input_image:image_url> | </image>"
|
||||
"00:message/user[3]:\n [01] <image>\n [02] <input_image:image_url>\n [03] </image>"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use serde_json::Value;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Notify;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio_tungstenite::accept_hdr_async_with_config;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
@@ -141,6 +142,32 @@ impl ResponsesRequest {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Returns `input_text` spans grouped by `message` input for the provided role.
|
||||
pub fn message_input_text_groups(&self, role: &str) -> Vec<Vec<String>> {
|
||||
self.inputs_of_type("message")
|
||||
.into_iter()
|
||||
.filter(|item| item.get("role").and_then(Value::as_str) == Some(role))
|
||||
.filter_map(|item| item.get("content").and_then(Value::as_array).cloned())
|
||||
.map(|content| {
|
||||
content
|
||||
.into_iter()
|
||||
.filter(|span| span.get("type").and_then(Value::as_str) == Some("input_text"))
|
||||
.filter_map(|span| span.get("text").and_then(Value::as_str).map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn has_message_with_input_texts(
|
||||
&self,
|
||||
role: &str,
|
||||
predicate: impl Fn(&[String]) -> bool,
|
||||
) -> bool {
|
||||
self.message_input_text_groups(role)
|
||||
.iter()
|
||||
.any(|texts| predicate(texts))
|
||||
}
|
||||
|
||||
/// Returns all `input_image` `image_url` spans from `message` inputs for the provided role.
|
||||
pub fn message_input_image_urls(&self, role: &str) -> Vec<String> {
|
||||
self.inputs_of_type("message")
|
||||
@@ -237,7 +264,7 @@ impl ResponsesRequest {
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
match output {
|
||||
Value::String(text) => Some((Some(text), None)),
|
||||
Value::String(_) | Value::Array(_) => Some((output_value_to_text(&output), None)),
|
||||
Value::Object(obj) => Some((
|
||||
obj.get("content")
|
||||
.and_then(Value::as_str)
|
||||
@@ -269,6 +296,87 @@ impl ResponsesRequest {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn output_value_to_text(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Array(items) => match items.as_slice() {
|
||||
[item] if item.get("type").and_then(Value::as_str) == Some("input_text") => {
|
||||
item.get("text").and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
[_] | [] | [_, _, ..] => None,
|
||||
},
|
||||
Value::Object(_) | Value::Number(_) | Value::Bool(_) | Value::Null => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::http::HeaderMap;
|
||||
use wiremock::http::Method;
|
||||
|
||||
fn request_with_input(input: Value) -> ResponsesRequest {
|
||||
ResponsesRequest(wiremock::Request {
|
||||
url: "http://localhost/v1/responses"
|
||||
.parse()
|
||||
.expect("valid request url"),
|
||||
method: Method::POST,
|
||||
headers: HeaderMap::new(),
|
||||
body: serde_json::to_vec(&serde_json::json!({ "input": input }))
|
||||
.expect("serialize request body"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn call_output_content_and_success_returns_only_single_text_content_item() {
|
||||
let single_text = request_with_input(serde_json::json!([
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call-1",
|
||||
"output": [{ "type": "input_text", "text": "hello" }]
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call-2",
|
||||
"output": [{ "type": "input_text", "text": "world" }]
|
||||
}
|
||||
]));
|
||||
assert_eq!(
|
||||
single_text.function_call_output_content_and_success("call-1"),
|
||||
Some((Some("hello".to_string()), None))
|
||||
);
|
||||
assert_eq!(
|
||||
single_text.custom_tool_call_output_content_and_success("call-2"),
|
||||
Some((Some("world".to_string()), None))
|
||||
);
|
||||
|
||||
let mixed_content = request_with_input(serde_json::json!([
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call-3",
|
||||
"output": [
|
||||
{ "type": "input_text", "text": "hello" },
|
||||
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call-4",
|
||||
"output": [{ "type": "input_image", "image_url": "data:image/png;base64,abc" }]
|
||||
}
|
||||
]));
|
||||
assert_eq!(
|
||||
mixed_content.function_call_output_content_and_success("call-3"),
|
||||
Some((None, None))
|
||||
);
|
||||
assert_eq!(
|
||||
mixed_content.custom_tool_call_output_content_and_success("call-4"),
|
||||
Some((None, None))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebSocketRequest {
|
||||
body: Value,
|
||||
@@ -309,6 +417,7 @@ pub struct WebSocketTestServer {
|
||||
uri: String,
|
||||
connections: Arc<Mutex<Vec<Vec<WebSocketRequest>>>>,
|
||||
handshakes: Arc<Mutex<Vec<WebSocketHandshake>>>,
|
||||
request_log_updated: Arc<Notify>,
|
||||
shutdown: oneshot::Sender<()>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
@@ -330,6 +439,26 @@ impl WebSocketTestServer {
|
||||
connections.first().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
pub async fn wait_for_request(
|
||||
&self,
|
||||
connection_index: usize,
|
||||
request_index: usize,
|
||||
) -> WebSocketRequest {
|
||||
loop {
|
||||
if let Some(request) = self
|
||||
.connections
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(connection_index)
|
||||
.and_then(|connection| connection.get(request_index))
|
||||
.cloned()
|
||||
{
|
||||
return request;
|
||||
}
|
||||
self.request_log_updated.notified().await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handshakes(&self) -> Vec<WebSocketHandshake> {
|
||||
self.handshakes.lock().unwrap().clone()
|
||||
}
|
||||
@@ -1043,6 +1172,7 @@ pub async fn start_websocket_server(connections: Vec<Vec<Vec<Value>>>) -> WebSoc
|
||||
pub async fn start_websocket_server_with_headers(
|
||||
connections: Vec<WebSocketConnectionConfig>,
|
||||
) -> WebSocketTestServer {
|
||||
let start = std::time::Instant::now();
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind websocket server");
|
||||
@@ -1050,8 +1180,10 @@ pub async fn start_websocket_server_with_headers(
|
||||
let uri = format!("ws://{addr}");
|
||||
let connections_log = Arc::new(Mutex::new(Vec::new()));
|
||||
let handshakes_log = Arc::new(Mutex::new(Vec::new()));
|
||||
let request_log_updated = Arc::new(Notify::new());
|
||||
let requests = Arc::clone(&connections_log);
|
||||
let handshakes = Arc::clone(&handshakes_log);
|
||||
let request_log = Arc::clone(&request_log_updated);
|
||||
let connections = Arc::new(Mutex::new(VecDeque::from(connections)));
|
||||
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||
|
||||
@@ -1133,9 +1265,51 @@ pub async fn start_websocket_server_with_headers(
|
||||
let mut log = requests.lock().unwrap();
|
||||
if let Some(connection_log) = log.get_mut(connection_index) {
|
||||
connection_log.push(WebSocketRequest { body });
|
||||
let request_index = connection_log.len() - 1;
|
||||
let request = &connection_log[request_index];
|
||||
let request_body = request.body_json();
|
||||
eprintln!(
|
||||
"[ws test server +{}ms] connection={} received request={} type={:?} role={:?} text={:?} data={:?}",
|
||||
start.elapsed().as_millis(),
|
||||
connection_index,
|
||||
request_index,
|
||||
request_body.get("type").and_then(Value::as_str),
|
||||
request_body
|
||||
.get("item")
|
||||
.and_then(|item| item.get("role"))
|
||||
.and_then(Value::as_str),
|
||||
request_body
|
||||
.get("item")
|
||||
.and_then(|item| item.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|content| content.first())
|
||||
.and_then(|content| content.get("text"))
|
||||
.and_then(Value::as_str),
|
||||
request_body
|
||||
.get("item")
|
||||
.and_then(|item| item.get("content"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|content| content.first())
|
||||
.and_then(|content| content.get("data"))
|
||||
.and_then(Value::as_str),
|
||||
);
|
||||
}
|
||||
request_log.notify_waiters();
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"[ws test server +{}ms] connection={} sending batch_size={} event_types={:?} audio_data={:?}",
|
||||
start.elapsed().as_millis(),
|
||||
connection_index,
|
||||
request_events.len(),
|
||||
request_events
|
||||
.iter()
|
||||
.map(|event| event.get("type").and_then(Value::as_str))
|
||||
.collect::<Vec<_>>(),
|
||||
request_events
|
||||
.iter()
|
||||
.find_map(|event| event.get("delta").and_then(Value::as_str)),
|
||||
);
|
||||
for event in &request_events {
|
||||
let Ok(payload) = serde_json::to_string(event) else {
|
||||
continue;
|
||||
@@ -1158,6 +1332,7 @@ pub async fn start_websocket_server_with_headers(
|
||||
uri,
|
||||
connections: connections_log,
|
||||
handshakes: handshakes_log,
|
||||
request_log_updated,
|
||||
shutdown: shutdown_tx,
|
||||
task,
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use codex_core::ThreadManager;
|
||||
use codex_core::built_in_model_providers;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -24,6 +23,7 @@ use wiremock::MockServer;
|
||||
|
||||
use crate::load_default_config_for_test;
|
||||
use crate::responses::WebSocketTestServer;
|
||||
use crate::responses::output_value_to_text;
|
||||
use crate::responses::start_mock_server;
|
||||
use crate::streaming_sse::StreamingSseServer;
|
||||
use crate::wait_for_event;
|
||||
@@ -300,7 +300,7 @@ impl TestCodex {
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -395,11 +395,7 @@ impl TestCodexHarness {
|
||||
|
||||
pub async fn custom_tool_call_output(&self, call_id: &str) -> String {
|
||||
let bodies = self.request_bodies().await;
|
||||
custom_tool_call_output(&bodies, call_id)
|
||||
.get("output")
|
||||
.and_then(Value::as_str)
|
||||
.expect("output string")
|
||||
.to_string()
|
||||
custom_tool_call_output_text(&bodies, call_id)
|
||||
}
|
||||
|
||||
pub async fn apply_patch_output(
|
||||
@@ -434,6 +430,14 @@ fn custom_tool_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value
|
||||
panic!("custom_tool_call_output {call_id} not found");
|
||||
}
|
||||
|
||||
fn custom_tool_call_output_text(bodies: &[Value], call_id: &str) -> String {
|
||||
let output = custom_tool_call_output(bodies, call_id)
|
||||
.get("output")
|
||||
.unwrap_or_else(|| panic!("custom_tool_call_output {call_id} missing output"));
|
||||
output_value_to_text(output)
|
||||
.unwrap_or_else(|| panic!("custom_tool_call_output {call_id} missing text output"))
|
||||
}
|
||||
|
||||
fn function_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value {
|
||||
for body in bodies {
|
||||
if let Some(items) = body.get("input").and_then(Value::as_array) {
|
||||
@@ -457,3 +461,36 @@ pub fn test_codex() -> TestCodexBuilder {
|
||||
home: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn custom_tool_call_output_text_returns_output_text() {
|
||||
let bodies = vec![json!({
|
||||
"input": [{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call-1",
|
||||
"output": "hello"
|
||||
}]
|
||||
})];
|
||||
|
||||
assert_eq!(custom_tool_call_output_text(&bodies, "call-1"), "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "custom_tool_call_output call-2 missing output")]
|
||||
fn custom_tool_call_output_text_panics_when_output_is_missing() {
|
||||
let bodies = vec![json!({
|
||||
"input": [{
|
||||
"type": "custom_tool_call_output",
|
||||
"call_id": "call-2"
|
||||
}]
|
||||
})];
|
||||
|
||||
let _ = custom_tool_call_output_text(&bodies, "call-2");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,7 +111,14 @@ async fn responses_stream_includes_subagent_header_on_review() {
|
||||
}];
|
||||
|
||||
let mut stream = client_session
|
||||
.stream(&prompt, &model_info, &otel_manager, effort, summary, None)
|
||||
.stream(
|
||||
&prompt,
|
||||
&model_info,
|
||||
&otel_manager,
|
||||
effort,
|
||||
summary.unwrap_or(model_info.default_reasoning_summary),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("stream failed");
|
||||
while let Some(event) = stream.next().await {
|
||||
@@ -216,7 +223,14 @@ async fn responses_stream_includes_subagent_header_on_other() {
|
||||
}];
|
||||
|
||||
let mut stream = client_session
|
||||
.stream(&prompt, &model_info, &otel_manager, effort, summary, None)
|
||||
.stream(
|
||||
&prompt,
|
||||
&model_info,
|
||||
&otel_manager,
|
||||
effort,
|
||||
summary.unwrap_or(model_info.default_reasoning_summary),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("stream failed");
|
||||
while let Some(event) = stream.next().await {
|
||||
@@ -267,7 +281,7 @@ async fn responses_respects_model_info_overrides_from_config() {
|
||||
config.model_provider_id = provider.name.clone();
|
||||
config.model_provider = provider.clone();
|
||||
config.model_supports_reasoning_summaries = Some(true);
|
||||
config.model_reasoning_summary = ReasoningSummary::Detailed;
|
||||
config.model_reasoning_summary = Some(ReasoningSummary::Detailed);
|
||||
let effort = config.model_reasoning_effort;
|
||||
let summary = config.model_reasoning_summary;
|
||||
let model = config.model.clone().expect("model configured");
|
||||
@@ -320,7 +334,14 @@ async fn responses_respects_model_info_overrides_from_config() {
|
||||
}];
|
||||
|
||||
let mut stream = client_session
|
||||
.stream(&prompt, &model_info, &otel_manager, effort, summary, None)
|
||||
.stream(
|
||||
&prompt,
|
||||
&model_info,
|
||||
&otel_manager,
|
||||
effort,
|
||||
summary.unwrap_or(model_info.default_reasoning_summary),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("stream failed");
|
||||
while let Some(event) = stream.next().await {
|
||||
|
||||
@@ -12,7 +12,6 @@ use std::sync::atomic::AtomicI32;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -312,7 +311,7 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -907,7 +906,7 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -987,7 +986,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1137,7 +1136,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1200,7 +1199,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1271,7 +1270,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1342,7 +1341,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -13,7 +13,6 @@ use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_protocol::approvals::NetworkApprovalProtocol;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyRuleAction;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -551,7 +550,7 @@ async fn submit_turn(
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -31,9 +31,14 @@ use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::WebSearchAction;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
@@ -74,40 +79,14 @@ fn assert_message_role(request_body: &serde_json::Value, role: &str) {
|
||||
assert_eq!(request_body["role"].as_str().unwrap(), role);
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
fn assert_message_equals(request_body: &serde_json::Value, text: &str) {
|
||||
let content = request_body["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid message content");
|
||||
|
||||
assert_eq!(
|
||||
content, text,
|
||||
"expected message content '{content}' to equal '{text}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
fn assert_message_starts_with(request_body: &serde_json::Value, text: &str) {
|
||||
let content = request_body["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid message content");
|
||||
|
||||
assert!(
|
||||
content.starts_with(text),
|
||||
"expected message content '{content}' to start with '{text}'"
|
||||
);
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
fn assert_message_ends_with(request_body: &serde_json::Value, text: &str) {
|
||||
let content = request_body["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("invalid message content");
|
||||
|
||||
assert!(
|
||||
content.ends_with(text),
|
||||
"expected message content '{content}' to end with '{text}'"
|
||||
);
|
||||
#[expect(clippy::unwrap_used)]
|
||||
fn message_input_texts(item: &serde_json::Value) -> Vec<&str> {
|
||||
item["content"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("text").and_then(|text| text.as_str()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Writes an `auth.json` into the provided `codex_home` with the specified parameters.
|
||||
@@ -305,19 +284,15 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
let request = resp_mock.single_request();
|
||||
let request_body = request.body_json();
|
||||
let input = request_body["input"].as_array().expect("input array");
|
||||
let messages: Vec<(String, String)> = input
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let role = item.get("role")?.as_str()?;
|
||||
let text = item
|
||||
.get("content")?
|
||||
.as_array()?
|
||||
.first()?
|
||||
.get("text")?
|
||||
.as_str()?;
|
||||
Some((role.to_string(), text.to_string()))
|
||||
})
|
||||
.collect();
|
||||
let mut messages: Vec<(String, String)> = Vec::new();
|
||||
for item in input {
|
||||
let Some(role) = item.get("role").and_then(|role| role.as_str()) else {
|
||||
continue;
|
||||
};
|
||||
for text in message_input_texts(item) {
|
||||
messages.push((role.to_string(), text.to_string()));
|
||||
}
|
||||
}
|
||||
let pos_prior_user = messages
|
||||
.iter()
|
||||
.position(|(role, text)| role == "user" && text == "resumed user message")
|
||||
@@ -354,8 +329,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
.position(|(role, text)| {
|
||||
role == "user"
|
||||
&& text.contains("be nice")
|
||||
&& (text.starts_with("# AGENTS.md instructions for ")
|
||||
|| text.starts_with("<user_instructions>"))
|
||||
&& (text.starts_with("# AGENTS.md instructions for "))
|
||||
})
|
||||
.expect("user instructions");
|
||||
let pos_environment = messages
|
||||
@@ -374,6 +348,144 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
||||
assert!(pos_environment < pos_new_user);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn resume_replays_legacy_js_repl_image_rollout_shapes() {
|
||||
skip_if_no_network!();
|
||||
|
||||
// Early js_repl builds persisted image tool results as two separate rollout items:
|
||||
// a string-valued custom_tool_call_output plus a standalone user input_image message.
|
||||
// Current image tests cover today's shapes; this keeps resume compatibility for that
|
||||
// legacy rollout representation.
|
||||
let legacy_custom_tool_call = ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "legacy-js-call".to_string(),
|
||||
name: "js_repl".to_string(),
|
||||
input: "console.log('legacy image flow')".to_string(),
|
||||
};
|
||||
let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
|
||||
let rollout = vec![
|
||||
RolloutLine {
|
||||
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: SessionMeta {
|
||||
id: ThreadId::default(),
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
cwd: ".".into(),
|
||||
originator: "test_originator".to_string(),
|
||||
cli_version: "test_version".to_string(),
|
||||
model_provider: Some("test-provider".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "2024-01-01T00:00:01.000Z".to_string(),
|
||||
item: RolloutItem::ResponseItem(legacy_custom_tool_call),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "2024-01-01T00:00:02.000Z".to_string(),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput {
|
||||
call_id: "legacy-js-call".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()),
|
||||
}),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "2024-01-01T00:00:03.000Z".to_string(),
|
||||
item: RolloutItem::ResponseItem(ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: legacy_image_url.to_string(),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
let tmpdir = TempDir::new().unwrap();
|
||||
let session_path = tmpdir
|
||||
.path()
|
||||
.join("resume-legacy-js-repl-image-rollout.jsonl");
|
||||
let mut f = std::fs::File::create(&session_path).unwrap();
|
||||
for line in rollout {
|
||||
writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let resp_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let codex_home = Arc::new(TempDir::new().unwrap());
|
||||
let mut builder = test_codex().with_model("gpt-5.1");
|
||||
let test = builder
|
||||
.resume(&server, codex_home, session_path.clone())
|
||||
.await
|
||||
.expect("resume conversation");
|
||||
test.submit_turn("after resume").await.unwrap();
|
||||
|
||||
let input = resp_mock.single_request().input();
|
||||
|
||||
let legacy_output_index = input
|
||||
.iter()
|
||||
.position(|item| {
|
||||
item.get("type").and_then(|value| value.as_str()) == Some("custom_tool_call_output")
|
||||
&& item.get("call_id").and_then(|value| value.as_str()) == Some("legacy-js-call")
|
||||
})
|
||||
.expect("legacy custom tool output should be replayed");
|
||||
assert_eq!(
|
||||
input[legacy_output_index]
|
||||
.get("output")
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("legacy js_repl stdout")
|
||||
);
|
||||
|
||||
let legacy_image_index = input
|
||||
.iter()
|
||||
.position(|item| {
|
||||
item.get("type").and_then(|value| value.as_str()) == Some("message")
|
||||
&& item.get("role").and_then(|value| value.as_str()) == Some("user")
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|entry| {
|
||||
entry.get("type").and_then(|value| value.as_str())
|
||||
== Some("input_image")
|
||||
&& entry.get("image_url").and_then(|value| value.as_str())
|
||||
== Some(legacy_image_url)
|
||||
})
|
||||
})
|
||||
})
|
||||
.expect("legacy injected image message should be replayed");
|
||||
|
||||
let new_user_index = input
|
||||
.iter()
|
||||
.position(|item| {
|
||||
item.get("type").and_then(|value| value.as_str()) == Some("message")
|
||||
&& item.get("role").and_then(|value| value.as_str()) == Some("user")
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|entry| {
|
||||
entry.get("type").and_then(|value| value.as_str()) == Some("input_text")
|
||||
&& entry.get("text").and_then(|value| value.as_str())
|
||||
== Some("after resume")
|
||||
})
|
||||
})
|
||||
})
|
||||
.expect("new user message should be present");
|
||||
|
||||
assert!(legacy_output_index < new_user_index);
|
||||
assert!(legacy_image_index < new_user_index);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn includes_conversation_id_and_model_headers_in_request() {
|
||||
skip_if_no_network!();
|
||||
@@ -664,16 +776,27 @@ async fn includes_user_instructions_message_in_request() {
|
||||
);
|
||||
|
||||
assert_message_role(&request_body["input"][1], "user");
|
||||
assert_message_starts_with(&request_body["input"][1], "# AGENTS.md instructions for ");
|
||||
assert_message_ends_with(&request_body["input"][1], "</INSTRUCTIONS>");
|
||||
let ui_text = request_body["input"][1]["content"][0]["text"]
|
||||
.as_str()
|
||||
let user_context_texts = message_input_texts(&request_body["input"][1]);
|
||||
assert!(
|
||||
user_context_texts
|
||||
.iter()
|
||||
.any(|text| text.starts_with("# AGENTS.md instructions for ")),
|
||||
"expected AGENTS text in contextual user message, got {user_context_texts:?}"
|
||||
);
|
||||
let ui_text = user_context_texts
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|text| text.contains("<INSTRUCTIONS>"))
|
||||
.expect("invalid message content");
|
||||
assert!(ui_text.contains("<INSTRUCTIONS>"));
|
||||
assert!(ui_text.contains("be nice"));
|
||||
assert_message_role(&request_body["input"][2], "user");
|
||||
assert_message_starts_with(&request_body["input"][2], "<environment_context>");
|
||||
assert_message_ends_with(&request_body["input"][2], "</environment_context>");
|
||||
assert!(
|
||||
user_context_texts
|
||||
.iter()
|
||||
.any(|text| text.starts_with("<environment_context>")
|
||||
&& text.ends_with("</environment_context>")),
|
||||
"expected environment context in contextual user message, got {user_context_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -727,10 +850,14 @@ async fn includes_apps_guidance_as_developer_message_when_enabled() {
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.and_then(|value| value.first())
|
||||
.and_then(|value| value.get("text"))
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|text| text.contains(apps_snippet))
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|entry| {
|
||||
entry
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|text| text.contains(apps_snippet))
|
||||
})
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
has_developer_apps_guidance,
|
||||
@@ -742,10 +869,14 @@ async fn includes_apps_guidance_as_developer_message_when_enabled() {
|
||||
&& item
|
||||
.get("content")
|
||||
.and_then(|value| value.as_array())
|
||||
.and_then(|value| value.first())
|
||||
.and_then(|value| value.get("text"))
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|text| text.contains(apps_snippet))
|
||||
.is_some_and(|content| {
|
||||
content.iter().any(|entry| {
|
||||
entry
|
||||
.get("text")
|
||||
.and_then(|value| value.as_str())
|
||||
.is_some_and(|text| text.contains(apps_snippet))
|
||||
})
|
||||
})
|
||||
});
|
||||
assert!(
|
||||
!has_user_apps_guidance,
|
||||
@@ -992,7 +1123,11 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_configured.model.clone(),
|
||||
effort: Some(ReasoningEffort::Low),
|
||||
summary: config.model_reasoning_summary,
|
||||
summary: Some(
|
||||
config
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(ReasoningSummary::Auto),
|
||||
),
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
@@ -1026,7 +1161,7 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> {
|
||||
.await;
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.model_reasoning_summary = ReasoningSummary::Concise;
|
||||
config.model_reasoning_summary = Some(ReasoningSummary::Concise);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
@@ -1058,6 +1193,75 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() -> anyhow::Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let resp_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut model_catalog: ModelsResponse =
|
||||
serde_json::from_str(include_str!("../../models.json")).expect("valid models.json");
|
||||
let model = model_catalog
|
||||
.models
|
||||
.iter_mut()
|
||||
.find(|model| model.slug == "gpt-5.1")
|
||||
.expect("gpt-5.1 exists in bundled models.json");
|
||||
model.supports_reasoning_summaries = true;
|
||||
model.default_reasoning_summary = ReasoningSummary::Detailed;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
config,
|
||||
session_configured,
|
||||
..
|
||||
} = test_codex()
|
||||
.with_model("gpt-5.1")
|
||||
.with_config(move |config| {
|
||||
config.model_catalog = Some(model_catalog);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: session_configured.model,
|
||||
effort: None,
|
||||
summary: Some(ReasoningSummary::Concise),
|
||||
collaboration_mode: None,
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request_body = resp_mock.single_request().body_json();
|
||||
|
||||
pretty_assertions::assert_eq!(
|
||||
request_body
|
||||
.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("summary"))
|
||||
.and_then(|value| value.as_str()),
|
||||
Some("concise")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -1070,7 +1274,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> {
|
||||
.await;
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.model_reasoning_summary = ReasoningSummary::None;
|
||||
config.model_reasoning_summary = Some(ReasoningSummary::None);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
@@ -1101,6 +1305,60 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reasoning_summary_none_overrides_model_catalog_default() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
let server = MockServer::start().await;
|
||||
|
||||
let resp_mock = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut model_catalog: ModelsResponse =
|
||||
serde_json::from_str(include_str!("../../models.json")).expect("valid models.json");
|
||||
let model = model_catalog
|
||||
.models
|
||||
.iter_mut()
|
||||
.find(|model| model.slug == "gpt-5.1")
|
||||
.expect("gpt-5.1 exists in bundled models.json");
|
||||
model.supports_reasoning_summaries = true;
|
||||
model.default_reasoning_summary = ReasoningSummary::Detailed;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_model("gpt-5.1")
|
||||
.with_config(move |config| {
|
||||
config.model_reasoning_summary = Some(ReasoningSummary::None);
|
||||
config.model_catalog = Some(model_catalog);
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "hello".into(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let request_body = resp_mock.single_request().body_json();
|
||||
pretty_assertions::assert_eq!(
|
||||
request_body
|
||||
.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("summary")),
|
||||
None
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn includes_default_verbosity_in_request() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -1283,19 +1541,42 @@ async fn includes_developer_instructions_message_in_request() {
|
||||
"expected permissions message to mention sandbox_mode, got {permissions_text:?}"
|
||||
);
|
||||
|
||||
assert_message_role(&request_body["input"][1], "developer");
|
||||
assert_message_equals(&request_body["input"][1], "be useful");
|
||||
assert_message_role(&request_body["input"][2], "user");
|
||||
assert_message_starts_with(&request_body["input"][2], "# AGENTS.md instructions for ");
|
||||
assert_message_ends_with(&request_body["input"][2], "</INSTRUCTIONS>");
|
||||
let ui_text = request_body["input"][2]["content"][0]["text"]
|
||||
.as_str()
|
||||
let developer_messages: Vec<&serde_json::Value> = request_body["input"]
|
||||
.as_array()
|
||||
.expect("input array")
|
||||
.iter()
|
||||
.filter(|item| item.get("role").and_then(|role| role.as_str()) == Some("developer"))
|
||||
.collect();
|
||||
assert!(
|
||||
developer_messages
|
||||
.iter()
|
||||
.any(|item| message_input_texts(item).contains(&"be useful")),
|
||||
"expected developer instructions in a developer message, got {:?}",
|
||||
request_body["input"]
|
||||
);
|
||||
|
||||
assert_message_role(&request_body["input"][1], "user");
|
||||
let user_context_texts = message_input_texts(&request_body["input"][1]);
|
||||
assert!(
|
||||
user_context_texts
|
||||
.iter()
|
||||
.any(|text| text.starts_with("# AGENTS.md instructions for ")),
|
||||
"expected AGENTS text in contextual user message, got {user_context_texts:?}"
|
||||
);
|
||||
let ui_text = user_context_texts
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|text| text.contains("<INSTRUCTIONS>"))
|
||||
.expect("invalid message content");
|
||||
assert!(ui_text.contains("<INSTRUCTIONS>"));
|
||||
assert!(ui_text.contains("be nice"));
|
||||
assert_message_role(&request_body["input"][3], "user");
|
||||
assert_message_starts_with(&request_body["input"][3], "<environment_context>");
|
||||
assert_message_ends_with(&request_body["input"][3], "</environment_context>");
|
||||
assert!(
|
||||
user_context_texts
|
||||
.iter()
|
||||
.any(|text| text.starts_with("<environment_context>")
|
||||
&& text.ends_with("</environment_context>")),
|
||||
"expected environment context in contextual user message, got {user_context_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -1426,11 +1707,18 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
|
||||
});
|
||||
prompt.input.push(ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-tool-call-id".into(),
|
||||
output: "ok".into(),
|
||||
output: FunctionCallOutputPayload::from_text("ok".into()),
|
||||
});
|
||||
|
||||
let mut stream = client_session
|
||||
.stream(&prompt, &model_info, &otel_manager, effort, summary, None)
|
||||
.stream(
|
||||
&prompt,
|
||||
&model_info,
|
||||
&otel_manager,
|
||||
effort,
|
||||
summary.unwrap_or(ReasoningSummary::Auto),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("responses stream to start");
|
||||
|
||||
|
||||
@@ -169,7 +169,11 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> {
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: test.config.model_reasoning_summary,
|
||||
summary: Some(
|
||||
test.config
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto),
|
||||
),
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
@@ -275,7 +279,11 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu
|
||||
sandbox_policy: test.config.permissions.sandbox_policy.get().clone(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: test.config.model_reasoning_summary,
|
||||
summary: Some(
|
||||
test.config
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(codex_protocol::config_types::ReasoningSummary::Auto),
|
||||
),
|
||||
collaboration_mode: Some(turn_mode),
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
|
||||
@@ -5,7 +5,6 @@ use codex_core::built_in_model_providers;
|
||||
use codex_core::compact::SUMMARIZATION_PROMPT;
|
||||
use codex_core::compact::SUMMARY_PREFIX;
|
||||
use codex_core::config::Config;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -756,16 +755,40 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
|
||||
let body = requests_payloads[0].body_json();
|
||||
let input = body.get("input").and_then(|v| v.as_array()).unwrap();
|
||||
|
||||
fn strip_agents_parts_from_user_message(
|
||||
value: &serde_json::Value,
|
||||
) -> Option<serde_json::Value> {
|
||||
let content = value
|
||||
.get("content")
|
||||
.and_then(|content| content.as_array())?;
|
||||
let filtered_content = content
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
!item
|
||||
.get("text")
|
||||
.and_then(|text| text.as_str())
|
||||
.is_some_and(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
})
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if filtered_content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut normalized = value.clone();
|
||||
normalized["content"] = serde_json::Value::Array(filtered_content);
|
||||
Some(normalized)
|
||||
}
|
||||
|
||||
fn normalize_inputs(values: &[serde_json::Value]) -> Vec<serde_json::Value> {
|
||||
values
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
.filter_map(|value| {
|
||||
if value
|
||||
.get("type")
|
||||
.and_then(|ty| ty.as_str())
|
||||
.is_some_and(|ty| ty == "function_call_output")
|
||||
{
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
|
||||
let text = value
|
||||
@@ -781,11 +804,13 @@ async fn multiple_auto_compact_per_task_runs_after_token_limit_hit() {
|
||||
if role == Some("developer")
|
||||
&& text.is_some_and(|text| text.contains("`sandbox_mode`"))
|
||||
{
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
!text.is_some_and(|text| text.starts_with("# AGENTS.md instructions for "))
|
||||
if role == Some("user") {
|
||||
return strip_agents_parts_from_user_message(value);
|
||||
}
|
||||
Some(value.clone())
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1633,7 +1658,7 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: resumed.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1722,7 +1747,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1745,7 +1770,7 @@ async fn pre_sampling_compact_runs_on_switch_to_smaller_context_model() {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1854,7 +1879,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -1901,7 +1926,7 @@ async fn pre_sampling_compact_runs_after_resume_and_switch_to_smaller_model() {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -3102,7 +3127,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: previous_model.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -3125,7 +3150,7 @@ async fn snapshot_request_shape_pre_turn_compaction_strips_incoming_model_switch
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: next_model.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -3184,7 +3209,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
|
||||
]);
|
||||
let mut responses = vec![first_turn];
|
||||
responses.extend(
|
||||
(0..6).map(|_| {
|
||||
(0..5).map(|_| {
|
||||
sse_failed(
|
||||
"compact-failed",
|
||||
"context_length_exceeded",
|
||||
|
||||
@@ -4,7 +4,6 @@ use anyhow::Result;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -55,7 +54,7 @@ async fn submit_user_turn(
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode,
|
||||
personality: None,
|
||||
})
|
||||
@@ -134,7 +133,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use anyhow::Context;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -126,7 +125,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -208,7 +207,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -377,7 +377,7 @@ async fn plan_mode_emits_plan_item_from_proposed_plan_block() -> anyhow::Result<
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
@@ -452,7 +452,7 @@ async fn plan_mode_strips_plan_from_agent_messages() -> anyhow::Result<()> {
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
@@ -559,7 +559,7 @@ async fn plan_mode_streaming_citations_are_stripped_across_added_deltas_and_done
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
@@ -744,7 +744,7 @@ async fn plan_mode_streaming_proposed_plan_tag_split_across_added_and_delta_is_p
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
@@ -856,7 +856,7 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> {
|
||||
sandbox_policy: codex_protocol::protocol::SandboxPolicy::DangerFullAccess,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
@@ -12,6 +13,12 @@ use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::MockServer;
|
||||
|
||||
fn custom_tool_output_text_and_success(
|
||||
@@ -24,6 +31,45 @@ fn custom_tool_output_text_and_success(
|
||||
(output.unwrap_or_default(), success)
|
||||
}
|
||||
|
||||
fn tool_names(body: &serde_json::Value) -> Vec<String> {
|
||||
body["tools"]
|
||||
.as_array()
|
||||
.expect("tools array should be present")
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
tool.get("name")
|
||||
.and_then(|value| value.as_str())
|
||||
.or_else(|| tool.get("type").and_then(|value| value.as_str()))
|
||||
.expect("tool should have a name or type")
|
||||
.to_string()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_too_old_node_script(dir: &Path) -> Result<std::path::PathBuf> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let path = dir.join("old-node.cmd");
|
||||
fs::write(&path, "@echo off\r\necho v0.0.1\r\n")?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let path = dir.join("old-node.sh");
|
||||
fs::write(&path, "#!/bin/sh\necho v0.0.1\n")?;
|
||||
let mut permissions = fs::metadata(&path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&path, permissions)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
anyhow::bail!("unsupported platform for js_repl test fixture");
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_js_repl_turn(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
@@ -54,6 +100,64 @@ async fn run_js_repl_turn(
|
||||
Ok(second_mock)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_is_not_advertised_when_startup_node_is_incompatible() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
if std::env::var_os("CODEX_JS_REPL_NODE_PATH").is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let temp = tempdir()?;
|
||||
let old_node = write_too_old_node_script(temp.path())?;
|
||||
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
config.features.enable(Feature::JsRepl);
|
||||
config.js_repl_node_path = Some(old_node);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let warning = wait_for_event_match(&test.codex, |event| match event {
|
||||
EventMsg::Warning(ev) if ev.message.contains("Disabled `js_repl` for this session") => {
|
||||
Some(ev.message.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
warning.contains("Node runtime"),
|
||||
"warning should explain the Node compatibility issue: {warning}"
|
||||
);
|
||||
|
||||
let request_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("hello").await?;
|
||||
|
||||
let body = request_mock.single_request().body_json();
|
||||
let tools = tool_names(&body);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool == "js_repl"),
|
||||
"js_repl should be omitted when startup validation fails: {tools:?}"
|
||||
);
|
||||
assert!(
|
||||
!tools.iter().any(|tool| tool == "js_repl_reset"),
|
||||
"js_repl_reset should be omitted when startup validation fails: {tools:?}"
|
||||
);
|
||||
let instructions = body["instructions"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
!instructions.contains("## JavaScript REPL (Node)"),
|
||||
"startup instructions should not mention js_repl when it is disabled: {instructions}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn js_repl_persists_top_level_bindings_and_supports_tla() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -84,7 +83,7 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::config::ProjectConfig;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -65,7 +64,7 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str)
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
use anyhow::Result;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use core_test_support::responses::ResponseMock;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_once;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn memories_startup_phase2_tracks_added_and_removed_inputs_across_runs() -> Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let home = Arc::new(TempDir::new()?);
|
||||
let db = init_state_db(&home).await?;
|
||||
|
||||
let now = Utc::now();
|
||||
let thread_a = seed_stage1_output(
|
||||
db.as_ref(),
|
||||
home.path(),
|
||||
now - ChronoDuration::hours(2),
|
||||
"raw memory A",
|
||||
"rollout summary A",
|
||||
"rollout-a",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let first_phase2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-phase2-1"),
|
||||
ev_assistant_message("msg-phase2-1", "phase2 complete"),
|
||||
ev_completed("resp-phase2-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let first = build_test_codex(&server, home.clone()).await?;
|
||||
let first_request = wait_for_single_request(&first_phase2).await;
|
||||
let first_prompt = phase2_prompt_text(&first_request);
|
||||
assert!(
|
||||
first_prompt.contains("- selected inputs this run: 1"),
|
||||
"expected selected count in first prompt: {first_prompt}"
|
||||
);
|
||||
assert!(
|
||||
first_prompt.contains("- newly added since the last successful Phase 2 run: 1"),
|
||||
"expected added count in first prompt: {first_prompt}"
|
||||
);
|
||||
assert!(
|
||||
first_prompt.contains("- removed from the last successful Phase 2 run: 0"),
|
||||
"expected removed count in first prompt: {first_prompt}"
|
||||
);
|
||||
assert!(
|
||||
first_prompt.contains(&format!("- [added] thread_id={thread_a},")),
|
||||
"expected thread A to be marked added: {first_prompt}"
|
||||
);
|
||||
assert!(
|
||||
first_prompt.contains("Removed from the last successful Phase 2 selection:\n- none"),
|
||||
"expected no removed items in first prompt: {first_prompt}"
|
||||
);
|
||||
|
||||
wait_for_phase2_success(db.as_ref(), thread_a).await?;
|
||||
let memory_root = home.path().join("memories");
|
||||
let raw_memories = tokio::fs::read_to_string(memory_root.join("raw_memories.md")).await?;
|
||||
assert!(raw_memories.contains("raw memory A"));
|
||||
assert!(!raw_memories.contains("raw memory B"));
|
||||
let rollout_summaries = read_rollout_summary_bodies(&memory_root).await?;
|
||||
assert_eq!(rollout_summaries.len(), 1);
|
||||
assert!(rollout_summaries[0].contains("rollout summary A"));
|
||||
assert!(rollout_summaries[0].contains("git_branch: branch-rollout-a"));
|
||||
|
||||
shutdown_test_codex(&first).await?;
|
||||
|
||||
let thread_b = seed_stage1_output(
|
||||
db.as_ref(),
|
||||
home.path(),
|
||||
now - ChronoDuration::hours(1),
|
||||
"raw memory B",
|
||||
"rollout summary B",
|
||||
"rollout-b",
|
||||
)
|
||||
.await?;
|
||||
|
||||
let second_phase2 = mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-phase2-2"),
|
||||
ev_assistant_message("msg-phase2-2", "phase2 complete"),
|
||||
ev_completed("resp-phase2-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let second = build_test_codex(&server, home.clone()).await?;
|
||||
let second_request = wait_for_single_request(&second_phase2).await;
|
||||
let second_prompt = phase2_prompt_text(&second_request);
|
||||
assert!(
|
||||
second_prompt.contains("- selected inputs this run: 1"),
|
||||
"expected selected count in second prompt: {second_prompt}"
|
||||
);
|
||||
assert!(
|
||||
second_prompt.contains("- newly added since the last successful Phase 2 run: 1"),
|
||||
"expected added count in second prompt: {second_prompt}"
|
||||
);
|
||||
assert!(
|
||||
second_prompt.contains("- removed from the last successful Phase 2 run: 1"),
|
||||
"expected removed count in second prompt: {second_prompt}"
|
||||
);
|
||||
assert!(
|
||||
second_prompt.contains(&format!("- [added] thread_id={thread_b},")),
|
||||
"expected thread B to be marked added: {second_prompt}"
|
||||
);
|
||||
assert!(
|
||||
second_prompt.contains(&format!("- thread_id={thread_a},")),
|
||||
"expected thread A to be marked removed: {second_prompt}"
|
||||
);
|
||||
|
||||
wait_for_phase2_success(db.as_ref(), thread_b).await?;
|
||||
let raw_memories = tokio::fs::read_to_string(memory_root.join("raw_memories.md")).await?;
|
||||
assert!(raw_memories.contains("raw memory B"));
|
||||
assert!(raw_memories.contains("raw memory A"));
|
||||
let rollout_summaries = read_rollout_summary_bodies(&memory_root).await?;
|
||||
assert_eq!(rollout_summaries.len(), 2);
|
||||
assert!(
|
||||
rollout_summaries
|
||||
.iter()
|
||||
.any(|summary| summary.contains("rollout summary B"))
|
||||
);
|
||||
assert!(
|
||||
rollout_summaries
|
||||
.iter()
|
||||
.any(|summary| summary.contains("git_branch: branch-rollout-b"))
|
||||
);
|
||||
assert!(
|
||||
rollout_summaries
|
||||
.iter()
|
||||
.any(|summary| summary.contains("rollout summary A"))
|
||||
);
|
||||
|
||||
shutdown_test_codex(&second).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_test_codex(server: &wiremock::MockServer, home: Arc<TempDir>) -> Result<TestCodex> {
|
||||
let mut builder = test_codex().with_home(home).with_config(|config| {
|
||||
config.features.enable(Feature::Sqlite);
|
||||
config.features.enable(Feature::MemoryTool);
|
||||
config.memories.max_raw_memories_for_global = 1;
|
||||
});
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
async fn init_state_db(home: &Arc<TempDir>) -> Result<Arc<codex_state::StateRuntime>> {
|
||||
let db =
|
||||
codex_state::StateRuntime::init(home.path().to_path_buf(), "test-provider".into(), None)
|
||||
.await?;
|
||||
db.mark_backfill_complete(None).await?;
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn seed_stage1_output(
|
||||
db: &codex_state::StateRuntime,
|
||||
codex_home: &Path,
|
||||
updated_at: chrono::DateTime<Utc>,
|
||||
raw_memory: &str,
|
||||
rollout_summary: &str,
|
||||
rollout_slug: &str,
|
||||
) -> Result<ThreadId> {
|
||||
let thread_id = ThreadId::new();
|
||||
let mut metadata_builder = codex_state::ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
codex_home.join(format!("rollout-{thread_id}.jsonl")),
|
||||
updated_at,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
metadata_builder.cwd = codex_home.join(format!("workspace-{rollout_slug}"));
|
||||
metadata_builder.model_provider = Some("test-provider".to_string());
|
||||
metadata_builder.git_branch = Some(format!("branch-{rollout_slug}"));
|
||||
let metadata = metadata_builder.build("test-provider");
|
||||
db.upsert_thread(&metadata).await?;
|
||||
|
||||
let claim = db
|
||||
.try_claim_stage1_job(
|
||||
thread_id,
|
||||
ThreadId::new(),
|
||||
updated_at.timestamp(),
|
||||
3_600,
|
||||
64,
|
||||
)
|
||||
.await?;
|
||||
let ownership_token = match claim {
|
||||
codex_state::Stage1JobClaimOutcome::Claimed { ownership_token } => ownership_token,
|
||||
other => panic!("unexpected stage-1 claim outcome: {other:?}"),
|
||||
};
|
||||
|
||||
assert!(
|
||||
db.mark_stage1_job_succeeded(
|
||||
thread_id,
|
||||
&ownership_token,
|
||||
updated_at.timestamp(),
|
||||
raw_memory,
|
||||
rollout_summary,
|
||||
Some(rollout_slug),
|
||||
)
|
||||
.await?,
|
||||
"stage-1 success should enqueue global consolidation"
|
||||
);
|
||||
|
||||
Ok(thread_id)
|
||||
}
|
||||
|
||||
async fn wait_for_single_request(mock: &ResponseMock) -> ResponsesRequest {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let requests = mock.requests();
|
||||
if let Some(request) = requests.into_iter().next() {
|
||||
return request;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for phase2 request"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::expect_used)]
|
||||
fn phase2_prompt_text(request: &ResponsesRequest) -> String {
|
||||
request
|
||||
.message_input_texts("user")
|
||||
.into_iter()
|
||||
.find(|text| text.contains("Current selected Phase 1 inputs:"))
|
||||
.expect("phase2 prompt text")
|
||||
}
|
||||
|
||||
async fn wait_for_phase2_success(
|
||||
db: &codex_state::StateRuntime,
|
||||
expected_thread_id: ThreadId,
|
||||
) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let selection = db.get_phase2_input_selection(1, 30).await?;
|
||||
if selection.selected.len() == 1
|
||||
&& selection.selected[0].thread_id == expected_thread_id
|
||||
&& selection.retained_thread_ids == vec![expected_thread_id]
|
||||
&& selection.removed.is_empty()
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for phase2 success for {expected_thread_id}"
|
||||
);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_rollout_summary_bodies(memory_root: &Path) -> Result<Vec<String>> {
|
||||
let mut dir = tokio::fs::read_dir(memory_root.join("rollout_summaries")).await?;
|
||||
let mut summaries = Vec::new();
|
||||
while let Some(entry) = dir.next_entry().await? {
|
||||
summaries.push(tokio::fs::read_to_string(entry.path()).await?);
|
||||
}
|
||||
summaries.sort();
|
||||
Ok(summaries)
|
||||
}
|
||||
|
||||
async fn shutdown_test_codex(test: &TestCodex) -> Result<()> {
|
||||
test.codex.submit(Op::Shutdown {}).await?;
|
||||
wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -83,6 +83,7 @@ mod json_result;
|
||||
mod list_dir;
|
||||
mod live_cli;
|
||||
mod live_reload;
|
||||
mod memories;
|
||||
mod model_info_overrides;
|
||||
mod model_overrides;
|
||||
mod model_switching;
|
||||
|
||||
@@ -58,7 +58,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -91,7 +91,7 @@ async fn model_change_appends_model_instructions_developer_message() -> Result<(
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: next_model.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -146,7 +146,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -179,7 +179,7 @@ async fn model_and_personality_change_only_appends_model_instructions() -> Resul
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: next_model.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -234,8 +234,10 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -293,7 +295,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: image_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -312,7 +314,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -391,8 +393,10 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -469,7 +473,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: large_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -524,7 +528,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: smaller_model_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ use std::sync::Arc;
|
||||
use anyhow::Result;
|
||||
use codex_core::config::types::Personality;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
@@ -26,6 +25,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::wait_for_event;
|
||||
use serde_json::json;
|
||||
|
||||
const PRETURN_CONTEXT_DIFF_CWD: &str = "PRETURN_CONTEXT_DIFF_CWD";
|
||||
|
||||
@@ -53,6 +53,30 @@ fn agents_message_count(request: &ResponsesRequest) -> usize {
|
||||
.count()
|
||||
}
|
||||
|
||||
fn format_environment_context_subagents_snapshot(subagents: &[&str]) -> String {
|
||||
let subagents_block = if subagents.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
let lines = subagents
|
||||
.iter()
|
||||
.map(|line| format!(" {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("\n <subagents>\n{lines}\n </subagents>")
|
||||
};
|
||||
let items = vec![json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": format!(
|
||||
"<environment_context>\n <cwd>/tmp/example</cwd>\n <shell>bash</shell>{subagents_block}\n</environment_context>"
|
||||
),
|
||||
}],
|
||||
})];
|
||||
context_snapshot::format_response_items_snapshot(items.as_slice(), &context_snapshot_options())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -97,7 +121,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -119,7 +143,7 @@ async fn snapshot_model_visible_layout_turn_overrides() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: Some(Personality::Friendly),
|
||||
})
|
||||
@@ -196,7 +220,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -218,7 +242,7 @@ async fn snapshot_model_visible_layout_cwd_change_does_not_refresh_agents() -> R
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -323,7 +347,7 @@ async fn snapshot_model_visible_layout_resume_with_personality_change() -> Resul
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: resumed.session_configured.model.clone(),
|
||||
effort: resumed.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: Some(Personality::Friendly),
|
||||
})
|
||||
@@ -445,3 +469,23 @@ async fn snapshot_model_visible_layout_resume_override_matches_rollout_model() -
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_model_visible_layout_environment_context_includes_one_subagent() -> Result<()> {
|
||||
insta::assert_snapshot!(
|
||||
"model_visible_layout_environment_context_includes_one_subagent",
|
||||
format_environment_context_subagents_snapshot(&["- agent-1: Atlas"])
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn snapshot_model_visible_layout_environment_context_includes_two_subagents() -> Result<()> {
|
||||
insta::assert_snapshot!(
|
||||
"model_visible_layout_environment_context_includes_two_subagents",
|
||||
format_environment_context_subagents_snapshot(&["- agent-1: Atlas", "- agent-2: Juniper"])
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -336,8 +336,10 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::CodexAuth;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -106,7 +105,7 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -97,7 +97,7 @@ async fn user_turn_personality_none_does_not_add_update_message() -> anyhow::Res
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -143,7 +143,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result<
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -196,7 +196,7 @@ async fn config_personality_none_sends_no_personality() -> anyhow::Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -255,7 +255,7 @@ async fn default_personality_is_pragmatic_without_config_toml() -> anyhow::Resul
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -302,7 +302,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -336,7 +336,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -398,7 +398,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -432,7 +432,7 @@ async fn user_turn_personality_same_value_does_not_add_update_message() -> anyho
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -504,7 +504,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -538,7 +538,7 @@ async fn user_turn_personality_skips_if_feature_disabled() -> anyhow::Result<()>
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -599,8 +599,10 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
|
||||
}),
|
||||
}),
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -646,7 +648,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: Some(Personality::Friendly),
|
||||
})
|
||||
@@ -706,8 +708,10 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
}),
|
||||
}),
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -756,7 +760,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -790,7 +794,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: remote_slug.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -30,21 +30,46 @@ use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn text_user_input(text: String) -> serde_json::Value {
|
||||
text_user_input_parts(vec![text])
|
||||
}
|
||||
|
||||
fn text_user_input_parts(texts: Vec<String>) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": text } ]
|
||||
"content": texts
|
||||
.into_iter()
|
||||
.map(|text| serde_json::json!({ "type": "input_text", "text": text }))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
fn default_env_context_str(cwd: &str, shell: &Shell) -> String {
|
||||
fn assert_default_env_context(text: &str, cwd: &str, shell: &Shell) {
|
||||
let shell_name = shell.name();
|
||||
format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{cwd}</cwd>
|
||||
<shell>{shell_name}</shell>
|
||||
</environment_context>"#
|
||||
)
|
||||
assert!(
|
||||
text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG),
|
||||
"expected environment context fragment: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!("<cwd>{cwd}</cwd>")),
|
||||
"expected cwd in environment context: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!("<shell>{shell_name}</shell>")),
|
||||
"expected shell in environment context: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("<current_date>") && text.contains("</current_date>"),
|
||||
"expected current_date in environment context: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("<timezone>") && text.contains("</timezone>"),
|
||||
"expected timezone in environment context: {text}"
|
||||
);
|
||||
assert!(
|
||||
text.ends_with("</environment_context>"),
|
||||
"expected closing environment_context tag: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_tool_names(body: &serde_json::Value, expected_names: &[&str]) {
|
||||
@@ -297,8 +322,8 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
let input1 = body1["input"].as_array().expect("input array");
|
||||
assert_eq!(
|
||||
input1.len(),
|
||||
4,
|
||||
"expected permissions + cached prefix + env + user msg"
|
||||
3,
|
||||
"expected permissions + cached contextual user prefix + user msg"
|
||||
);
|
||||
|
||||
let ui_text = input1[1]["content"][0]["text"]
|
||||
@@ -311,13 +336,16 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests
|
||||
|
||||
let shell = default_user_shell();
|
||||
let cwd_str = config.cwd.to_string_lossy();
|
||||
let expected_env_text = default_env_context_str(&cwd_str, &shell);
|
||||
let env_text = input1[1]["content"][1]["text"]
|
||||
.as_str()
|
||||
.expect("environment context text");
|
||||
assert_default_env_context(env_text, &cwd_str, &shell);
|
||||
assert_eq!(
|
||||
input1[2],
|
||||
text_user_input(expected_env_text),
|
||||
"expected environment context after UI message"
|
||||
input1[1]["content"][1]["type"].as_str(),
|
||||
Some("input_text"),
|
||||
"expected environment context bundled after UI message in cached contextual message"
|
||||
);
|
||||
assert_eq!(input1[3], text_user_input("hello 1".to_string()));
|
||||
assert_eq!(input1[2], text_user_input("hello 1".to_string()));
|
||||
|
||||
let body2 = req2.single_request().body_json();
|
||||
let input2 = body2["input"].as_array().expect("input array");
|
||||
@@ -402,8 +430,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let request1 = req1.single_request();
|
||||
let request2 = req2.single_request();
|
||||
let body1 = request1.body_json();
|
||||
let body2 = request2.body_json();
|
||||
// prompt_cache_key should remain constant across overrides
|
||||
assert_eq!(
|
||||
body1["prompt_cache_key"], body2["prompt_cache_key"],
|
||||
@@ -500,17 +530,32 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
|
||||
let env_texts: Vec<&str> = input
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
msg["content"]
|
||||
.as_array()
|
||||
.and_then(|content| content.first())
|
||||
.and_then(|item| item["text"].as_str())
|
||||
msg["content"].as_array().map(|content| {
|
||||
content
|
||||
.iter()
|
||||
.filter_map(|item| item["text"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
.filter(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG))
|
||||
.collect();
|
||||
assert!(
|
||||
!env_texts.is_empty(),
|
||||
"expected environment context to be emitted: {env_texts:?}"
|
||||
);
|
||||
assert!(
|
||||
env_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<current_date>") && text.contains("</current_date>")),
|
||||
"expected current_date in environment context: {env_texts:?}"
|
||||
);
|
||||
assert!(
|
||||
env_texts
|
||||
.iter()
|
||||
.any(|text| text.contains("<timezone>") && text.contains("</timezone>")),
|
||||
"expected timezone in environment context: {env_texts:?}"
|
||||
);
|
||||
|
||||
let env_count = input
|
||||
.iter()
|
||||
@@ -559,11 +604,14 @@ async fn override_before_first_turn_emits_environment_context() -> anyhow::Resul
|
||||
let user_texts: Vec<&str> = input
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
msg["content"]
|
||||
.as_array()
|
||||
.and_then(|content| content.first())
|
||||
.and_then(|item| item["text"].as_str())
|
||||
msg["content"].as_array().map(|content| {
|
||||
content
|
||||
.iter()
|
||||
.filter_map(|item| item["text"].as_str())
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
})
|
||||
.flatten()
|
||||
.collect();
|
||||
assert!(
|
||||
user_texts.contains(&"first message"),
|
||||
@@ -631,7 +679,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
sandbox_policy: new_policy.clone(),
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
summary: Some(ReasoningSummary::Detailed),
|
||||
collaboration_mode: None,
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
@@ -639,8 +687,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let request1 = req1.single_request();
|
||||
let request2 = req2.single_request();
|
||||
let body1 = request1.body_json();
|
||||
let body2 = request2.body_json();
|
||||
|
||||
// prompt_cache_key should remain constant across per-turn overrides
|
||||
assert_eq!(
|
||||
@@ -655,43 +705,34 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": "hello 2" } ]
|
||||
});
|
||||
let shell = default_user_shell();
|
||||
|
||||
let expected_env_text_2 = format!(
|
||||
r#"<environment_context>
|
||||
<cwd>{}</cwd>
|
||||
<shell>{}</shell>
|
||||
</environment_context>"#,
|
||||
new_cwd.path().display(),
|
||||
shell.name()
|
||||
);
|
||||
let expected_env_msg_2 = serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [ { "type": "input_text", "text": expected_env_text_2 } ]
|
||||
});
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
let body1_input = body1["input"].as_array().expect("input array");
|
||||
let expected_model_switch_msg = body2["input"][body1_input.len()].clone();
|
||||
let expected_settings_update_msg = body2["input"][body1_input.len()].clone();
|
||||
assert_ne!(
|
||||
expected_settings_update_msg, expected_permissions_msg,
|
||||
"expected updated permissions message after per-turn override"
|
||||
);
|
||||
assert_eq!(
|
||||
expected_model_switch_msg["role"].as_str(),
|
||||
expected_settings_update_msg["role"].as_str(),
|
||||
Some("developer")
|
||||
);
|
||||
assert!(
|
||||
expected_model_switch_msg["content"][0]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("<model_switch>")),
|
||||
"expected model switch message after model override: {expected_model_switch_msg:?}"
|
||||
);
|
||||
let expected_permissions_msg_2 = body2["input"][body1_input.len() + 2].clone();
|
||||
assert_ne!(
|
||||
expected_permissions_msg_2, expected_permissions_msg,
|
||||
"expected updated permissions message after per-turn override"
|
||||
request2.has_message_with_input_texts("developer", |texts| {
|
||||
texts.iter().any(|text| text.contains("<model_switch>"))
|
||||
}),
|
||||
"expected model switch section after model override: {expected_settings_update_msg:?}"
|
||||
);
|
||||
let expected_env_msg_2 = body2["input"][body1_input.len() + 1].clone();
|
||||
assert_eq!(expected_env_msg_2["role"].as_str(), Some("user"));
|
||||
let env_text = expected_env_msg_2["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("environment context text");
|
||||
let shell = default_user_shell();
|
||||
let expected_cwd = new_cwd.path().display().to_string();
|
||||
assert_default_env_context(env_text, &expected_cwd, &shell);
|
||||
let mut expected_body2 = body1_input.to_vec();
|
||||
expected_body2.push(expected_model_switch_msg);
|
||||
expected_body2.push(expected_settings_update_msg);
|
||||
expected_body2.push(expected_env_msg_2);
|
||||
expected_body2.push(expected_permissions_msg_2);
|
||||
expected_body2.push(expected_user_message_2);
|
||||
assert_eq!(body2["input"], serde_json::Value::Array(expected_body2));
|
||||
|
||||
@@ -746,7 +787,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)),
|
||||
collaboration_mode: None,
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
@@ -765,7 +806,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model.clone(),
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)),
|
||||
collaboration_mode: None,
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
@@ -773,22 +814,34 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let request1 = req1.single_request();
|
||||
let request2 = req2.single_request();
|
||||
let body1 = request1.body_json();
|
||||
let body2 = request2.body_json();
|
||||
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
let expected_ui_msg = body1["input"][1].clone();
|
||||
|
||||
let shell = default_user_shell();
|
||||
let default_cwd_lossy = default_cwd.to_string_lossy();
|
||||
let expected_env_text_1 = expected_ui_msg["content"][1]["text"]
|
||||
.as_str()
|
||||
.expect("cached environment context text")
|
||||
.to_string();
|
||||
assert_default_env_context(&expected_env_text_1, &default_cwd_lossy, &shell);
|
||||
|
||||
let expected_env_msg_1 = text_user_input(default_env_context_str(&default_cwd_lossy, &shell));
|
||||
let expected_contextual_user_msg_1 = text_user_input_parts(vec![
|
||||
expected_ui_msg["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("cached user instructions text")
|
||||
.to_string(),
|
||||
expected_env_text_1,
|
||||
]);
|
||||
let expected_user_message_1 = text_user_input("hello 1".to_string());
|
||||
|
||||
let expected_input_1 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg.clone(),
|
||||
expected_ui_msg.clone(),
|
||||
expected_env_msg_1.clone(),
|
||||
expected_contextual_user_msg_1.clone(),
|
||||
expected_user_message_1.clone(),
|
||||
]);
|
||||
assert_eq!(body1["input"], expected_input_1);
|
||||
@@ -796,8 +849,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a
|
||||
let expected_user_message_2 = text_user_input("hello 2".to_string());
|
||||
let expected_input_2 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg,
|
||||
expected_ui_msg,
|
||||
expected_env_msg_1,
|
||||
expected_contextual_user_msg_1,
|
||||
expected_user_message_1,
|
||||
expected_user_message_2,
|
||||
]);
|
||||
@@ -854,7 +906,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
sandbox_policy: default_sandbox_policy.clone(),
|
||||
model: default_model,
|
||||
effort: default_effort,
|
||||
summary: default_summary,
|
||||
summary: Some(default_summary.unwrap_or(ReasoningSummary::Auto)),
|
||||
collaboration_mode: None,
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
@@ -873,7 +925,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: "o3".to_string(),
|
||||
effort: Some(ReasoningEffort::High),
|
||||
summary: ReasoningSummary::Detailed,
|
||||
summary: Some(ReasoningSummary::Detailed),
|
||||
collaboration_mode: None,
|
||||
final_output_json_schema: None,
|
||||
personality: None,
|
||||
@@ -881,49 +933,57 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu
|
||||
.await?;
|
||||
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
|
||||
|
||||
let body1 = req1.single_request().body_json();
|
||||
let body2 = req2.single_request().body_json();
|
||||
let request1 = req1.single_request();
|
||||
let request2 = req2.single_request();
|
||||
let body1 = request1.body_json();
|
||||
let body2 = request2.body_json();
|
||||
|
||||
let expected_permissions_msg = body1["input"][0].clone();
|
||||
let expected_ui_msg = body1["input"][1].clone();
|
||||
|
||||
let shell = default_user_shell();
|
||||
let expected_env_text_1 = default_env_context_str(&default_cwd.to_string_lossy(), &shell);
|
||||
let expected_env_msg_1 = text_user_input(expected_env_text_1);
|
||||
let expected_env_text_1 = expected_ui_msg["content"][1]["text"]
|
||||
.as_str()
|
||||
.expect("cached environment context text")
|
||||
.to_string();
|
||||
assert_default_env_context(&expected_env_text_1, &default_cwd.to_string_lossy(), &shell);
|
||||
let expected_contextual_user_msg_1 = text_user_input_parts(vec![
|
||||
expected_ui_msg["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("cached user instructions text")
|
||||
.to_string(),
|
||||
expected_env_text_1,
|
||||
]);
|
||||
let expected_user_message_1 = text_user_input("hello 1".to_string());
|
||||
let expected_input_1 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg.clone(),
|
||||
expected_ui_msg.clone(),
|
||||
expected_env_msg_1.clone(),
|
||||
expected_contextual_user_msg_1.clone(),
|
||||
expected_user_message_1.clone(),
|
||||
]);
|
||||
assert_eq!(body1["input"], expected_input_1);
|
||||
|
||||
let body1_input = body1["input"].as_array().expect("input array");
|
||||
let expected_model_switch_msg = body2["input"][body1_input.len()].clone();
|
||||
let expected_settings_update_msg = body2["input"][body1_input.len()].clone();
|
||||
assert_ne!(
|
||||
expected_settings_update_msg, expected_permissions_msg,
|
||||
"expected updated permissions message after policy change"
|
||||
);
|
||||
assert_eq!(
|
||||
expected_model_switch_msg["role"].as_str(),
|
||||
expected_settings_update_msg["role"].as_str(),
|
||||
Some("developer")
|
||||
);
|
||||
assert!(
|
||||
expected_model_switch_msg["content"][0]["text"]
|
||||
.as_str()
|
||||
.is_some_and(|text| text.contains("<model_switch>")),
|
||||
"expected model switch message after model override: {expected_model_switch_msg:?}"
|
||||
);
|
||||
let expected_permissions_msg_2 = body2["input"][body1_input.len() + 1].clone();
|
||||
assert_ne!(
|
||||
expected_permissions_msg_2, expected_permissions_msg,
|
||||
"expected updated permissions message after policy change"
|
||||
request2.has_message_with_input_texts("developer", |texts| {
|
||||
texts.iter().any(|text| text.contains("<model_switch>"))
|
||||
}),
|
||||
"expected model switch section after model override: {expected_settings_update_msg:?}"
|
||||
);
|
||||
let expected_user_message_2 = text_user_input("hello 2".to_string());
|
||||
let expected_input_2 = serde_json::Value::Array(vec![
|
||||
expected_permissions_msg,
|
||||
expected_ui_msg,
|
||||
expected_env_msg_1,
|
||||
expected_contextual_user_msg_1,
|
||||
expected_user_message_1,
|
||||
expected_model_switch_msg,
|
||||
expected_permissions_msg_2,
|
||||
expected_settings_update_msg,
|
||||
expected_user_message_2,
|
||||
]);
|
||||
assert_eq!(body2["input"], expected_input_2);
|
||||
|
||||
@@ -715,6 +715,7 @@ async fn inbound_realtime_text_ignores_user_role_and_still_forwards_audio() -> R
|
||||
async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_audio() -> Result<()>
|
||||
{
|
||||
skip_if_no_network!(Ok(()));
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let (gate_completed_tx, gate_completed_rx) = oneshot::channel();
|
||||
let first_chunks = vec![
|
||||
@@ -806,18 +807,45 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
eprintln!(
|
||||
"[realtime test +{}ms] saw trigger text={:?}",
|
||||
start.elapsed().as_millis(),
|
||||
"delegate now"
|
||||
);
|
||||
|
||||
let audio_out = tokio::time::timeout(
|
||||
Duration::from_millis(500),
|
||||
wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::AudioOut(frame),
|
||||
}) => Some(frame.clone()),
|
||||
_ => None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.expect("timed out waiting for realtime audio after echoed user-role message");
|
||||
let mirrored_request = realtime_server.wait_for_request(0, 1).await;
|
||||
let mirrored_request_body = mirrored_request.body_json();
|
||||
eprintln!(
|
||||
"[realtime test +{}ms] saw mirrored request type={:?} role={:?} text={:?} data={:?}",
|
||||
start.elapsed().as_millis(),
|
||||
mirrored_request_body["type"].as_str(),
|
||||
mirrored_request_body["item"]["role"].as_str(),
|
||||
mirrored_request_body["item"]["content"][0]["text"].as_str(),
|
||||
mirrored_request_body["item"]["content"][0]["data"].as_str(),
|
||||
);
|
||||
assert_eq!(
|
||||
mirrored_request_body["type"].as_str(),
|
||||
Some("conversation.item.create")
|
||||
);
|
||||
assert_eq!(
|
||||
mirrored_request_body["item"]["content"][0]["text"].as_str(),
|
||||
Some("assistant says hi")
|
||||
);
|
||||
|
||||
let audio_out = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::AudioOut(frame),
|
||||
}) => Some(frame.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
eprintln!(
|
||||
"[realtime test +{}ms] saw audio out data={} sample_rate={} num_channels={}",
|
||||
start.elapsed().as_millis(),
|
||||
audio_out.data,
|
||||
audio_out.sample_rate,
|
||||
audio_out.num_channels
|
||||
);
|
||||
assert_eq!(audio_out.data, "AQID");
|
||||
|
||||
let completion = completions
|
||||
@@ -828,6 +856,10 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au
|
||||
completion
|
||||
.await
|
||||
.expect("delegated turn request did not complete");
|
||||
eprintln!(
|
||||
"[realtime test +{}ms] delegated completion resolved",
|
||||
start.elapsed().as_millis()
|
||||
);
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
|
||||
@@ -139,6 +139,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<(
|
||||
},
|
||||
];
|
||||
remote_model.supports_reasoning_summaries = true;
|
||||
remote_model.default_reasoning_summary = ReasoningSummary::Detailed;
|
||||
mount_models_once(
|
||||
&server,
|
||||
ModelsResponse {
|
||||
@@ -175,7 +176,7 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<(
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: requested_model.to_string(),
|
||||
effort: None,
|
||||
summary: config.model_reasoning_summary,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -189,8 +190,13 @@ async fn remote_models_long_model_slug_is_sent_with_high_reasoning() -> Result<(
|
||||
.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("effort"))
|
||||
.and_then(|value| value.as_str());
|
||||
let reasoning_summary = body
|
||||
.get("reasoning")
|
||||
.and_then(|reasoning| reasoning.get("summary"))
|
||||
.and_then(|value| value.as_str());
|
||||
assert_eq!(body["model"].as_str(), Some(requested_model));
|
||||
assert_eq!(reasoning_effort, Some("high"));
|
||||
assert_eq!(reasoning_summary, Some("detailed"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -227,7 +233,11 @@ async fn namespaced_model_slug_uses_catalog_metadata_without_fallback_warning()
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
model: requested_model.to_string(),
|
||||
effort: None,
|
||||
summary: config.model_reasoning_summary,
|
||||
summary: Some(
|
||||
config
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(ReasoningSummary::Auto),
|
||||
),
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -284,8 +294,10 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -379,7 +391,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REMOTE_MODEL_SLUG.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: Some(ReasoningSummary::Auto),
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -520,8 +532,10 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
base_instructions: remote_base.to_string(),
|
||||
model_messages: None,
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -590,7 +604,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: model.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: Some(ReasoningSummary::Auto),
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -980,8 +994,10 @@ fn test_remote_model_with_policy(
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy,
|
||||
supports_parallel_tool_calls: false,
|
||||
|
||||
@@ -4,7 +4,6 @@ use anyhow::Result;
|
||||
use codex_core::config::Constrained;
|
||||
use codex_core::features::Feature;
|
||||
use codex_core::sandboxing::SandboxPermissions;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -111,7 +110,7 @@ async fn submit_turn(
|
||||
sandbox_policy,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -5,7 +5,6 @@ use std::collections::HashMap;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -138,7 +137,7 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: Some(CollaborationMode {
|
||||
mode,
|
||||
settings: Settings {
|
||||
@@ -254,7 +253,7 @@ where
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: Some(collaboration_mode),
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ use codex_core::CodexAuth;
|
||||
use codex_core::NewThread;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InitialHistory;
|
||||
use codex_protocol::protocol::ResumedHistory;
|
||||
@@ -27,6 +28,8 @@ fn resume_history(
|
||||
let turn_ctx = TurnContextItem {
|
||||
turn_id: Some(turn_id.clone()),
|
||||
cwd: config.cwd.clone(),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
@@ -34,7 +37,9 @@ fn resume_history(
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
effort: config.model_reasoning_effort,
|
||||
summary: config.model_reasoning_summary,
|
||||
summary: config
|
||||
.model_reasoning_summary
|
||||
.unwrap_or(ReasoningSummary::Auto),
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
|
||||
@@ -605,7 +605,9 @@ async fn review_input_isolated_from_parent_history() {
|
||||
|
||||
let env_text = input
|
||||
.iter()
|
||||
.filter_map(|msg| msg["content"][0]["text"].as_str())
|
||||
.filter_map(|msg| msg.get("content").and_then(|content| content.as_array()))
|
||||
.flat_map(|content| content.iter())
|
||||
.filter_map(|entry| entry.get("text").and_then(|text| text.as_str()))
|
||||
.find(|text| text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG))
|
||||
.expect("env text");
|
||||
assert!(
|
||||
@@ -615,7 +617,9 @@ async fn review_input_isolated_from_parent_history() {
|
||||
|
||||
let review_text = input
|
||||
.iter()
|
||||
.filter_map(|msg| msg["content"][0]["text"].as_str())
|
||||
.filter_map(|msg| msg.get("content").and_then(|content| content.as_array()))
|
||||
.flat_map(|content| content.iter())
|
||||
.filter_map(|entry| entry.get("text").and_then(|text| text.as_str()))
|
||||
.find(|text| *text == review_prompt)
|
||||
.expect("review prompt text");
|
||||
assert_eq!(
|
||||
|
||||
@@ -104,6 +104,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
config
|
||||
@@ -128,7 +129,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -246,6 +247,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
config
|
||||
@@ -295,7 +297,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -398,8 +400,10 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
base_instructions: "base instructions".to_string(),
|
||||
model_messages: None,
|
||||
supports_reasoning_summaries: false,
|
||||
default_reasoning_summary: ReasoningSummary::Auto,
|
||||
support_verbosity: false,
|
||||
default_verbosity: None,
|
||||
availability_nux: None,
|
||||
apply_patch_tool_type: None,
|
||||
truncation_policy: TruncationPolicyConfig::bytes(10_000),
|
||||
supports_parallel_tool_calls: false,
|
||||
@@ -462,6 +466,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
config
|
||||
@@ -492,7 +497,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: text_only_model_slug.to_string(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -580,6 +585,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
config
|
||||
@@ -604,7 +610,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -739,6 +745,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
config
|
||||
@@ -763,7 +770,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -958,6 +965,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
},
|
||||
);
|
||||
config
|
||||
@@ -982,7 +990,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use anyhow::Result;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
@@ -49,7 +48,7 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() ->
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -146,7 +145,7 @@ async fn response_model_field_mismatch_emits_warning_when_header_matches_request
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -230,7 +229,7 @@ async fn openai_model_header_mismatch_only_emits_one_warning_per_turn() -> Resul
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -278,7 +277,7 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()>
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: REQUESTED_MODEL.to_string(),
|
||||
effort: test.config.model_reasoning_effort,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -133,6 +133,7 @@ fn rmcp_server_config(command: String) -> McpServerConfig {
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use anyhow::Result;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecCommandBeginEvent;
|
||||
@@ -162,7 +161,7 @@ async fn run_snapshot_command_with_options(
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -248,7 +247,7 @@ async fn run_shell_command_snapshot_with_options(
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -317,7 +316,7 @@ async fn run_tool_turn_on_harness(
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -535,7 +534,7 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model,
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
use anyhow::Result;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecApprovalRequestEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::mount_function_call_agent_response;
|
||||
@@ -56,7 +58,7 @@ async fn submit_turn_with_policies(
|
||||
sandbox_policy,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
@@ -64,18 +66,25 @@ async fn submit_turn_with_policies(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn wait_for_turn_complete_without_skill_approval(test: &TestCodex) {
|
||||
wait_for_event_match(test.codex.as_ref(), |event| match event {
|
||||
EventMsg::SkillRequestApproval(request) => {
|
||||
panic!("unexpected skill approval request: {request:?}");
|
||||
}
|
||||
EventMsg::TurnComplete(_) => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
fn write_skill_with_shell_script(home: &Path, name: &str, script_name: &str) -> Result<PathBuf> {
|
||||
write_skill_with_shell_script_contents(
|
||||
home,
|
||||
name,
|
||||
script_name,
|
||||
r#"#!/bin/sh
|
||||
echo 'zsh-fork-stdout'
|
||||
echo 'zsh-fork-stderr' >&2
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_skill_with_shell_script(home: &Path, name: &str, script_name: &str) -> Result<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
fn write_skill_with_shell_script_contents(
|
||||
home: &Path,
|
||||
name: &str,
|
||||
script_name: &str,
|
||||
script_contents: &str,
|
||||
) -> Result<PathBuf> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let skill_dir = home.join("skills").join(name);
|
||||
@@ -93,13 +102,7 @@ description: {name} skill
|
||||
)?;
|
||||
|
||||
let script_path = scripts_dir.join(script_name);
|
||||
fs::write(
|
||||
&script_path,
|
||||
r#"#!/bin/sh
|
||||
echo 'zsh-fork-stdout'
|
||||
echo 'zsh-fork-stderr' >&2
|
||||
"#,
|
||||
)?;
|
||||
fs::write(&script_path, script_contents)?;
|
||||
let mut permissions = fs::metadata(&script_path)?.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&script_path, permissions)?;
|
||||
@@ -140,34 +143,134 @@ fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_zsh_fork_prompts_for_skill_script_execution() -> Result<()> {
|
||||
use codex_config::Constrained;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
#[derive(Clone)]
|
||||
struct ZshForkRuntime {
|
||||
zsh_path: PathBuf,
|
||||
main_execve_wrapper_exe: PathBuf,
|
||||
}
|
||||
|
||||
skip_if_no_network!(Ok(()));
|
||||
impl ZshForkRuntime {
|
||||
fn apply_to_config(
|
||||
&self,
|
||||
config: &mut Config,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
) {
|
||||
use codex_config::Constrained;
|
||||
|
||||
config.features.enable(Feature::ShellTool);
|
||||
config.features.enable(Feature::ShellZshFork);
|
||||
config.zsh_path = Some(self.zsh_path.clone());
|
||||
config.main_execve_wrapper_exe = Some(self.main_execve_wrapper_exe.clone());
|
||||
config.permissions.allow_login_shell = false;
|
||||
config.permissions.approval_policy = Constrained::allow_any(approval_policy);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(sandbox_policy);
|
||||
}
|
||||
}
|
||||
|
||||
fn restrictive_workspace_write_policy() -> SandboxPolicy {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn zsh_fork_runtime(test_name: &str) -> Result<Option<ZshForkRuntime>> {
|
||||
let Some(zsh_path) = find_test_zsh_path()? else {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
};
|
||||
if !supports_exec_wrapper_intercept(&zsh_path) {
|
||||
eprintln!(
|
||||
"skipping zsh-fork skill test: zsh does not support EXEC_WRAPPER intercepts ({})",
|
||||
"skipping {test_name}: zsh does not support EXEC_WRAPPER intercepts ({})",
|
||||
zsh_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")
|
||||
else {
|
||||
eprintln!("skipping zsh-fork skill test: unable to resolve `codex-execve-wrapper` binary");
|
||||
eprintln!("skipping {test_name}: unable to resolve `codex-execve-wrapper` binary");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(Some(ZshForkRuntime {
|
||||
zsh_path,
|
||||
main_execve_wrapper_exe,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn build_zsh_fork_test<F>(
|
||||
server: &wiremock::MockServer,
|
||||
runtime: ZshForkRuntime,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
pre_build_hook: F,
|
||||
) -> Result<TestCodex>
|
||||
where
|
||||
F: FnOnce(&Path) + Send + 'static,
|
||||
{
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(pre_build_hook)
|
||||
.with_config(move |config| {
|
||||
runtime.apply_to_config(config, approval_policy, sandbox_policy);
|
||||
});
|
||||
builder.build(server).await
|
||||
}
|
||||
|
||||
fn skill_script_command(test: &TestCodex, script_name: &str) -> Result<(String, String)> {
|
||||
let script_path = fs::canonicalize(
|
||||
test.codex_home_path()
|
||||
.join("skills/mbolin-test-skill/scripts")
|
||||
.join(script_name),
|
||||
)?;
|
||||
let script_path_str = script_path.to_string_lossy().into_owned();
|
||||
let command = shlex::try_join([script_path_str.as_str()])?;
|
||||
Ok((script_path_str, command))
|
||||
}
|
||||
|
||||
async fn wait_for_exec_approval_request(test: &TestCodex) -> Option<ExecApprovalRequestEvent> {
|
||||
wait_for_event_match(test.codex.as_ref(), |event| match event {
|
||||
EventMsg::ExecApprovalRequest(request) => Some(Some(request.clone())),
|
||||
EventMsg::TurnComplete(_) => Some(None),
|
||||
_ => None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn wait_for_turn_complete(test: &TestCodex) {
|
||||
wait_for_event(test.codex.as_ref(), |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
fn output_shows_sandbox_denial(output: &str) -> bool {
|
||||
output.contains("Permission denied")
|
||||
|| output.contains("Operation not permitted")
|
||||
|| output.contains("Read-only file system")
|
||||
}
|
||||
|
||||
/// Focus on the approval payload: the skill should prompt before execution and
|
||||
/// only advertise the permissions declared in its metadata.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_zsh_fork_prompts_for_skill_script_execution() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("zsh-fork skill prompt test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let tool_call_id = "zsh-fork-skill-call";
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(|home| {
|
||||
let test = build_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::OnRequest,
|
||||
SandboxPolicy::new_workspace_write_policy(),
|
||||
|home| {
|
||||
write_skill_with_shell_script(home, "mbolin-test-skill", "hello-mbolin.sh").unwrap();
|
||||
write_skill_metadata(
|
||||
home,
|
||||
@@ -182,25 +285,11 @@ permissions:
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
})
|
||||
.with_config(move |config| {
|
||||
config.features.enable(Feature::ShellTool);
|
||||
config.features.enable(Feature::ShellZshFork);
|
||||
config.zsh_path = Some(zsh_path.clone());
|
||||
config.main_execve_wrapper_exe = Some(main_execve_wrapper_exe);
|
||||
config.permissions.allow_login_shell = false;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
||||
config.permissions.sandbox_policy =
|
||||
Constrained::allow_any(SandboxPolicy::new_workspace_write_policy());
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let script_path = fs::canonicalize(
|
||||
test.codex_home_path()
|
||||
.join("skills/mbolin-test-skill/scripts/hello-mbolin.sh"),
|
||||
)?;
|
||||
let script_path_str = script_path.to_string_lossy().into_owned();
|
||||
let command = shlex::try_join([script_path_str.as_str()])?;
|
||||
let (script_path_str, command) = skill_script_command(&test, "hello-mbolin.sh")?;
|
||||
let arguments = shell_command_arguments(&command)?;
|
||||
let mocks =
|
||||
mount_function_call_agent_response(&server, tool_call_id, &arguments, "shell_command")
|
||||
@@ -214,12 +303,7 @@ permissions:
|
||||
)
|
||||
.await?;
|
||||
|
||||
let maybe_approval = wait_for_event_match(test.codex.as_ref(), |event| match event {
|
||||
EventMsg::ExecApprovalRequest(request) => Some(Some(request.clone())),
|
||||
EventMsg::TurnComplete(_) => Some(None),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let maybe_approval = wait_for_exec_approval_request(&test).await;
|
||||
let approval = match maybe_approval {
|
||||
Some(approval) => approval,
|
||||
None => {
|
||||
@@ -234,6 +318,14 @@ permissions:
|
||||
};
|
||||
assert_eq!(approval.call_id, tool_call_id);
|
||||
assert_eq!(approval.command, vec![script_path_str.clone()]);
|
||||
assert_eq!(
|
||||
approval.available_decisions,
|
||||
Some(vec![
|
||||
ReviewDecision::Approved,
|
||||
ReviewDecision::ApprovedForSession,
|
||||
ReviewDecision::Abort,
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
approval.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
@@ -249,14 +341,11 @@ permissions:
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::Approved,
|
||||
decision: ReviewDecision::Denied,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(test.codex.as_ref(), |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
wait_for_turn_complete(&test).await;
|
||||
|
||||
let call_output = mocks
|
||||
.completion
|
||||
@@ -264,69 +353,357 @@ permissions:
|
||||
.function_call_output(tool_call_id);
|
||||
let output = call_output["output"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
output.contains("zsh-fork-stdout"),
|
||||
"expected stdout marker in function_call_output: {output:?}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("zsh-fork-stderr"),
|
||||
"expected stderr marker in function_call_output: {output:?}"
|
||||
output.contains("Execution denied: User denied execution"),
|
||||
"expected rejection marker in function_call_output: {output:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look for `additional_permissions == None`, then verify that both the first
|
||||
/// run and the cached session-approval rerun stay inside the turn sandbox.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_zsh_fork_skill_without_permissions_inherits_turn_sandbox() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("zsh-fork inherited skill sandbox test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
||||
let outside_path = outside_dir
|
||||
.path()
|
||||
.join("zsh-fork-skill-inherited-sandbox.txt");
|
||||
let outside_path_quoted = shlex::try_join([outside_path.to_string_lossy().as_ref()])?;
|
||||
let script_contents = format!(
|
||||
"#!/bin/sh\nprintf '%s' forbidden > {outside_path_quoted}\ncat {outside_path_quoted}\n"
|
||||
);
|
||||
let outside_path_for_hook = outside_path.clone();
|
||||
let script_contents_for_hook = script_contents.clone();
|
||||
let workspace_write_policy = restrictive_workspace_write_policy();
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let test = build_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_policy.clone(),
|
||||
move |home| {
|
||||
let _ = fs::remove_file(&outside_path_for_hook);
|
||||
write_skill_with_shell_script_contents(
|
||||
home,
|
||||
"mbolin-test-skill",
|
||||
"sandboxed.sh",
|
||||
&script_contents_for_hook,
|
||||
)
|
||||
.unwrap();
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (script_path_str, command) = skill_script_command(&test, "sandboxed.sh")?;
|
||||
|
||||
let first_call_id = "zsh-fork-skill-permissions-1";
|
||||
let first_arguments = shell_command_arguments(&command)?;
|
||||
let first_mocks = mount_function_call_agent_response(
|
||||
&server,
|
||||
first_call_id,
|
||||
&first_arguments,
|
||||
"shell_command",
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn_with_policies(
|
||||
&test,
|
||||
"use $mbolin-test-skill",
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_policy.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let maybe_approval = wait_for_exec_approval_request(&test).await;
|
||||
let approval = match maybe_approval {
|
||||
Some(approval) => approval,
|
||||
None => panic!("expected exec approval request before completion"),
|
||||
};
|
||||
assert_eq!(approval.call_id, first_call_id);
|
||||
assert_eq!(approval.command, vec![script_path_str.clone()]);
|
||||
assert_eq!(approval.additional_permissions, None);
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::ApprovedForSession,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_turn_complete(&test).await;
|
||||
|
||||
let first_output = first_mocks
|
||||
.completion
|
||||
.single_request()
|
||||
.function_call_output(first_call_id)["output"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
output_shows_sandbox_denial(&first_output) || !first_output.contains("forbidden"),
|
||||
"expected inherited turn sandbox denial on first run, got output: {first_output:?}"
|
||||
);
|
||||
assert!(
|
||||
!outside_path.exists(),
|
||||
"first run should not write outside the turn sandbox"
|
||||
);
|
||||
|
||||
let second_call_id = "zsh-fork-skill-permissions-2";
|
||||
let second_arguments = shell_command_arguments(&command)?;
|
||||
let second_mocks = mount_function_call_agent_response(
|
||||
&server,
|
||||
second_call_id,
|
||||
&second_arguments,
|
||||
"shell_command",
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn_with_policies(
|
||||
&test,
|
||||
"use $mbolin-test-skill",
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_policy,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cached_approval = wait_for_exec_approval_request(&test).await;
|
||||
assert!(
|
||||
cached_approval.is_none(),
|
||||
"expected second run to reuse the cached session approval"
|
||||
);
|
||||
|
||||
let second_output = second_mocks
|
||||
.completion
|
||||
.single_request()
|
||||
.function_call_output(second_call_id)["output"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
output_shows_sandbox_denial(&second_output) || !second_output.contains("forbidden"),
|
||||
"expected cached skill approval to retain inherited turn sandboxing, got output: {second_output:?}"
|
||||
);
|
||||
assert!(
|
||||
!outside_path.exists(),
|
||||
"cached session approval should not widen a permissionless skill to full access"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The validation to focus on is: writes to the skill-approved folder succeed,
|
||||
/// and writes to an unrelated folder fail, both before and after cached approval.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_zsh_fork_skill_session_approval_enforces_skill_permissions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(runtime) = zsh_fork_runtime("zsh-fork explicit skill sandbox test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let outside_dir = tempfile::tempdir_in(std::env::current_dir()?)?;
|
||||
let allowed_dir = outside_dir.path().join("allowed-output");
|
||||
let blocked_dir = outside_dir.path().join("blocked-output");
|
||||
fs::create_dir_all(&allowed_dir)?;
|
||||
fs::create_dir_all(&blocked_dir)?;
|
||||
|
||||
let allowed_path = allowed_dir.join("allowed.txt");
|
||||
let blocked_path = blocked_dir.join("blocked.txt");
|
||||
let allowed_path_quoted = shlex::try_join([allowed_path.to_string_lossy().as_ref()])?;
|
||||
let blocked_path_quoted = shlex::try_join([blocked_path.to_string_lossy().as_ref()])?;
|
||||
let script_contents = format!(
|
||||
"#!/bin/sh\nprintf '%s' allowed > {allowed_path_quoted}\ncat {allowed_path_quoted}\nprintf '%s' forbidden > {blocked_path_quoted}\nif [ -f {blocked_path_quoted} ]; then echo blocked-created; fi\n"
|
||||
);
|
||||
let allowed_dir_for_hook = allowed_dir.clone();
|
||||
let allowed_path_for_hook = allowed_path.clone();
|
||||
let blocked_path_for_hook = blocked_path.clone();
|
||||
let script_contents_for_hook = script_contents.clone();
|
||||
|
||||
let permissions_yaml = format!(
|
||||
"permissions:\n file_system:\n write:\n - \"{}\"\n",
|
||||
allowed_dir.display()
|
||||
);
|
||||
|
||||
let workspace_write_policy = restrictive_workspace_write_policy();
|
||||
let server = start_mock_server().await;
|
||||
let test = build_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_policy.clone(),
|
||||
move |home| {
|
||||
let _ = fs::remove_file(&allowed_path_for_hook);
|
||||
let _ = fs::remove_file(&blocked_path_for_hook);
|
||||
fs::create_dir_all(&allowed_dir_for_hook).unwrap();
|
||||
fs::create_dir_all(blocked_path_for_hook.parent().unwrap()).unwrap();
|
||||
write_skill_with_shell_script_contents(
|
||||
home,
|
||||
"mbolin-test-skill",
|
||||
"sandboxed.sh",
|
||||
&script_contents_for_hook,
|
||||
)
|
||||
.unwrap();
|
||||
write_skill_metadata(home, "mbolin-test-skill", &permissions_yaml).unwrap();
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let (script_path_str, command) = skill_script_command(&test, "sandboxed.sh")?;
|
||||
|
||||
let first_call_id = "zsh-fork-skill-permissions-1";
|
||||
let first_arguments = shell_command_arguments(&command)?;
|
||||
let first_mocks = mount_function_call_agent_response(
|
||||
&server,
|
||||
first_call_id,
|
||||
&first_arguments,
|
||||
"shell_command",
|
||||
)
|
||||
.await;
|
||||
|
||||
submit_turn_with_policies(
|
||||
&test,
|
||||
"use $mbolin-test-skill",
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_policy.clone(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let maybe_approval = wait_for_exec_approval_request(&test).await;
|
||||
let approval = match maybe_approval {
|
||||
Some(approval) => approval,
|
||||
None => panic!("expected exec approval request before completion"),
|
||||
};
|
||||
assert_eq!(approval.call_id, first_call_id);
|
||||
assert_eq!(approval.command, vec![script_path_str.clone()]);
|
||||
assert_eq!(
|
||||
approval.additional_permissions,
|
||||
Some(PermissionProfile {
|
||||
file_system: Some(FileSystemPermissions {
|
||||
read: None,
|
||||
write: Some(vec![allowed_dir.clone()]),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
);
|
||||
|
||||
test.codex
|
||||
.submit(Op::ExecApproval {
|
||||
id: approval.effective_approval_id(),
|
||||
turn_id: None,
|
||||
decision: ReviewDecision::ApprovedForSession,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_turn_complete(&test).await;
|
||||
|
||||
let first_output = first_mocks
|
||||
.completion
|
||||
.single_request()
|
||||
.function_call_output(first_call_id)["output"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
first_output.contains("allowed"),
|
||||
"expected skill sandbox to permit writes to the approved folder, got output: {first_output:?}"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&allowed_path)?, "allowed");
|
||||
assert!(
|
||||
!blocked_path.exists(),
|
||||
"first run should not write outside the explicit skill sandbox"
|
||||
);
|
||||
assert!(
|
||||
!first_output.contains("blocked-created"),
|
||||
"blocked path should not have been created: {first_output:?}"
|
||||
);
|
||||
|
||||
let second_call_id = "zsh-fork-skill-permissions-2";
|
||||
let second_arguments = shell_command_arguments(&command)?;
|
||||
let second_mocks = mount_function_call_agent_response(
|
||||
&server,
|
||||
second_call_id,
|
||||
&second_arguments,
|
||||
"shell_command",
|
||||
)
|
||||
.await;
|
||||
|
||||
let _ = fs::remove_file(&allowed_path);
|
||||
let _ = fs::remove_file(&blocked_path);
|
||||
|
||||
submit_turn_with_policies(
|
||||
&test,
|
||||
"use $mbolin-test-skill",
|
||||
AskForApproval::OnRequest,
|
||||
workspace_write_policy,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let cached_approval = wait_for_exec_approval_request(&test).await;
|
||||
assert!(
|
||||
cached_approval.is_none(),
|
||||
"expected second run to reuse the cached session approval"
|
||||
);
|
||||
|
||||
let second_output = second_mocks
|
||||
.completion
|
||||
.single_request()
|
||||
.function_call_output(second_call_id)["output"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
assert!(
|
||||
second_output.contains("allowed"),
|
||||
"expected cached skill approval to retain the explicit skill sandbox, got output: {second_output:?}"
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&allowed_path)?, "allowed");
|
||||
assert!(
|
||||
!blocked_path.exists(),
|
||||
"cached session approval should not widen skill execution beyond the explicit skill sandbox"
|
||||
);
|
||||
assert!(
|
||||
!second_output.contains("blocked-created"),
|
||||
"blocked path should not have been created after cached approval: {second_output:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// This stays narrow on purpose: the important check is that `WorkspaceWrite`
|
||||
/// continues to deny writes outside the workspace even under `zsh-fork`.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {
|
||||
use codex_config::Constrained;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let Some(zsh_path) = find_test_zsh_path()? else {
|
||||
return Ok(());
|
||||
};
|
||||
if !supports_exec_wrapper_intercept(&zsh_path) {
|
||||
eprintln!(
|
||||
"skipping zsh-fork sandbox test: zsh does not support EXEC_WRAPPER intercepts ({})",
|
||||
zsh_path.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(main_execve_wrapper_exe) = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")
|
||||
else {
|
||||
eprintln!(
|
||||
"skipping zsh-fork sandbox test: unable to resolve `codex-execve-wrapper` binary"
|
||||
);
|
||||
let Some(runtime) = zsh_fork_runtime("zsh-fork workspace sandbox test")? else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let tool_call_id = "zsh-fork-workspace-write-deny";
|
||||
let outside_path = "/tmp/codex-zsh-fork-workspace-write-deny.txt";
|
||||
let workspace_write_policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let policy_for_config = workspace_write_policy.clone();
|
||||
let workspace_write_policy = restrictive_workspace_write_policy();
|
||||
let _ = fs::remove_file(outside_path);
|
||||
let mut builder = test_codex()
|
||||
.with_pre_build_hook(move |_| {
|
||||
let test = build_zsh_fork_test(
|
||||
&server,
|
||||
runtime,
|
||||
AskForApproval::Never,
|
||||
workspace_write_policy.clone(),
|
||||
move |_| {
|
||||
let _ = fs::remove_file(outside_path);
|
||||
})
|
||||
.with_config(move |config| {
|
||||
config.features.enable(Feature::ShellTool);
|
||||
config.features.enable(Feature::ShellZshFork);
|
||||
config.zsh_path = Some(zsh_path.clone());
|
||||
config.main_execve_wrapper_exe = Some(main_execve_wrapper_exe);
|
||||
config.permissions.allow_login_shell = false;
|
||||
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::Never);
|
||||
config.permissions.sandbox_policy = Constrained::allow_any(policy_for_config);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let command = format!("touch {outside_path}");
|
||||
let arguments = shell_command_arguments(&command)?;
|
||||
@@ -342,7 +719,7 @@ async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {
|
||||
)
|
||||
.await?;
|
||||
|
||||
wait_for_turn_complete_without_skill_approval(&test).await;
|
||||
wait_for_turn_complete(&test).await;
|
||||
|
||||
let call_output = mocks
|
||||
.completion
|
||||
@@ -350,9 +727,7 @@ async fn shell_zsh_fork_still_enforces_workspace_write_sandbox() -> Result<()> {
|
||||
.function_call_output(tool_call_id);
|
||||
let output = call_output["output"].as_str().unwrap_or_default();
|
||||
assert!(
|
||||
output.contains("Permission denied")
|
||||
|| output.contains("Operation not permitted")
|
||||
|| output.contains("Read-only file system"),
|
||||
output_shows_sandbox_denial(output),
|
||||
"expected sandbox denial, got output: {output:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -77,7 +77,7 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: codex_protocol::config_types::ReasoningSummary::Auto,
|
||||
summary: None,
|
||||
collaboration_mode: None,
|
||||
personality: None,
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user