From 038e90ecece118be59489c8837d3dcbac551a681 Mon Sep 17 00:00:00 2001 From: Blomios Date: Mon, 27 Jul 2026 09:05:34 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20livrable=20ticket=20#91=20=E2=80=94=20n?= =?UTF-8?q?otification=20fin=20BackgroundTask=20enrichie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: enrichissement HeadlessRendezvous avec requester/target/conversationId - Frontend: toast 'Requester -> Target completed/...' explicite - Clic ouvrant viewer de conversation pour visualiser l'échange --- .../application/src/orchestrator/service.rs | 66 +++++++++ crates/application/src/workstate/mod.rs | 33 +++++ crates/backend/src/dto.rs | 121 +++++++++++++++- crates/backend/src/events.rs | 113 ++++++++++++++- crates/domain/src/events.rs | 20 ++- crates/domain/src/lib.rs | 1 + crates/web-server/src/lib.rs | 1 + .../src/adapters/workStateNormalization.ts | 12 ++ frontend/src/domain/index.ts | 6 + .../projects/ProjectsView.ls7.test.tsx | 84 ++++++++++- .../src/features/projects/ProjectsView.tsx | 134 ++++++++++++++---- .../src/features/workstate/workstate.test.tsx | 7 + 12 files changed, 563 insertions(+), 35 deletions(-) diff --git a/crates/application/src/orchestrator/service.rs b/crates/application/src/orchestrator/service.rs index 7a85fb3..e6f0239 100644 --- a/crates/application/src/orchestrator/service.rs +++ b/crates/application/src/orchestrator/service.rs @@ -127,6 +127,22 @@ fn resolve_background_cwd( ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned())) } +fn rendezvous_context_for_task(task: &BackgroundTask) -> Option { + match &task.kind { + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id, + target_agent_id, + conversation_id, + .. + } => Some(domain::RendezvousContext { + requester_agent_id: *requester_agent_id, + target_agent_id: *target_agent_id, + conversation_id: *conversation_id, + }), + _ => None, + } +} + fn normalize_path_no_parent(path: &Path) -> Result { let mut out = PathBuf::new(); for component in path.components() { @@ -847,22 +863,26 @@ impl OrchestratorService { if task.is_terminal() { return Ok(()); } + let rendezvous = rendezvous_context_for_task(&task); let event = match &result { BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted { project_id: project.id, task_id, owner_agent_id, + rendezvous, }, BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed { project_id: project.id, task_id, owner_agent_id, + rendezvous, }, BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => { DomainEvent::BackgroundTaskCancelled { project_id: project.id, task_id, owner_agent_id, + rendezvous, } } }; @@ -2967,6 +2987,52 @@ mod tests { assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS)); } + #[test] + fn rendezvous_context_is_extracted_from_headless_background_task_kind() { + let requester = aid(1); + let target = aid(2); + let conversation_id = domain::ConversationId::from_uuid(uuid::Uuid::from_u128(3)); + let task = BackgroundTask::new( + TaskId::from_uuid(uuid::Uuid::from_u128(4)), + domain::ProjectId::from_uuid(uuid::Uuid::from_u128(5)), + target, + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id: Some(requester), + target_agent_id: target, + ticket_id: TicketId::from_uuid(uuid::Uuid::from_u128(6)), + conversation_id, + }, + BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + + let context = rendezvous_context_for_task(&task).expect("rendezvous context"); + + assert_eq!(context.requester_agent_id, Some(requester)); + assert_eq!(context.target_agent_id, target); + assert_eq!(context.conversation_id, conversation_id); + } + + #[test] + fn rendezvous_context_is_absent_for_command_background_task() { + let task = BackgroundTask::new( + TaskId::from_uuid(uuid::Uuid::from_u128(7)), + domain::ProjectId::from_uuid(uuid::Uuid::from_u128(8)), + aid(9), + BackgroundTaskKind::Command { + label: "cargo test".to_owned(), + }, + BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + + assert_eq!(rendezvous_context_for_task(&task), None); + } + #[test] fn explicit_profile_submit_delay_is_preserved() { let p = profile(3, "OpenAI Codex CLI", "codex") diff --git a/crates/application/src/workstate/mod.rs b/crates/application/src/workstate/mod.rs index b75a150..184650a 100644 --- a/crates/application/src/workstate/mod.rs +++ b/crates/application/src/workstate/mod.rs @@ -231,6 +231,12 @@ pub struct AgentBackgroundTaskState { pub stdout_tail: Option, /// Bounded stderr tail. pub stderr_tail: Option, + /// Agent that requested a headless rendezvous, when this task is one. + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous, when this task is one. + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous, when this task is one. + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -592,6 +598,8 @@ impl GetProjectWorkState { impl From for AgentBackgroundTaskState { fn from(task: BackgroundTask) -> Self { let (exit_code, summary, stdout_tail, stderr_tail) = flatten_background_result(&task); + let (requester_agent_id, target_agent_id, conversation_id) = + flatten_background_rendezvous_context(&task.kind); Self { task_id: task.id, kind: BackgroundTaskKindLabel::from(&task.kind), @@ -600,12 +608,37 @@ impl From for AgentBackgroundTaskState { summary, stdout_tail, stderr_tail, + requester_agent_id, + target_agent_id, + conversation_id, created_at_ms: task.created_at_ms, updated_at_ms: task.updated_at_ms, } } } +fn flatten_background_rendezvous_context( + kind: &BackgroundTaskKind, +) -> ( + Option, + Option, + Option, +) { + match kind { + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id, + target_agent_id, + conversation_id, + .. + } => ( + *requester_agent_id, + Some(*target_agent_id), + Some(*conversation_id), + ), + _ => (None, None, None), + } +} + impl From<&BackgroundTaskKind> for BackgroundTaskKindLabel { fn from(kind: &BackgroundTaskKind) -> Self { match kind { diff --git a/crates/backend/src/dto.rs b/crates/backend/src/dto.rs index 599fda8..ec07b76 100644 --- a/crates/backend/src/dto.rs +++ b/crates/backend/src/dto.rs @@ -2611,6 +2611,15 @@ pub struct AgentBackgroundTaskStateDto { /// Bounded stderr tail. #[serde(skip_serializing_if = "Option::is_none")] pub stderr_tail: Option, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -2627,6 +2636,9 @@ impl From for AgentBackgroundTaskStateDto { summary: task.summary, stdout_tail: task.stdout_tail, stderr_tail: task.stderr_tail, + requester_agent_id: task.requester_agent_id.map(|id| id.to_string()), + target_agent_id: task.target_agent_id.map(|id| id.to_string()), + conversation_id: task.conversation_id.map(|id| id.to_string()), created_at_ms: task.created_at_ms, updated_at_ms: task.updated_at_ms, } @@ -4052,6 +4064,15 @@ pub struct BackgroundTaskDto { /// Bounded stderr tail (unset for PTY-backed commands, which merge streams). #[serde(skip_serializing_if = "Option::is_none")] pub stderr_tail: Option, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + pub requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + pub conversation_id: Option, /// Creation timestamp, epoch milliseconds. pub created_at_ms: u64, /// Last update timestamp, epoch milliseconds. @@ -4093,6 +4114,8 @@ fn background_state_label(state: BackgroundTaskState) -> &'static str { impl From for BackgroundTaskDto { fn from(task: BackgroundTask) -> Self { + let (requester_agent_id, target_agent_id, conversation_id) = + background_rendezvous_context_labels(&task.kind); let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result { Some(BackgroundTaskResult::Success { exit_code, @@ -4134,12 +4157,33 @@ impl From for BackgroundTaskDto { summary, stdout_tail, stderr_tail, + requester_agent_id, + target_agent_id, + conversation_id, created_at_ms: task.created_at_ms, updated_at_ms: task.updated_at_ms, } } } +fn background_rendezvous_context_labels( + kind: &BackgroundTaskKind, +) -> (Option, Option, Option) { + match kind { + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id, + target_agent_id, + conversation_id, + .. + } => ( + requester_agent_id.map(|id| id.to_string()), + Some(target_agent_id.to_string()), + Some(conversation_id.to_string()), + ), + _ => (None, None, None), + } +} + /// Parses a task-id string (UUID) coming from the frontend. /// /// # Errors @@ -4188,7 +4232,8 @@ pub struct SpawnBackgroundCommandRequestDto { #[cfg(test)] mod tests { use application::McpToolPermissionCatalogue; - use domain::{AgentId, ProjectMcpToolPermissions}; + use domain::mailbox::TicketId; + use domain::{AgentId, ConversationId, ProjectMcpToolPermissions}; use serde_json::json; use uuid::Uuid; @@ -4239,4 +4284,78 @@ mod tests { }) ); } + + #[test] + fn background_task_dto_exposes_rendezvous_context_only_for_headless_rendezvous() { + let project_id = ProjectId::from_uuid(Uuid::from_u128(1)); + let owner = AgentId::from_uuid(Uuid::from_u128(2)); + let requester = AgentId::from_uuid(Uuid::from_u128(3)); + let conversation_id = ConversationId::from_uuid(Uuid::from_u128(4)); + let task = BackgroundTask::new( + TaskId::from_uuid(Uuid::from_u128(5)), + project_id, + owner, + BackgroundTaskKind::HeadlessRendezvous { + requester_agent_id: Some(requester), + target_agent_id: owner, + ticket_id: TicketId::from_uuid(Uuid::from_u128(6)), + conversation_id, + }, + domain::BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + + let json = serde_json::to_value(BackgroundTaskDto::from(task)).unwrap(); + + assert_eq!(json["kind"], "headlessRendezvous"); + assert_eq!(json["requesterAgentId"], requester.to_string()); + assert_eq!(json["targetAgentId"], owner.to_string()); + assert_eq!(json["conversationId"], conversation_id.to_string()); + + let command = BackgroundTask::new( + TaskId::from_uuid(Uuid::from_u128(7)), + project_id, + owner, + BackgroundTaskKind::Command { + label: "cargo test".to_owned(), + }, + domain::BackgroundTaskWakePolicy::RecordOnly, + 100, + None, + ) + .unwrap(); + let json = serde_json::to_value(BackgroundTaskDto::from(command)).unwrap(); + assert!(json.get("requesterAgentId").is_none()); + assert!(json.get("targetAgentId").is_none()); + assert!(json.get("conversationId").is_none()); + } + + #[test] + fn agent_background_task_state_dto_exposes_rendezvous_context() { + let requester = AgentId::from_uuid(Uuid::from_u128(11)); + let target = AgentId::from_uuid(Uuid::from_u128(12)); + let conversation_id = ConversationId::from_uuid(Uuid::from_u128(13)); + let state = AgentBackgroundTaskState { + task_id: TaskId::from_uuid(Uuid::from_u128(14)), + kind: BackgroundTaskKindLabel::HeadlessRendezvous, + state: BackgroundTaskState::Completed, + exit_code: None, + summary: Some("ok".to_owned()), + stdout_tail: None, + stderr_tail: None, + requester_agent_id: Some(requester), + target_agent_id: Some(target), + conversation_id: Some(conversation_id), + created_at_ms: 100, + updated_at_ms: 200, + }; + + let json = serde_json::to_value(AgentBackgroundTaskStateDto::from(state)).unwrap(); + + assert_eq!(json["requesterAgentId"], requester.to_string()); + assert_eq!(json["targetAgentId"], target.to_string()); + assert_eq!(json["conversationId"], conversation_id.to_string()); + } } diff --git a/crates/backend/src/events.rs b/crates/backend/src/events.rs index 060c0e4..68864bd 100644 --- a/crates/backend/src/events.rs +++ b/crates/backend/src/events.rs @@ -6,7 +6,7 @@ use serde::Serialize; use domain::conversation::ConversationParty; -use domain::events::{DomainEvent, OrchestrationSource}; +use domain::events::{DomainEvent, OrchestrationSource, RendezvousContext}; use domain::input::AgentLiveness; use domain::model_server::ModelServerLifecycleStatus; use domain::{IssueLinkKind, IssuePriority, IssueStatus}; @@ -298,6 +298,15 @@ pub enum DomainEventDto { agent_id: String, /// Lightweight event/state label. state: String, + /// Agent that requested a headless rendezvous, when known. + #[serde(skip_serializing_if = "Option::is_none")] + requester_agent_id: Option, + /// Target agent for a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + target_agent_id: Option, + /// Conversation opened by a headless rendezvous. + #[serde(skip_serializing_if = "Option::is_none")] + conversation_id: Option, }, /// An agent inbox queue depth changed. #[serde(rename_all = "camelCase")] @@ -725,6 +734,24 @@ fn conversation_party_wire(party: ConversationParty) -> String { } } +fn rendezvous_requester_agent_id(rendezvous: &Option) -> Option { + rendezvous + .as_ref() + .and_then(|ctx| ctx.requester_agent_id.map(|id| id.to_string())) +} + +fn rendezvous_target_agent_id(rendezvous: &Option) -> Option { + rendezvous + .as_ref() + .map(|ctx| ctx.target_agent_id.to_string()) +} + +fn rendezvous_conversation_id(rendezvous: &Option) -> Option { + rendezvous + .as_ref() + .map(|ctx| ctx.conversation_id.to_string()) +} + impl From<&DomainEvent> for DomainEventDto { fn from(e: &DomainEvent) -> Self { match e { @@ -862,6 +889,9 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "started".to_owned(), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::BackgroundTaskStateChanged { project_id, @@ -873,36 +903,51 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: format!("{state:?}"), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::BackgroundTaskCompleted { project_id, task_id, owner_agent_id, + rendezvous, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "completed".to_owned(), + requester_agent_id: rendezvous_requester_agent_id(rendezvous), + target_agent_id: rendezvous_target_agent_id(rendezvous), + conversation_id: rendezvous_conversation_id(rendezvous), }, DomainEvent::BackgroundTaskFailed { project_id, task_id, owner_agent_id, + rendezvous, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "failed".to_owned(), + requester_agent_id: rendezvous_requester_agent_id(rendezvous), + target_agent_id: rendezvous_target_agent_id(rendezvous), + conversation_id: rendezvous_conversation_id(rendezvous), }, DomainEvent::BackgroundTaskCancelled { project_id, task_id, owner_agent_id, + rendezvous, } => Self::BackgroundTaskChanged { project_id: project_id.to_string(), task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "cancelled".to_owned(), + requester_agent_id: rendezvous_requester_agent_id(rendezvous), + target_agent_id: rendezvous_target_agent_id(rendezvous), + conversation_id: rendezvous_conversation_id(rendezvous), }, DomainEvent::BackgroundTaskCompletionDeliveryPending { project_id, @@ -913,6 +958,9 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "deliveryPending".to_owned(), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::BackgroundTaskCompletionDelivered { project_id, @@ -923,6 +971,9 @@ impl From<&DomainEvent> for DomainEventDto { task_id: task_id.to_string(), agent_id: owner_agent_id.to_string(), state: "delivered".to_owned(), + requester_agent_id: None, + target_agent_id: None, + conversation_id: None, }, DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged { agent_id: agent_id.to_string(), @@ -1205,7 +1256,7 @@ mod tests { use super::*; use domain::ids::AgentId; use domain::mailbox::TicketId; - use domain::{LocalModelServerId, ProjectId}; + use domain::{ConversationId, LocalModelServerId, ProjectId, TaskId}; use serde_json::json; fn agent(n: u128) -> AgentId { @@ -1216,6 +1267,14 @@ mod tests { LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n)) } + fn task(n: u128) -> TaskId { + TaskId::from_uuid(uuid::Uuid::from_u128(n)) + } + + fn conversation(n: u128) -> ConversationId { + ConversationId::from_uuid(uuid::Uuid::from_u128(n)) + } + #[test] fn model_server_status_changed_relays_ready_to_dto_and_wire() { let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged { @@ -1348,6 +1407,56 @@ mod tests { ); } + #[test] + fn background_completion_relays_rendezvous_context_to_wire() { + let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1)); + let task_id = task(2); + let requester = agent(3); + let target = agent(4); + let conversation_id = conversation(5); + + let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskCompleted { + project_id, + task_id, + owner_agent_id: target, + rendezvous: Some(RendezvousContext { + requester_agent_id: Some(requester), + target_agent_id: target, + conversation_id, + }), + }); + + assert_eq!( + serde_json::to_value(&dto).unwrap(), + json!({ + "type": "backgroundTaskChanged", + "projectId": project_id.to_string(), + "taskId": task_id.to_string(), + "agentId": target.to_string(), + "state": "completed", + "requesterAgentId": requester.to_string(), + "targetAgentId": target.to_string(), + "conversationId": conversation_id.to_string(), + }) + ); + } + + #[test] + fn background_failure_without_rendezvous_omits_context_fields() { + let dto = DomainEventDto::from(&DomainEvent::BackgroundTaskFailed { + project_id: ProjectId::from_uuid(uuid::Uuid::from_u128(1)), + task_id: task(2), + owner_agent_id: agent(3), + rendezvous: None, + }); + + let json = serde_json::to_value(&dto).unwrap(); + assert_eq!(json["type"], "backgroundTaskChanged"); + assert!(json.get("requesterAgentId").is_none()); + assert!(json.get("targetAgentId").is_none()); + assert!(json.get("conversationId").is_none()); + } + /// 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 ». diff --git a/crates/domain/src/events.rs b/crates/domain/src/events.rs index 9e44c52..ce1a283 100644 --- a/crates/domain/src/events.rs +++ b/crates/domain/src/events.rs @@ -1,7 +1,7 @@ //! Domain events published on the [`crate::ports::EventBus`] and relayed to the //! presentation layer (ARCHITECTURE §3.2). -use crate::conversation::ConversationParty; +use crate::conversation::{ConversationId, ConversationParty}; use crate::device::DeviceId; use crate::ids::{ AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId, @@ -14,6 +14,18 @@ use crate::plugin::{PluginId, PluginMcpServerId, PluginVersion}; use crate::sprint::{SprintOrder, SprintVersion}; use crate::template::TemplateVersion; +/// Context carried by terminal background-task events when they come from a +/// headless inter-agent rendezvous. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RendezvousContext { + /// Agent that requested the rendezvous, when known. + pub requester_agent_id: Option, + /// Target agent that owns the rendezvous conversation. + pub target_agent_id: AgentId, + /// Conversation opened by the target turn. + pub conversation_id: ConversationId, +} + /// Which entry door a processed orchestration request arrived through. /// /// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two @@ -142,6 +154,8 @@ pub enum DomainEvent { task_id: TaskId, /// The agent that owns completion delivery. owner_agent_id: AgentId, + /// Rendezvous context when this terminal task is a headless ask. + rendezvous: Option, }, /// A first-class background task failed. BackgroundTaskFailed { @@ -151,6 +165,8 @@ pub enum DomainEvent { task_id: TaskId, /// The agent that owns completion delivery. owner_agent_id: AgentId, + /// Rendezvous context when this terminal task is a headless ask. + rendezvous: Option, }, /// A first-class background task was cancelled. BackgroundTaskCancelled { @@ -160,6 +176,8 @@ pub enum DomainEvent { task_id: TaskId, /// The agent that owns completion delivery. owner_agent_id: AgentId, + /// Rendezvous context when this terminal task is a headless ask. + rendezvous: Option, }, /// A first-class background task has a terminal result not yet delivered. BackgroundTaskCompletionDeliveryPending { diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index fe3c8f5..f6cd8f0 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -76,6 +76,7 @@ mod validation; // --------------------------------------------------------------------------- pub use error::DomainError; +pub use events::RendezvousContext; pub use ids::{ AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey, diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index e14ca7c..6853d1f 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -5874,6 +5874,7 @@ mod tests { project_id, task_id, owner_agent_id: owner, + rendezvous: None, }); let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv()) .await diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts index 295c73d..3d76e09 100644 --- a/frontend/src/adapters/workStateNormalization.ts +++ b/frontend/src/adapters/workStateNormalization.ts @@ -100,6 +100,15 @@ function normalizeBackgroundTask( fallbackAgentId: string, ): BackgroundCompletion { const task = isRecord(value) ? value : {}; + const requesterAgentId = optionalString( + task.requesterAgentId ?? task.requester_agent_id, + ); + const targetAgentId = optionalString( + task.targetAgentId ?? task.target_agent_id, + ); + const conversationId = optionalString( + task.conversationId ?? task.conversation_id, + ); return { taskId: stringValue(task.taskId, `legacy-task-${index}`), ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId), @@ -110,6 +119,9 @@ function normalizeBackgroundTask( summary: nullableString(task.summary), stdoutTail: nullableString(task.stdoutTail), stderrTail: nullableString(task.stderrTail), + ...(requesterAgentId ? { requesterAgentId } : {}), + ...(targetAgentId ? { targetAgentId } : {}), + ...(conversationId ? { conversationId } : {}), updatedAtMs: numberValue(task.updatedAtMs), }; } diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts index 8ff6e81..70df616 100644 --- a/frontend/src/domain/index.ts +++ b/frontend/src/domain/index.ts @@ -300,6 +300,9 @@ export type DomainEvent = taskId: string; agentId: string; state: string; + requesterAgentId?: string; + targetAgentId?: string; + conversationId?: string; } | { type: "agentInboxChanged"; @@ -608,6 +611,9 @@ export interface BackgroundCompletion { summary: string | null; stdoutTail: string | null; stderrTail: string | null; + requesterAgentId?: string; + targetAgentId?: string; + conversationId?: string; /** Last-update timestamp (epoch ms); chronological ordering key. */ updatedAtMs: number; } diff --git a/frontend/src/features/projects/ProjectsView.ls7.test.tsx b/frontend/src/features/projects/ProjectsView.ls7.test.tsx index efed359..331acd5 100644 --- a/frontend/src/features/projects/ProjectsView.ls7.test.tsx +++ b/frontend/src/features/projects/ProjectsView.ls7.test.tsx @@ -90,10 +90,17 @@ function fixedConversation(): ConversationGateway { }; } -function renderView(project: MockProjectGateway) { - const agent = new MockAgentGateway(); +function renderView( + project: MockProjectGateway, + overrides: { + agent?: MockAgentGateway; + system?: MockSystemGateway; + } = {}, +) { + const agent = overrides.agent ?? new MockAgentGateway(); + const system = overrides.system ?? new MockSystemGateway(); const gateways = { - system: new MockSystemGateway(), + system, project, agent, profile: new MockProfileGateway(), @@ -224,4 +231,75 @@ describe("ProjectsView — LS7 conversation viewer integration", () => { screen.queryByRole("button", { name: "← Retour aux terminaux" }), ).toBeNull(); }); + + it("labels rendezvous completion toasts with requester and target, then opens the conversation", async () => { + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + const agent = new MockAgentGateway(); + const system = new MockSystemGateway(); + const requester = await agent.createAgent(created.id, { + name: "Main", + profileId: "codex", + }); + const target = await agent.createAgent(created.id, { + name: "DevBackend", + profileId: "codex", + }); + renderView(project, { agent, system }); + + await openProjectAndWorkTab("/p/a"); + system.emit({ + type: "backgroundTaskChanged", + projectId: created.id, + agentId: target.id, + taskId: "task-rendezvous-91", + state: "completed", + requesterAgentId: requester.id, + targetAgentId: target.id, + conversationId: CONV_ID, + }); + + const toast = await screen.findByRole("button", { + name: /Main -> DevBackend completed/i, + }); + expect(within(toast).getByText("Task task-ren")).toBeTruthy(); + + fireEvent.click(toast); + + expect( + await screen.findByRole("button", { name: "← Retour aux terminaux" }), + ).toBeTruthy(); + expect(await screen.findByText("contenu du fil ouvert")).toBeTruthy(); + expect( + screen.queryByRole("button", { name: /Main -> DevBackend completed/i }), + ).toBeNull(); + }); + + it("keeps the generic background-task toast when rendezvous agent ids are absent", async () => { + const project = new MockProjectGateway(); + const created = await project.createProject("alpha", "/p/a"); + const system = new MockSystemGateway(); + renderView(project, { system }); + + await openProjectAndWorkTab("/p/a"); + system.emit({ + type: "backgroundTaskChanged", + projectId: created.id, + agentId: "agent-generic-1", + taskId: "task-generic-1", + state: "failed", + }); + + const toast = await screen.findByRole("button", { + name: /Background task failed/i, + }); + expect(within(toast).getByText("agent-ge · task-gen")).toBeTruthy(); + + fireEvent.click(toast); + + expect( + screen.queryByRole("button", { name: "← Retour aux terminaux" }), + ).toBeNull(); + expect(await screen.findByText("Work")).toBeTruthy(); + }); }); diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 6ee10b8..8ac1f08 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -35,7 +35,7 @@ import { useEffect, useState, type ReactNode } from "react"; -import type { DomainEvent, LayoutInfo } from "@/domain"; +import type { Agent, DomainEvent, LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; import { ConversationViewer } from "@/features/conversations"; import { @@ -98,6 +98,14 @@ interface BackgroundTaskToast { agentId: string; taskId: string; state: string; + title: string; + subtitle: string; + conversationId?: string; +} + +interface PendingConversationOpen { + projectId: string; + conversationId: string; } function isTerminalBackgroundTaskEvent( @@ -112,9 +120,30 @@ function isTerminalBackgroundTaskEvent( ); } +function shortTaskId(id: string): string { + return id.slice(0, 8); +} + +function fallbackAgentLabel(id: string): string { + return shortTaskId(id); +} + +function agentLabel(agents: Agent[], agentId: string): string { + return ( + agents.find((candidate) => candidate.id === agentId)?.name ?? + fallbackAgentLabel(agentId) + ); +} + export function ProjectsView() { const vm = useProjects(); - const { system, window: windowGateway, focusedProject, git } = useGateways(); + const { + system, + window: windowGateway, + focusedProject, + git, + agent, + } = useGateways(); const [name, setName] = useState(""); const [root, setRoot] = useState(""); // Placement of every open view (#22): each panel is "closed" (absent), @@ -142,6 +171,8 @@ export function ProjectsView() { const [viewerConversationId, setViewerConversationId] = useState< string | null >(null); + const [pendingConversationOpen, setPendingConversationOpen] = + useState(null); const [taskToasts, setTaskToasts] = useState([]); const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; @@ -195,6 +226,14 @@ export function ProjectsView() { setViewerConversationId(null); }, [active?.id]); + useEffect(() => { + if (!active || pendingConversationOpen?.projectId !== active.id) return; + setSettingsSection(null); + setViewerConversationId(pendingConversationOpen.conversationId); + dismissFloating(); + setPendingConversationOpen(null); + }, [active?.id, pendingConversationOpen]); + // Publish the focused project (#47) so detached panel-only windows follow the // main window: they render this project, or an "open a project" shell when // none is active. Publish on EVERY change of `active` — including @@ -211,17 +250,44 @@ export function ProjectsView() { let cancelled = false; void system.onDomainEvent((event) => { if (!isTerminalBackgroundTaskEvent(event)) return; - const toast: BackgroundTaskToast = { - id: `${event.taskId}-${event.state}-${Date.now()}`, - projectId: event.projectId, - agentId: event.agentId, - taskId: event.taskId, - state: event.state, - }; - setTaskToasts((prev) => [...prev.slice(-2), toast]); - window.setTimeout(() => { - setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); - }, 7000); + void (async () => { + const hasRendezvousAgents = Boolean( + event.requesterAgentId && event.targetAgentId, + ); + const labels = hasRendezvousAgents + ? await agent + .listAgents(event.projectId) + .then((agents) => ({ + requester: agentLabel(agents, event.requesterAgentId!), + target: agentLabel(agents, event.targetAgentId!), + })) + .catch(() => ({ + requester: fallbackAgentLabel(event.requesterAgentId!), + target: fallbackAgentLabel(event.targetAgentId!), + })) + : null; + if (cancelled) return; + const toast: BackgroundTaskToast = { + id: `${event.taskId}-${event.state}-${Date.now()}`, + projectId: event.projectId, + agentId: event.agentId, + taskId: event.taskId, + state: event.state, + title: labels + ? `${labels.requester} -> ${labels.target} ${event.state}` + : `Background task ${event.state}`, + subtitle: labels + ? `Task ${shortTaskId(event.taskId)}` + : `${shortTaskId(event.agentId)} · ${shortTaskId(event.taskId)}`, + ...(event.conversationId + ? { conversationId: event.conversationId } + : {}), + }; + setTaskToasts((prev) => [...prev.slice(-2), toast]); + window.setTimeout(() => { + setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); + }, 7000); + })(); }).then((u) => { if (cancelled) u(); else unsubscribe = u; @@ -230,7 +296,7 @@ export function ProjectsView() { cancelled = true; unsubscribe?.(); }; - }, [system]); + }, [agent, system]); const activeLayoutKind = activeLayout?.kind ?? "terminal"; @@ -367,6 +433,29 @@ export function ProjectsView() { dismissFloating(); } + async function handleTaskToastClick(toast: BackgroundTaskToast) { + setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id)); + const projectOpen = vm.openTabs.some((tab) => tab.id === toast.projectId); + + if (toast.conversationId) { + setPendingConversationOpen({ + projectId: toast.projectId, + conversationId: toast.conversationId, + }); + if (projectOpen) { + vm.activateTab(toast.projectId); + } else { + await vm.openProject(toast.projectId); + } + return; + } + + if (projectOpen) vm.activateTab(toast.projectId); + setSettingsSection(null); + setViewerConversationId(null); + setPlacement("work", "floating"); + } + // ── Menus (#26) ───────────────────────────────────────────────────────── // A single « Panneaux » menu: one entry per panel, each opening a submenu with // the placement actions directly (closed / floating / docked left/right / @@ -804,24 +893,13 @@ export function ProjectsView() { key={toast.id} type="button" className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong" - onClick={() => { - const projectOpen = vm.openTabs.some( - (tab) => tab.id === toast.projectId, - ); - if (projectOpen) vm.activateTab(toast.projectId); - setSettingsSection(null); - setViewerConversationId(null); - setPlacement("work", "floating"); - setTaskToasts((prev) => - prev.filter((item) => item.id !== toast.id), - ); - }} + onClick={() => void handleTaskToastClick(toast)} > - Background task {toast.state} + {toast.title} - {toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)} + {toast.subtitle} ))} diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index a16253a..0773008 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -180,6 +180,7 @@ describe("ProjectWorkStatePanel", () => { // Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase, // `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted // when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs. + // Headless rendezvous tasks may carry requester/target/conversation context. const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, { agents: [ @@ -200,6 +201,9 @@ describe("ProjectWorkStatePanel", () => { taskId: "task-queued-1", kind: "headlessRendezvous", state: "queued", + requesterAgentId: "agent-main", + targetAgentId: "agent-bg", + conversationId: "conversation-rdv", createdAtMs: 12, updatedAtMs: 13, }, @@ -241,6 +245,9 @@ describe("ProjectWorkStatePanel", () => { expect(tasks[2]?.stderrTail).toBe("boom"); // `summary` (ticket #5) is carried through when present. expect(tasks[2]?.summary).toBe("process failed"); + expect(tasks[1]?.requesterAgentId).toBe("agent-main"); + expect(tasks[1]?.targetAgentId).toBe("agent-bg"); + expect(tasks[1]?.conversationId).toBe("conversation-rdv"); // updatedAtMs (backend-emitted) is carried through for chronological ordering. expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);