feat: livrable ticket #91 — notification fin BackgroundTask enrichie
- Backend: enrichissement HeadlessRendezvous avec requester/target/conversationId - Frontend: toast 'Requester -> Target completed/...' explicite - Clic ouvrant viewer de conversation pour visualiser l'échange
This commit is contained in:
@ -127,6 +127,22 @@ fn resolve_background_cwd(
|
|||||||
ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
|
ProjectPath::new(path).map_err(|_| AppError::Invalid("invalid background task cwd".to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rendezvous_context_for_task(task: &BackgroundTask) -> Option<domain::RendezvousContext> {
|
||||||
|
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<PathBuf, AppError> {
|
fn normalize_path_no_parent(path: &Path) -> Result<PathBuf, AppError> {
|
||||||
let mut out = PathBuf::new();
|
let mut out = PathBuf::new();
|
||||||
for component in path.components() {
|
for component in path.components() {
|
||||||
@ -847,22 +863,26 @@ impl OrchestratorService {
|
|||||||
if task.is_terminal() {
|
if task.is_terminal() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let rendezvous = rendezvous_context_for_task(&task);
|
||||||
let event = match &result {
|
let event = match &result {
|
||||||
BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted {
|
BackgroundTaskResult::Success { .. } => DomainEvent::BackgroundTaskCompleted {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
task_id,
|
task_id,
|
||||||
owner_agent_id,
|
owner_agent_id,
|
||||||
|
rendezvous,
|
||||||
},
|
},
|
||||||
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
|
BackgroundTaskResult::Failure { .. } => DomainEvent::BackgroundTaskFailed {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
task_id,
|
task_id,
|
||||||
owner_agent_id,
|
owner_agent_id,
|
||||||
|
rendezvous,
|
||||||
},
|
},
|
||||||
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
|
BackgroundTaskResult::Cancelled { .. } | BackgroundTaskResult::Expired { .. } => {
|
||||||
DomainEvent::BackgroundTaskCancelled {
|
DomainEvent::BackgroundTaskCancelled {
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
task_id,
|
task_id,
|
||||||
owner_agent_id,
|
owner_agent_id,
|
||||||
|
rendezvous,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -2967,6 +2987,52 @@ mod tests {
|
|||||||
assert_eq!(submit.delay_ms, Some(CODEX_SUBMIT_DELAY_MS));
|
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]
|
#[test]
|
||||||
fn explicit_profile_submit_delay_is_preserved() {
|
fn explicit_profile_submit_delay_is_preserved() {
|
||||||
let p = profile(3, "OpenAI Codex CLI", "codex")
|
let p = profile(3, "OpenAI Codex CLI", "codex")
|
||||||
|
|||||||
@ -231,6 +231,12 @@ pub struct AgentBackgroundTaskState {
|
|||||||
pub stdout_tail: Option<String>,
|
pub stdout_tail: Option<String>,
|
||||||
/// Bounded stderr tail.
|
/// Bounded stderr tail.
|
||||||
pub stderr_tail: Option<String>,
|
pub stderr_tail: Option<String>,
|
||||||
|
/// Agent that requested a headless rendezvous, when this task is one.
|
||||||
|
pub requester_agent_id: Option<AgentId>,
|
||||||
|
/// Target agent for a headless rendezvous, when this task is one.
|
||||||
|
pub target_agent_id: Option<AgentId>,
|
||||||
|
/// Conversation opened by a headless rendezvous, when this task is one.
|
||||||
|
pub conversation_id: Option<domain::ConversationId>,
|
||||||
/// Creation timestamp, epoch milliseconds.
|
/// Creation timestamp, epoch milliseconds.
|
||||||
pub created_at_ms: u64,
|
pub created_at_ms: u64,
|
||||||
/// Last update timestamp, epoch milliseconds.
|
/// Last update timestamp, epoch milliseconds.
|
||||||
@ -592,6 +598,8 @@ impl GetProjectWorkState {
|
|||||||
impl From<BackgroundTask> for AgentBackgroundTaskState {
|
impl From<BackgroundTask> for AgentBackgroundTaskState {
|
||||||
fn from(task: BackgroundTask) -> Self {
|
fn from(task: BackgroundTask) -> Self {
|
||||||
let (exit_code, summary, stdout_tail, stderr_tail) = flatten_background_result(&task);
|
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 {
|
Self {
|
||||||
task_id: task.id,
|
task_id: task.id,
|
||||||
kind: BackgroundTaskKindLabel::from(&task.kind),
|
kind: BackgroundTaskKindLabel::from(&task.kind),
|
||||||
@ -600,12 +608,37 @@ impl From<BackgroundTask> for AgentBackgroundTaskState {
|
|||||||
summary,
|
summary,
|
||||||
stdout_tail,
|
stdout_tail,
|
||||||
stderr_tail,
|
stderr_tail,
|
||||||
|
requester_agent_id,
|
||||||
|
target_agent_id,
|
||||||
|
conversation_id,
|
||||||
created_at_ms: task.created_at_ms,
|
created_at_ms: task.created_at_ms,
|
||||||
updated_at_ms: task.updated_at_ms,
|
updated_at_ms: task.updated_at_ms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn flatten_background_rendezvous_context(
|
||||||
|
kind: &BackgroundTaskKind,
|
||||||
|
) -> (
|
||||||
|
Option<AgentId>,
|
||||||
|
Option<AgentId>,
|
||||||
|
Option<domain::ConversationId>,
|
||||||
|
) {
|
||||||
|
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 {
|
impl From<&BackgroundTaskKind> for BackgroundTaskKindLabel {
|
||||||
fn from(kind: &BackgroundTaskKind) -> Self {
|
fn from(kind: &BackgroundTaskKind) -> Self {
|
||||||
match kind {
|
match kind {
|
||||||
|
|||||||
@ -2611,6 +2611,15 @@ pub struct AgentBackgroundTaskStateDto {
|
|||||||
/// Bounded stderr tail.
|
/// Bounded stderr tail.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub stderr_tail: Option<String>,
|
pub stderr_tail: Option<String>,
|
||||||
|
/// Agent that requested a headless rendezvous, when known.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub requester_agent_id: Option<String>,
|
||||||
|
/// Target agent for a headless rendezvous.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_agent_id: Option<String>,
|
||||||
|
/// Conversation opened by a headless rendezvous.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
/// Creation timestamp, epoch milliseconds.
|
/// Creation timestamp, epoch milliseconds.
|
||||||
pub created_at_ms: u64,
|
pub created_at_ms: u64,
|
||||||
/// Last update timestamp, epoch milliseconds.
|
/// Last update timestamp, epoch milliseconds.
|
||||||
@ -2627,6 +2636,9 @@ impl From<AgentBackgroundTaskState> for AgentBackgroundTaskStateDto {
|
|||||||
summary: task.summary,
|
summary: task.summary,
|
||||||
stdout_tail: task.stdout_tail,
|
stdout_tail: task.stdout_tail,
|
||||||
stderr_tail: task.stderr_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,
|
created_at_ms: task.created_at_ms,
|
||||||
updated_at_ms: task.updated_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).
|
/// Bounded stderr tail (unset for PTY-backed commands, which merge streams).
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub stderr_tail: Option<String>,
|
pub stderr_tail: Option<String>,
|
||||||
|
/// Agent that requested a headless rendezvous, when known.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub requester_agent_id: Option<String>,
|
||||||
|
/// Target agent for a headless rendezvous.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub target_agent_id: Option<String>,
|
||||||
|
/// Conversation opened by a headless rendezvous.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
/// Creation timestamp, epoch milliseconds.
|
/// Creation timestamp, epoch milliseconds.
|
||||||
pub created_at_ms: u64,
|
pub created_at_ms: u64,
|
||||||
/// Last update timestamp, epoch milliseconds.
|
/// Last update timestamp, epoch milliseconds.
|
||||||
@ -4093,6 +4114,8 @@ fn background_state_label(state: BackgroundTaskState) -> &'static str {
|
|||||||
|
|
||||||
impl From<BackgroundTask> for BackgroundTaskDto {
|
impl From<BackgroundTask> for BackgroundTaskDto {
|
||||||
fn from(task: BackgroundTask) -> Self {
|
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 {
|
let (exit_code, summary, stdout_tail, stderr_tail) = match &task.result {
|
||||||
Some(BackgroundTaskResult::Success {
|
Some(BackgroundTaskResult::Success {
|
||||||
exit_code,
|
exit_code,
|
||||||
@ -4134,12 +4157,33 @@ impl From<BackgroundTask> for BackgroundTaskDto {
|
|||||||
summary,
|
summary,
|
||||||
stdout_tail,
|
stdout_tail,
|
||||||
stderr_tail,
|
stderr_tail,
|
||||||
|
requester_agent_id,
|
||||||
|
target_agent_id,
|
||||||
|
conversation_id,
|
||||||
created_at_ms: task.created_at_ms,
|
created_at_ms: task.created_at_ms,
|
||||||
updated_at_ms: task.updated_at_ms,
|
updated_at_ms: task.updated_at_ms,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn background_rendezvous_context_labels(
|
||||||
|
kind: &BackgroundTaskKind,
|
||||||
|
) -> (Option<String>, Option<String>, Option<String>) {
|
||||||
|
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.
|
/// Parses a task-id string (UUID) coming from the frontend.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@ -4188,7 +4232,8 @@ pub struct SpawnBackgroundCommandRequestDto {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use application::McpToolPermissionCatalogue;
|
use application::McpToolPermissionCatalogue;
|
||||||
use domain::{AgentId, ProjectMcpToolPermissions};
|
use domain::mailbox::TicketId;
|
||||||
|
use domain::{AgentId, ConversationId, ProjectMcpToolPermissions};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use uuid::Uuid;
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use domain::conversation::ConversationParty;
|
use domain::conversation::ConversationParty;
|
||||||
use domain::events::{DomainEvent, OrchestrationSource};
|
use domain::events::{DomainEvent, OrchestrationSource, RendezvousContext};
|
||||||
use domain::input::AgentLiveness;
|
use domain::input::AgentLiveness;
|
||||||
use domain::model_server::ModelServerLifecycleStatus;
|
use domain::model_server::ModelServerLifecycleStatus;
|
||||||
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
|
||||||
@ -298,6 +298,15 @@ pub enum DomainEventDto {
|
|||||||
agent_id: String,
|
agent_id: String,
|
||||||
/// Lightweight event/state label.
|
/// Lightweight event/state label.
|
||||||
state: String,
|
state: String,
|
||||||
|
/// Agent that requested a headless rendezvous, when known.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
requester_agent_id: Option<String>,
|
||||||
|
/// Target agent for a headless rendezvous.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
target_agent_id: Option<String>,
|
||||||
|
/// Conversation opened by a headless rendezvous.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
conversation_id: Option<String>,
|
||||||
},
|
},
|
||||||
/// An agent inbox queue depth changed.
|
/// An agent inbox queue depth changed.
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
@ -725,6 +734,24 @@ fn conversation_party_wire(party: ConversationParty) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rendezvous_requester_agent_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
|
||||||
|
rendezvous
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|ctx| ctx.requester_agent_id.map(|id| id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendezvous_target_agent_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
|
||||||
|
rendezvous
|
||||||
|
.as_ref()
|
||||||
|
.map(|ctx| ctx.target_agent_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendezvous_conversation_id(rendezvous: &Option<RendezvousContext>) -> Option<String> {
|
||||||
|
rendezvous
|
||||||
|
.as_ref()
|
||||||
|
.map(|ctx| ctx.conversation_id.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
impl From<&DomainEvent> for DomainEventDto {
|
impl From<&DomainEvent> for DomainEventDto {
|
||||||
fn from(e: &DomainEvent) -> Self {
|
fn from(e: &DomainEvent) -> Self {
|
||||||
match e {
|
match e {
|
||||||
@ -862,6 +889,9 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
agent_id: owner_agent_id.to_string(),
|
agent_id: owner_agent_id.to_string(),
|
||||||
state: "started".to_owned(),
|
state: "started".to_owned(),
|
||||||
|
requester_agent_id: None,
|
||||||
|
target_agent_id: None,
|
||||||
|
conversation_id: None,
|
||||||
},
|
},
|
||||||
DomainEvent::BackgroundTaskStateChanged {
|
DomainEvent::BackgroundTaskStateChanged {
|
||||||
project_id,
|
project_id,
|
||||||
@ -873,36 +903,51 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
agent_id: owner_agent_id.to_string(),
|
agent_id: owner_agent_id.to_string(),
|
||||||
state: format!("{state:?}"),
|
state: format!("{state:?}"),
|
||||||
|
requester_agent_id: None,
|
||||||
|
target_agent_id: None,
|
||||||
|
conversation_id: None,
|
||||||
},
|
},
|
||||||
DomainEvent::BackgroundTaskCompleted {
|
DomainEvent::BackgroundTaskCompleted {
|
||||||
project_id,
|
project_id,
|
||||||
task_id,
|
task_id,
|
||||||
owner_agent_id,
|
owner_agent_id,
|
||||||
|
rendezvous,
|
||||||
} => Self::BackgroundTaskChanged {
|
} => Self::BackgroundTaskChanged {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
agent_id: owner_agent_id.to_string(),
|
agent_id: owner_agent_id.to_string(),
|
||||||
state: "completed".to_owned(),
|
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 {
|
DomainEvent::BackgroundTaskFailed {
|
||||||
project_id,
|
project_id,
|
||||||
task_id,
|
task_id,
|
||||||
owner_agent_id,
|
owner_agent_id,
|
||||||
|
rendezvous,
|
||||||
} => Self::BackgroundTaskChanged {
|
} => Self::BackgroundTaskChanged {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
agent_id: owner_agent_id.to_string(),
|
agent_id: owner_agent_id.to_string(),
|
||||||
state: "failed".to_owned(),
|
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 {
|
DomainEvent::BackgroundTaskCancelled {
|
||||||
project_id,
|
project_id,
|
||||||
task_id,
|
task_id,
|
||||||
owner_agent_id,
|
owner_agent_id,
|
||||||
|
rendezvous,
|
||||||
} => Self::BackgroundTaskChanged {
|
} => Self::BackgroundTaskChanged {
|
||||||
project_id: project_id.to_string(),
|
project_id: project_id.to_string(),
|
||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
agent_id: owner_agent_id.to_string(),
|
agent_id: owner_agent_id.to_string(),
|
||||||
state: "cancelled".to_owned(),
|
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 {
|
DomainEvent::BackgroundTaskCompletionDeliveryPending {
|
||||||
project_id,
|
project_id,
|
||||||
@ -913,6 +958,9 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
agent_id: owner_agent_id.to_string(),
|
agent_id: owner_agent_id.to_string(),
|
||||||
state: "deliveryPending".to_owned(),
|
state: "deliveryPending".to_owned(),
|
||||||
|
requester_agent_id: None,
|
||||||
|
target_agent_id: None,
|
||||||
|
conversation_id: None,
|
||||||
},
|
},
|
||||||
DomainEvent::BackgroundTaskCompletionDelivered {
|
DomainEvent::BackgroundTaskCompletionDelivered {
|
||||||
project_id,
|
project_id,
|
||||||
@ -923,6 +971,9 @@ impl From<&DomainEvent> for DomainEventDto {
|
|||||||
task_id: task_id.to_string(),
|
task_id: task_id.to_string(),
|
||||||
agent_id: owner_agent_id.to_string(),
|
agent_id: owner_agent_id.to_string(),
|
||||||
state: "delivered".to_owned(),
|
state: "delivered".to_owned(),
|
||||||
|
requester_agent_id: None,
|
||||||
|
target_agent_id: None,
|
||||||
|
conversation_id: None,
|
||||||
},
|
},
|
||||||
DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged {
|
DomainEvent::AgentInboxQueued { agent_id, depth } => Self::AgentInboxChanged {
|
||||||
agent_id: agent_id.to_string(),
|
agent_id: agent_id.to_string(),
|
||||||
@ -1205,7 +1256,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use domain::ids::AgentId;
|
use domain::ids::AgentId;
|
||||||
use domain::mailbox::TicketId;
|
use domain::mailbox::TicketId;
|
||||||
use domain::{LocalModelServerId, ProjectId};
|
use domain::{ConversationId, LocalModelServerId, ProjectId, TaskId};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn agent(n: u128) -> AgentId {
|
fn agent(n: u128) -> AgentId {
|
||||||
@ -1216,6 +1267,14 @@ mod tests {
|
|||||||
LocalModelServerId::from_uuid(uuid::Uuid::from_u128(n))
|
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]
|
#[test]
|
||||||
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
|
fn model_server_status_changed_relays_ready_to_dto_and_wire() {
|
||||||
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
|
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
|
/// 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
|
/// 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 ».
|
/// `resetsAtMs` — le fait neutre que le front badge « limité jusqu'à HH:MM ».
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
||||||
//! presentation layer (ARCHITECTURE §3.2).
|
//! presentation layer (ARCHITECTURE §3.2).
|
||||||
|
|
||||||
use crate::conversation::ConversationParty;
|
use crate::conversation::{ConversationId, ConversationParty};
|
||||||
use crate::device::DeviceId;
|
use crate::device::DeviceId;
|
||||||
use crate::ids::{
|
use crate::ids::{
|
||||||
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
|
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::sprint::{SprintOrder, SprintVersion};
|
||||||
use crate::template::TemplateVersion;
|
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<AgentId>,
|
||||||
|
/// 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.
|
/// Which entry door a processed orchestration request arrived through.
|
||||||
///
|
///
|
||||||
/// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two
|
/// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two
|
||||||
@ -142,6 +154,8 @@ pub enum DomainEvent {
|
|||||||
task_id: TaskId,
|
task_id: TaskId,
|
||||||
/// The agent that owns completion delivery.
|
/// The agent that owns completion delivery.
|
||||||
owner_agent_id: AgentId,
|
owner_agent_id: AgentId,
|
||||||
|
/// Rendezvous context when this terminal task is a headless ask.
|
||||||
|
rendezvous: Option<RendezvousContext>,
|
||||||
},
|
},
|
||||||
/// A first-class background task failed.
|
/// A first-class background task failed.
|
||||||
BackgroundTaskFailed {
|
BackgroundTaskFailed {
|
||||||
@ -151,6 +165,8 @@ pub enum DomainEvent {
|
|||||||
task_id: TaskId,
|
task_id: TaskId,
|
||||||
/// The agent that owns completion delivery.
|
/// The agent that owns completion delivery.
|
||||||
owner_agent_id: AgentId,
|
owner_agent_id: AgentId,
|
||||||
|
/// Rendezvous context when this terminal task is a headless ask.
|
||||||
|
rendezvous: Option<RendezvousContext>,
|
||||||
},
|
},
|
||||||
/// A first-class background task was cancelled.
|
/// A first-class background task was cancelled.
|
||||||
BackgroundTaskCancelled {
|
BackgroundTaskCancelled {
|
||||||
@ -160,6 +176,8 @@ pub enum DomainEvent {
|
|||||||
task_id: TaskId,
|
task_id: TaskId,
|
||||||
/// The agent that owns completion delivery.
|
/// The agent that owns completion delivery.
|
||||||
owner_agent_id: AgentId,
|
owner_agent_id: AgentId,
|
||||||
|
/// Rendezvous context when this terminal task is a headless ask.
|
||||||
|
rendezvous: Option<RendezvousContext>,
|
||||||
},
|
},
|
||||||
/// A first-class background task has a terminal result not yet delivered.
|
/// A first-class background task has a terminal result not yet delivered.
|
||||||
BackgroundTaskCompletionDeliveryPending {
|
BackgroundTaskCompletionDeliveryPending {
|
||||||
|
|||||||
@ -76,6 +76,7 @@ mod validation;
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
pub use events::RendezvousContext;
|
||||||
|
|
||||||
pub use ids::{
|
pub use ids::{
|
||||||
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey,
|
AgentId, IssueId, LayoutId, LocalModelServerId, NodeId, ProfileId, ProjectId, RuntimeAgentKey,
|
||||||
|
|||||||
@ -5874,6 +5874,7 @@ mod tests {
|
|||||||
project_id,
|
project_id,
|
||||||
task_id,
|
task_id,
|
||||||
owner_agent_id: owner,
|
owner_agent_id: owner,
|
||||||
|
rendezvous: None,
|
||||||
});
|
});
|
||||||
let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@ -100,6 +100,15 @@ function normalizeBackgroundTask(
|
|||||||
fallbackAgentId: string,
|
fallbackAgentId: string,
|
||||||
): BackgroundCompletion {
|
): BackgroundCompletion {
|
||||||
const task = isRecord(value) ? value : {};
|
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 {
|
return {
|
||||||
taskId: stringValue(task.taskId, `legacy-task-${index}`),
|
taskId: stringValue(task.taskId, `legacy-task-${index}`),
|
||||||
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
|
ownerAgentId: stringValue(task.ownerAgentId, fallbackAgentId),
|
||||||
@ -110,6 +119,9 @@ function normalizeBackgroundTask(
|
|||||||
summary: nullableString(task.summary),
|
summary: nullableString(task.summary),
|
||||||
stdoutTail: nullableString(task.stdoutTail),
|
stdoutTail: nullableString(task.stdoutTail),
|
||||||
stderrTail: nullableString(task.stderrTail),
|
stderrTail: nullableString(task.stderrTail),
|
||||||
|
...(requesterAgentId ? { requesterAgentId } : {}),
|
||||||
|
...(targetAgentId ? { targetAgentId } : {}),
|
||||||
|
...(conversationId ? { conversationId } : {}),
|
||||||
updatedAtMs: numberValue(task.updatedAtMs),
|
updatedAtMs: numberValue(task.updatedAtMs),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -300,6 +300,9 @@ export type DomainEvent =
|
|||||||
taskId: string;
|
taskId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
requesterAgentId?: string;
|
||||||
|
targetAgentId?: string;
|
||||||
|
conversationId?: string;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "agentInboxChanged";
|
type: "agentInboxChanged";
|
||||||
@ -608,6 +611,9 @@ export interface BackgroundCompletion {
|
|||||||
summary: string | null;
|
summary: string | null;
|
||||||
stdoutTail: string | null;
|
stdoutTail: string | null;
|
||||||
stderrTail: string | null;
|
stderrTail: string | null;
|
||||||
|
requesterAgentId?: string;
|
||||||
|
targetAgentId?: string;
|
||||||
|
conversationId?: string;
|
||||||
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
/** Last-update timestamp (epoch ms); chronological ordering key. */
|
||||||
updatedAtMs: number;
|
updatedAtMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -90,10 +90,17 @@ function fixedConversation(): ConversationGateway {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderView(project: MockProjectGateway) {
|
function renderView(
|
||||||
const agent = new MockAgentGateway();
|
project: MockProjectGateway,
|
||||||
|
overrides: {
|
||||||
|
agent?: MockAgentGateway;
|
||||||
|
system?: MockSystemGateway;
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
|
const agent = overrides.agent ?? new MockAgentGateway();
|
||||||
|
const system = overrides.system ?? new MockSystemGateway();
|
||||||
const gateways = {
|
const gateways = {
|
||||||
system: new MockSystemGateway(),
|
system,
|
||||||
project,
|
project,
|
||||||
agent,
|
agent,
|
||||||
profile: new MockProfileGateway(),
|
profile: new MockProfileGateway(),
|
||||||
@ -224,4 +231,75 @@ describe("ProjectsView — LS7 conversation viewer integration", () => {
|
|||||||
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
screen.queryByRole("button", { name: "← Retour aux terminaux" }),
|
||||||
).toBeNull();
|
).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, type ReactNode } from "react";
|
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 { LayoutGrid, LayoutTabs } from "@/features/layout";
|
||||||
import { ConversationViewer } from "@/features/conversations";
|
import { ConversationViewer } from "@/features/conversations";
|
||||||
import {
|
import {
|
||||||
@ -98,6 +98,14 @@ interface BackgroundTaskToast {
|
|||||||
agentId: string;
|
agentId: string;
|
||||||
taskId: string;
|
taskId: string;
|
||||||
state: string;
|
state: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
conversationId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingConversationOpen {
|
||||||
|
projectId: string;
|
||||||
|
conversationId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTerminalBackgroundTaskEvent(
|
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() {
|
export function ProjectsView() {
|
||||||
const vm = useProjects();
|
const vm = useProjects();
|
||||||
const { system, window: windowGateway, focusedProject, git } = useGateways();
|
const {
|
||||||
|
system,
|
||||||
|
window: windowGateway,
|
||||||
|
focusedProject,
|
||||||
|
git,
|
||||||
|
agent,
|
||||||
|
} = useGateways();
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [root, setRoot] = useState("");
|
const [root, setRoot] = useState("");
|
||||||
// Placement of every open view (#22): each panel is "closed" (absent),
|
// Placement of every open view (#22): each panel is "closed" (absent),
|
||||||
@ -142,6 +171,8 @@ export function ProjectsView() {
|
|||||||
const [viewerConversationId, setViewerConversationId] = useState<
|
const [viewerConversationId, setViewerConversationId] = useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
|
const [pendingConversationOpen, setPendingConversationOpen] =
|
||||||
|
useState<PendingConversationOpen | null>(null);
|
||||||
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
const [taskToasts, setTaskToasts] = useState<BackgroundTaskToast[]>([]);
|
||||||
|
|
||||||
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null;
|
||||||
@ -195,6 +226,14 @@ export function ProjectsView() {
|
|||||||
setViewerConversationId(null);
|
setViewerConversationId(null);
|
||||||
}, [active?.id]);
|
}, [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
|
// 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
|
// main window: they render this project, or an "open a project" shell when
|
||||||
// none is active. Publish on EVERY change of `active` — including
|
// none is active. Publish on EVERY change of `active` — including
|
||||||
@ -211,17 +250,44 @@ export function ProjectsView() {
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void system.onDomainEvent((event) => {
|
void system.onDomainEvent((event) => {
|
||||||
if (!isTerminalBackgroundTaskEvent(event)) return;
|
if (!isTerminalBackgroundTaskEvent(event)) return;
|
||||||
const toast: BackgroundTaskToast = {
|
void (async () => {
|
||||||
id: `${event.taskId}-${event.state}-${Date.now()}`,
|
const hasRendezvousAgents = Boolean(
|
||||||
projectId: event.projectId,
|
event.requesterAgentId && event.targetAgentId,
|
||||||
agentId: event.agentId,
|
);
|
||||||
taskId: event.taskId,
|
const labels = hasRendezvousAgents
|
||||||
state: event.state,
|
? await agent
|
||||||
};
|
.listAgents(event.projectId)
|
||||||
setTaskToasts((prev) => [...prev.slice(-2), toast]);
|
.then((agents) => ({
|
||||||
window.setTimeout(() => {
|
requester: agentLabel(agents, event.requesterAgentId!),
|
||||||
setTaskToasts((prev) => prev.filter((item) => item.id !== toast.id));
|
target: agentLabel(agents, event.targetAgentId!),
|
||||||
}, 7000);
|
}))
|
||||||
|
.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) => {
|
}).then((u) => {
|
||||||
if (cancelled) u();
|
if (cancelled) u();
|
||||||
else unsubscribe = u;
|
else unsubscribe = u;
|
||||||
@ -230,7 +296,7 @@ export function ProjectsView() {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
};
|
};
|
||||||
}, [system]);
|
}, [agent, system]);
|
||||||
|
|
||||||
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
const activeLayoutKind = activeLayout?.kind ?? "terminal";
|
||||||
|
|
||||||
@ -367,6 +433,29 @@ export function ProjectsView() {
|
|||||||
dismissFloating();
|
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) ─────────────────────────────────────────────────────────
|
// ── Menus (#26) ─────────────────────────────────────────────────────────
|
||||||
// A single « Panneaux » menu: one entry per panel, each opening a submenu with
|
// A single « Panneaux » menu: one entry per panel, each opening a submenu with
|
||||||
// the placement actions directly (closed / floating / docked left/right /
|
// the placement actions directly (closed / floating / docked left/right /
|
||||||
@ -804,24 +893,13 @@ export function ProjectsView() {
|
|||||||
key={toast.id}
|
key={toast.id}
|
||||||
type="button"
|
type="button"
|
||||||
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
|
className="rounded-md border border-border bg-surface px-3 py-2 text-left shadow-lg hover:border-border-strong"
|
||||||
onClick={() => {
|
onClick={() => void handleTaskToastClick(toast)}
|
||||||
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),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="block text-sm font-medium text-content">
|
<span className="block text-sm font-medium text-content">
|
||||||
Background task {toast.state}
|
{toast.title}
|
||||||
</span>
|
</span>
|
||||||
<span className="mt-0.5 block truncate text-xs text-muted">
|
<span className="mt-0.5 block truncate text-xs text-muted">
|
||||||
{toast.agentId.slice(0, 8)} · {toast.taskId.slice(0, 8)}
|
{toast.subtitle}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -180,6 +180,7 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
// Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase,
|
// Mirrors AgentBackgroundTaskStateDto (crates/app-tauri/src/dto.rs): camelCase,
|
||||||
// `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted
|
// `state` (not `status`), optional exitCode/summary/stdoutTail/stderrTail omitted
|
||||||
// when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs.
|
// when absent, createdAtMs/updatedAtMs present, NO ownerAgentId/projectId/finishedAtMs.
|
||||||
|
// Headless rendezvous tasks may carry requester/target/conversation context.
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState(PROJECT_ID, {
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
agents: [
|
agents: [
|
||||||
@ -200,6 +201,9 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
taskId: "task-queued-1",
|
taskId: "task-queued-1",
|
||||||
kind: "headlessRendezvous",
|
kind: "headlessRendezvous",
|
||||||
state: "queued",
|
state: "queued",
|
||||||
|
requesterAgentId: "agent-main",
|
||||||
|
targetAgentId: "agent-bg",
|
||||||
|
conversationId: "conversation-rdv",
|
||||||
createdAtMs: 12,
|
createdAtMs: 12,
|
||||||
updatedAtMs: 13,
|
updatedAtMs: 13,
|
||||||
},
|
},
|
||||||
@ -241,6 +245,9 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
expect(tasks[2]?.stderrTail).toBe("boom");
|
expect(tasks[2]?.stderrTail).toBe("boom");
|
||||||
// `summary` (ticket #5) is carried through when present.
|
// `summary` (ticket #5) is carried through when present.
|
||||||
expect(tasks[2]?.summary).toBe("process failed");
|
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.
|
// updatedAtMs (backend-emitted) is carried through for chronological ordering.
|
||||||
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);
|
expect(tasks.map((t) => t.updatedAtMs)).toEqual([11, 13, 15, 17]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user