Files
IdeA/crates/domain/src/events.rs
Blomios 8fe93d1652 feat(backend): appareils appairés persistants, révocables et code éphémère (#77 B1-B4)
L'appairage ne survivait pas au redémarrage et son code, permanent, était
imprimé sur la sortie standard. Un appareil appairé devient une entité
persistante, nommée et révocable, derrière un code désormais éphémère.

- B1 : port DeviceSessionStore et adapter FsDeviceSessionStore, entités de
  domaine (PairedDevice, DeviceId, SessionTokenHash, DeviceName). Les tokens
  sont hachés en SHA-256 et comparés en temps constant (subtle) : le store
  ne peut pas rejouer une session qu'il a servie. Cookie Max-Age 400 j à
  renouvellement glissant, lastSeenAtMs throttlé.
- B2 : code éphémère en mémoire, TTL 10 min et usage unique, toute
  génération invalidant la précédente. POST /api/pairing-code authentifiée,
  flag --new-code. Le code est retiré du boot et l'eprintln! qui l'imprimait
  est supprimé.
- B3 : endpoints devices (list/rename/revoke/revoke-all/logout), event
  DeviceRevoked et ActiveConnectionRegistry par device_id, fermant sans
  délai les WebSockets d'un appareil révoqué.
- B4 : port PairAttemptLimiter et adapter mémoire, rate-limit par origine et
  global sur horloge injectée, donc testable sans attente réelle.

La normalisation du code passe côté serveur : elle absorbe la dette #76, que
la seule normalisation frontend de #75 ne faisait que masquer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 13:26:56 +02:00

747 lines
26 KiB
Rust

//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
//! presentation layer (ARCHITECTURE §3.2).
use crate::conversation::ConversationParty;
use crate::device::DeviceId;
use crate::ids::{
AgentId, IssueId, LocalModelServerId, ProfileId, ProjectId, SessionId, SkillId, SprintId,
TaskId, TemplateId,
};
use crate::issue::{IssueLinkKind, IssuePriority, IssueRef, IssueStatus, IssueVersion};
use crate::mailbox::TicketId;
use crate::memory::MemorySlug;
use crate::sprint::{SprintOrder, SprintVersion};
use crate::template::TemplateVersion;
/// Which entry door a processed orchestration request arrived through.
///
/// IdeA exposes the *same* [`crate::OrchestratorService::dispatch`] behind two
/// substitutable driving adapters (ARCHITECTURE §14.3 + cadrage `orchestration-v3`):
/// the `.ideai/requests` filesystem watcher and the MCP server. This tag — set by
/// the adapter that received the request, never inferred in the application — lets
/// the presentation layer surface *how* a delegation came in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrchestrationSource {
/// A JSON request file dropped under `.ideai/requests/`.
File,
/// A `tools/call` on the MCP server.
Mcp,
}
/// Events emitted by the domain/application as state changes occur.
///
/// Deliberately *not* `Serialize`/`Deserialize`: events are an in-process
/// concern relayed to IPC by an infrastructure adapter, which owns the wire
/// format. `PtyOutput` in particular is usually short-circuited to a Tauri
/// channel rather than serialised here.
#[derive(Debug, Clone, PartialEq)]
pub enum DomainEvent {
/// A project was created.
ProjectCreated {
/// The new project.
project_id: ProjectId,
},
/// An agent was launched in a terminal.
AgentLaunched {
/// The agent.
agent_id: AgentId,
/// The session it runs in.
session_id: SessionId,
},
/// An agent launch failed before a runtime session could be created.
AgentLaunchFailed {
/// The agent whose launch failed.
agent_id: AgentId,
/// Failure cause namespace.
cause: String,
/// Stable error code.
code: String,
/// Human-readable message.
message: String,
},
/// A local model-server status changed while preparing a launch.
ModelServerStatusChanged {
/// The local model server.
server_id: LocalModelServerId,
/// New lifecycle status.
status: crate::model_server::ModelServerLifecycleStatus,
},
/// A first-class background task started running.
BackgroundTaskStarted {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task changed state.
BackgroundTaskStateChanged {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
/// New state.
state: crate::background_task::BackgroundTaskState,
},
/// A first-class background task completed successfully.
BackgroundTaskCompleted {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task failed.
BackgroundTaskFailed {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task was cancelled.
BackgroundTaskCancelled {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task has a terminal result not yet delivered.
BackgroundTaskCompletionDeliveryPending {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// A first-class background task completion was delivered.
BackgroundTaskCompletionDelivered {
/// The owning project.
project_id: ProjectId,
/// The task.
task_id: TaskId,
/// The agent that owns completion delivery.
owner_agent_id: AgentId,
},
/// An item was queued in an agent inbox.
AgentInboxQueued {
/// Target agent.
agent_id: AgentId,
/// Queue depth after enqueue.
depth: usize,
},
/// An item was drained from an agent inbox.
AgentInboxDrained {
/// Target agent.
agent_id: AgentId,
/// Queue depth after drain.
depth: usize,
},
/// A wake was scheduled for an agent.
AgentWakeScheduled {
/// Owning project.
project_id: ProjectId,
/// Target agent.
agent_id: AgentId,
},
/// A wake turn started for an agent.
AgentWakeStarted {
/// Owning project.
project_id: ProjectId,
/// Target agent.
agent_id: AgentId,
},
/// A wake attempt failed.
AgentWakeFailed {
/// Owning project.
project_id: ProjectId,
/// Target agent.
agent_id: AgentId,
/// Human-readable reason.
reason: String,
},
/// A target agent produced a synchronous reply to an inter-agent `ask`
/// (ARCHITECTURE §17.4): the requester sent a task via `agent.message`, IdeA
/// drove the target's structured session to its turn `Final`, and this is the
/// observability beacon for that completed rendezvous. Carries the target id and
/// the reply size (a lightweight metric); the full body is returned to the
/// requester out-of-band, not in the event payload (the bus stays I/O-free).
AgentReplied {
/// The agent that produced the reply.
agent_id: AgentId,
/// Number of bytes in the reply content (preview metric, not the payload).
reply_len: usize,
},
/// Intermediate assistant announcement emitted live during an inter-agent
/// structured turn. Ephemeral observability event: never persisted, never terminal.
AgentAnnouncement {
/// Owning project.
project_id: ProjectId,
/// Human or source agent that requested the turn.
requester: ConversationParty,
/// Target agent producing the announcement.
target: AgentId,
/// FIFO ticket correlating this turn.
ticket: TicketId,
/// Announcement text.
text: String,
/// Wall-clock timestamp in epoch milliseconds.
at_ms: u64,
},
/// An agent's process exited.
AgentExited {
/// The agent.
agent_id: AgentId,
/// Exit code.
code: i32,
},
/// A ticket assistant session was opened for an issue.
TicketAssistantOpened {
/// Bound issue.
issue_ref: IssueRef,
/// Runtime profile used by the assistant.
profile_id: ProfileId,
},
/// A ticket assistant session was closed for an issue.
TicketAssistantClosed {
/// Bound issue.
issue_ref: IssueRef,
},
/// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on
/// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an
/// explicit signal). Discrete, low-frequency beacon relayed to the front so the
/// mediated-input view can dim "Envoyer" while a turn is in flight (it never
/// blocks the enqueue — the FIFO keeps accepting; the flag is purely advisory).
AgentBusyChanged {
/// The agent whose state changed.
agent_id: AgentId,
/// `true` when a turn is in flight, `false` when the agent is idle.
busy: bool,
},
/// An agent's **liveness** (alive/stalled) changed (lot 2, chantier
/// readiness/heartbeat). Emitted **once per transition** by the stall detector:
/// `Alive→Stalled` when no proof of liveness arrived for longer than the profile's
/// `stall_after_ms`, and `Stalled→Alive` when a late battement (delta / tool
/// activity / heartbeat) revives it or the agent returns to `Idle`. Discrete,
/// low-frequency beacon (no spam) relayed to the front so the mediated-input view
/// can badge a frozen agent. Purely advisory — the FIFO and the turn keep running.
AgentLivenessChanged {
/// The agent whose liveness changed.
agent_id: AgentId,
/// The new liveness state.
liveness: crate::input::AgentLiveness,
},
/// An agent's runtime profile was changed (hot-swap of the AI engine).
AgentProfileChanged {
/// The agent.
agent_id: AgentId,
/// The new runtime profile it now uses.
profile_id: ProfileId,
},
/// A template was updated (content changed, version bumped).
TemplateUpdated {
/// The template.
template_id: TemplateId,
/// New version.
version: TemplateVersion,
},
/// A synchronized agent is behind its template.
AgentDriftDetected {
/// The drifting agent.
agent_id: AgentId,
/// Version the agent is currently at.
from: TemplateVersion,
/// Version available from the template.
to: TemplateVersion,
},
/// A synchronized agent received its template update.
AgentSynced {
/// The agent.
agent_id: AgentId,
/// Version it was brought up to.
to: TemplateVersion,
},
/// A paired device was revoked and any live transport for it must be closed.
DeviceRevoked {
/// Revoked device.
device_id: DeviceId,
},
/// All paired devices were revoked and every live paired transport must close.
AllDevicesRevoked,
/// A skill was assigned to (or unassigned from) an agent.
SkillAssigned {
/// The agent whose skill set changed.
agent_id: AgentId,
/// The skill involved.
skill_id: SkillId,
/// `true` if assigned, `false` if unassigned.
assigned: bool,
},
/// A tab's layout changed.
LayoutChanged {
/// The project whose layout changed.
project_id: ProjectId,
},
/// A remote host connection was established.
RemoteConnected {
/// The project on that remote.
project_id: ProjectId,
},
/// Git state for a project changed.
GitStateChanged {
/// The project.
project_id: ProjectId,
},
/// An orchestrator request (dropped under `.ideai/requests/`) was processed
/// by IdeA on behalf of a requester agent (ARCHITECTURE §14.3). Relayed so the
/// frontend can surface orchestration activity; the resulting cell/tab opens
/// off the [`AgentLaunched`](Self::AgentLaunched) event for `spawn_agent`.
OrchestratorRequestProcessed {
/// Id of the requesting (orchestrator) agent — the request subdirectory.
requester_id: String,
/// The action that was processed (`spawn_agent`, `stop_agent`, …).
action: String,
/// Whether IdeA handled it successfully.
ok: bool,
/// Which entry door the request arrived through (file watcher vs MCP),
/// tagged by the receiving adapter.
source: OrchestrationSource,
},
/// A memory note was created or updated (`.md` written, index upserted).
MemorySaved {
/// The saved note's slug.
slug: MemorySlug,
},
/// A memory note was deleted.
MemoryDeleted {
/// The deleted note's slug.
slug: MemorySlug,
},
/// The aggregated `MEMORY.md` index was rebuilt for a project.
MemoryIndexRebuilt {
/// The project whose index was rebuilt.
project_id: ProjectId,
},
/// An issue was created.
IssueCreated {
/// Stable issue id.
issue_id: IssueId,
/// Human-friendly issue reference.
issue_ref: IssueRef,
},
/// An issue was updated.
IssueUpdated {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New optimistic version.
version: IssueVersion,
},
/// An issue was deleted.
IssueDeleted {
/// Owning project.
project_id: ProjectId,
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Sprint membership released by the deletion, when any.
freed_sprint: Option<SprintId>,
},
/// An issue status changed.
IssueStatusChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New status.
status: IssueStatus,
/// New optimistic version.
version: IssueVersion,
},
/// An issue priority changed.
IssuePriorityChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New priority.
priority: IssuePriority,
/// New optimistic version.
version: IssueVersion,
},
/// An issue carnet was updated.
IssueCarnetUpdated {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// New optimistic version.
version: IssueVersion,
},
/// Two issues were linked.
IssueLinked {
/// Source issue.
issue_ref: IssueRef,
/// Target issue.
target: IssueRef,
/// Link kind.
kind: IssueLinkKind,
/// New optimistic version.
version: IssueVersion,
},
/// Two issues were unlinked.
IssueUnlinked {
/// Source issue.
issue_ref: IssueRef,
/// Target issue.
target: IssueRef,
/// Link kind.
kind: IssueLinkKind,
/// New optimistic version.
version: IssueVersion,
},
/// An agent was assigned to an issue.
IssueAgentAssigned {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Assigned agent.
agent_id: AgentId,
/// New optimistic version.
version: IssueVersion,
},
/// An agent was unassigned from an issue.
IssueAgentUnassigned {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Unassigned agent.
agent_id: AgentId,
/// New optimistic version.
version: IssueVersion,
},
/// A sprint was created.
SprintCreated {
/// Stable sprint id.
sprint_id: SprintId,
/// Reorderable sprint order.
order: SprintOrder,
},
/// A sprint was renamed.
SprintRenamed {
/// Stable sprint id.
sprint_id: SprintId,
/// New sprint name.
name: String,
/// New optimistic version.
version: SprintVersion,
},
/// Sprint orders changed.
SprintReordered {
/// Stable sprint id.
sprint_id: SprintId,
/// New sprint order.
order: SprintOrder,
/// New optimistic version.
version: SprintVersion,
},
/// A sprint was deleted.
SprintDeleted {
/// Stable sprint id.
sprint_id: SprintId,
},
/// An issue was moved into, out of, or between sprints.
IssueSprintChanged {
/// Human-friendly issue reference.
issue_ref: IssueRef,
/// Previous sprint.
from: Option<SprintId>,
/// New sprint.
to: Option<SprintId>,
/// New optimistic version.
version: IssueVersion,
},
/// A project's memory grew past the recall budget while no embedder is
/// configured (strategy `none`): the first moment a semantic embedder would
/// help (ARCHITECTURE §14.5.5, LOT C3). Published **at most once per session per
/// project** (and never again once the user chose "ne plus demander"); the
/// frontend surfaces a one-time, dismissible suggestion. Carries the detected
/// local environment and the compiled-in capabilities so the UI never proposes
/// a strategy this binary cannot run.
EmbedderSuggested {
/// The project whose memory crossed the budget.
project_id: ProjectId,
/// Whether an Ollama-style local embedding server was detected (best-effort).
ollama_detected: bool,
/// Ids of the recommended ONNX models already present in the local cache.
onnx_cached: Vec<String>,
/// Whether the HTTP capability (`localServer`/`api`) is compiled in.
vector_http_enabled: bool,
/// Whether the in-process ONNX capability (`localOnnx`) is compiled in.
vector_onnx_enabled: bool,
},
/// A delegation is ready to be injected into the agent's **native terminal**
/// (ARCHITECTURE §20.2/§20.3). Published when a turn *starts* (Idle→Busy); the
/// backend stays the authority of the FIFO/busy state and **no longer writes the
/// turn into the PTY** — the frontend cell runs the write-portal handshake and
/// writes `text` + `submit_sequence` through the single PTY writer (the front).
/// Discrete, low-frequency (one per delegation). Relayed to the front as
/// `delegationReady` like every other [`DomainEvent`].
DelegationReady {
/// The target agent whose terminal will receive the delegation.
agent_id: AgentId,
/// The mailbox ticket correlating this turn (acked back via
/// `delegation_delivered`); the requester's `ask` is woken by `idea_reply`.
ticket: TicketId,
/// The task text to inject (written without a trailing `\n` by the portal).
text: String,
/// Target profile's submit sequence (the cell applies it after the text).
/// `None` ⇒ the front applies its default (`"\r"`); never hard-coded in the
/// domain.
submit_sequence: Option<String>,
/// Target profile's submit delay in ms. `None` ⇒ front default (~60 ms).
submit_delay_ms: Option<u32>,
},
/// Un agent vient d'entrer en **limite de session/débit** (ARCHITECTURE §21).
/// Publié quand le service de limite enregistre une nouvelle `SessionLimit` (niveau
/// 1 structuré ou niveau 2 motif). Balise discrète, basse fréquence, relayée au
/// front pour afficher le badge « limité jusqu'à HH:MM ». Model-agnostique : ne
/// porte que le fait neutre « limité, reset à T (peut-être) ».
AgentRateLimited {
/// L'agent entré en limite.
agent_id: AgentId,
/// Instant de reset en **époche-millisecondes**. `None` ⇒ heure inconnue
/// (pas de reprise auto, filet humain).
resets_at_ms: Option<i64>,
},
/// Une **reprise automatique** a été armée pour un agent limité (ARCHITECTURE §21).
/// Publié après que le service a calculé le plan ([`crate::session_limit::plan_resume`])
/// et armé le `Scheduler`. Relayé au front pour afficher le compte à rebours + le
/// bouton « Annuler la reprise » (fenêtre annulable).
AgentResumeScheduled {
/// L'agent dont la reprise est programmée.
agent_id: AgentId,
/// Échéance du réveil en **époche-millisecondes**.
fire_at_ms: i64,
},
/// La **reprise automatique** d'un agent a été **annulée** (ARCHITECTURE §21) :
/// l'utilisateur a cliqué « Annuler la reprise » dans la fenêtre annulable. Relayé
/// au front pour retirer le compte à rebours.
AgentResumeCancelled {
/// L'agent dont la reprise a été annulée.
agent_id: AgentId,
},
/// Un agent a effectivement été **relancé** après une limite (ARCHITECTURE §21) :
/// le réveil a tiré (ou reprise immédiate), l'agent a redémarré via
/// [`crate::ports::SessionPlan::Resume`] avec un prompt de reprise court. Relayé au
/// front pour effacer l'état « limité ».
AgentResumed {
/// L'agent relancé.
agent_id: AgentId,
},
/// **Filet humain (niveau 3)** : une limite de session est **suspectée** sans
/// qu'aucune heure de reset fiable ne soit connue (ARCHITECTURE §21.1 niveau 3) —
/// typiquement un agent passé `Stalled` (lot 2) sans `SessionLimit` connue. IdeA ne
/// reprend **jamais** à l'aveugle : ce signal demande au front de **solliciter
/// l'utilisateur** (« limite détectée mais heure inconnue — reprendre à ? »).
AgentRateLimitSuspected {
/// L'agent dont la limite est suspectée.
agent_id: AgentId,
/// Instant de reset en **époche-millisecondes** si une estimation existe,
/// sinon `None` (l'utilisateur fournira l'heure).
resets_at_ms: Option<i64>,
},
/// The project's **orchestrator designation** changed (cadrage « orchestrateur
/// du projet », T1). Emitted when an agent is designated (radio selection) or the
/// designation is cleared back to the default (e.g. the designated agent was
/// deleted — lazy succession to the oldest agent). Relayed to the front so the UI
/// can move the radio / refresh the orchestrator badge. Model-agnostic: carries
/// only the neutral fact « who orchestrates now ».
OrchestratorChanged {
/// The project whose designation changed.
project_id: ProjectId,
/// The newly designated orchestrator agent, or `None` for the default
/// (the oldest agent orchestrates).
orchestrator: Option<AgentId>,
},
/// Raw PTY output (usually routed to a dedicated channel, not this bus).
PtyOutput {
/// The session.
session_id: SessionId,
/// Output bytes.
bytes: Vec<u8>,
},
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn profile(n: u128) -> ProfileId {
ProfileId::from_uuid(uuid::Uuid::from_u128(n))
}
fn issue_ref(n: u64) -> IssueRef {
IssueRef::from(crate::IssueNumber::new(n).unwrap())
}
// -- §21 : constructibilité + égalité PartialEq des 5 variantes --------------
#[test]
fn agent_rate_limited_constructs_and_compares() {
let ev = DomainEvent::AgentRateLimited {
agent_id: agent(1),
resets_at_ms: Some(1_700_000_000_000),
};
assert_eq!(
ev,
DomainEvent::AgentRateLimited {
agent_id: agent(1),
resets_at_ms: Some(1_700_000_000_000),
}
);
// Une heure différente ⇒ inégaux.
assert_ne!(
ev,
DomainEvent::AgentRateLimited {
agent_id: agent(1),
resets_at_ms: None,
}
);
}
#[test]
fn agent_resume_scheduled_constructs_and_compares() {
let ev = DomainEvent::AgentResumeScheduled {
agent_id: agent(2),
fire_at_ms: 1_700_000_000_000,
};
assert_eq!(
ev,
DomainEvent::AgentResumeScheduled {
agent_id: agent(2),
fire_at_ms: 1_700_000_000_000,
}
);
assert_ne!(
ev,
DomainEvent::AgentResumeScheduled {
agent_id: agent(2),
fire_at_ms: 0,
}
);
}
#[test]
fn agent_resume_cancelled_constructs_and_compares() {
let ev = DomainEvent::AgentResumeCancelled { agent_id: agent(3) };
assert_eq!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(3) });
assert_ne!(ev, DomainEvent::AgentResumeCancelled { agent_id: agent(4) });
}
#[test]
fn agent_resumed_constructs_and_compares() {
let ev = DomainEvent::AgentResumed { agent_id: agent(5) };
assert_eq!(ev, DomainEvent::AgentResumed { agent_id: agent(5) });
assert_ne!(ev, DomainEvent::AgentResumed { agent_id: agent(6) });
}
#[test]
fn agent_rate_limit_suspected_constructs_and_compares() {
let ev = DomainEvent::AgentRateLimitSuspected {
agent_id: agent(7),
resets_at_ms: None,
};
assert_eq!(
ev,
DomainEvent::AgentRateLimitSuspected {
agent_id: agent(7),
resets_at_ms: None,
}
);
assert_ne!(
ev,
DomainEvent::AgentRateLimitSuspected {
agent_id: agent(7),
resets_at_ms: Some(42),
}
);
}
#[test]
fn orchestrator_changed_constructs_and_compares() {
let project = ProjectId::from_uuid(uuid::Uuid::from_u128(100));
let ev = DomainEvent::OrchestratorChanged {
project_id: project,
orchestrator: Some(agent(1)),
};
assert_eq!(
ev,
DomainEvent::OrchestratorChanged {
project_id: project,
orchestrator: Some(agent(1)),
}
);
// Clearing to the default (None) is a distinct event.
assert_ne!(
ev,
DomainEvent::OrchestratorChanged {
project_id: project,
orchestrator: None,
}
);
}
#[test]
fn ticket_assistant_opened_constructs_and_compares() {
let ev = DomainEvent::TicketAssistantOpened {
issue_ref: issue_ref(7),
profile_id: profile(9),
};
assert_eq!(
ev,
DomainEvent::TicketAssistantOpened {
issue_ref: issue_ref(7),
profile_id: profile(9),
}
);
assert_ne!(
ev,
DomainEvent::TicketAssistantOpened {
issue_ref: issue_ref(8),
profile_id: profile(9),
}
);
}
#[test]
fn ticket_assistant_closed_constructs_and_compares() {
let ev = DomainEvent::TicketAssistantClosed {
issue_ref: issue_ref(7),
};
assert_eq!(
ev,
DomainEvent::TicketAssistantClosed {
issue_ref: issue_ref(7),
}
);
assert_ne!(
ev,
DomainEvent::TicketAssistantClosed {
issue_ref: issue_ref(8),
}
);
}
#[test]
fn distinct_session_limit_variants_are_not_equal() {
// Les variantes ne se confondent pas entre elles malgré des champs proches.
assert_ne!(
DomainEvent::AgentResumeCancelled { agent_id: agent(8) },
DomainEvent::AgentResumed { agent_id: agent(8) }
);
}
}