fix(opencode): les tours headless sont épinglés au project root via --dir et une reprise sans Final retente un tour frais — idea_ask_agent ne rattache plus la session OpenCode d'un autre projet ni ne perd le Final — #255 (QA verte)

This commit is contained in:
2026-08-24 13:11:49 +02:00
parent 778b4a601d
commit 8bea678fc2
2 changed files with 177 additions and 8 deletions

View File

@ -210,7 +210,8 @@ impl AgentSessionFactory for StructuredSessionFactory {
id, id,
profile.command.clone(), profile.command.clone(),
profile.args.clone(), profile.args.clone(),
cwd, cwd.clone(),
ctx.project_root.clone(),
seed, seed,
env.to_vec(), env.to_vec(),
plan, plan,

View File

@ -550,6 +550,7 @@ pub struct OpenCodeSession {
command: String, command: String,
prefix_args: Vec<String>, prefix_args: Vec<String>,
cwd: String, cwd: String,
project_root: String,
env: Vec<(String, String)>, env: Vec<(String, String)>,
engine_session_id: Mutex<Option<String>>, engine_session_id: Mutex<Option<String>>,
current_child: Mutex<Option<Arc<AsyncMutex<tokio::process::Child>>>>, current_child: Mutex<Option<Arc<AsyncMutex<tokio::process::Child>>>>,
@ -578,6 +579,7 @@ impl OpenCodeSession {
command_prefix: impl Into<String>, command_prefix: impl Into<String>,
profile_args: Vec<String>, profile_args: Vec<String>,
cwd: impl Into<String>, cwd: impl Into<String>,
project_root: impl Into<String>,
seed: Option<String>, seed: Option<String>,
env: Vec<(String, String)>, env: Vec<(String, String)>,
sandbox: Option<SandboxPlan>, sandbox: Option<SandboxPlan>,
@ -591,6 +593,7 @@ impl OpenCodeSession {
command, command,
prefix_args: prefix, prefix_args: prefix,
cwd: cwd.into(), cwd: cwd.into(),
project_root: project_root.into(),
env, env,
engine_session_id: Mutex::new(seed), engine_session_id: Mutex::new(seed),
current_child: Mutex::new(None), current_child: Mutex::new(None),
@ -599,11 +602,14 @@ impl OpenCodeSession {
}) })
} }
fn build_args(&self, prompt: &str) -> Vec<String> { fn build_args(&self, prompt: &str, engine_session_id: Option<&str>) -> Vec<String> {
let mut args = self.prefix_args.clone(); let mut args = self.prefix_args.clone();
args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]); args.extend(["run".to_owned(), "--format".to_owned(), "json".to_owned()]);
if let Some(engine_id) = self.conversation_id() { if !self.project_root.trim().is_empty() {
args.extend(["--session".to_owned(), engine_id]); 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.push(prompt.to_owned());
args args
@ -670,8 +676,31 @@ impl OpenCodeSession {
prompt: &str, prompt: &str,
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>, tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
) -> Result<ReplyStream, AgentSessionError> { ) -> Result<ReplyStream, AgentSessionError> {
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<std::sync::mpsc::Sender<ReplyEvent>>,
expected_session: Option<String>,
) -> Result<Vec<ReplyEvent>, AgentSessionError> {
let mut cmd = Command::new(&self.command); 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()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
@ -709,7 +738,6 @@ impl OpenCodeSession {
(stdout, stderr) (stdout, stderr)
}; };
let expected_session = self.conversation_id();
let mut lines = BufReader::new(stdout).lines(); let mut lines = BufReader::new(stdout).lines();
let mut collected = Vec::new(); let mut collected = Vec::new();
let mut live = tap let mut live = tap
@ -765,7 +793,7 @@ impl OpenCodeSession {
if !status.success() { if !status.success() {
if let Ok(events) = parsed { if let Ok(events) = parsed {
if has_structured_terminal_event(&events) { 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") { if stderr.contains("server unavailable") && stderr.contains("key=idea") {
@ -786,10 +814,14 @@ impl OpenCodeSession {
} }
let events = parsed?; 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)] #[cfg(test)]
mod tests { mod tests {
#[cfg(unix)] #[cfg(unix)]
@ -1166,6 +1198,7 @@ exit 1
command, command,
args, args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
tmp.path().to_string_lossy(),
None, None,
Vec::new(), Vec::new(),
None, None,
@ -1214,6 +1247,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"message
command, command,
args, args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
tmp.path().to_string_lossy(),
None, None,
Vec::new(), Vec::new(),
None, None,
@ -1286,6 +1320,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag
command, command,
args, args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), Vec::new(),
None, None,
@ -1347,6 +1382,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag
command, command,
args, args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), Vec::new(),
None, None,
@ -1431,6 +1467,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}'
command, command,
args, args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
tmp.path().to_string_lossy(),
None, None,
Vec::new(), Vec::new(),
None, None,
@ -1472,6 +1509,7 @@ printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}'
command, command,
args, args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), Vec::new(),
None, None,
@ -1491,6 +1529,135 @@ printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}'
assert_eq!(argv.last(), Some(&"prompt")); 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::<Vec<_>>();
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)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn seeded_session_ignores_and_does_not_capture_other_session_events() { 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, command,
args, args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), Vec::new(),
None, None,