From 8bea678fc28db878e39817b6555791ff53c048b5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 24 Aug 2026 13:11:49 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix(opencode):=20les=20tours=20headless=20s?= =?UTF-8?q?ont=20=C3=A9pingl=C3=A9s=20au=20project=20root=20via=20--dir=20?= =?UTF-8?q?et=20une=20reprise=20sans=20Final=20retente=20un=20tour=20frais?= =?UTF-8?q?=20=E2=80=94=20idea=5Fask=5Fagent=20ne=20rattache=20plus=20la?= =?UTF-8?q?=20session=20OpenCode=20d'un=20autre=20projet=20ni=20ne=20perd?= =?UTF-8?q?=20le=20Final=20=E2=80=94=20#255=20(QA=20verte)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/infrastructure/src/session/factory.rs | 3 +- crates/infrastructure/src/session/opencode.rs | 182 +++++++++++++++++- 2 files changed, 177 insertions(+), 8 deletions(-) diff --git a/crates/infrastructure/src/session/factory.rs b/crates/infrastructure/src/session/factory.rs index fec3e547..5de3f734 100644 --- a/crates/infrastructure/src/session/factory.rs +++ b/crates/infrastructure/src/session/factory.rs @@ -210,7 +210,8 @@ impl AgentSessionFactory for StructuredSessionFactory { id, profile.command.clone(), profile.args.clone(), - cwd, + cwd.clone(), + ctx.project_root.clone(), seed, env.to_vec(), plan, diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index d4bbeb67..8758d202 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -550,6 +550,7 @@ pub struct OpenCodeSession { command: String, prefix_args: Vec, cwd: String, + project_root: String, env: Vec<(String, String)>, engine_session_id: Mutex>, current_child: Mutex>>>, @@ -578,6 +579,7 @@ impl OpenCodeSession { command_prefix: impl Into, profile_args: Vec, cwd: impl Into, + project_root: impl Into, seed: Option, env: Vec<(String, String)>, sandbox: Option, @@ -591,6 +593,7 @@ impl OpenCodeSession { command, prefix_args: prefix, cwd: cwd.into(), + project_root: project_root.into(), env, engine_session_id: Mutex::new(seed), current_child: Mutex::new(None), @@ -599,11 +602,14 @@ impl OpenCodeSession { }) } - fn build_args(&self, prompt: &str) -> Vec { + fn build_args(&self, prompt: &str, engine_session_id: Option<&str>) -> Vec { let mut args = self.prefix_args.clone(); args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]); - if let Some(engine_id) = self.conversation_id() { - args.extend(["--session".to_owned(), engine_id]); + if !self.project_root.trim().is_empty() { + args.extend(["--dir".to_owned(), self.project_root.clone()]); + } + if let Some(engine_id) = engine_session_id { + args.extend(["--session".to_owned(), engine_id.to_owned()]); } args.push(prompt.to_owned()); args @@ -670,8 +676,31 @@ impl OpenCodeSession { prompt: &str, tap: Option>, ) -> Result { + let initial_session = self.conversation_id(); + let first = self + .run_once(prompt, tap.clone(), initial_session.clone()) + .await; + let events = match first { + Ok(events) => events, + Err(err) + if initial_session.is_some() + && matches!(err, AgentSessionError::Decode(ref message) if opencode_missing_final(message)) => + { + self.run_once(prompt, tap, None).await? + } + Err(err) => return Err(err), + }; + Ok(Box::new(events.into_iter())) + } + + async fn run_once( + &self, + prompt: &str, + tap: Option>, + expected_session: Option, + ) -> Result, AgentSessionError> { let mut cmd = Command::new(&self.command); - cmd.args(self.build_args(prompt)) + cmd.args(self.build_args(prompt, expected_session.as_deref())) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -709,7 +738,6 @@ impl OpenCodeSession { (stdout, stderr) }; - let expected_session = self.conversation_id(); let mut lines = BufReader::new(stdout).lines(); let mut collected = Vec::new(); let mut live = tap @@ -765,7 +793,7 @@ impl OpenCodeSession { if !status.success() { if let Ok(events) = parsed { if has_structured_terminal_event(&events) { - return Ok(Box::new(stream_events(events, has_tap).into_iter())); + return Ok(stream_events(events, has_tap)); } } if stderr.contains("server unavailable") && stderr.contains("key=idea") { @@ -786,10 +814,14 @@ impl OpenCodeSession { } let events = parsed?; - Ok(Box::new(stream_events(events, has_tap).into_iter())) + Ok(stream_events(events, has_tap)) } } +fn opencode_missing_final(message: &str) -> bool { + message.contains("aucun final textuel exploitable") +} + #[cfg(test)] mod tests { #[cfg(unix)] @@ -1166,6 +1198,7 @@ exit 1 command, args, tmp.path().to_string_lossy(), + tmp.path().to_string_lossy(), None, Vec::new(), None, @@ -1214,6 +1247,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"message command, args, tmp.path().to_string_lossy(), + tmp.path().to_string_lossy(), None, Vec::new(), None, @@ -1286,6 +1320,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag command, args, tmp.path().to_string_lossy(), + tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), None, @@ -1347,6 +1382,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag command, args, tmp.path().to_string_lossy(), + tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), None, @@ -1431,6 +1467,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}' command, args, tmp.path().to_string_lossy(), + tmp.path().to_string_lossy(), None, Vec::new(), None, @@ -1472,6 +1509,7 @@ printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}' command, args, tmp.path().to_string_lossy(), + tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), None, @@ -1491,6 +1529,135 @@ printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}' assert_eq!(argv.last(), Some(&"prompt")); } + #[cfg(unix)] + #[tokio::test] + async fn send_passes_project_root_as_opencode_dir_without_changing_process_cwd() { + let tmp = TempDir::new("project-dir"); + let run_dir = tmp.path().join("run"); + let project_root = tmp.path().join("project"); + fs::create_dir_all(&run_dir).unwrap(); + fs::create_dir_all(&project_root).unwrap(); + let script = tmp.path().join("opencode-fixture.sh"); + let args_file = tmp.path().join("args.txt"); + let cwd_file = tmp.path().join("cwd.txt"); + write_executable_script( + &script, + format!( + r#"#!/bin/sh +pwd > '{}' +printf '%s\n' "$@" > '{}' +printf '%s\n' '{{"type":"step_start","sessionID":"fresh-engine"}}' +printf '%s\n' '{{"type":"text","sessionID":"fresh-engine","part":{{"text":"ok"}}}}' +printf '%s\n' '{{"type":"step_finish","sessionID":"fresh-engine"}}' +"#, + cwd_file.display(), + args_file.display() + ), + ); + let (command, args) = shell_script_command(&script); + let session = OpenCodeSession::new( + SessionId::new_random(), + command, + args, + run_dir.to_string_lossy(), + project_root.to_string_lossy(), + None, + Vec::new(), + None, + None, + ) + .unwrap(); + + session.send("prompt").await.unwrap().for_each(drop); + + let cwd = fs::read_to_string(cwd_file).unwrap(); + assert_eq!(cwd.trim(), run_dir.to_string_lossy()); + let args = fs::read_to_string(args_file).unwrap(); + let argv: Vec<&str> = args.lines().collect(); + assert!( + argv.windows(2) + .any(|pair| pair == ["--dir", project_root.to_string_lossy().as_ref()]), + "OpenCode headless runs must pin the real project root with --dir, got: {argv:?}" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn resumed_session_without_final_retries_once_as_fresh_project_scoped_run() { + let tmp = TempDir::new("resume-empty-retry"); + let run_dir = tmp.path().join("run"); + let project_root = tmp.path().join("project"); + fs::create_dir_all(&run_dir).unwrap(); + fs::create_dir_all(&project_root).unwrap(); + let script = tmp.path().join("opencode-fixture.sh"); + let count_file = tmp.path().join("count.txt"); + let first_args = tmp.path().join("args-1.txt"); + let second_args = tmp.path().join("args-2.txt"); + write_executable_script( + &script, + format!( + r#"#!/bin/sh +count=0 +if [ -f '{count}' ]; then count=$(cat '{count}'); fi +count=$((count + 1)) +printf '%s\n' "$count" > '{count}' +printf '%s\n' "$@" > "{args_prefix}-$count.txt" +if [ "$count" = "1" ]; then + exit 0 +fi +printf '%s\n' '{{"type":"step_start","sessionID":"fresh-engine"}}' +printf '%s\n' '{{"type":"text","sessionID":"fresh-engine","part":{{"text":"réponse fraîche"}}}}' +printf '%s\n' '{{"type":"step_finish","sessionID":"fresh-engine"}}' +"#, + count = count_file.display(), + args_prefix = tmp.path().join("args").display() + ), + ); + let (command, args) = shell_script_command(&script); + let session = OpenCodeSession::new( + SessionId::new_random(), + command, + args, + run_dir.to_string_lossy(), + project_root.to_string_lossy(), + Some("stale-engine".to_owned()), + Vec::new(), + None, + None, + ) + .unwrap(); + + let events = session.send("prompt").await.unwrap().collect::>(); + + assert_eq!( + events.last(), + Some(&ReplyEvent::Final { + content: "réponse fraîche".to_owned() + }) + ); + assert_eq!(session.conversation_id().as_deref(), Some("fresh-engine")); + let first = fs::read_to_string(first_args).unwrap(); + let first_argv: Vec<&str> = first.lines().collect(); + assert!( + first_argv + .windows(2) + .any(|pair| pair == ["--session", "stale-engine"]), + "first attempt must resume the seeded OpenCode session: {first_argv:?}" + ); + let second = fs::read_to_string(second_args).unwrap(); + let second_argv: Vec<&str> = second.lines().collect(); + assert!( + !second_argv.iter().any(|arg| *arg == "--session"), + "retry after empty resumed stdout must be a fresh run: {second_argv:?}" + ); + assert!( + second_argv + .windows(2) + .any(|pair| pair == ["--dir", project_root.to_string_lossy().as_ref()]), + "fresh retry must still be scoped to the project root: {second_argv:?}" + ); + } + #[cfg(unix)] #[tokio::test] async fn seeded_session_ignores_and_does_not_capture_other_session_events() { @@ -1511,6 +1678,7 @@ printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2 command, args, tmp.path().to_string_lossy(), + tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), None, From 6294cebba0160aebc409c3c4cab9b5f146f6d86c Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 24 Aug 2026 13:12:02 +0200 Subject: [PATCH 2/2] =?UTF-8?q?chore(tickets):=20synchro=20du=20store=20lo?= =?UTF-8?q?cal=20=E2=80=94=20cr=C3=A9ation=20#255=20(bug=20OpenCode=20idea?= =?UTF-8?q?=5Fask=5Fagent=20cross-project=20/=20Final=20manquant)=20+=20d?= =?UTF-8?q?=C3=A9rive=20r=C3=A9siduelle=20du=20store:=20statut=20#254=20qa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ideai/tickets/255/carnet.md | 6 ++++++ .ideai/tickets/255/issue.md | 17 +++++++++++++++++ .ideai/tickets/counter.json | 2 +- .ideai/tickets/index.json | 18 ++++++++++++++++-- 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 .ideai/tickets/255/carnet.md create mode 100644 .ideai/tickets/255/issue.md diff --git a/.ideai/tickets/255/carnet.md b/.ideai/tickets/255/carnet.md new file mode 100644 index 00000000..5b722fbf --- /dev/null +++ b/.ideai/tickets/255/carnet.md @@ -0,0 +1,6 @@ +--- +issueRef: "#255" +version: 3 +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedAt: 1787569806822 +--- diff --git a/.ideai/tickets/255/issue.md b/.ideai/tickets/255/issue.md new file mode 100644 index 00000000..bcfe9094 --- /dev/null +++ b/.ideai/tickets/255/issue.md @@ -0,0 +1,17 @@ +--- +id: "bfcee119-765e-4622-b132-8fd5667d98d9" +number: 255 +title: "Bug OpenCode: idea_ask_agent peut reattacher un agent d'un autre projet ou perdre le Final" +status: "qa" +priority: "high" +sprint: null +links: [] +agentRefs: [] +attachments: [] +createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +createdAt: 1787569298214 +updatedAt: 1787569806822 +version: 3 +--- +Symptome utilisateur: dans un autre projet, une delegation Main -> Git via idea_ask_agent renvoie soit une reponse sur un autre depot, soit 'finished without a structured Final response'. La meme demande executee directement dans la cellule Git fonctionne. Suspicion forte: bug specifique au chemin OpenCode headless (reattache/session/cwd/contexte projet) et/ou a la capture de Final OpenCode. Objectif: verifier si le bug est bien specifique a OpenCode, identifier la cause, corriger, puis valider par test. \ No newline at end of file diff --git a/.ideai/tickets/counter.json b/.ideai/tickets/counter.json index 7d744907..6797258c 100644 --- a/.ideai/tickets/counter.json +++ b/.ideai/tickets/counter.json @@ -1,3 +1,3 @@ { - "nextNumber": 255 + "nextNumber": 256 } \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 0cb3a73e..a1c1468f 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -3425,7 +3425,7 @@ "issueRef": "#254", "path": "254", "title": "Stabiliser les rendez-vous inter-agents OpenCode/GLM et éliminer les faux NoReply/perms sur projets externes", - "status": "inProgress", + "status": "qa", "priority": "high", "sprint": null, "assignedAgentIds": [], @@ -3433,7 +3433,21 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1787566369625 + "updatedAt": 1787566909446 + }, + { + "issueRef": "#255", + "path": "255", + "title": "Bug OpenCode: idea_ask_agent peut reattacher un agent d'un autre projet ou perdre le Final", + "status": "qa", + "priority": "high", + "sprint": null, + "assignedAgentIds": [], + "createdBy": { + "kind": "agent", + "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" + }, + "updatedAt": 1787569806822 } ] } \ No newline at end of file