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:
@ -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 ».
|
||||
|
||||
Reference in New Issue
Block a user