Files
IdeA/crates/backend/src/events.rs
Blomios 171c6c923c feat(wave): #119/#122/#131/#132 verts + sprint plugins ESM/persistance #135/#136/#139
État d'intégration confiné à la branche batch. Les tickets #119 (skills →
capacités agent découvrables), #122 (override permissions par défaut), #131
(effort par agent/presets) et #132 (outil MCP d'édition du contexte projet)
sont verts en périmètre. Le sprint plugins multi-fichiers ESM / persistance
plugin-owned (#135/#136/#139) est co-implémenté dans les MÊMES fichiers de
câblage (frontend/src/ports/index.ts, backend/src/lib.rs, domain/ports.rs,
backend/dto.rs), inséparable sans staging interactif (indisponible ici).

Commit unique volontaire : préserve l'état vert QA sans découpe hunk risquée.
NON mergé vers develop tant que #137 (QA e2e plugins) n'est pas vert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 11:06:23 +02:00

1589 lines
56 KiB
Rust

//! Transport-neutral DTOs for domain events.
//!
//! The event relay itself belongs to each driving adapter. This module owns only
//! the stable JSON wire shape shared by Tauri IPC and the headless web server.
use serde::Serialize;
use domain::conversation::ConversationParty;
use domain::events::{DomainEvent, OrchestrationSource, RendezvousContext};
use domain::input::AgentLiveness;
use domain::model_server::ModelServerLifecycleStatus;
use domain::{IssueLinkKind, IssuePriority, IssueStatus};
/// Name of the Tauri event carrying relayed [`DomainEvent`]s.
pub const DOMAIN_EVENT: &str = "domain://event";
/// Dedicated Tauri event for local model-server status changes.
pub const MODEL_SERVER_STATUS_CHANGED: &str = "model_server_status_changed";
/// Dedicated Tauri event for launch failures.
pub const AGENT_LAUNCH_FAILED: &str = "agent_launch_failed";
/// Model-server lifecycle status on the Tauri wire.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum ModelServerStatusDto {
/// No server is configured for this profile.
NotConfigured,
/// Readiness probing is in progress.
Probing,
/// IdeA is starting the process.
Starting,
/// Server is downloading/preparing a remote model.
#[serde(rename_all = "camelCase")]
Downloading {
/// Downloaded bytes when known.
downloaded_bytes: Option<u64>,
/// Total bytes when known.
total_bytes: Option<u64>,
/// Completion percentage when known.
percent: Option<f32>,
/// Remote model source.
source: Option<String>,
},
/// Server is ready.
#[serde(rename_all = "camelCase")]
Ready {
/// `true` when an existing server was reused.
reused: bool,
},
/// Server preparation failed.
#[serde(rename_all = "camelCase")]
Failed {
/// Stable failure code.
code: String,
/// Human-readable message.
message: String,
},
}
impl From<&ModelServerLifecycleStatus> for ModelServerStatusDto {
fn from(status: &ModelServerLifecycleStatus) -> Self {
match status {
ModelServerLifecycleStatus::NotConfigured => Self::NotConfigured,
ModelServerLifecycleStatus::Probing => Self::Probing,
ModelServerLifecycleStatus::Starting => Self::Starting,
ModelServerLifecycleStatus::Downloading {
downloaded_bytes,
total_bytes,
percent,
source,
} => Self::Downloading {
downloaded_bytes: *downloaded_bytes,
total_bytes: *total_bytes,
percent: *percent,
source: source.clone(),
},
ModelServerLifecycleStatus::Ready { reused } => Self::Ready { reused: *reused },
ModelServerLifecycleStatus::Failed { code, message } => Self::Failed {
code: code.clone(),
message: message.clone(),
},
}
}
}
/// Payload for [`MODEL_SERVER_STATUS_CHANGED`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ModelServerStatusChangedDto {
/// Local model server id.
pub server_id: String,
/// Lifecycle status.
pub status: ModelServerStatusDto,
}
/// Payload for [`AGENT_LAUNCH_FAILED`].
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentLaunchFailedDto {
/// Agent id.
pub agent_id: String,
/// Failure cause namespace.
pub cause: String,
/// Stable error code.
pub code: String,
/// Human-readable message.
pub message: String,
}
/// Serialisable mirror of [`DomainEvent`] for the IPC wire (camelCase, tagged).
///
/// `type` is the discriminant; payload fields are flattened per variant. This is
/// the single owner of the event wire format on the backend side.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum DomainEventDto {
/// A project was created.
#[serde(rename_all = "camelCase")]
ProjectCreated {
/// Project id (UUID string).
project_id: String,
},
/// A plugin was installed.
#[serde(rename_all = "camelCase")]
PluginInstalled {
/// Plugin id.
plugin_id: String,
/// Installed version.
version: String,
},
/// A plugin was enabled.
#[serde(rename_all = "camelCase")]
PluginEnabled {
/// Plugin id.
plugin_id: String,
},
/// A plugin was disabled.
#[serde(rename_all = "camelCase")]
PluginDisabled {
/// Plugin id.
plugin_id: String,
/// Whether a restart is needed for full JS purge.
restart_required: bool,
},
/// A plugin was uninstalled.
#[serde(rename_all = "camelCase")]
PluginUninstalled {
/// Plugin id.
plugin_id: String,
/// Whether a restart is needed for full JS purge.
restart_required: bool,
},
/// A plugin failed to load.
#[serde(rename_all = "camelCase")]
PluginLoadFailed {
/// Plugin id.
plugin_id: String,
/// Failure reason.
reason: String,
},
/// A plugin MCP server started.
#[serde(rename_all = "camelCase")]
PluginMcpServerStarted {
/// Plugin id.
plugin_id: String,
/// Server id.
server_id: String,
},
/// A plugin MCP server stopped.
#[serde(rename_all = "camelCase")]
PluginMcpServerStopped {
/// Plugin id.
plugin_id: String,
/// Server id.
server_id: String,
},
/// An agent was launched.
#[serde(rename_all = "camelCase")]
AgentLaunched {
/// Agent id.
agent_id: String,
/// Session id.
session_id: String,
},
/// An agent launch failed before runtime session creation.
#[serde(rename_all = "camelCase")]
AgentLaunchFailed {
/// Agent id.
agent_id: String,
/// Failure cause namespace.
cause: String,
/// Stable error code.
code: String,
/// Human-readable message.
message: String,
},
/// Local model-server status changed.
#[serde(rename_all = "camelCase")]
ModelServerStatusChanged {
/// Local model server id.
server_id: String,
/// Lifecycle status.
status: ModelServerStatusDto,
},
/// An agent exited.
#[serde(rename_all = "camelCase")]
AgentExited {
/// Agent id.
agent_id: String,
/// Exit code.
code: i32,
},
/// A ticket assistant chat was opened.
#[serde(rename_all = "camelCase")]
TicketAssistantOpened {
/// Bound ticket reference.
issue_ref: String,
/// Runtime profile id.
profile_id: String,
},
/// A ticket assistant chat was closed.
#[serde(rename_all = "camelCase")]
TicketAssistantClosed {
/// Bound ticket reference.
issue_ref: String,
},
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
/// "Envoyer" while `busy` is `true`.
#[serde(rename_all = "camelCase")]
AgentBusyChanged {
/// Agent id.
agent_id: String,
/// `true` when a turn is in flight, `false` when idle.
busy: bool,
},
/// A delegation is ready to be injected into the agent's **native terminal**
/// (ARCHITECTURE §20). The frontend write-portal writes `text` + `submitSequence`
/// (default `"\r"`) after `submitDelayMs` (default ~60) once the human line is empty,
/// then acks via the `delegation_delivered` command. The backend no longer PTY-writes
/// the turn.
#[serde(rename_all = "camelCase")]
DelegationReady {
/// Target agent id (UUID string).
agent_id: String,
/// Mailbox ticket id (UUID string) to ack back via `delegation_delivered`.
ticket: String,
/// Task text to inject (written without a trailing newline by the portal).
text: String,
/// Profile's submit sequence; `null` ⇒ the front applies its default (`"\r"`).
#[serde(skip_serializing_if = "Option::is_none")]
submit_sequence: Option<String>,
/// Profile's submit delay in ms; `null` ⇒ the front default (~60 ms).
#[serde(skip_serializing_if = "Option::is_none")]
submit_delay_ms: Option<u32>,
},
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
#[serde(rename_all = "camelCase")]
AgentReplied {
/// Target agent id.
agent_id: String,
/// 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
/// battement or when the turn ends.
#[serde(rename_all = "camelCase")]
AgentLivenessChanged {
/// Agent id (UUID string).
agent_id: String,
/// New liveness, as a lowercase string (`"alive"` / `"stalled"`).
liveness: AgentLivenessDto,
},
/// A background task lifecycle/delivery event occurred.
#[serde(rename_all = "camelCase")]
BackgroundTaskChanged {
/// Project id.
project_id: String,
/// Task id.
task_id: String,
/// Owner agent id.
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<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.
#[serde(rename_all = "camelCase")]
AgentInboxChanged {
/// Agent id.
agent_id: String,
/// Queue depth after the operation.
depth: usize,
/// Queue operation label.
action: String,
},
/// An owner wake event occurred.
#[serde(rename_all = "camelCase")]
AgentWakeChanged {
/// Project id.
project_id: String,
/// Agent id.
agent_id: String,
/// Wake operation label.
action: String,
/// Failure reason, when any.
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
},
/// An agent's runtime profile was changed (hot-swap of the AI engine).
#[serde(rename_all = "camelCase")]
AgentProfileChanged {
/// Agent id.
agent_id: String,
/// The new runtime profile id.
profile_id: String,
},
/// A template was updated.
#[serde(rename_all = "camelCase")]
TemplateUpdated {
/// Template id.
template_id: String,
/// New version.
version: u64,
},
/// A synchronized agent drifted from its template.
#[serde(rename_all = "camelCase")]
AgentDriftDetected {
/// Agent id.
agent_id: String,
/// Current version.
from: u64,
/// Available version.
to: u64,
},
/// A synchronized agent was brought up to date.
#[serde(rename_all = "camelCase")]
AgentSynced {
/// Agent id.
agent_id: String,
/// Version synced to.
to: u64,
},
/// A paired device was revoked.
#[serde(rename_all = "camelCase")]
DeviceRevoked {
/// Device id.
device_id: String,
},
/// Every paired device was revoked.
AllDevicesRevoked,
/// A skill was assigned to (or unassigned from) an agent.
#[serde(rename_all = "camelCase")]
SkillAssigned {
/// Agent id.
agent_id: String,
/// Skill id.
skill_id: String,
/// `true` if assigned, `false` if unassigned.
assigned: bool,
},
/// A tab's layout changed.
#[serde(rename_all = "camelCase")]
LayoutChanged {
/// Project id.
project_id: String,
},
/// A remote connection was established.
#[serde(rename_all = "camelCase")]
RemoteConnected {
/// Project id.
project_id: String,
},
/// Git state changed.
#[serde(rename_all = "camelCase")]
GitStateChanged {
/// Project id.
project_id: String,
},
/// A workspace file changed through the public plugin workspace API.
#[serde(rename_all = "camelCase")]
PluginWorkspaceFileChanged {
/// Project id.
project_id: String,
/// Relative workspace path.
path: String,
/// Public operation label.
operation: String,
},
/// An issue-backed public ticket was created.
#[serde(rename_all = "camelCase")]
IssueCreated {
/// Issue id.
issue_id: String,
/// Public ticket reference (`#N`).
issue_ref: String,
},
/// An issue-backed public ticket was updated.
#[serde(rename_all = "camelCase")]
IssueUpdated {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New optimistic version.
version: u64,
},
/// An issue-backed public ticket was deleted.
#[serde(rename_all = "camelCase")]
IssueDeleted {
/// Project id.
project_id: String,
/// Deleted public ticket reference (`#N`).
issue_ref: String,
/// Released sprint id, when any.
freed_sprint: Option<String>,
},
/// A public ticket status changed.
#[serde(rename_all = "camelCase")]
IssueStatusChanged {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New status.
status: String,
/// New optimistic version.
version: u64,
},
/// A public ticket priority changed.
#[serde(rename_all = "camelCase")]
IssuePriorityChanged {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New priority.
priority: String,
/// New optimistic version.
version: u64,
},
/// A public ticket carnet changed.
#[serde(rename_all = "camelCase")]
IssueCarnetUpdated {
/// Public ticket reference (`#N`).
issue_ref: String,
/// New optimistic version.
version: u64,
},
/// A public ticket link was added.
#[serde(rename_all = "camelCase")]
IssueLinked {
/// Public source ticket reference (`#N`).
issue_ref: String,
/// Public target ticket reference (`#N`).
target: String,
/// Link kind.
kind: String,
/// New optimistic version.
version: u64,
},
/// A public ticket link was removed.
#[serde(rename_all = "camelCase")]
IssueUnlinked {
/// Public source ticket reference (`#N`).
issue_ref: String,
/// Public target ticket reference (`#N`).
target: String,
/// Link kind.
kind: String,
/// New optimistic version.
version: u64,
},
/// An agent was assigned to a public ticket.
#[serde(rename_all = "camelCase")]
IssueAgentAssigned {
/// Public ticket reference (`#N`).
issue_ref: String,
/// Assigned agent id.
agent_id: String,
/// New optimistic version.
version: u64,
},
/// An agent was unassigned from a public ticket.
#[serde(rename_all = "camelCase")]
IssueAgentUnassigned {
/// Public ticket reference (`#N`).
issue_ref: String,
/// Unassigned agent id.
agent_id: String,
/// New optimistic version.
version: u64,
},
/// A sprint was created.
#[serde(rename_all = "camelCase")]
SprintCreated {
/// Stable sprint id.
sprint_id: String,
/// Reorderable order.
order: u32,
},
/// A sprint was renamed.
#[serde(rename_all = "camelCase")]
SprintRenamed {
/// Stable sprint id.
sprint_id: String,
/// New name.
name: String,
/// New optimistic version.
version: u64,
},
/// A sprint was reordered.
#[serde(rename_all = "camelCase")]
SprintReordered {
/// Stable sprint id.
sprint_id: String,
/// New order.
order: u32,
/// New optimistic version.
version: u64,
},
/// A sprint was deleted.
#[serde(rename_all = "camelCase")]
SprintDeleted {
/// Stable sprint id.
sprint_id: String,
},
/// A ticket changed sprint membership.
#[serde(rename_all = "camelCase")]
IssueSprintChanged {
/// Public ticket reference (`#N`).
issue_ref: String,
/// Previous sprint id.
from: Option<String>,
/// New sprint id.
to: Option<String>,
/// New optimistic version.
version: u64,
},
/// An orchestrator request was processed on behalf of a requester agent.
#[serde(rename_all = "camelCase")]
OrchestratorRequestProcessed {
/// Id of the requesting (orchestrator) agent.
requester_id: String,
/// The action that was processed.
action: String,
/// Whether IdeA handled it successfully.
ok: bool,
/// Which entry door the request arrived through (`"file"` watcher vs
/// `"mcp"` server). Serialised as a lowercase string so the frontend can
/// badge the source.
source: OrchestrationSourceDto,
},
/// The project's orchestrator designation changed (T1).
#[serde(rename_all = "camelCase")]
OrchestratorChanged {
/// The project whose designation changed.
project_id: String,
/// The newly designated orchestrator agent, or `None` for the default
/// (the oldest agent orchestrates).
#[serde(skip_serializing_if = "Option::is_none")]
orchestrator: Option<String>,
},
/// The project's global context was written directly.
#[serde(rename_all = "camelCase")]
ProjectContextUpdated {
/// The project whose global context changed.
project_id: String,
/// Writer party (`"user"` or agent id).
by: String,
/// Epoch-milliseconds of the write.
at_ms: i64,
},
/// A memory note was created or updated.
#[serde(rename_all = "camelCase")]
MemorySaved {
/// The saved note's slug.
slug: String,
},
/// A memory note was deleted.
#[serde(rename_all = "camelCase")]
MemoryDeleted {
/// The deleted note's slug.
slug: String,
},
/// The aggregated `MEMORY.md` index was rebuilt.
#[serde(rename_all = "camelCase")]
MemoryIndexRebuilt {
/// Project id.
project_id: String,
},
/// A project's memory crossed the recall budget while no embedder is configured
/// (LOT C3 — §14.5.5): a one-time, dismissible "configure an embedder?" hint.
#[serde(rename_all = "camelCase")]
EmbedderSuggested {
/// Project id.
project_id: String,
/// Whether a local Ollama-style embedding server was detected.
ollama_detected: bool,
/// Ids of recommended ONNX models already present in the local cache.
onnx_cached: Vec<String>,
/// Whether the HTTP capability is compiled in.
vector_http_enabled: bool,
/// Whether the in-process ONNX capability is compiled in.
vector_onnx_enabled: bool,
},
/// An agent entered a **session/rate limit** (ARCHITECTURE §21). Low-frequency,
/// model-agnostic badge: carries only the neutral "limited, maybe resets at T"
/// fact. The frontend badges "limité jusqu'à HH:MM".
#[serde(rename_all = "camelCase")]
AgentRateLimited {
/// Agent id (UUID string).
agent_id: String,
/// Reset instant in **epoch-milliseconds**; `null` ⇒ unknown (no auto-resume).
#[serde(skip_serializing_if = "Option::is_none")]
resets_at_ms: Option<i64>,
},
/// An **auto-resume** was armed for a rate-limited agent (ARCHITECTURE §21). The
/// frontend shows the countdown + the "Annuler la reprise" button (cancellable
/// window).
#[serde(rename_all = "camelCase")]
AgentResumeScheduled {
/// Agent id (UUID string).
agent_id: String,
/// Wake-up deadline in **epoch-milliseconds**.
fire_at_ms: i64,
},
/// An agent's **auto-resume** was **cancelled** (ARCHITECTURE §21): the user
/// clicked "Annuler la reprise". The frontend removes the countdown.
#[serde(rename_all = "camelCase")]
AgentResumeCancelled {
/// Agent id (UUID string).
agent_id: String,
},
/// An agent was effectively **resumed** after a limit (ARCHITECTURE §21): the
/// wake-up fired (or immediate resume). The frontend clears the "limited" state.
#[serde(rename_all = "camelCase")]
AgentResumed {
/// Agent id (UUID string).
agent_id: String,
},
/// **Human net (level 3)**: a session limit is **suspected** without any reliable
/// reset time (ARCHITECTURE §21.1). IdeA never resumes blind: this asks the front
/// to **prompt the user** ("limit detected but time unknown — resume at?").
#[serde(rename_all = "camelCase")]
AgentRateLimitSuspected {
/// Agent id (UUID string).
agent_id: String,
/// Reset instant in **epoch-milliseconds** if an estimate exists, else `null`.
#[serde(skip_serializing_if = "Option::is_none")]
resets_at_ms: Option<i64>,
},
/// Raw PTY output (normally routed to a per-session channel, not here).
#[serde(rename_all = "camelCase")]
PtyOutput {
/// Session id.
session_id: String,
/// Output bytes.
bytes: Vec<u8>,
},
}
/// Wire mirror of [`OrchestrationSource`]: which entry door a processed
/// orchestration request arrived through. Serialised as a lowercase string
/// (`"file"` / `"mcp"`) the frontend badges on the event.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum OrchestrationSourceDto {
/// A `.ideai/requests` JSON file (filesystem watcher).
File,
/// A `tools/call` on the MCP server.
Mcp,
}
/// Wire mirror of [`AgentLiveness`]: the alive/stalled liveness of an agent (lot 2),
/// serialised as a lowercase string (`"alive"` / `"stalled"`) the frontend badges.
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "camelCase")]
pub enum AgentLivenessDto {
/// The agent shows proof of liveness (or is idle).
Alive,
/// No proof of liveness past the profile's `stallAfterMs` threshold.
Stalled,
}
impl From<AgentLiveness> for AgentLivenessDto {
fn from(liveness: AgentLiveness) -> Self {
match liveness {
AgentLiveness::Alive => Self::Alive,
AgentLiveness::Stalled => Self::Stalled,
}
}
}
impl From<OrchestrationSource> for OrchestrationSourceDto {
fn from(source: OrchestrationSource) -> Self {
match source {
OrchestrationSource::File => Self::File,
OrchestrationSource::Mcp => Self::Mcp,
}
}
}
fn issue_status_wire(status: IssueStatus) -> &'static str {
match status {
IssueStatus::Open => "open",
IssueStatus::InProgress => "inProgress",
IssueStatus::Qa => "QA",
IssueStatus::Closed => "closed",
}
}
fn issue_priority_wire(priority: IssuePriority) -> &'static str {
match priority {
IssuePriority::Low => "low",
IssuePriority::Medium => "medium",
IssuePriority::High => "high",
IssuePriority::Critical => "critical",
}
}
fn issue_link_kind_wire(kind: IssueLinkKind) -> &'static str {
match kind {
IssueLinkKind::RelatesTo => "relatesTo",
IssueLinkKind::Blocks => "blocks",
IssueLinkKind::BlockedBy => "blockedBy",
IssueLinkKind::Duplicates => "duplicates",
IssueLinkKind::DependsOn => "dependsOn",
}
}
fn conversation_party_wire(party: ConversationParty) -> String {
match party {
ConversationParty::User => "user".to_owned(),
ConversationParty::Agent { agent_id } => agent_id.to_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 {
fn from(e: &DomainEvent) -> Self {
match e {
DomainEvent::ProjectCreated { project_id } => Self::ProjectCreated {
project_id: project_id.to_string(),
},
DomainEvent::PluginInstalled { plugin_id, version } => Self::PluginInstalled {
plugin_id: plugin_id.to_string(),
version: version.as_str().to_owned(),
},
DomainEvent::PluginEnabled { plugin_id } => Self::PluginEnabled {
plugin_id: plugin_id.to_string(),
},
DomainEvent::PluginDisabled {
plugin_id,
restart_required,
} => Self::PluginDisabled {
plugin_id: plugin_id.to_string(),
restart_required: *restart_required,
},
DomainEvent::PluginUninstalled {
plugin_id,
restart_required,
} => Self::PluginUninstalled {
plugin_id: plugin_id.to_string(),
restart_required: *restart_required,
},
DomainEvent::PluginLoadFailed { plugin_id, reason } => Self::PluginLoadFailed {
plugin_id: plugin_id.to_string(),
reason: reason.clone(),
},
DomainEvent::PluginMcpServerStarted {
plugin_id,
server_id,
} => Self::PluginMcpServerStarted {
plugin_id: plugin_id.to_string(),
server_id: server_id.as_str().to_owned(),
},
DomainEvent::PluginMcpServerStopped {
plugin_id,
server_id,
} => Self::PluginMcpServerStopped {
plugin_id: plugin_id.to_string(),
server_id: server_id.as_str().to_owned(),
},
DomainEvent::AgentLaunched {
agent_id,
session_id,
} => Self::AgentLaunched {
agent_id: agent_id.to_string(),
session_id: session_id.to_string(),
},
DomainEvent::AgentLaunchFailed {
agent_id,
cause,
code,
message,
} => Self::AgentLaunchFailed {
agent_id: agent_id.to_string(),
cause: cause.clone(),
code: code.clone(),
message: message.clone(),
},
DomainEvent::ModelServerStatusChanged { server_id, status } => {
Self::ModelServerStatusChanged {
server_id: server_id.to_string(),
status: status.into(),
}
}
DomainEvent::AgentExited { agent_id, code } => Self::AgentExited {
agent_id: agent_id.to_string(),
code: *code,
},
DomainEvent::TicketAssistantOpened {
issue_ref,
profile_id,
} => Self::TicketAssistantOpened {
issue_ref: issue_ref.to_string(),
profile_id: profile_id.to_string(),
},
DomainEvent::TicketAssistantClosed { issue_ref } => Self::TicketAssistantClosed {
issue_ref: issue_ref.to_string(),
},
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
agent_id: agent_id.to_string(),
busy: *busy,
},
DomainEvent::DelegationReady {
agent_id,
ticket,
text,
submit_sequence,
submit_delay_ms,
} => Self::DelegationReady {
agent_id: agent_id.to_string(),
ticket: ticket.to_string(),
text: text.clone(),
submit_sequence: submit_sequence.clone(),
submit_delay_ms: *submit_delay_ms,
},
DomainEvent::AgentReplied {
agent_id,
reply_len,
} => Self::AgentReplied {
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(),
liveness: (*liveness).into(),
}
}
DomainEvent::BackgroundTaskStarted {
project_id,
task_id,
owner_agent_id,
} => Self::BackgroundTaskChanged {
project_id: project_id.to_string(),
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,
task_id,
owner_agent_id,
state,
} => Self::BackgroundTaskChanged {
project_id: project_id.to_string(),
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,
task_id,
owner_agent_id,
} => Self::BackgroundTaskChanged {
project_id: project_id.to_string(),
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,
task_id,
owner_agent_id,
} => Self::BackgroundTaskChanged {
project_id: project_id.to_string(),
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(),
depth: *depth,
action: "queued".to_owned(),
},
DomainEvent::AgentInboxDrained { agent_id, depth } => Self::AgentInboxChanged {
agent_id: agent_id.to_string(),
depth: *depth,
action: "drained".to_owned(),
},
DomainEvent::AgentWakeScheduled {
project_id,
agent_id,
} => Self::AgentWakeChanged {
project_id: project_id.to_string(),
agent_id: agent_id.to_string(),
action: "scheduled".to_owned(),
reason: None,
},
DomainEvent::AgentWakeStarted {
project_id,
agent_id,
} => Self::AgentWakeChanged {
project_id: project_id.to_string(),
agent_id: agent_id.to_string(),
action: "started".to_owned(),
reason: None,
},
DomainEvent::AgentWakeFailed {
project_id,
agent_id,
reason,
} => Self::AgentWakeChanged {
project_id: project_id.to_string(),
agent_id: agent_id.to_string(),
action: "failed".to_owned(),
reason: Some(reason.clone()),
},
DomainEvent::AgentProfileChanged {
agent_id,
profile_id,
} => Self::AgentProfileChanged {
agent_id: agent_id.to_string(),
profile_id: profile_id.to_string(),
},
DomainEvent::TemplateUpdated {
template_id,
version,
} => Self::TemplateUpdated {
template_id: template_id.to_string(),
version: version.get(),
},
DomainEvent::AgentDriftDetected { agent_id, from, to } => Self::AgentDriftDetected {
agent_id: agent_id.to_string(),
from: from.get(),
to: to.get(),
},
DomainEvent::AgentSynced { agent_id, to } => Self::AgentSynced {
agent_id: agent_id.to_string(),
to: to.get(),
},
DomainEvent::DeviceRevoked { device_id } => Self::DeviceRevoked {
device_id: device_id.to_string(),
},
DomainEvent::AllDevicesRevoked => Self::AllDevicesRevoked,
DomainEvent::SkillAssigned {
agent_id,
skill_id,
assigned,
} => Self::SkillAssigned {
agent_id: agent_id.to_string(),
skill_id: skill_id.to_string(),
assigned: *assigned,
},
DomainEvent::LayoutChanged { project_id } => Self::LayoutChanged {
project_id: project_id.to_string(),
},
DomainEvent::RemoteConnected { project_id } => Self::RemoteConnected {
project_id: project_id.to_string(),
},
DomainEvent::GitStateChanged { project_id } => Self::GitStateChanged {
project_id: project_id.to_string(),
},
DomainEvent::PluginWorkspaceFileChanged {
project_id,
path,
operation,
} => Self::PluginWorkspaceFileChanged {
project_id: project_id.to_string(),
path: path.clone(),
operation: operation.clone(),
},
DomainEvent::IssueCreated {
issue_id,
issue_ref,
} => Self::IssueCreated {
issue_id: issue_id.to_string(),
issue_ref: issue_ref.to_string(),
},
DomainEvent::IssueUpdated { issue_ref, version } => Self::IssueUpdated {
issue_ref: issue_ref.to_string(),
version: version.get(),
},
DomainEvent::IssueDeleted {
project_id,
issue_ref,
freed_sprint,
} => Self::IssueDeleted {
project_id: project_id.to_string(),
issue_ref: issue_ref.to_string(),
freed_sprint: freed_sprint.map(|sprint| sprint.to_string()),
},
DomainEvent::IssueStatusChanged {
issue_ref,
status,
version,
} => Self::IssueStatusChanged {
issue_ref: issue_ref.to_string(),
status: issue_status_wire(*status).to_owned(),
version: version.get(),
},
DomainEvent::IssuePriorityChanged {
issue_ref,
priority,
version,
} => Self::IssuePriorityChanged {
issue_ref: issue_ref.to_string(),
priority: issue_priority_wire(*priority).to_owned(),
version: version.get(),
},
DomainEvent::IssueCarnetUpdated { issue_ref, version } => Self::IssueCarnetUpdated {
issue_ref: issue_ref.to_string(),
version: version.get(),
},
DomainEvent::IssueLinked {
issue_ref,
target,
kind,
version,
} => Self::IssueLinked {
issue_ref: issue_ref.to_string(),
target: target.to_string(),
kind: issue_link_kind_wire(*kind).to_owned(),
version: version.get(),
},
DomainEvent::IssueUnlinked {
issue_ref,
target,
kind,
version,
} => Self::IssueUnlinked {
issue_ref: issue_ref.to_string(),
target: target.to_string(),
kind: issue_link_kind_wire(*kind).to_owned(),
version: version.get(),
},
DomainEvent::IssueAgentAssigned {
issue_ref,
agent_id,
version,
} => Self::IssueAgentAssigned {
issue_ref: issue_ref.to_string(),
agent_id: agent_id.to_string(),
version: version.get(),
},
DomainEvent::IssueAgentUnassigned {
issue_ref,
agent_id,
version,
} => Self::IssueAgentUnassigned {
issue_ref: issue_ref.to_string(),
agent_id: agent_id.to_string(),
version: version.get(),
},
DomainEvent::SprintCreated { sprint_id, order } => Self::SprintCreated {
sprint_id: sprint_id.to_string(),
order: order.get(),
},
DomainEvent::SprintRenamed {
sprint_id,
name,
version,
} => Self::SprintRenamed {
sprint_id: sprint_id.to_string(),
name: name.clone(),
version: version.get(),
},
DomainEvent::SprintReordered {
sprint_id,
order,
version,
} => Self::SprintReordered {
sprint_id: sprint_id.to_string(),
order: order.get(),
version: version.get(),
},
DomainEvent::SprintDeleted { sprint_id } => Self::SprintDeleted {
sprint_id: sprint_id.to_string(),
},
DomainEvent::IssueSprintChanged {
issue_ref,
from,
to,
version,
} => Self::IssueSprintChanged {
issue_ref: issue_ref.to_string(),
from: from.map(|id| id.to_string()),
to: to.map(|id| id.to_string()),
version: version.get(),
},
DomainEvent::OrchestratorRequestProcessed {
requester_id,
action,
ok,
source,
} => Self::OrchestratorRequestProcessed {
requester_id: requester_id.clone(),
action: action.clone(),
ok: *ok,
source: (*source).into(),
},
DomainEvent::OrchestratorChanged {
project_id,
orchestrator,
} => Self::OrchestratorChanged {
project_id: project_id.to_string(),
orchestrator: orchestrator.as_ref().map(|a| a.to_string()),
},
DomainEvent::ProjectContextUpdated {
project_id,
by,
at_ms,
} => Self::ProjectContextUpdated {
project_id: project_id.to_string(),
by: conversation_party_wire(*by),
at_ms: *at_ms,
},
DomainEvent::MemorySaved { slug } => Self::MemorySaved {
slug: slug.as_str().to_string(),
},
DomainEvent::MemoryDeleted { slug } => Self::MemoryDeleted {
slug: slug.as_str().to_string(),
},
DomainEvent::MemoryIndexRebuilt { project_id } => Self::MemoryIndexRebuilt {
project_id: project_id.to_string(),
},
DomainEvent::EmbedderSuggested {
project_id,
ollama_detected,
onnx_cached,
vector_http_enabled,
vector_onnx_enabled,
} => Self::EmbedderSuggested {
project_id: project_id.to_string(),
ollama_detected: *ollama_detected,
onnx_cached: onnx_cached.clone(),
vector_http_enabled: *vector_http_enabled,
vector_onnx_enabled: *vector_onnx_enabled,
},
DomainEvent::AgentRateLimited {
agent_id,
resets_at_ms,
} => Self::AgentRateLimited {
agent_id: agent_id.to_string(),
resets_at_ms: *resets_at_ms,
},
DomainEvent::AgentResumeScheduled {
agent_id,
fire_at_ms,
} => Self::AgentResumeScheduled {
agent_id: agent_id.to_string(),
fire_at_ms: *fire_at_ms,
},
DomainEvent::AgentResumeCancelled { agent_id } => Self::AgentResumeCancelled {
agent_id: agent_id.to_string(),
},
DomainEvent::AgentResumed { agent_id } => Self::AgentResumed {
agent_id: agent_id.to_string(),
},
DomainEvent::AgentRateLimitSuspected {
agent_id,
resets_at_ms,
} => Self::AgentRateLimitSuspected {
agent_id: agent_id.to_string(),
resets_at_ms: *resets_at_ms,
},
DomainEvent::PtyOutput { session_id, bytes } => Self::PtyOutput {
session_id: session_id.to_string(),
bytes: bytes.clone(),
},
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::AgentId;
use domain::mailbox::TicketId;
use domain::{ConversationId, LocalModelServerId, ProjectId, TaskId};
use serde_json::json;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn server(n: u128) -> LocalModelServerId {
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 {
server_id: server(35),
status: ModelServerLifecycleStatus::Ready { reused: true },
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "modelServerStatusChanged");
assert_eq!(json["serverId"], server(35).to_string());
assert_eq!(json["status"]["state"], "ready");
assert_eq!(json["status"]["reused"], true);
}
#[test]
fn model_server_status_changed_relays_downloading_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::ModelServerStatusChanged {
server_id: server(37),
status: ModelServerLifecycleStatus::Downloading {
downloaded_bytes: None,
total_bytes: None,
percent: None,
source: Some("Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF".to_owned()),
},
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "modelServerStatusChanged");
assert_eq!(json["serverId"], server(37).to_string());
assert_eq!(json["status"]["state"], "downloading");
assert_eq!(json["status"]["downloadedBytes"], serde_json::Value::Null);
assert_eq!(json["status"]["totalBytes"], serde_json::Value::Null);
assert_eq!(json["status"]["percent"], serde_json::Value::Null);
assert_eq!(
json["status"]["source"],
"Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF"
);
}
#[test]
fn agent_launch_failed_relays_model_server_cause() {
let dto = DomainEventDto::from(&DomainEvent::AgentLaunchFailed {
agent_id: agent(36),
cause: "model_server".to_owned(),
code: "path_not_accessible".to_owned(),
message: "path not accessible: /models/missing.gguf".to_owned(),
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "agentLaunchFailed");
assert_eq!(json["agentId"], agent(36).to_string());
assert_eq!(json["cause"], "model_server");
assert_eq!(json["code"], "path_not_accessible");
}
/// Lot 2 : un `AgentLivenessChanged{Stalled}` du domaine se relaie en DTO
/// `Stalled` portant le même agent, et se sérialise en `"stalled"` (le mot que
/// le front badge). Garantit le câblage présentation de la détection de stall.
#[test]
fn liveness_changed_stalled_relays_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
agent_id: agent(7),
liveness: AgentLiveness::Stalled,
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "agentLivenessChanged");
assert_eq!(json["agentId"], agent(7).to_string());
assert_eq!(json["liveness"], "stalled");
}
/// La reprise `Stalled→Alive` se relaie en DTO `Alive` ⇒ wire `"alive"`.
#[test]
fn liveness_changed_alive_relays_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::AgentLivenessChanged {
agent_id: agent(3),
liveness: AgentLiveness::Alive,
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "agentLivenessChanged");
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,
})
);
}
#[test]
fn project_context_updated_relays_writer_to_wire() {
let project_id = ProjectId::from_uuid(uuid::Uuid::from_u128(1));
let writer = agent(2);
let dto = DomainEventDto::from(&DomainEvent::ProjectContextUpdated {
project_id,
by: ConversationParty::agent(writer),
at_ms: 987_654,
});
assert_eq!(
serde_json::to_value(&dto).unwrap(),
json!({
"type": "projectContextUpdated",
"projectId": project_id.to_string(),
"by": writer.to_string(),
"atMs": 987654,
})
);
}
#[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 ».
#[test]
fn rate_limited_relays_to_dto_and_wire() {
let dto = DomainEventDto::from(&DomainEvent::AgentRateLimited {
agent_id: agent(11),
resets_at_ms: Some(1_700_000_000_000),
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "agentRateLimited");
assert_eq!(json["agentId"], agent(11).to_string());
assert_eq!(json["resetsAtMs"], 1_700_000_000_000_i64);
}
#[test]
fn issue_sprint_changed_relays_to_dto_and_wire() {
let from = domain::SprintId::from_uuid(uuid::Uuid::from_u128(41));
let to = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
let dto = DomainEventDto::from(&DomainEvent::IssueSprintChanged {
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
from: Some(from),
to: Some(to),
version: domain::IssueVersion::new(3).unwrap(),
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "issueSprintChanged");
assert_eq!(json["issueRef"], "#7");
assert_eq!(json["from"], from.to_string());
assert_eq!(json["to"], to.to_string());
assert_eq!(json["version"], 3);
}
#[test]
fn issue_deleted_relays_to_dto_and_wire() {
let project_id = domain::ProjectId::from_uuid(uuid::Uuid::from_u128(40));
let sprint_id = domain::SprintId::from_uuid(uuid::Uuid::from_u128(42));
let dto = DomainEventDto::from(&DomainEvent::IssueDeleted {
project_id,
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
freed_sprint: Some(sprint_id),
});
let json = serde_json::to_value(&dto).expect("serialisable");
assert_eq!(json["type"], "issueDeleted");
assert_eq!(json["projectId"], project_id.to_string());
assert_eq!(json["issueRef"], "#7");
assert_eq!(json["freedSprint"], sprint_id.to_string());
}
#[test]
fn ticket_assistant_events_relay_to_dto_and_wire() {
let profile_id = domain::ProfileId::from_uuid(uuid::Uuid::from_u128(9));
let opened = DomainEventDto::from(&DomainEvent::TicketAssistantOpened {
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
profile_id,
});
let opened = serde_json::to_value(&opened).expect("serialisable");
assert_eq!(opened["type"], "ticketAssistantOpened");
assert_eq!(opened["issueRef"], "#7");
assert_eq!(opened["profileId"], profile_id.to_string());
let closed = DomainEventDto::from(&DomainEvent::TicketAssistantClosed {
issue_ref: domain::IssueRef::from(domain::IssueNumber::new(7).unwrap()),
});
let closed = serde_json::to_value(&closed).expect("serialisable");
assert_eq!(closed["type"], "ticketAssistantClosed");
assert_eq!(closed["issueRef"], "#7");
}
}