fix: project model and reasoning_effort into Codex CLI args, stop rewriting .codex/config.toml

- Add model_reasoning_effort field to AgentProfile (domain layer)
- Remove model parameter from codex_config_toml, stop rewriting model in TOML
- Pass model and model_reasoning_effort via Codex CLI -c overrides on every exec
- Update CodexExecSession with new_with_policy_and_overrides factory
- Cover new conversation and resume flows with tests
This commit is contained in:
2026-08-02 17:19:26 +02:00
parent b730e356aa
commit dcc7a6f216
9 changed files with 363 additions and 91 deletions

View File

@ -160,6 +160,10 @@ pub struct CodexExecSession {
writable_roots: Vec<String>,
/// Structured policy projection of Codex workspace-write sandbox network access.
network_access: Option<bool>,
/// Profile-selected model forwarded as a Codex config override on every exec turn.
model: Option<String>,
/// Profile-selected reasoning effort forwarded as a Codex config override.
model_reasoning_effort: Option<String>,
/// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé).
env: Vec<(String, String)>,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
@ -188,7 +192,7 @@ impl CodexExecSession {
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self::new_with_policy(
Self::new_with_policy_and_overrides(
id,
command,
cwd,
@ -196,6 +200,8 @@ impl CodexExecSession {
"workspace-write",
writable_roots,
None,
None,
None,
env,
sandbox,
sandbox_enforcer,
@ -217,6 +223,39 @@ impl CodexExecSession {
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self::new_with_policy_and_overrides(
id,
command,
cwd,
seed_conversation_id,
sandbox_mode,
writable_roots,
network_access,
None,
None,
env,
sandbox,
sandbox_enforcer,
)
}
/// Construit l'adapter avec politique + overrides de config issus du profil IdeA.
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn new_with_policy_and_overrides(
id: SessionId,
command: impl Into<String>,
cwd: impl Into<String>,
seed_conversation_id: Option<String>,
sandbox_mode: impl Into<String>,
writable_roots: Vec<String>,
network_access: Option<bool>,
model: Option<String>,
model_reasoning_effort: Option<String>,
env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self {
Self {
id,
@ -225,6 +264,8 @@ impl CodexExecSession {
sandbox_mode: sandbox_mode.into(),
writable_roots,
network_access,
model,
model_reasoning_effort,
env,
conversation_id: Mutex::new(seed_conversation_id),
sandbox,
@ -269,6 +310,25 @@ impl CodexExecSession {
"sandbox_workspace_write.network_access={network_access}"
));
}
if let Some(model) = self
.model
.as_deref()
.filter(|model| !model.trim().is_empty())
{
args.push("-c".to_owned());
args.push(format!("model={}", codex_toml_string(model)));
}
if let Some(effort) = self
.model_reasoning_effort
.as_deref()
.filter(|effort| !effort.trim().is_empty())
{
args.push("-c".to_owned());
args.push(format!(
"model_reasoning_effort={}",
codex_toml_string(effort)
));
}
if let Some(id) = conversation_id {
args.push("resume".to_owned());
args.push(id);
@ -293,6 +353,10 @@ impl CodexExecSession {
}
}
fn codex_toml_string(value: &str) -> String {
serde_json::to_string(value).expect("string serialization cannot fail")
}
fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
if let Some((_, existing)) = env.iter_mut().find(|(k, _)| k == key) {
*existing = value.to_owned();

View File

@ -195,7 +195,7 @@ impl AgentSessionFactory for StructuredSessionFactory {
None,
),
};
Arc::new(CodexExecSession::new_with_policy(
Arc::new(CodexExecSession::new_with_policy_and_overrides(
id,
command,
cwd,
@ -203,6 +203,8 @@ impl AgentSessionFactory for StructuredSessionFactory {
policy.0,
policy.1,
policy.2,
profile.model.clone(),
profile.model_reasoning_effort.clone(),
env.to_vec(),
plan,
enforcer,

View File

@ -625,6 +625,54 @@ mod tests {
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn factory_passes_codex_profile_model_overrides_to_exec_session() {
let factory = StructuredSessionFactory::new();
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let codex = structured_profile(StructuredAdapter::Codex, &cmd)
.with_model("gpt-5.4")
.with_model_reasoning_effort("medium");
let ctx = PreparedContext {
content: MarkdownDoc::new("# ctx"),
relative_path: "AGENTS.md".to_owned(),
project_root: "/project/root".to_owned(),
};
let session = factory
.start(
&codex,
&ctx,
&cwd(),
&SessionPlan::None,
None,
&[],
None,
None,
)
.await
.expect("start Codex ok");
let content = drain_final(session.as_ref()).await;
assert_eq!(content, "ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert!(
args.windows(2).any(|w| w == ["-c", "model=\"gpt-5.4\""]),
"factory must relay profile.model to Codex exec overrides, got: {args:?}"
);
assert!(
args.windows(2)
.any(|w| w == ["-c", "model_reasoning_effort=\"medium\""]),
"factory must relay profile.model_reasoning_effort to Codex exec overrides, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn factory_resume_seeds_conversation_id() {
let factory = StructuredSessionFactory::new();
@ -1774,6 +1822,101 @@ mod tests {
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_new_conversation_command_carries_profile_model_overrides() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"thread.started","thread_id":"cx-new"}"#,
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new_with_policy_and_overrides(
SessionId::new_random(),
cmd.clone(),
"/",
None,
"workspace-write",
vec!["/project/root".to_owned()],
Some(true),
Some("gpt-5.4".to_owned()),
Some("medium".to_owned()),
Vec::new(),
None,
None,
);
let _ = session.send("salut").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert_eq!(
args,
vec![
"exec",
"--json",
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"-c",
"sandbox_workspace_write.network_access=true",
"-c",
"model=\"gpt-5.4\"",
"-c",
"model_reasoning_effort=\"medium\"",
"salut",
],
"profile model overrides must be passed before the prompt, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_resume_command_carries_profile_model_overrides_before_resume() {
let (cmd, argv) = make_recording_fake(&[
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"ok"}}"#,
]);
let session = CodexExecSession::new_with_policy_and_overrides(
SessionId::new_random(),
cmd.clone(),
"/",
Some("cx-id".to_owned()),
"workspace-write",
vec!["/project/root".to_owned()],
None,
Some("gpt-5.4".to_owned()),
Some("medium".to_owned()),
Vec::new(),
None,
None,
);
let _ = session.send("vas-y").await.expect("send ok");
let recorded = std::fs::read_to_string(&argv).expect("argv");
let args: Vec<&str> = recorded.lines().collect();
assert_eq!(
args,
vec![
"exec",
"--json",
"--skip-git-repo-check",
"--sandbox",
"workspace-write",
"--add-dir",
"/project/root",
"-c",
"model=\"gpt-5.4\"",
"-c",
"model_reasoning_effort=\"medium\"",
"resume",
"cx-id",
"vas-y",
],
"profile model overrides must precede resume, got: {args:?}"
);
let _ = std::fs::remove_file(&cmd);
let _ = std::fs::remove_file(&argv);
}
#[tokio::test]
async fn codex_read_only_policy_omits_add_dir_and_approval_flag() {
let (cmd, argv) = make_recording_fake(&[