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

@ -12,6 +12,7 @@
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use domain::conversation::ConversationParty;
use domain::events::{DomainEvent, OrchestrationSource};
use domain::input::AgentLiveness;
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
@ -86,6 +87,22 @@ pub enum DomainEventDto {
/// Reply length in bytes (metric, not the payload).
reply_len: usize,
},
/// Intermediate assistant announcement emitted live during an inter-agent turn.
#[serde(rename_all = "camelCase")]
AgentAnnouncement {
/// Project id.
project_id: String,
/// Requester party (`"user"` or requester agent id).
requester: String,
/// Target agent id.
target: String,
/// FIFO ticket id.
ticket_id: String,
/// Announcement text.
text: String,
/// Wall-clock timestamp in epoch milliseconds.
at_ms: u64,
},
/// An agent's liveness (alive/stalled) changed (lot 2, readiness/heartbeat). The
/// frontend can badge a frozen agent; `"stalled"` while no proof of liveness arrived
/// for longer than the profile's `stallAfterMs`, back to `"alive"` on a late
@ -464,6 +481,13 @@ fn issue_link_kind_wire(kind: IssueLinkKind) -> &'static str {
}
}
fn conversation_party_wire(party: ConversationParty) -> String {
match party {
ConversationParty::User => "user".to_owned(),
ConversationParty::Agent { agent_id } => agent_id.to_string(),
}
}
impl From<&DomainEvent> for DomainEventDto {
fn from(e: &DomainEvent) -> Self {
match e {
@ -505,6 +529,21 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(),
reply_len: *reply_len,
},
DomainEvent::AgentAnnouncement {
project_id,
requester,
target,
ticket,
text,
at_ms,
} => Self::AgentAnnouncement {
project_id: project_id.to_string(),
requester: conversation_party_wire(*requester),
target: target.to_string(),
ticket_id: ticket.to_string(),
text: text.clone(),
at_ms: *at_ms,
},
DomainEvent::AgentLivenessChanged { agent_id, liveness } => {
Self::AgentLivenessChanged {
agent_id: agent_id.to_string(),
@ -843,6 +882,9 @@ pub fn spawn_relay(app: AppHandle, bus: &TokioBroadcastEventBus) {
mod tests {
use super::*;
use domain::ids::AgentId;
use domain::mailbox::TicketId;
use domain::ProjectId;
use serde_json::json;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
@ -875,6 +917,56 @@ mod tests {
assert_eq!(json["liveness"], "alive");
}
#[test]
fn agent_announcement_relays_to_camel_case_wire_with_requester_party() {
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
let requester_agent = agent(2);
let target = agent(3);
let ticket = TicketId::from_uuid(uuid::Uuid::from_u128(4));
let human = DomainEventDto::from(&DomainEvent::AgentAnnouncement {
project_id,
requester: ConversationParty::User,
target,
ticket,
text: "statut humain".into(),
at_ms: 123_456,
});
assert_eq!(
serde_json::to_value(&human).unwrap(),
json!({
"type": "agentAnnouncement",
"projectId": project_id.to_string(),
"requester": "user",
"target": target.to_string(),
"ticketId": ticket.to_string(),
"text": "statut humain",
"atMs": 123456,
})
);
let agent_requester = DomainEventDto::from(&DomainEvent::AgentAnnouncement {
project_id,
requester: ConversationParty::agent(requester_agent),
target,
ticket,
text: "statut agent".into(),
at_ms: 654_321,
});
assert_eq!(
serde_json::to_value(&agent_requester).unwrap(),
json!({
"type": "agentAnnouncement",
"projectId": project_id.to_string(),
"requester": requester_agent.to_string(),
"target": target.to_string(),
"ticketId": ticket.to_string(),
"text": "statut agent",
"atMs": 654321,
})
);
}
/// LS6 : un `AgentRateLimited` du domaine se relaie en DTO portant le même agent
/// et l'heure de reset (époche-ms), et se sérialise en `"agentRateLimited"` avec
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».