feat(inter-agent): backend des annonces live d'agent (B0-B3)
Redémarre le ticket #4 sur la base develop. Émission d'annonces live d'un agent vers l'UI, distinctes du Final de délégation : - domain: ReplyEvent::Announcement/Final et DomainEvent::AgentAnnouncement (events.rs), port d'émission (ports.rs), gating de readiness (readiness.rs). - application: mapping des événements structurés en annonces (agent/structured.rs, agent/mod.rs, lib.rs) et relais côté orchestrateur (orchestrator/service.rs). - infrastructure/session: parse des annonces + fix du Final pour Claude et Codex, propagé aux adaptateurs et à la conformance (claude.rs, codex.rs, conformance.rs, mod.rs, process.rs, sandbox_e2e.rs). - app-tauri: relais Tauri des annonces vers le front (events.rs, chat.rs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -90,7 +90,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn run_turn_drains_every_line_in_order() {
|
||||
let fake = FakeCli::printing(&["ligne-1", "ligne-2", "ligne-3"]);
|
||||
let lines = run_turn(&fake.spawn_line(), None, None)
|
||||
let lines = run_turn(&fake.spawn_line(), None, None, None)
|
||||
.await
|
||||
.expect("run_turn réussit");
|
||||
assert_eq!(lines, vec!["ligne-1", "ligne-2", "ligne-3"]);
|
||||
@ -106,7 +106,9 @@ mod tests {
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
};
|
||||
let err = run_turn(&spec, None, None).await.expect_err("doit échouer");
|
||||
let err = run_turn(&spec, None, None, None)
|
||||
.await
|
||||
.expect_err("doit échouer");
|
||||
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
@ -595,7 +597,7 @@ mod tests {
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
};
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)), None, None)
|
||||
.await
|
||||
.expect_err("doit expirer");
|
||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
||||
@ -858,6 +860,129 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_two_agent_messages_preserve_first_as_announcement_and_last_as_final() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"thread.started","thread_id":"c"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"je regarde"}}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"résultat"}}"#,
|
||||
]);
|
||||
let s = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
ReplyEvent::Announcement {
|
||||
text: "je regarde".into()
|
||||
},
|
||||
ReplyEvent::Final {
|
||||
content: "résultat".into()
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_single_agent_message_is_final_without_announcement() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"thread.started","thread_id":"c"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"résultat"}}"#,
|
||||
]);
|
||||
let s = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![ReplyEvent::Final {
|
||||
content: "résultat".into()
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_zero_agent_message_has_no_final() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"thread.started","thread_id":"c"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"reasoning","text":"analyse"}}"#,
|
||||
]);
|
||||
let s = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let events: Vec<_> = s.send("x").await.expect("send").collect();
|
||||
assert!(
|
||||
events
|
||||
.iter()
|
||||
.all(|e| !matches!(e, ReplyEvent::Final { .. })),
|
||||
"aucun agent_message => aucun Final"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn codex_send_with_tap_emits_each_agent_message_live_as_announcement() {
|
||||
let fake = FakeCli::printing(&[
|
||||
r#"{"type":"thread.started","thread_id":"c"}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i0","type":"agent_message","text":"je regarde"}}"#,
|
||||
r#"{"type":"item.completed","item":{"id":"i1","type":"agent_message","text":"résultat"}}"#,
|
||||
]);
|
||||
let s = CodexExecSession::new(
|
||||
SessionId::new_random(),
|
||||
fake.command(),
|
||||
"/",
|
||||
None,
|
||||
Vec::new(),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
let events: Vec<_> = s.send_with_tap("x", tx).await.expect("send").collect();
|
||||
let live: Vec<_> = rx.into_iter().collect();
|
||||
|
||||
assert_eq!(
|
||||
live,
|
||||
vec![
|
||||
ReplyEvent::Announcement {
|
||||
text: "je regarde".into()
|
||||
},
|
||||
ReplyEvent::Announcement {
|
||||
text: "résultat".into()
|
||||
},
|
||||
],
|
||||
"le tap live publie chaque agent_message, y compris celui qui deviendra Final"
|
||||
);
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![ReplyEvent::Final {
|
||||
content: "résultat".into()
|
||||
}],
|
||||
"le flux final du chemin tap garde seulement le dernier Final pour le demandeur"
|
||||
);
|
||||
}
|
||||
|
||||
/// LIMITE/ÉCART (à arbitrer) : un flux SANS `Final` ne provoque PAS d'erreur au
|
||||
/// niveau de l'adapter — `send()` renvoie Ok avec uniquement des deltas et AUCUN
|
||||
/// `Final`. Le §17.9 D2 mentionne « flux sans Final ⇒ Io » ; l'adapter actuel ne
|
||||
@ -892,7 +1017,9 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn run_turn_empty_output_is_ok() {
|
||||
let fake = FakeCli::printing(&[]);
|
||||
let lines = run_turn(&fake.spawn_line(), None, None).await.expect("ok");
|
||||
let lines = run_turn(&fake.spawn_line(), None, None, None)
|
||||
.await
|
||||
.expect("ok");
|
||||
assert!(lines.is_empty());
|
||||
}
|
||||
|
||||
@ -902,7 +1029,7 @@ mod tests {
|
||||
let fake = FakeCli::printing(&["pong"]);
|
||||
let mut spec = fake.spawn_line();
|
||||
spec.stdin = Some("ping".to_owned());
|
||||
let lines = run_turn(&spec, None, None).await.expect("ok");
|
||||
let lines = run_turn(&spec, None, None, None).await.expect("ok");
|
||||
assert_eq!(lines, vec!["pong"]);
|
||||
}
|
||||
|
||||
@ -965,7 +1092,7 @@ mod tests {
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
};
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)), None)
|
||||
let err = run_turn(&spec, Some(Duration::from_millis(50)), None, None)
|
||||
.await
|
||||
.expect_err("doit expirer");
|
||||
assert!(matches!(err, AgentSessionError::Timeout), "vu: {err:?}");
|
||||
|
||||
Reference in New Issue
Block a user