merge(orchestrator): intègre feature/glm-opencode-delegation-fix — fix délégation GLM/OpenCode (QA verte)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 09:50:06 +02:00
3 changed files with 301 additions and 58 deletions

View File

@ -79,12 +79,17 @@ fn submit_config_for_profile(profile: &AgentProfile) -> SubmitConfig {
} }
fn structured_no_reply_error(err: &domain::ports::AgentSessionError) -> bool { fn structured_no_reply_error(err: &domain::ports::AgentSessionError) -> bool {
matches!( match err {
err,
domain::ports::AgentSessionError::Io(message) domain::ports::AgentSessionError::Io(message)
if message.contains("sans événement Final") | domain::ports::AgentSessionError::Decode(message) => {
message.contains("sans événement Final")
|| message.contains("without a structured final") || message.contains("without a structured final")
) || message.contains("aucun final textuel exploitable")
}
domain::ports::AgentSessionError::Start(_) | domain::ports::AgentSessionError::Timeout => {
false
}
}
} }
fn bound_task_text(value: &str, max_bytes: usize) -> String { fn bound_task_text(value: &str, max_bytes: usize) -> String {

View File

@ -1170,6 +1170,7 @@ impl TestMailbox {
enum TestCompletion { enum TestCompletion {
Replied(String), Replied(String),
NoReply, NoReply,
DecodeNoFinal,
Cancelled, Cancelled,
} }
@ -1467,6 +1468,9 @@ impl AgentSession for CompletionSession {
TestCompletion::NoReply => Err(AgentSessionError::Io( TestCompletion::NoReply => Err(AgentSessionError::Io(
"target returned without a structured final".to_owned(), "target returned without a structured final".to_owned(),
)), )),
TestCompletion::DecodeNoFinal => Err(AgentSessionError::Decode(
"OpenCode n'a produit aucun final textuel exploitable".to_owned(),
)),
TestCompletion::Cancelled => Err(AgentSessionError::Io( TestCompletion::Cancelled => Err(AgentSessionError::Io(
"structured test turn cancelled".to_owned(), "structured test turn cancelled".to_owned(),
)), )),
@ -2134,6 +2138,28 @@ async fn ask_target_returns_to_prompt_without_reply_is_a_typed_error() {
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "head retired by completion"); assert_eq!(fx.mailbox.pending(&aid(1)), 0, "head retired by completion");
} }
#[tokio::test]
async fn structured_opencode_decode_without_final_is_a_typed_no_reply_error() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.mailbox
.completions()
.push(aid(1), TestCompletion::DecodeNoFinal);
let err = timeout(TEST_GUARD, ask)
.await
.expect("ask completes promptly, not after the long timeout")
.expect("join ok")
.expect_err("OpenCode no-final decode is a typed no-reply error");
assert_eq!(err.code(), "TARGET_RETURNED_NO_REPLY", "got {err:?}");
}
/// An `idea_reply` that lands **first** still wins over a (later) no-reply completion: /// An `idea_reply` that lands **first** still wins over a (later) no-reply completion:
/// the completion then finds the head gone and is a no-op (idempotent race). /// the completion then finds the head gone and is a no-op (idempotent race).
#[tokio::test] #[tokio::test]

View File

@ -3,7 +3,8 @@
//! OpenCode est piloté comme host process local : IdeA génère un `opencode.json` //! OpenCode est piloté comme host process local : IdeA génère un `opencode.json`
//! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est //! isolé dans le run dir, OpenCode lance le bridge MCP `idea`, puis chaque tour est
//! un `opencode run --format json <prompt>`. L'adapter ne connaît que le contrat //! un `opencode run --format json <prompt>`. L'adapter ne connaît que le contrat
//! JSONL minimal observé/cadré : `step_start`, `text`, `step_finish`. //! JSONL observé/cadré : `step_start`, `text(part.text)`, `tool_use`,
//! `step_finish`, `error`.
use std::process::Stdio; use std::process::Stdio;
use std::sync::Arc; use std::sync::Arc;
@ -25,6 +26,8 @@ pub enum ParsedEvent {
StepStart, StepStart,
/// Fragment texte assistant. /// Fragment texte assistant.
Text(String), Text(String),
/// Activité outil OpenCode.
ToolActivity(String),
/// Fin d'étape OpenCode. /// Fin d'étape OpenCode.
StepFinish, StepFinish,
/// Erreur terminale remontée par OpenCode. /// Erreur terminale remontée par OpenCode.
@ -41,14 +44,13 @@ pub fn extract_session_id(line: &str) -> Result<Option<String>, AgentSessionErro
} }
let value: Value = serde_json::from_str(trimmed) let value: Value = serde_json::from_str(trimmed)
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?; .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
Ok(value Ok(
.get("sessionID") json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| {
.or_else(|| value.get("session_id")) value
.or_else(|| value.get("sessionId")) .get("part")
.and_then(Value::as_str) .and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"]))
.map(str::trim) }),
.filter(|id| !id.is_empty()) )
.map(str::to_owned))
} }
/// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable. /// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable.
@ -59,34 +61,77 @@ const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans déta
/// # Errors /// # Errors
/// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide. /// [`AgentSessionError::Decode`] si la ligne non vide n'est pas du JSON valide.
pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> { pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
parse_jsonl_record(line).map(|record| record.event)
}
#[derive(Debug, Clone)]
struct ParsedRecord {
event: ParsedEvent,
session_id: Option<String>,
message_id: Option<String>,
}
fn parse_jsonl_record(line: &str) -> Result<ParsedRecord, AgentSessionError> {
let trimmed = line.trim(); let trimmed = line.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Ok(ParsedEvent::Ignored); return Ok(ParsedRecord {
event: ParsedEvent::Ignored,
session_id: None,
message_id: None,
});
} }
let value: Value = serde_json::from_str(trimmed) let value: Value = serde_json::from_str(trimmed)
.map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?; .map_err(|e| AgentSessionError::Decode(format!("ligne JSON OpenCode illisible: {e}")))?;
match value.get("type").and_then(Value::as_str) { let part = value.get("part");
Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart), let session_id =
Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish), json_string_field(&value, &["sessionID", "session_id", "sessionId"]).or_else(|| {
part.and_then(|part| json_string_field(part, &["sessionID", "session_id", "sessionId"]))
});
let message_id =
json_string_field(&value, &["messageID", "message_id", "messageId"]).or_else(|| {
part.and_then(|part| json_string_field(part, &["messageID", "message_id", "messageId"]))
});
let event = match value.get("type").and_then(Value::as_str).or_else(|| {
part.and_then(|part| part.get("type"))
.and_then(Value::as_str)
}) {
Some("step_start" | "step.start" | "step-start") => ParsedEvent::StepStart,
Some("step_finish" | "step.finish" | "step-finish") => ParsedEvent::StepFinish,
// Le texte réel est niché sous `.part.text` (cf. `packages/opencode/src/cli/cmd/run.ts`) ; // Le texte réel est niché sous `.part.text` (cf. `packages/opencode/src/cli/cmd/run.ts`) ;
// un seul événement `text` par part, déjà finalisé — pas de delta à dédupliquer. // un seul événement `text` par part, déjà finalisé — pas de delta à dédupliquer.
Some("text") => Ok(ParsedEvent::Text( Some("text") => part
value .and_then(|part| part.get("text"))
.get("part") .or_else(|| value.get("text"))
.and_then(|part| part.get("text")) .or_else(|| value.get("content"))
.and_then(Value::as_str) .and_then(Value::as_str)
.unwrap_or_default() .map(|text| ParsedEvent::Text(text.to_owned()))
.to_owned(), .unwrap_or(ParsedEvent::Ignored),
)), Some("tool_use" | "tool.use" | "tool") => ParsedEvent::ToolActivity(tool_label(&value)),
Some("error") => Ok(ParsedEvent::Error(opencode_error_message(&value))), Some("error" | "session.error") => ParsedEvent::Error(opencode_error_message(&value)),
_ => Ok(ParsedEvent::Ignored), _ => ParsedEvent::Ignored,
} };
Ok(ParsedRecord {
event,
session_id,
message_id,
})
}
fn json_string_field(value: &Value, names: &[&str]) -> Option<String> {
names
.iter()
.find_map(|name| value.get(*name).and_then(Value::as_str))
.map(str::trim)
.filter(|id| !id.is_empty())
.map(str::to_owned)
} }
/// Extrait un message d'erreur exploitable depuis `value.error`, qu'il s'agisse d'une /// Extrait un message d'erreur exploitable depuis `value.error`, qu'il s'agisse d'une
/// chaîne brute ou d'un objet `{message: ...}` (forme exacte non confirmée empiriquement). /// chaîne brute ou d'un objet `{message: ...}` (forme exacte non confirmée empiriquement).
fn opencode_error_message(value: &Value) -> String { fn opencode_error_message(value: &Value) -> String {
let error = value.get("error"); let error = value
.get("error")
.or_else(|| value.get("properties").and_then(|props| props.get("error")));
error error
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::to_owned) .map(str::to_owned)
@ -96,29 +141,59 @@ fn opencode_error_message(value: &Value) -> String {
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::to_owned) .map(str::to_owned)
}) })
.or_else(|| {
error
.and_then(|e| e.get("data"))
.and_then(|data| data.get("message"))
.and_then(Value::as_str)
.map(str::to_owned)
})
.or_else(|| {
error
.and_then(|e| e.get("name"))
.and_then(Value::as_str)
.map(str::to_owned)
})
.unwrap_or_else(|| OPENCODE_ERROR_FALLBACK.to_owned()) .unwrap_or_else(|| OPENCODE_ERROR_FALLBACK.to_owned())
} }
fn tool_label(value: &Value) -> String {
let part = value.get("part").unwrap_or(value);
json_string_field(part, &["tool", "name"])
.or_else(|| json_string_field(value, &["tool", "name"]))
.unwrap_or_else(|| "outil OpenCode".to_owned())
}
/// Convertit des lignes JSONL OpenCode en événements domaine. /// Convertit des lignes JSONL OpenCode en événements domaine.
/// ///
/// Le `Final` est la concaténation ordonnée des événements `text`. Un tour sans /// Le `Final` est la concaténation ordonnée des événements `text` du tour courant.
/// texte mais terminé proprement (`step_finish`) émet un `Final` vide : cela /// Si OpenCode rejoue des événements d'une même session, les identifiants de
/// couvre les tours tool-call-only tout en gardant un hard-fail pour un vrai flux /// message (`messageID`/`messageId`) servent à conserver seulement le dernier
/// vide, uniquement ignoré, ou seulement démarré. /// message assistant observable. Un tour terminé sans texte exploitable est une
/// erreur de décodage, pas un succès vide.
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> { pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
parse_jsonl_turn_scoped(lines, None)
}
fn parse_jsonl_turn_scoped(
lines: &[String],
expected_session_id: Option<&str>,
) -> Result<Vec<ReplyEvent>, AgentSessionError> {
let records = current_turn_records(lines, expected_session_id)?;
let mut events = Vec::new(); let mut events = Vec::new();
let mut final_text = String::new(); let mut final_text = String::new();
let mut error_seen = false; let mut error_seen = false;
let mut step_finish_seen = false; for record in records {
for line in lines { match record.event {
match parse_jsonl_event(line)? {
ParsedEvent::StepStart => { ParsedEvent::StepStart => {
events.push(ReplyEvent::Heartbeat); events.push(ReplyEvent::Heartbeat);
} }
ParsedEvent::StepFinish => { ParsedEvent::StepFinish => {
step_finish_seen = true;
events.push(ReplyEvent::Heartbeat); events.push(ReplyEvent::Heartbeat);
} }
ParsedEvent::ToolActivity(label) => {
events.push(ReplyEvent::ToolActivity { label });
}
ParsedEvent::Text(text) => { ParsedEvent::Text(text) => {
if !text.is_empty() { if !text.is_empty() {
final_text.push_str(&text); final_text.push_str(&text);
@ -136,14 +211,8 @@ pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessio
if error_seen { if error_seen {
return Ok(events); return Ok(events);
} }
if step_finish_seen {
events.push(ReplyEvent::Final {
content: String::new(),
});
return Ok(events);
}
return Err(AgentSessionError::Decode( return Err(AgentSessionError::Decode(
"OpenCode n'a produit aucun final textuel".to_owned(), "OpenCode n'a produit aucun final textuel exploitable".to_owned(),
)); ));
} }
events.push(ReplyEvent::Final { events.push(ReplyEvent::Final {
@ -152,6 +221,49 @@ pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessio
Ok(events) Ok(events)
} }
fn current_turn_records(
lines: &[String],
expected_session_id: Option<&str>,
) -> Result<Vec<ParsedRecord>, AgentSessionError> {
let mut records = Vec::new();
for line in lines {
let record = parse_jsonl_record(line)?;
if let (Some(expected), Some(actual)) = (expected_session_id, record.session_id.as_deref())
{
if actual != expected {
continue;
}
}
if !matches!(record.event, ParsedEvent::Ignored) {
records.push(record);
}
}
let Some(current_message_id) = records.iter().rev().find_map(|record| {
matches!(
record.event,
ParsedEvent::Text(_)
| ParsedEvent::ToolActivity(_)
| ParsedEvent::StepStart
| ParsedEvent::StepFinish
| ParsedEvent::Error(_)
)
.then(|| record.message_id.as_deref())
.flatten()
.map(str::to_owned)
}) else {
return Ok(records);
};
Ok(records
.into_iter()
.filter(|record| {
record
.message_id
.as_deref()
.is_none_or(|message_id| message_id == current_message_id.as_str())
})
.collect())
}
fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool { fn has_structured_terminal_event(events: &[ReplyEvent]) -> bool {
events events
.iter() .iter()
@ -301,6 +413,7 @@ impl AgentSession for OpenCodeSession {
.take() .take()
.ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?; .ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?;
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();
while let Some(line) = lines while let Some(line) = lines
@ -309,7 +422,12 @@ impl AgentSession for OpenCodeSession {
.map_err(|e| AgentSessionError::Io(e.to_string()))? .map_err(|e| AgentSessionError::Io(e.to_string()))?
{ {
if let Some(engine_id) = extract_session_id(&line)? { if let Some(engine_id) = extract_session_id(&line)? {
self.capture_session_id(engine_id); if expected_session
.as_deref()
.is_none_or(|expected| expected == engine_id)
{
self.capture_session_id(engine_id);
}
} }
collected.push(line); collected.push(line);
} }
@ -324,7 +442,8 @@ impl AgentSession for OpenCodeSession {
.await .await
.map_err(|e| AgentSessionError::Io(e.to_string()))?; .map_err(|e| AgentSessionError::Io(e.to_string()))?;
let stderr = String::from_utf8_lossy(&stderr_bytes); let stderr = String::from_utf8_lossy(&stderr_bytes);
let parsed = parse_jsonl_turn(&collected); let parsed_session = expected_session.or_else(|| self.conversation_id());
let parsed = parse_jsonl_turn_scoped(&collected, parsed_session.as_deref());
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) {
@ -410,21 +529,15 @@ mod tests {
} }
#[test] #[test]
fn parse_jsonl_turn_accepts_tool_only_finished_turn() { fn parse_jsonl_turn_rejects_tool_only_finished_turn() {
let events = parse_jsonl_turn(&[ let err = parse_jsonl_turn(&[
r#"{"type":"step_start"}"#.to_owned(), r#"{"type":"step_start"}"#.to_owned(),
r#"{"type":"step_finish"}"#.to_owned(), r#"{"type":"step_finish"}"#.to_owned(),
]) ])
.unwrap(); .unwrap_err();
assert_eq!( assert!(
events, matches!(err, AgentSessionError::Decode(ref message) if message.contains("aucun final textuel exploitable")),
vec![ "tool-only turn must be actionable, not a fake empty success: {err:?}"
ReplyEvent::Heartbeat,
ReplyEvent::Heartbeat,
ReplyEvent::Final {
content: String::new()
}
]
); );
} }
@ -498,6 +611,64 @@ mod tests {
assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned())); assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
} }
#[test]
fn parse_jsonl_event_accepts_tool_use() {
let event = parse_jsonl_event(r#"{"type":"tool_use","part":{"tool":"bash"}}"#).unwrap();
assert_eq!(event, ParsedEvent::ToolActivity("bash".to_owned()));
}
#[test]
fn parse_jsonl_turn_keeps_latest_message_when_opencode_replays_history() {
let events = parse_jsonl_turn(&[
r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"old","type":"step-start"}}"#
.to_owned(),
r#"{"type":"text","sessionID":"s1","part":{"messageID":"old","type":"text","text":"ancienne réponse"}}"#
.to_owned(),
r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"old","type":"step-finish"}}"#
.to_owned(),
r#"{"type":"step_start","sessionID":"s1","part":{"messageID":"new","type":"step-start"}}"#
.to_owned(),
r#"{"type":"text","sessionID":"s1","part":{"messageID":"new","type":"text","text":"réponse courante"}}"#
.to_owned(),
r#"{"type":"step_finish","sessionID":"s1","part":{"messageID":"new","type":"step-finish"}}"#
.to_owned(),
])
.unwrap();
assert_eq!(
events.last(),
Some(&ReplyEvent::Final {
content: "réponse courante".to_owned()
})
);
assert!(
!events.iter().any(|event| matches!(
event,
ReplyEvent::TextDelta { text } if text.contains("ancienne")
)),
"replayed text from a previous message must not pollute the current final: {events:?}"
);
}
#[test]
fn parse_jsonl_turn_scoped_ignores_other_sessions_when_known() {
let events = parse_jsonl_turn_scoped(
&[
r#"{"type":"text","sessionID":"other","part":{"messageID":"m1","text":"pollution"}}"#
.to_owned(),
r#"{"type":"text","sessionID":"current","part":{"messageID":"m2","text":"ok"}}"#
.to_owned(),
],
Some("current"),
)
.unwrap();
assert_eq!(
events.last(),
Some(&ReplyEvent::Final {
content: "ok".to_owned()
})
);
}
#[cfg(unix)] #[cfg(unix)]
struct TempDir(PathBuf); struct TempDir(PathBuf);
@ -674,4 +845,45 @@ 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 seeded_session_ignores_and_does_not_capture_other_session_events() {
let tmp = TempDir::new("ignore-other-session");
let script = tmp.path().join("opencode-fixture.sh");
fs::write(
&script,
r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"m1"}}'
printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"m1","text":"réponse seed"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"m1"}}'
printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2","text":"pollution"}}'
"#,
)
.unwrap();
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new(
SessionId::new_random(),
script.to_string_lossy(),
Vec::new(),
tmp.path().to_string_lossy(),
Some("seeded-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 seed".to_owned()
})
);
assert_eq!(session.conversation_id().as_deref(), Some("seeded-engine"));
}
} }