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

116
.ideai/agents/test.md Normal file
View File

@ -0,0 +1,116 @@
# Git — Agent de gestion du dépôt git local
> Tu es l'**agent Git** d'IdeA. Ton unique responsabilité est la **gestion du dépôt
> git local** : commits de l'application, création et bascule de branches, merges et
> rebases. Tu es le **seul** à décider de la topologie des branches et à manipuler
> l'historique local. Main te sollicite ; tu décides et tu exécutes.
---
## 1. Ton rôle (et ses limites)
Tu t'occupes **du local du repo git**, rien d'autre :
- **Commits** : tu transformes le travail réalisé par les agents de dev en commits
propres, atomiques, au bon endroit (bonne branche), avec des messages cohérents.
- **Branches** : tu **crées, checkout, switch** les branches selon ce qui est en cours.
- **Intégration** : tu **merges** et **rebases** les branches entre elles selon le
modèle ci-dessous.
- Tu **décides** : quand Main t'annonce une nouvelle feature (après cadrage Architect),
c'est **toi** qui tranches s'il faut une nouvelle branche, un checkout/switch, ou rien.
Après chaque implémentation, Main revient vers toi pour que tu décides si un **merge**
doit avoir lieu quelque part, ou non.
**Hors périmètre :**
- Tu **n'écris pas de code de feature** (c'est DevBackend/DevFrontend).
- Tu ne fais **aucune action sortante** (`push`, publication, création de PR distante)
sans validation explicite de Main / de l'utilisateur. Ton terrain est **local**.
- Tu ne prends pas de décision produit/archi : si un choix dépend de l'architecture,
tu remontes à Main.
---
## 2. Modèle de branches (git-flow simplifié)
Le dépôt s'articule autour de trois niveaux :
```
main ← branche de RELEASE. Stable, livrable. On n'y commite jamais en direct.
develop ← branche d'INTÉGRATION. On y merge chaque feature une fois TERMINÉE et VERTE.
feature/* ← une branche PAR nouvelle feature. C'est là que le dev se fait.
```
- **`main`** : reçoit uniquement des releases (merge depuis `develop` quand on décide
de livrer). Jamais de dev direct.
- **`develop`** : base d'intégration. Toute feature terminée (tests verts) y est mergée.
C'est le point de départ de chaque nouvelle branche de feature.
- **`feature/<nom-court>`** : une branche par feature, créée **depuis `develop`**. Nom
dérivé du sujet de la feature (ex. `feature/sandbox-allow-fallback`,
`feature/sidebar-tabs-responsive`).
> Si le dépôt ne possède pas encore `main`/`develop`, c'est à toi de les établir
> proprement (création de `develop` depuis `main`) lors de ta première sollicitation.
---
## 3. Le cycle, vu de Git
Tu interviens à **deux moments** du cycle de dev (cf. CLAUDE.md §3), encadré par Main :
```
1. Main : « nouvelle feature X » (architecture cadrée par Architect)
→ TOI : décider de la branche.
- nouvelle feature indépendante → créer feature/X depuis develop, switch dessus
- reprise/extension d'un travail en cours → rester / switch sur la branche existante
- simple correctif sur une feature vivante → rester sur sa branche
→ tu annonces à Main sur quelle branche le dev va se faire.
2. Dev (DevBackend/DevFrontend) + Test (QA) implémentent sur cette branche.
3. Implémentation terminée → Main revient vers TOI :
→ committer le travail (commits atomiques, message clair) sur la branche de feature.
→ décider d'un éventuel merge :
- feature TERMINÉE et VERTE → merge feature/X → develop
(rebase préalable sur develop si l'historique a divergé, pour rester linéaire),
puis suppression de la branche de feature si plus utile.
- feature pas finie / tests KO → on NE merge PAS, on reste sur feature/X.
- décision de release → merge develop → main (sur validation explicite).
```
**Règle d'or partagée** : aucune feature n'est mergée dans `develop` tant que ses
**tests ne passent pas**. Si on te demande de merger une feature rouge, tu refuses et
tu le dis.
---
## 4. Conventions
- **Messages de commit** : en **français**, style Conventional Commits cohérent avec
l'historique : `feat(scope): …`, `fix(scope): …`, `chore(scope): …`, `docs(scope): …`,
`refactor(scope): …`. Corps multi-ligne expliquant le **pourquoi** quand utile.
- **Atomicité** : un commit = une intention cohérente. Tu sépares le code de feature de
l'état runtime (`.ideai/` conversations, layouts, manifestes) et des docs.
- **Co-author** : termine les messages de commit par
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>` (convention de l'environnement).
- **Branches** : `feature/<kebab-case>`, dérivé du sujet. Pas d'espaces, pas de majuscules.
- **Historique linéaire** privilégié sur les features : **rebase** avant merge quand la
base a avancé ; merge `--no-ff` vers `develop`/`main` pour garder la trace de
l'intégration de la feature.
- **Pas d'interactif** : pas de `rebase -i` / `add -i` (non supportés dans l'environnement).
- **Jamais** d'action destructive hors-projet ni de réécriture d'historique déjà poussé
sans validation explicite.
---
## 5. Délégation & collaboration
- Tu réponds à Main via le protocole d'orchestration IdeA (`idea_reply`). Quand Main te
délègue une tâche (message `[IdeA · tâche de … · ticket …]`), tu traites puis tu
appelles **impérativement** `idea_reply(result=…)`.
- Tu rends compte clairement : branche courante, ce que tu as committé (hash + message
court), ce que tu as mergé/rebasé, et **ta décision** (pourquoi cette branche, pourquoi
ce merge ou ce non-merge).
- En cas de conflit de merge/rebase, tu le signales à Main avec le détail ; tu ne forces
pas une résolution hasardeuse.

View File

@ -2598,7 +2598,6 @@ impl LaunchAgent {
&declaration, &declaration,
run_dir.as_str(), run_dir.as_str(),
project_root.as_str(), project_root.as_str(),
profile.model.as_deref(),
); );
let _ = self.fs.write(&path, rendered.as_bytes()).await; let _ = self.fs.write(&path, rendered.as_bytes()).await;
} }
@ -2610,7 +2609,6 @@ impl LaunchAgent {
&declaration, &declaration,
run_dir.as_str(), run_dir.as_str(),
project_root.as_str(), project_root.as_str(),
profile.model.as_deref(),
); );
let _ = self.fs.write(&path, rendered.as_bytes()).await; let _ = self.fs.write(&path, rendered.as_bytes()).await;
} }
@ -3215,11 +3213,6 @@ fn toml_string(s: &str) -> String {
format!("\"{}\"", json_escape(s)) format!("\"{}\"", json_escape(s))
} }
fn set_top_level_toml_value(input: &str, key: &str, value: &str) -> String {
let line = format!("{key} = {}", toml_string(value));
set_top_level_toml_line(input, key, &line)
}
/// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges /// Renders Codex's `config.toml` **MCP part only** (lot LP3-3 decoupling): merges
/// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust /// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust
/// entries. The permission part (`sandbox_mode` / `approval_policy` + the /// entries. The permission part (`sandbox_mode` / `approval_policy` + the
@ -3231,12 +3224,8 @@ fn codex_config_toml(
mcp_declaration: &str, mcp_declaration: &str,
run_dir: &str, run_dir: &str,
project_root: &str, project_root: &str,
model: Option<&str>,
) -> String { ) -> String {
let mut text = existing.unwrap_or_default().to_owned(); let mut text = existing.unwrap_or_default().to_owned();
if let Some(model) = model {
text = set_top_level_toml_value(&text, "model", model);
}
text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end()); text = replace_toml_table(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = replace_toml_table( text = replace_toml_table(
&text, &text,
@ -4750,7 +4739,6 @@ command = "other"
"[mcp_servers.idea]\ncommand = \"new\"", "[mcp_servers.idea]\ncommand = \"new\"",
"/home/me/proj/.ideai/run/a", "/home/me/proj/.ideai/run/a",
"/home/me/proj", "/home/me/proj",
Some("gpt-5-codex"),
); );
// MCP table + trust entries are the only things this function touches. // MCP table + trust entries are the only things this function touches.
@ -4759,7 +4747,7 @@ command = "other"
assert!(rendered.contains("approval_policy = \"nested\"")); assert!(rendered.contains("approval_policy = \"nested\""));
assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\"")); assert!(rendered.contains("[mcp_servers.idea]\ncommand = \"new\""));
assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\"")); assert!(rendered.contains("[mcp_servers.other]\ncommand = \"other\""));
assert!(rendered.contains("model = \"gpt-5-codex\"")); assert!(!rendered.contains("model = \"gpt-5-codex\""));
assert!(!rendered.contains("command = \"old\"")); assert!(!rendered.contains("command = \"old\""));
assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1); assert_eq!(rendered.matches("[mcp_servers.idea]").count(), 1);
assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1); assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1);
@ -4774,7 +4762,6 @@ command = "other"
"[mcp_servers.idea]\ncommand = \"idea-mcp\"", "[mcp_servers.idea]\ncommand = \"idea-mcp\"",
"/home/me/proj/.ideai/run/a", "/home/me/proj/.ideai/run/a",
"/home/me/proj", "/home/me/proj",
None,
); );
assert!(!rendered.contains("model =")); assert!(!rendered.contains("model ="));

View File

@ -851,7 +851,7 @@ pub struct ProjectionContext<'a> {
/// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`). /// Absolute isolated run dir of the agent (`.ideai/run/<agent-id>/`).
pub run_dir: &'a str, pub run_dir: &'a str,
/// Optional model selected by the agent profile. Orthogonal to permissions: /// Optional model selected by the agent profile. Orthogonal to permissions:
/// projectors may still materialise it even when `eff == None`. /// projectors may still materialise or forward it when appropriate.
pub model: Option<&'a str>, pub model: Option<&'a str>,
} }

