merge(opencode): délégations idea_ask_agent fiables cross-project — tours headless épinglés au project root (--dir) + retry frais si une reprise de session perd le Final — #255 (QA verte)
This commit is contained in:
6
.ideai/tickets/255/carnet.md
Normal file
6
.ideai/tickets/255/carnet.md
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
issueRef: "#255"
|
||||||
|
version: 3
|
||||||
|
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
|
||||||
|
updatedAt: 1787569806822
|
||||||
|
---
|
||||||
17
.ideai/tickets/255/issue.md
Normal file
17
.ideai/tickets/255/issue.md
Normal file
@ -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.
|
||||||
@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"nextNumber": 255
|
"nextNumber": 256
|
||||||
}
|
}
|
||||||
@ -3425,7 +3425,7 @@
|
|||||||
"issueRef": "#254",
|
"issueRef": "#254",
|
||||||
"path": "254",
|
"path": "254",
|
||||||
"title": "Stabiliser les rendez-vous inter-agents OpenCode/GLM et éliminer les faux NoReply/perms sur projets externes",
|
"title": "Stabiliser les rendez-vous inter-agents OpenCode/GLM et éliminer les faux NoReply/perms sur projets externes",
|
||||||
"status": "inProgress",
|
"status": "qa",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"sprint": null,
|
"sprint": null,
|
||||||
"assignedAgentIds": [],
|
"assignedAgentIds": [],
|
||||||
@ -3433,7 +3433,21 @@
|
|||||||
"kind": "agent",
|
"kind": "agent",
|
||||||
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user