From dcc7a6f216c358e55c55ccdb4b718bf6195c7de4 Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 2 Aug 2026 17:19:26 +0200 Subject: [PATCH] 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 --- .ideai/agents/test.md | 116 ++++++++++++++ crates/application/src/agent/lifecycle.rs | 15 +- crates/domain/src/permission.rs | 2 +- crates/domain/src/profile.rs | 20 ++- crates/infrastructure/src/assistant/mod.rs | 40 +---- crates/infrastructure/src/permission/codex.rs | 48 ++---- crates/infrastructure/src/session/codex.rs | 66 +++++++- crates/infrastructure/src/session/factory.rs | 4 +- crates/infrastructure/src/session/mod.rs | 143 ++++++++++++++++++ 9 files changed, 363 insertions(+), 91 deletions(-) create mode 100644 .ideai/agents/test.md diff --git a/.ideai/agents/test.md b/.ideai/agents/test.md new file mode 100644 index 0000000..cabb0c8 --- /dev/null +++ b/.ideai/agents/test.md @@ -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/`** : 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 ` (convention de l'environnement). +- **Branches** : `feature/`, 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. diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index f33888d..fdb093f 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -2598,7 +2598,6 @@ impl LaunchAgent { &declaration, run_dir.as_str(), project_root.as_str(), - profile.model.as_deref(), ); let _ = self.fs.write(&path, rendered.as_bytes()).await; } @@ -2610,7 +2609,6 @@ impl LaunchAgent { &declaration, run_dir.as_str(), project_root.as_str(), - profile.model.as_deref(), ); let _ = self.fs.write(&path, rendered.as_bytes()).await; } @@ -3215,11 +3213,6 @@ fn toml_string(s: &str) -> String { 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 /// the `[mcp_servers.idea]` table and ensures the run-dir + project-root trust /// entries. The permission part (`sandbox_mode` / `approval_policy` + the @@ -3231,12 +3224,8 @@ fn codex_config_toml( mcp_declaration: &str, run_dir: &str, project_root: &str, - model: Option<&str>, ) -> String { 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, @@ -4750,7 +4739,6 @@ command = "other" "[mcp_servers.idea]\ncommand = \"new\"", "/home/me/proj/.ideai/run/a", "/home/me/proj", - Some("gpt-5-codex"), ); // 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("[mcp_servers.idea]\ncommand = \"new\"")); 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_eq!(rendered.matches("[mcp_servers.idea]").count(), 1); assert_eq!(rendered.matches("[projects.\"/home/me/proj\"]").count(), 1); @@ -4774,7 +4762,6 @@ command = "other" "[mcp_servers.idea]\ncommand = \"idea-mcp\"", "/home/me/proj/.ideai/run/a", "/home/me/proj", - None, ); assert!(!rendered.contains("model =")); diff --git a/crates/domain/src/permission.rs b/crates/domain/src/permission.rs index d28e339..fa35ab0 100644 --- a/crates/domain/src/permission.rs +++ b/crates/domain/src/permission.rs @@ -851,7 +851,7 @@ pub struct ProjectionContext<'a> { /// Absolute isolated run dir of the agent (`.ideai/run//`). pub run_dir: &'a str, /// 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>, } diff --git a/crates/domain/src/profile.rs b/crates/domain/src/profile.rs index 74e92bd..89dc887 100644 --- a/crates/domain/src/profile.rs +++ b/crates/domain/src/profile.rs @@ -910,6 +910,10 @@ pub struct AgentProfile { /// CLI. OpenCode garde ses champs dédiés. #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, + /// 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, /// Capacité **MCP** (ARCHITECTURE §14.3, orchestration v3, Décision 1). /// `None` ⇒ repli fichier `.ideai/requests` + prose (comportement actuel). /// `Some(_)` ⇒ IdeA matérialise la config MCP de cette CLI au lancement et @@ -1117,6 +1121,7 @@ impl AgentProfile { opencode: None, opencode_provider: None, model: None, + model_reasoning_effort: None, mcp: None, liveness: None, rate_limit_pattern: None, @@ -1174,6 +1179,13 @@ impl AgentProfile { 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) -> Self { + self.model_reasoning_effort = Some(effort.into()); + self + } + /// Builder : fixe la [`McpCapability`] (§14.3, orchestration v3) et renvoie le /// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les /// 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() { let profile = profile_without_mcp() .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"); 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("claudeProvider"), "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"); assert_eq!(back.model.as_deref(), Some("gpt-5-codex")); + assert_eq!(back.model_reasoning_effort.as_deref(), Some("medium")); } #[test] diff --git a/crates/infrastructure/src/assistant/mod.rs b/crates/infrastructure/src/assistant/mod.rs index 3ce03e4..e0bc563 100644 --- a/crates/infrastructure/src/assistant/mod.rs +++ b/crates/infrastructure/src/assistant/mod.rs @@ -239,7 +239,6 @@ impl TicketAssistantEnvironmentPreparer { &declaration, cwd.as_str(), project.root.as_str(), - profile.model.as_deref(), ); self.write_file(&path, rendered.as_bytes()).await?; env.push((home_env.clone(), parent_dir(cwd, target))); @@ -518,12 +517,8 @@ fn codex_config_toml( mcp_declaration: &str, run_dir: &str, project_root: &str, - model: Option<&str>, ) -> String { 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, @@ -538,33 +533,6 @@ fn codex_config_toml( 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 { let header = format!("[{table}]"); let mut out = Vec::new(); @@ -669,13 +637,7 @@ mod codex_config_toml_tests { .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 rendered = codex_config_toml( - Some(existing), - &declaration, - "/run/assistant", - "/proj", - None, - ); + let rendered = codex_config_toml(Some(existing), &declaration, "/run/assistant", "/proj"); assert!( rendered.contains("user_key = \"keep\""), diff --git a/crates/infrastructure/src/permission/codex.rs b/crates/infrastructure/src/permission/codex.rs index 8e4d9c4..73b3a88 100644 --- a/crates/infrastructure/src/permission/codex.rs +++ b/crates/infrastructure/src/permission/codex.rs @@ -58,7 +58,7 @@ impl PermissionProjector for CodexPermissionProjector { // orthogonal and still gets an explicit env override to avoid stale inheritance. let Some(permissions) = eff else { return PermissionProjection { - files: vec![codex_model_and_network_file(ctx, network)], + files: vec![codex_network_file(network)], env: codex_network_env(network), ..PermissionProjection::empty() }; @@ -67,15 +67,11 @@ impl PermissionProjector for CodexPermissionProjector { let approval = codex_approval_policy(permissions); 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!( "sandbox_mode = {}\napproval_policy = {}\n", toml_string(sandbox), toml_string(approval), ); - append_codex_model_config(&mut contents, ctx); contents.push_str(&format!( "\n[{}]\nnetwork_access = {}\n\n{}", SANDBOX_WORKSPACE_WRITE_TABLE, network_access, CODEX_CODE_MODE_FEATURES_TOML, @@ -95,8 +91,8 @@ impl PermissionProjector for CodexPermissionProjector { PermissionProjection { files: vec![ProjectedFile::MergeToml { rel_path: CONFIG_REL_PATH.to_owned(), - managed_tables: codex_managed_tables(ctx), - managed_keys: codex_managed_keys(ctx, true), + managed_tables: codex_managed_tables(), + managed_keys: codex_managed_keys(true), contents, }], args, @@ -110,53 +106,36 @@ impl PermissionProjector for CodexPermissionProjector { } } -fn codex_model_and_network_file( - ctx: &ProjectionContext, - network: Option, -) -> ProjectedFile { - let mut contents = String::new(); - append_codex_model_config(&mut contents, ctx); - if !contents.is_empty() { - contents.push('\n'); - } - contents.push_str(&format!( +fn codex_network_file(network: Option) -> ProjectedFile { + let contents = format!( "[{}]\nnetwork_access = {}\n\n{}", SANDBOX_WORKSPACE_WRITE_TABLE, codex_network_access(network), CODEX_CODE_MODE_FEATURES_TOML, - )); + ); ProjectedFile::MergeToml { rel_path: CONFIG_REL_PATH.to_owned(), - managed_tables: codex_managed_tables(ctx), - managed_keys: codex_managed_keys(ctx, false), + managed_tables: codex_managed_tables(), + managed_keys: codex_managed_keys(false), contents, } } -fn codex_managed_keys(ctx: &ProjectionContext, include_permissions: bool) -> Vec { +fn codex_managed_keys(include_permissions: bool) -> Vec { let mut keys = Vec::new(); if include_permissions { keys.extend(PERMISSION_MANAGED_KEYS.iter().map(|k| (*k).to_owned())); } - if ctx.model.is_some() { - keys.push("model".to_owned()); - } keys } -fn codex_managed_tables(_ctx: &ProjectionContext) -> Vec { +fn codex_managed_tables() -> Vec { vec![ SANDBOX_WORKSPACE_WRITE_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) -> Vec<(String, String)> { // 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 @@ -259,7 +238,7 @@ mod tests { } #[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 { project_root: "/proj", run_dir: "/run/agent", @@ -274,10 +253,11 @@ mod tests { contents, .. } => { - assert!(managed_keys.contains(&"model".to_owned())); + assert!(!managed_keys.contains(&"model".to_owned())); assert!(!managed_keys.contains(&"model_provider".to_owned())); 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_providers"), "{contents}"); assert!(!contents.contains("base_url"), "{contents}"); diff --git a/crates/infrastructure/src/session/codex.rs b/crates/infrastructure/src/session/codex.rs index 38ad9b6..b7b2f49 100644 --- a/crates/infrastructure/src/session/codex.rs +++ b/crates/infrastructure/src/session/codex.rs @@ -160,6 +160,10 @@ pub struct CodexExecSession { writable_roots: Vec, /// Structured policy projection of Codex workspace-write sandbox network access. network_access: Option, + /// Profile-selected model forwarded as a Codex config override on every exec turn. + model: Option, + /// Profile-selected reasoning effort forwarded as a Codex config override. + model_reasoning_effort: Option, /// 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, sandbox_enforcer: Option>, ) -> 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, sandbox_enforcer: Option>, + ) -> 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, + cwd: impl Into, + seed_conversation_id: Option, + sandbox_mode: impl Into, + writable_roots: Vec, + network_access: Option, + model: Option, + model_reasoning_effort: Option, + env: Vec<(String, String)>, + sandbox: Option, + sandbox_enforcer: Option>, ) -> 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(); diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index 3a0395a..69e257e 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -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, diff --git a/crates/infrastructure/src/session/mod.rs b/crates/infrastructure/src/session/mod.rs index d0704c3..e5b464f 100644 --- a/crates/infrastructure/src/session/mod.rs +++ b/crates/infrastructure/src/session/mod.rs @@ -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(&[