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:
2026-07-04 19:50:03 +02:00
parent ebd992e41a
commit aa5a4f30ae
15 changed files with 605 additions and 52 deletions

View File

@ -214,8 +214,51 @@ impl AgentSession for CodexExecSession {
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.send_inner(prompt, None, true).await
}
async fn send_with_tap(
&self,
prompt: &str,
tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
self.send_inner(prompt, Some(tap), false).await
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
// « un run par tour » ⇒ pas de process long survivant : idempotent, no-op.
Ok(())
}
}
impl CodexExecSession {
async fn send_inner(
&self,
prompt: &str,
tap: Option<std::sync::mpsc::Sender<ReplyEvent>>,
include_stream_announcements: bool,
) -> Result<ReplyStream, AgentSessionError> {
let spec = self.build_spawn_line(prompt);
let raw_lines = run_turn(&spec, None, self.sandbox_enforcer.as_ref()).await?;
let (line_tap, parser_thread) = tap.map_or((None, None), |event_tap| {
let (line_tx, line_rx) = std::sync::mpsc::channel::<String>();
let parser = std::thread::spawn(move || {
for line in line_rx {
if let Ok(parsed) = parse_event(&line) {
for event in parsed.events {
if let ReplyEvent::Final { content } = event {
let _ = event_tap.send(ReplyEvent::Announcement { text: content });
}
}
}
}
});
(Some(line_tx), Some(parser))
});
let raw_result = run_turn(&spec, None, self.sandbox_enforcer.as_ref(), line_tap).await;
if let Some(parser) = parser_thread {
let _ = parser.join();
}
let raw_lines = raw_result?;
let mut events = Vec::new();
let mut captured_id = None;
@ -236,11 +279,13 @@ impl AgentSession for CodexExecSession {
match event {
ReplyEvent::Final { content } => {
// Un `agent_message` précédemment retenu est supersédé : il devient
// une activité non terminale, on garde le plus récent en conclusion.
// une annonce non terminale, on garde le plus récent en conclusion.
if last_final.is_some() {
events.push(ReplyEvent::ToolActivity {
label: "agent_message".to_owned(),
});
if include_stream_announcements {
events.push(ReplyEvent::Announcement {
text: last_final.take().expect("présent"),
});
}
}
last_final = Some(content);
}
@ -259,9 +304,4 @@ impl AgentSession for CodexExecSession {
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
// « un run par tour » ⇒ pas de process long survivant : idempotent, no-op.
Ok(())
}
}