fix(backend): lecture du texte JSONL OpenCode niché sous part.text
Le parseur lisait value.text/value.content à la racine alors que `opencode run --format json` niche le texte réel sous value.part.text (cf. packages/opencode/src/cli/cmd/run.ts). Conséquence : idea_ask_agent vers un profil OpenCode échouait systématiquement avec « OpenCode n'a produit aucun final textuel » malgré une réponse CLI normale. Ajoute aussi la gestion des événements type "error" (ParsedEvent::Error, ReplyEvent::Error) pour distinguer un échec explicite d'un final vide, et des tests de conformité sur le format JSONL réel confirmé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -26,10 +26,15 @@ pub enum ParsedEvent {
|
|||||||
Text(String),
|
Text(String),
|
||||||
/// Fin d'étape OpenCode.
|
/// Fin d'étape OpenCode.
|
||||||
StepFinish,
|
StepFinish,
|
||||||
|
/// Erreur terminale remontée par OpenCode.
|
||||||
|
Error(String),
|
||||||
/// Evénement JSON valide mais hors contrat minimal.
|
/// Evénement JSON valide mais hors contrat minimal.
|
||||||
Ignored,
|
Ignored,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Message d'erreur par défaut quand `error` n'est ni une chaîne ni un objet exploitable.
|
||||||
|
const OPENCODE_ERROR_FALLBACK: &str = "OpenCode a signalé une erreur sans détail exploitable";
|
||||||
|
|
||||||
/// Parse une ligne JSONL OpenCode.
|
/// Parse une ligne JSONL OpenCode.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -44,18 +49,37 @@ pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
|
|||||||
match value.get("type").and_then(Value::as_str) {
|
match value.get("type").and_then(Value::as_str) {
|
||||||
Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart),
|
Some("step_start") | Some("step.start") => Ok(ParsedEvent::StepStart),
|
||||||
Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish),
|
Some("step_finish") | Some("step.finish") => Ok(ParsedEvent::StepFinish),
|
||||||
|
// 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.
|
||||||
Some("text") => Ok(ParsedEvent::Text(
|
Some("text") => Ok(ParsedEvent::Text(
|
||||||
value
|
value
|
||||||
.get("text")
|
.get("part")
|
||||||
.or_else(|| value.get("content"))
|
.and_then(|part| part.get("text"))
|
||||||
.and_then(Value::as_str)
|
.and_then(Value::as_str)
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_owned(),
|
.to_owned(),
|
||||||
)),
|
)),
|
||||||
|
Some("error") => Ok(ParsedEvent::Error(opencode_error_message(&value))),
|
||||||
_ => Ok(ParsedEvent::Ignored),
|
_ => Ok(ParsedEvent::Ignored),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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).
|
||||||
|
fn opencode_error_message(value: &Value) -> String {
|
||||||
|
let error = value.get("error");
|
||||||
|
error
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
.or_else(|| {
|
||||||
|
error
|
||||||
|
.and_then(|e| e.get("message"))
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| OPENCODE_ERROR_FALLBACK.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 stdout JSONL
|
/// Le `Final` est la concaténation ordonnée des événements `text`. Un stdout JSONL
|
||||||
@ -64,6 +88,7 @@ pub fn parse_jsonl_event(line: &str) -> Result<ParsedEvent, AgentSessionError> {
|
|||||||
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessionError> {
|
||||||
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;
|
||||||
for line in lines {
|
for line in lines {
|
||||||
match parse_jsonl_event(line)? {
|
match parse_jsonl_event(line)? {
|
||||||
ParsedEvent::StepStart | ParsedEvent::StepFinish => {
|
ParsedEvent::StepStart | ParsedEvent::StepFinish => {
|
||||||
@ -75,10 +100,17 @@ pub fn parse_jsonl_turn(lines: &[String]) -> Result<Vec<ReplyEvent>, AgentSessio
|
|||||||
events.push(ReplyEvent::TextDelta { text });
|
events.push(ReplyEvent::TextDelta { text });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ParsedEvent::Error(message) => {
|
||||||
|
error_seen = true;
|
||||||
|
events.push(ReplyEvent::Error { message });
|
||||||
|
}
|
||||||
ParsedEvent::Ignored => {}
|
ParsedEvent::Ignored => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if final_text.trim().is_empty() {
|
if final_text.trim().is_empty() {
|
||||||
|
if error_seen {
|
||||||
|
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".to_owned(),
|
||||||
));
|
));
|
||||||
@ -278,8 +310,8 @@ mod tests {
|
|||||||
fn parse_jsonl_turn_concatenates_text_and_adds_final() {
|
fn parse_jsonl_turn_concatenates_text_and_adds_final() {
|
||||||
let events = parse_jsonl_turn(&[
|
let events = parse_jsonl_turn(&[
|
||||||
r#"{"type":"step_start"}"#.to_owned(),
|
r#"{"type":"step_start"}"#.to_owned(),
|
||||||
r#"{"type":"text","text":"hel"}"#.to_owned(),
|
r#"{"type":"text","part":{"text":"hel"}}"#.to_owned(),
|
||||||
r#"{"type":"text","text":"lo"}"#.to_owned(),
|
r#"{"type":"text","part":{"text":"lo"}}"#.to_owned(),
|
||||||
r#"{"type":"step_finish"}"#.to_owned(),
|
r#"{"type":"step_finish"}"#.to_owned(),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -312,4 +344,60 @@ mod tests {
|
|||||||
let err = parse_jsonl_event("{nope").unwrap_err();
|
let err = parse_jsonl_event("{nope").unwrap_err();
|
||||||
assert!(matches!(err, AgentSessionError::Decode(_)));
|
assert!(matches!(err, AgentSessionError::Decode(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Format JSONL réel de `opencode run --format json`, confirmé par lecture du
|
||||||
|
/// source `packages/opencode/src/cli/cmd/run.ts` : le texte est niché sous
|
||||||
|
/// `.part.text`, pas à la racine.
|
||||||
|
fn opencode_script() -> Vec<&'static str> {
|
||||||
|
vec![
|
||||||
|
r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#,
|
||||||
|
r#"{"type":"text","timestamp":2,"sessionID":"s1","part":{"id":"p1","type":"text","text":"réponse réelle","time":{"start":1,"end":2}}}"#,
|
||||||
|
r#"{"type":"step_finish","timestamp":3,"sessionID":"s1"}"#,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_jsonl_turn_reads_text_nested_under_part() {
|
||||||
|
let lines: Vec<String> = opencode_script().into_iter().map(str::to_owned).collect();
|
||||||
|
let events = parse_jsonl_turn(&lines).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
ReplyEvent::Heartbeat,
|
||||||
|
ReplyEvent::TextDelta {
|
||||||
|
text: "réponse réelle".to_owned()
|
||||||
|
},
|
||||||
|
ReplyEvent::Heartbeat,
|
||||||
|
ReplyEvent::Final {
|
||||||
|
content: "réponse réelle".to_owned()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_jsonl_turn_emits_error_event() {
|
||||||
|
let events = parse_jsonl_turn(&[
|
||||||
|
r#"{"type":"step_start","timestamp":1,"sessionID":"s1"}"#.to_owned(),
|
||||||
|
r#"{"type":"error","timestamp":2,"sessionID":"s1","error":{"message":"quelque chose a échoué"}}"#
|
||||||
|
.to_owned(),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![
|
||||||
|
ReplyEvent::Heartbeat,
|
||||||
|
ReplyEvent::Error {
|
||||||
|
message: "quelque chose a échoué".to_owned()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_jsonl_event_error_accepts_raw_string() {
|
||||||
|
let event =
|
||||||
|
parse_jsonl_event(r#"{"type":"error","error":"panne réseau"}"#).unwrap();
|
||||||
|
assert_eq!(event, ParsedEvent::Error("panne réseau".to_owned()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user