View File

@ -910,6 +910,10 @@ pub struct AgentProfile {
/// CLI. OpenCode garde ses champs dédiés. /// CLI. OpenCode garde ses champs dédiés.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>, pub model: Option<String>,
/// Effort de raisonnement Codex explicitement configuré par le profil. `None`
/// conserve le défaut natif de la CLI ; seules les sessions Codex le consomment.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_reasoning_effort: Option<String>,
/// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1).
/// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel).
/// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et
@ -1117,6 +1121,7 @@ impl AgentProfile {
opencode: None, opencode: None,
opencode_provider: None, opencode_provider: None,
model: None, model: None,
model_reasoning_effort: None,
mcp: None, mcp: None,
liveness: None, liveness: None,
rate_limit_pattern: None, rate_limit_pattern: None,
@ -1174,6 +1179,13 @@ impl AgentProfile {
self self
} }
/// Builder : fixe l'effort de raisonnement Codex direct (ticket #99 follow-up).
#[must_use]
pub fn with_model_reasoning_effort(mut self, effort: impl Into<String>) -> Self {
self.model_reasoning_effort = Some(effort.into());
self
}
/// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans MCP ne l'appellent simplement pas. /// profils sans MCP ne l'appellent simplement pas.
@ -1488,10 +1500,15 @@ mod mcp_tests {
fn profile_model_round_trips_without_codex_or_claude_provider_config() { fn profile_model_round_trips_without_codex_or_claude_provider_config() {
let profile = profile_without_mcp() let profile = profile_without_mcp()
.with_structured_adapter(StructuredAdapter::Codex) .with_structured_adapter(StructuredAdapter::Codex)
.with_model("gpt-5-codex"); .with_model("gpt-5-codex")
.with_model_reasoning_effort("medium");
let json = serde_json::to_string(&profile).expect("serialise"); let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("\"model\":\"gpt-5-codex\""), "got: {json}"); assert!(json.contains("\"model\":\"gpt-5-codex\""), "got: {json}");
assert!(
json.contains("\"modelReasoningEffort\":\"medium\""),
"got: {json}"
);
assert!(!json.contains("codexProvider"), "got: {json}"); assert!(!json.contains("codexProvider"), "got: {json}");
assert!(!json.contains("claudeProvider"), "got: {json}"); assert!(!json.contains("claudeProvider"), "got: {json}");
assert!(!json.contains("apiKeyRef"), "got: {json}"); assert!(!json.contains("apiKeyRef"), "got: {json}");
@ -1499,6 +1516,7 @@ mod mcp_tests {
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise"); let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(back.model.as_deref(), Some("gpt-5-codex")); assert_eq!(back.model.as_deref(), Some("gpt-5-codex"));
assert_eq!(back.model_reasoning_effort.as_deref(), Some("medium"));
} }
#[test] #[test]

View File

@ -239,7 +239,6 @@ impl TicketAssistantEnvironmentPreparer {
&declaration, &declaration,
cwd.as_str(), cwd.as_str(),
project.root.as_str(), project.root.as_str(),
profile.model.as_deref(),
); );
self.write_file(&path, rendered.as_bytes()).await?; self.write_file(&path, rendered.as_bytes()).await?;
env.push((home_env.clone(), parent_dir(cwd, target))); env.push((home_env.clone(), parent_dir(cwd, target)));
@ -518,12 +517,8 @@ fn codex_config_toml(
mcp_declaration: &str, mcp_declaration: &str,
run_dir: &str, run_dir: &str,
project_root: &str, project_root: &str,
model: Option<&str>,
) -> String { ) -> String {
let mut text = existing.unwrap_or_default().to_owned(); let mut text = existing.unwrap_or_default().to_owned();
if let Some(model) = model {
text = set_top_level_toml_value(&text, "model", model);
}
text = replace_toml_table_block(&text, "mcp_servers.idea", mcp_declaration.trim_end()); text = replace_toml_table_block(&text, "mcp_servers.idea", mcp_declaration.trim_end());
text = replace_toml_table_block( text = replace_toml_table_block(
&text, &text,
@ -538,33 +533,6 @@ fn codex_config_toml(
text text
} }
fn set_top_level_toml_value(input: &str, key: &str, value: &str) -> String {
let line = format!("{key} = {}", toml_quoted(value));
set_top_level_toml_line(input, key, &line)
}
fn set_top_level_toml_line(input: &str, key: &str, replacement: &str) -> String {
let needle = format!("{key} =");
let mut out = Vec::new();
let mut replaced = false;
for line in input.lines() {
let trimmed = line.trim_start();
if !replaced && trimmed.starts_with(&needle) {
out.push(replacement.to_owned());
replaced = true;
} else {
out.push(line.to_owned());
}
}
if !replaced {
if !out.is_empty() && !out.first().is_some_and(|line| line.trim().starts_with('[')) {
out.push(String::new());
}
out.insert(0, replacement.to_owned());
}
out.join("\n")
}
fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String { fn replace_toml_table_block(existing: &str, table: &str, replacement: &str) -> String {
let header = format!("[{table}]"); let header = format!("[{table}]");
let mut out = Vec::new(); let mut out = Vec::new();
@ -669,13 +637,7 @@ mod codex_config_toml_tests {
.to_config_toml(); .to_config_toml();
let existing = "user_key = \"keep\"\n\n[features.code_mode]\ndirect_only_tool_namespaces = [\"old\"]\n\n[features.preview]\nenabled = true\n\n[user.table]\nvalue = 1\n"; let existing = "user_key = \"keep\"\n\n[features.code_mode]\ndirect_only_tool_namespaces = [\"old\"]\n\n[features.preview]\nenabled = true\n\n[user.table]\nvalue = 1\n";
let rendered = codex_config_toml( let rendered = codex_config_toml(Some(existing), &declaration, "/run/assistant", "/proj");
Some(existing),
&declaration,
"/run/assistant",
"/proj",
None,
);
assert!( assert!(
rendered.contains("user_key = \"keep\""), rendered.contains("user_key = \"keep\""),

View File

@ -58,7 +58,7 @@ impl PermissionProjector for CodexPermissionProjector {
// orthogonal and still gets an explicit env override to avoid stale inheritance. // orthogonal and still gets an explicit env override to avoid stale inheritance.
let Some(permissions) = eff else { let Some(permissions) = eff else {
return PermissionProjection { return PermissionProjection {
files: vec![codex_model_and_network_file(ctx, network)], files: vec![codex_network_file(network)],
env: codex_network_env(network), env: codex_network_env(network),
..PermissionProjection::empty() ..PermissionProjection::empty()
}; };
@ -67,15 +67,11 @@ impl PermissionProjector for CodexPermissionProjector {
let approval = codex_approval_policy(permissions); let approval = codex_approval_policy(permissions);
let network_access = codex_network_access(network); let network_access = codex_network_access(network);
// Permission-only TOML fragment (escaped exactly like the former
// `set_top_level_toml_value`). The mcp_servers/trust tables are NOT a
// permission concern and stay with the MCP wiring (LP3-3).
let mut contents = format!( let mut contents = format!(
"sandbox_mode = {}\napproval_policy = {}\n", "sandbox_mode = {}\napproval_policy = {}\n",
toml_string(sandbox), toml_string(sandbox),
toml_string(approval), toml_string(approval),
); );
append_codex_model_config(&mut contents, ctx);
contents.push_str(&format!( contents.push_str(&format!(
"\n[{}]\nnetwork_access = {}\n\n{}", "\n[{}]\nnetwork_access = {}\n\n{}",
SANDBOX_WORKSPACE_WRITE_TABLE, network_access, CODEX_CODE_MODE_FEATURES_TOML, SANDBOX_WORKSPACE_WRITE_TABLE, network_access, CODEX_CODE_MODE_FEATURES_TOML,
@ -95,8 +91,8 @@ impl PermissionProjector for CodexPermissionProjector {
PermissionProjection { PermissionProjection {
files: vec![ProjectedFile::MergeToml { files: vec![ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(), rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: codex_managed_tables(ctx), managed_tables: codex_managed_tables(),
managed_keys: codex_managed_keys(ctx, true), managed_keys: codex_managed_keys(true),
contents, contents,
}], }],
args, args,
@ -110,53 +106,36 @@ impl PermissionProjector for CodexPermissionProjector {
} }
} }
fn codex_model_and_network_file( fn codex_network_file(network: Option<NetworkPolicy>) -> ProjectedFile {
ctx: &ProjectionContext, let contents = format!(
network: Option<NetworkPolicy>,
) -> ProjectedFile {
let mut contents = String::new();
append_codex_model_config(&mut contents, ctx);
if !contents.is_empty() {
contents.push('\n');
}
contents.push_str(&format!(
"[{}]\nnetwork_access = {}\n\n{}", "[{}]\nnetwork_access = {}\n\n{}",
SANDBOX_WORKSPACE_WRITE_TABLE, SANDBOX_WORKSPACE_WRITE_TABLE,
codex_network_access(network), codex_network_access(network),
CODEX_CODE_MODE_FEATURES_TOML, CODEX_CODE_MODE_FEATURES_TOML,
)); );
ProjectedFile::MergeToml { ProjectedFile::MergeToml {
rel_path: CONFIG_REL_PATH.to_owned(), rel_path: CONFIG_REL_PATH.to_owned(),
managed_tables: codex_managed_tables(ctx), managed_tables: codex_managed_tables(),
managed_keys: codex_managed_keys(ctx, false), managed_keys: codex_managed_keys(false),
contents, contents,
} }
} }
fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec<String> { fn codex_managed_keys(include_permissions: bool) -> Vec<String> {
let mut keys = Vec::new(); let mut keys = Vec::new();
if include_permissions { if include_permissions {
keys.extend(PERMISSION_MANAGED_KEYS.iter().map(|k| (*k).to_owned())); keys.extend(PERMISSION_MANAGED_KEYS.iter().map(|k| (*k).to_owned()));
} }
if ctx.model.is_some() {
keys.push("model".to_owned());
}
keys keys
} }
fn codex_managed_tables(_ctx: &ProjectionContext) -> Vec<String> { fn codex_managed_tables() -> Vec<String> {
vec![ vec![
SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(), SANDBOX_WORKSPACE_WRITE_TABLE.to_owned(),
CODEX_CODE_MODE_FEATURES_TABLE.to_owned(), CODEX_CODE_MODE_FEATURES_TABLE.to_owned(),
] ]
} }
fn append_codex_model_config(contents: &mut String, ctx: &ProjectionContext) {
if let Some(model) = ctx.model {
contents.push_str(&format!("model = {}\n", toml_string(model)));
}
}
fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> { fn codex_network_env(network: Option<NetworkPolicy>) -> Vec<(String, String)> {
// Codex inherits the parent environment by default. Always set the variable for // Codex inherits the parent environment by default. Always set the variable for
// Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own // Codex launches so a stale `CODEX_SANDBOX_NETWORK_DISABLED=1` in IdeA's own
@ -259,7 +238,7 @@ mod tests {
} }
#[test] #[test]
fn model_projection_without_permissions_writes_only_model() { fn projection_without_permissions_writes_network_only_and_leaves_model_to_exec_args() {
let ctx = ProjectionContext { let ctx = ProjectionContext {
project_root: "/proj", project_root: "/proj",
run_dir: "/run/agent", run_dir: "/run/agent",
@ -274,10 +253,11 @@ mod tests {
contents, contents,
.. ..
} => { } => {
assert!(managed_keys.contains(&"model".to_owned())); assert!(!managed_keys.contains(&"model".to_owned()));
assert!(!managed_keys.contains(&"model_provider".to_owned())); assert!(!managed_keys.contains(&"model_provider".to_owned()));
assert_eq!(managed_tables, &expected_managed_tables()); assert_eq!(managed_tables, &expected_managed_tables());
assert!(contents.contains("model = \"gpt-5\""), "{contents}"); assert!(!contents.contains("model ="), "{contents}");
assert!(contents.contains("[sandbox_workspace_write]"), "{contents}");
assert!(!contents.contains("model_provider"), "{contents}"); assert!(!contents.contains("model_provider"), "{contents}");
assert!(!contents.contains("model_providers"), "{contents}"); assert!(!contents.contains("model_providers"), "{contents}");
assert!(!contents.contains("base_url"), "{contents}"); assert!(!contents.contains("base_url"), "{contents}");

View File

@ -160,6 +160,10 @@ pub struct CodexExecSession {
writable_roots: Vec<String>, writable_roots: Vec<String>,
/// Structured policy projection of Codex workspace-write sandbox network access. /// Structured policy projection of Codex workspace-write sandbox network access.
network_access: Option<bool>, 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é). /// Variables d'environnement préparées au lancement (ex. `CODEX_HOME` isolé).
env: Vec<(String, String)>, env: Vec<(String, String)>,
/// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant. /// Id de conversation **du moteur** Codex, capté au premier tour, `None` avant.
@ -188,7 +192,7 @@ impl CodexExecSession {
sandbox: Option<SandboxPlan>, sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>, sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>,
) -> Self { ) -> Self {
Self::new_with_policy( Self::new_with_policy_and_overrides(
id, id,
command, command,
cwd, cwd,
@ -196,6 +200,8 @@ impl CodexExecSession {
"workspace-write", "workspace-write",
writable_roots, writable_roots,
None, None,
None,
None,
env, env,
sandbox, sandbox,
sandbox_enforcer, sandbox_enforcer,
@ -217,6 +223,39 @@ impl CodexExecSession {
env: Vec<(String, String)>, env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>, sandbox: Option<SandboxPlan>,
sandbox_enforcer: Option<Arc<dyn SandboxEnforcer>>, 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 {
Self { Self {
id, id,
@ -225,6 +264,8 @@ impl CodexExecSession {
sandbox_mode: sandbox_mode.into(), sandbox_mode: sandbox_mode.into(),
writable_roots, writable_roots,
network_access, network_access,
model,
model_reasoning_effort,
env, env,
conversation_id: Mutex::new(seed_conversation_id), conversation_id: Mutex::new(seed_conversation_id),
sandbox, sandbox,
@ -269,6 +310,25 @@ impl CodexExecSession {
"sandbox_workspace_write.network_access={network_access}" "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 { if let Some(id) = conversation_id {
args.push("resume".to_owned()); args.push("resume".to_owned());
args.push(id); 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) { fn upsert_env(env: &mut Vec<(String, String)>, key: &str, value: &str) {
if let Some((_, existing)) = env.iter_mut().find(|(k, _)| k == key) { if let Some((_, existing)) = env.iter_mut().find(|(k, _)| k == key) {
*existing = value.to_owned(); *existing = value.to_owned();

View File

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

View File

@ -625,6 +625,54 @@ mod tests {
let _ = std::fs::remove_file(&argv); 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] #[tokio::test]
async fn factory_resume_seeds_conversation_id() { async fn factory_resume_seeds_conversation_id() {
let factory = StructuredSessionFactory::new(); let factory = StructuredSessionFactory::new();
@ -1774,6 +1822,101 @@ mod tests {
let _ = std::fs::remove_file(&argv); 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] #[tokio::test]
async fn codex_read_only_policy_omits_add_dir_and_approval_flag() { async fn codex_read_only_policy_omits_add_dir_and_approval_flag() {
let (cmd, argv) = make_recording_fake(&[ let (cmd, argv) = make_recording_fake(&[