Skip to main content

af_agent_session/
lib.rs

1//! Event-sourced Agent sessions. The append-only log is the conversation SSOT;
2//! messages, runs, interactions and UI state are deterministic projections.
3
4#![deny(missing_docs)]
5#![deny(rustdoc::broken_intra_doc_links)]
6
7use af_context::{InputId, InteractionId, ProfileRevisionId, RunId, SessionId, ToolCallId};
8use std::collections::{BTreeMap, BTreeSet};
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15/// Wire/storage format of [`Event`]; only structural changes bump it.
16pub const SESSION_EVENT_FORMAT_VERSION: u32 = 1;
17
18/// One committed Session event with its position in the log.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct SessionEvent {
21    /// Session this record belongs to.
22    pub session_id: SessionId,
23    /// Strictly increasing sequence inside the Session.
24    pub seq: u64,
25    /// When the event happened.
26    pub occurred_at: DateTime<Utc>,
27    /// The event payload.
28    pub event: Event,
29}
30
31impl SessionEvent {
32    /// An event awaiting append: sequence 0 until the store assigns it.
33    pub fn pending(session_id: impl Into<SessionId>, event: Event) -> Self {
34        Self {
35            session_id: session_id.into(),
36            seq: 0,
37            occurred_at: Utc::now(),
38            event,
39        }
40    }
41
42    /// Format version of the payload.
43    pub fn format_version(&self) -> u32 {
44        self.event.format_version()
45    }
46
47    /// Stable snake_case event type.
48    pub fn event_type(&self) -> &str {
49        self.event.event_type()
50    }
51
52    /// Whether a reader that does not know this event may skip it.
53    pub fn ignorable(&self) -> bool {
54        self.event.ignorable()
55    }
56}
57
58/// Every model-visible fact and lifecycle transition of a Session. The log is the source of truth; everything else is a projection.
59#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum Event {
62    /// First event of a Session, pinning its Profile revision.
63    SessionCreated {
64        /// Profile revision the Session pins for its lifetime.
65        profile_revision_id: ProfileRevisionId,
66    },
67    /// First event of a forked Session, citing the parent position it copied through.
68    SessionForked {
69        /// Session the fork was cut from.
70        parent_session_id: SessionId,
71        /// Last parent sequence included in the fork.
72        parent_seq: u64,
73    },
74    /// Tombstone: active and queued Runs are closed, listing hides the Session, history stays readable.
75    SessionDeleted {
76        /// Why the Session was deleted.
77        reason: String,
78    },
79    /// Input entered the inbox for `run_id`.
80    InputQueued {
81        /// Queued input.
82        input_id: InputId,
83        /// Run that will consume the input (a new Run for `followup`).
84        run_id: RunId,
85        /// Delivery semantics.
86        mode: DeliveryMode,
87        /// Submitted content.
88        content: Vec<ContentBlock>,
89        /// Skill slug invoked explicitly with this input.
90        explicit_skill: Option<String>,
91    },
92    /// The Run took the input out of the inbox.
93    InputClaimed {
94        /// Claimed input.
95        input_id: InputId,
96        /// Claiming Run.
97        run_id: RunId,
98    },
99    /// The input was dropped before a Run consumed it.
100    InputCancelled {
101        /// Cancelled input.
102        input_id: InputId,
103        /// Run that would have consumed it.
104        run_id: RunId,
105        /// Machine-readable cancellation code.
106        error_code: String,
107    },
108    /// A Run began executing.
109    RunStarted {
110        /// The Run.
111        run_id: RunId,
112        /// Input that started it.
113        input_id: InputId,
114    },
115    /// The Run parked on an interaction.
116    RunWaiting {
117        /// The Run.
118        run_id: RunId,
119        /// Interaction it waits for.
120        interaction_id: InteractionId,
121    },
122    /// The Run resumed after its interaction resolved.
123    RunResumed {
124        /// The Run.
125        run_id: RunId,
126        /// Resolved interaction.
127        interaction_id: InteractionId,
128    },
129    /// The Run reached a terminal status.
130    RunFinished {
131        /// The Run.
132        run_id: RunId,
133        /// Terminal status.
134        status: RunStatus,
135        /// Machine-readable failure code.
136        error_code: Option<String>,
137    },
138    /// A Turn opened.
139    TurnStarted {
140        /// The Run.
141        run_id: RunId,
142        /// Turn number.
143        turn: u32,
144    },
145    /// A Turn closed.
146    TurnFinished {
147        /// The Run.
148        run_id: RunId,
149        /// Turn number.
150        turn: u32,
151    },
152    /// A Step opened.
153    StepStarted {
154        /// The Run.
155        run_id: RunId,
156        /// Step number.
157        step: u32,
158    },
159    /// A Step closed.
160    StepFinished {
161        /// The Run.
162        run_id: RunId,
163        /// Step number.
164        step: u32,
165    },
166    /// User content entered the transcript.
167    UserMessage {
168        /// The Run.
169        run_id: RunId,
170        /// Content blocks.
171        content: Vec<ContentBlock>,
172    },
173    /// Streamed assistant text fragment.
174    AssistantDelta {
175        /// The Run.
176        run_id: RunId,
177        /// Step producing it.
178        step: u32,
179        /// Provider attempt producing it.
180        attempt: u32,
181        /// Text fragment.
182        content: String,
183    },
184    /// Final assistant content for a step.
185    AssistantMessage {
186        /// The Run.
187        run_id: RunId,
188        /// Step.
189        step: u32,
190        /// Provider attempt that succeeded.
191        attempt: u32,
192        /// Content blocks.
193        content: Vec<ContentBlock>,
194    },
195    /// The model requested tool calls.
196    AssistantToolCalls {
197        /// The Run.
198        run_id: RunId,
199        /// Step.
200        step: u32,
201        /// Text accompanying the calls.
202        content: Option<String>,
203        /// Requested calls in model order.
204        calls: Vec<RecordedToolCall>,
205    },
206    /// One tool call was admitted with immutable arguments.
207    ToolCall {
208        /// The Run.
209        run_id: RunId,
210        /// Step.
211        step: u32,
212        /// Call identity.
213        call_id: ToolCallId,
214        /// Tool name.
215        tool: String,
216        /// Immutable arguments.
217        arguments: Value,
218    },
219    /// Authorization outcome for a tool call.
220    ToolAuthorization {
221        /// The Run.
222        run_id: RunId,
223        /// Step.
224        step: u32,
225        /// Call identity.
226        call_id: ToolCallId,
227        /// Outcome.
228        status: ToolAuthorizationStatus,
229        /// Why it was denied or parked.
230        reason: Option<String>,
231    },
232    /// Tool execution began.
233    ToolExecutionStarted {
234        /// The Run.
235        run_id: RunId,
236        /// Step.
237        step: u32,
238        /// Call identity.
239        call_id: ToolCallId,
240    },
241    /// Tool execution finished.
242    ToolResult {
243        /// The Run.
244        run_id: RunId,
245        /// Step.
246        step: u32,
247        /// Call identity.
248        call_id: ToolCallId,
249        /// Result value.
250        result: Value,
251        /// Whether the result is an error.
252        is_error: bool,
253    },
254    /// Usage settled for one idempotent operation.
255    UsageRecorded {
256        /// The Run.
257        run_id: RunId,
258        /// Idempotent operation identity.
259        operation_id: String,
260        /// Prompt tokens.
261        prompt_tokens: u64,
262        /// Completion tokens.
263        completion_tokens: u64,
264        /// Provider-neutral cost units.
265        #[serde(default)]
266        cost_units: u64,
267    },
268    /// A model retry was scheduled.
269    RetryScheduled {
270        /// The Run.
271        run_id: RunId,
272        /// Failed attempt number.
273        attempt: u32,
274        /// Backoff before the next attempt.
275        delay_ms: u64,
276        /// Failure reason.
277        reason: String,
278    },
279    /// The frozen model request before it is sent; the durable attempt identity makes crashes reconcilable.
280    ModelRequestPrepared {
281        /// The Run.
282        run_id: RunId,
283        /// Step.
284        step: u32,
285        /// Attempt number.
286        attempt: u32,
287        /// Durable provider attempt identity.
288        #[serde(default)]
289        provider_attempt_id: String,
290        /// Usage operation identity.
291        #[serde(default)]
292        operation_id: String,
293        /// Prompt tokens reserved.
294        #[serde(default)]
295        reserved_prompt_tokens: u64,
296        /// Completion tokens reserved.
297        #[serde(default)]
298        reserved_completion_tokens: u64,
299        /// Redacted request as sent.
300        request: Value,
301        /// Prompt sections the request was composed from.
302        prompt_sections: Value,
303    },
304    /// Context a contributor injected into a step.
305    ContextInjected {
306        /// The Run.
307        run_id: RunId,
308        /// Step.
309        step: u32,
310        /// Contribution identity.
311        contribution_id: String,
312        /// Contributor source.
313        source: String,
314        /// Contributor version.
315        version: String,
316        /// `trusted` or `untrusted`.
317        authority: String,
318        /// Rendering form.
319        form: String,
320        /// Content blocks.
321        content: Vec<ContentBlock>,
322    },
323    /// A model attempt failed.
324    ModelAttemptFailed {
325        /// The Run.
326        run_id: RunId,
327        /// Step.
328        step: u32,
329        /// Attempt number.
330        attempt: u32,
331        /// Failure.
332        error: String,
333        /// Whether a retry may help.
334        retryable: bool,
335    },
336    /// Compaction began.
337    CompactionStarted {
338        /// The Run.
339        run_id: RunId,
340        /// Compaction identity.
341        compaction_id: String,
342        /// Last sequence the summary covers.
343        source_through_seq: u64,
344    },
345    /// Large tool results were truncated in the transcript.
346    ToolResultsPruned {
347        /// The Run.
348        run_id: RunId,
349        /// Truncated calls.
350        call_ids: Vec<ToolCallId>,
351    },
352    /// A summary replaced transcript through `through_seq`.
353    SummaryReplaced {
354        /// The Run.
355        run_id: RunId,
356        /// Last sequence replaced.
357        through_seq: u64,
358        /// Summary text.
359        summary: String,
360        /// Compactor name.
361        compactor: String,
362        /// Model used.
363        model: String,
364    },
365    /// Compaction finished.
366    CompactionFinished {
367        /// The Run.
368        run_id: RunId,
369        /// Compaction identity.
370        compaction_id: String,
371        /// `completed` or `failed`.
372        status: String,
373        /// Failure, when failed.
374        error: Option<String>,
375    },
376    /// The Run asked the user for an approval or answer.
377    InteractionRequested {
378        /// The Run.
379        run_id: RunId,
380        /// Interaction identity.
381        interaction_id: InteractionId,
382        /// Approval or question.
383        kind: InteractionKind,
384        /// What is being asked.
385        payload: Value,
386    },
387    /// The user resolved an interaction.
388    InteractionResolved {
389        /// The Run.
390        run_id: RunId,
391        /// Interaction identity.
392        interaction_id: InteractionId,
393        /// Outcome.
394        resolution: InteractionResolution,
395        /// Answer payload.
396        payload: Value,
397    },
398    /// A child Session was created for this Run.
399    ChildSessionLinked {
400        /// The Run.
401        run_id: RunId,
402        /// Child Session.
403        child_session_id: SessionId,
404        /// Subagent provider.
405        provider: String,
406    },
407    /// Plugin-defined event; readers that do not know `event_type` treat it as data.
408    Extension {
409        /// The Run.
410        run_id: RunId,
411        /// Plugin that emitted it.
412        plugin_id: String,
413        /// Plugin event type.
414        event_type: String,
415        /// Plugin payload.
416        payload: Value,
417    },
418    /// An event this build cannot decode; `ignorable` decides whether replay may skip it.
419    #[serde(skip)]
420    Opaque {
421        /// Producer format version.
422        format_version: u32,
423        /// Producer event type.
424        event_type: String,
425        /// Whether readers may skip it.
426        ignorable: bool,
427        /// Raw payload.
428        payload: Value,
429    },
430}
431
432impl Event {
433    /// Format version of this event.
434    pub fn format_version(&self) -> u32 {
435        match self {
436            Self::Opaque { format_version, .. } => *format_version,
437            _ => SESSION_EVENT_FORMAT_VERSION,
438        }
439    }
440
441    /// Stable snake_case event type.
442    pub fn event_type(&self) -> &str {
443        match self {
444            Self::SessionCreated { .. } => "session_created",
445            Self::SessionForked { .. } => "session_forked",
446            Self::SessionDeleted { .. } => "session_deleted",
447            Self::InputQueued { .. } => "input_queued",
448            Self::InputClaimed { .. } => "input_claimed",
449            Self::InputCancelled { .. } => "input_cancelled",
450            Self::RunStarted { .. } => "run_started",
451            Self::RunWaiting { .. } => "run_waiting",
452            Self::RunResumed { .. } => "run_resumed",
453            Self::RunFinished { .. } => "run_finished",
454            Self::TurnStarted { .. } => "turn_started",
455            Self::TurnFinished { .. } => "turn_finished",
456            Self::StepStarted { .. } => "step_started",
457            Self::StepFinished { .. } => "step_finished",
458            Self::UserMessage { .. } => "user_message",
459            Self::AssistantDelta { .. } => "assistant_delta",
460            Self::AssistantMessage { .. } => "assistant_message",
461            Self::AssistantToolCalls { .. } => "assistant_tool_calls",
462            Self::ToolCall { .. } => "tool_call",
463            Self::ToolAuthorization { .. } => "tool_authorization",
464            Self::ToolExecutionStarted { .. } => "tool_execution_started",
465            Self::ToolResult { .. } => "tool_result",
466            Self::UsageRecorded { .. } => "usage_recorded",
467            Self::RetryScheduled { .. } => "retry_scheduled",
468            Self::ModelRequestPrepared { .. } => "model_request_prepared",
469            Self::ContextInjected { .. } => "context_injected",
470            Self::ModelAttemptFailed { .. } => "model_attempt_failed",
471            Self::CompactionStarted { .. } => "compaction_started",
472            Self::ToolResultsPruned { .. } => "tool_results_pruned",
473            Self::SummaryReplaced { .. } => "summary_replaced",
474            Self::CompactionFinished { .. } => "compaction_finished",
475            Self::InteractionRequested { .. } => "interaction_requested",
476            Self::InteractionResolved { .. } => "interaction_resolved",
477            Self::ChildSessionLinked { .. } => "child_session_linked",
478            Self::Extension { .. } => "extension",
479            Self::Opaque { event_type, .. } => event_type,
480        }
481    }
482
483    /// Whether an unknown reader may skip this event.
484    pub fn ignorable(&self) -> bool {
485        match self {
486            Self::Opaque { ignorable, .. } => *ignorable,
487            _ => false,
488        }
489    }
490}
491
492/// How queued input reaches a Run.
493#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum DeliveryMode {
496    /// Runs as its own Turn after the current Run.
497    Followup,
498    /// Interrupts the current Run at the next step boundary.
499    Steer,
500    /// Added as context to the next step without waking the loop.
501    Inject,
502}
503
504/// Terminal Run status.
505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(rename_all = "snake_case")]
507pub enum RunStatus {
508    /// Finished with an answer.
509    Completed,
510    /// Failed with an error code.
511    Failed,
512    /// Cancelled by the user or platform.
513    Cancelled,
514    /// Stopped at a step, tool-call or token limit.
515    MaxStepsReached,
516}
517
518impl RunStatus {
519    /// Stable snake_case name.
520    pub const fn as_str(self) -> &'static str {
521        match self {
522            Self::Completed => "completed",
523            Self::Failed => "failed",
524            Self::Cancelled => "cancelled",
525            Self::MaxStepsReached => "max_steps_reached",
526        }
527    }
528}
529
530/// What the Run asks the user for.
531#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
532#[serde(rename_all = "snake_case")]
533pub enum InteractionKind {
534    /// Approve or reject a side effect.
535    Action,
536    /// Answer a question.
537    UserQuestion,
538}
539
540impl InteractionKind {
541    /// Stable snake_case name.
542    pub const fn as_str(self) -> &'static str {
543        match self {
544            Self::Action => "action",
545            Self::UserQuestion => "user_question",
546        }
547    }
548}
549
550/// How the user resolved an interaction.
551#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "snake_case")]
553pub enum InteractionResolution {
554    /// Approved.
555    Confirmed,
556    /// Rejected.
557    Rejected,
558    /// Answered a question.
559    Answered,
560}
561
562impl InteractionResolution {
563    /// Stable snake_case name.
564    pub const fn as_str(self) -> &'static str {
565        match self {
566            Self::Confirmed => "confirmed",
567            Self::Rejected => "rejected",
568            Self::Answered => "answered",
569        }
570    }
571}
572
573/// Authorization outcome of a tool call.
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
575#[serde(rename_all = "snake_case")]
576pub enum ToolAuthorizationStatus {
577    /// May execute.
578    Allowed,
579    /// Parked until the interaction resolves.
580    Waiting,
581    /// Refused.
582    Denied,
583}
584
585/// A tool call as recorded in `AssistantToolCalls`.
586#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
587pub struct RecordedToolCall {
588    /// Tool call this record refers to.
589    pub call_id: ToolCallId,
590    /// Tool name.
591    pub tool: String,
592    /// JSON arguments passed to the tool.
593    pub arguments: Value,
594}
595
596impl ToolAuthorizationStatus {
597    /// Stable snake_case name.
598    pub const fn as_str(self) -> &'static str {
599        match self {
600            Self::Allowed => "allowed",
601            Self::Waiting => "waiting",
602            Self::Denied => "denied",
603        }
604    }
605}
606
607/// Typed content unit; UI and models parse types, never prose conventions.
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
609#[serde(tag = "type", rename_all = "snake_case")]
610pub enum ContentBlock {
611    /// Plain text.
612    Text {
613        /// The text.
614        text: String,
615    },
616    /// Attached resource resolved by the host.
617    Resource {
618        /// Resource identity.
619        resource_id: String,
620        /// MIME type.
621        media_type: String,
622    },
623    /// Product-typed data for a registered UI slot.
624    Data {
625        /// Slot name.
626        slot: String,
627        /// Slot payload.
628        value: Value,
629    },
630    /// Citation of a retrieved resource.
631    Citation {
632        /// Resource identity.
633        resource_id: String,
634        /// Display label.
635        label: String,
636        /// Where to open it.
637        uri: String,
638        /// Supporting excerpt.
639        excerpt: Option<String>,
640    },
641}
642
643/// Deterministic projection of a Session log used by the runtime, recovery and UI.
644#[derive(Debug, Clone, Default, PartialEq)]
645pub struct SessionProjection {
646    /// Session this record belongs to; `None` until the first event is applied.
647    pub session_id: Option<SessionId>,
648    /// Immutable Agent Profile revision pinned by the Session; `None` before
649    /// `SessionCreated`.
650    pub profile_revision_id: Option<ProfileRevisionId>,
651    /// Whether the Session was deleted.
652    pub deleted: bool,
653    /// Sequence of the last applied event.
654    pub last_seq: u64,
655    /// Run currently executing, if any.
656    pub active_run_id: Option<RunId>,
657    /// Interaction the active Run waits for, if any.
658    pub waiting_interaction_id: Option<InteractionId>,
659    /// Conversation messages in request order.
660    pub messages: Vec<ProjectedMessage>,
661    /// Context injected so far, in order.
662    pub injected_context: Vec<ProjectedContext>,
663    /// State of every Run seen.
664    pub run_status: BTreeMap<RunId, RunState>,
665    /// Tool calls admitted but not yet resulted.
666    pub open_tool_calls: BTreeMap<ToolCallId, OpenToolCall>,
667    /// Open tool calls whose execution started.
668    pub started_tool_calls: BTreeSet<ToolCallId>,
669    /// Inputs queued but not claimed.
670    pub queued_inputs: BTreeMap<InputId, (RunId, DeliveryMode)>,
671    /// Inputs claimed by a Run.
672    pub claimed_inputs: BTreeMap<InputId, RunId>,
673    /// Open Turn, if any.
674    pub open_turn: Option<u32>,
675    /// Open steps (at most one).
676    pub open_steps: BTreeSet<u32>,
677    /// Next step number.
678    pub next_step: u32,
679    /// Open compaction `(id, through_seq)`, if any.
680    pub open_compaction: Option<(String, u64)>,
681    /// Short human-readable summary.
682    pub summary: Option<String>,
683    usage_operations: BTreeMap<(RunId, String), (u64, u64, u64)>,
684    pending_provider_attempts: BTreeMap<(RunId, String), (u64, u64)>,
685    seen_tool_calls: BTreeSet<ToolCallId>,
686}
687
688/// Projected lifecycle state of one Run: live, parked on an interaction, or terminal.
689#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
690#[serde(rename_all = "snake_case")]
691pub enum RunState {
692    /// Executing.
693    Running,
694    /// Parked on an interaction.
695    WaitingForInput,
696    /// Finished.
697    Terminal(RunStatus),
698}
699
700impl RunState {
701    /// Stable snake_case name.
702    pub const fn as_str(self) -> &'static str {
703        match self {
704            Self::Running => "running",
705            Self::WaitingForInput => "waiting_for_input",
706            Self::Terminal(status) => status.as_str(),
707        }
708    }
709
710    /// Whether the Run has finished.
711    pub const fn is_terminal(self) -> bool {
712        matches!(self, Self::Terminal(_))
713    }
714}
715
716/// Injected context as projected for UI and replay.
717#[derive(Debug, Clone, PartialEq)]
718pub struct ProjectedContext {
719    /// Run this record belongs to.
720    pub run_id: RunId,
721    /// 1-based step number inside the Turn.
722    pub step: u32,
723    /// Contribution identity.
724    pub contribution_id: String,
725    /// Contributor source.
726    pub source: String,
727    /// Semantic version string.
728    pub version: String,
729    /// `trusted` or `untrusted`.
730    pub authority: String,
731    /// Rendering form.
732    pub form: String,
733    /// Content blocks carried by this record.
734    pub content: Vec<ContentBlock>,
735}
736
737/// Message as projected for UI and replay.
738#[derive(Debug, Clone, PartialEq)]
739pub struct ProjectedMessage {
740    /// `user` or `assistant`.
741    pub role: &'static str,
742    /// Run this record belongs to.
743    pub run_id: RunId,
744    /// Content blocks carried by this record.
745    pub content: Vec<ContentBlock>,
746}
747
748/// A tool call awaiting its result.
749#[derive(Debug, Clone, PartialEq)]
750pub struct OpenToolCall {
751    /// Run this record belongs to.
752    pub run_id: RunId,
753    /// 1-based step number inside the Turn.
754    pub step: u32,
755    /// Tool name.
756    pub tool: String,
757    /// JSON arguments passed to the tool.
758    pub arguments: Value,
759    /// Sequence of the Session event this was derived from.
760    pub source_event_seq: u64,
761}
762
763impl SessionProjection {
764    /// Rebuild the projection from a committed log; fails on any invariant violation.
765    pub fn replay(events: &[SessionEvent]) -> Result<Self, EventError> {
766        let mut projection = Self::default();
767        for event in events {
768            projection.apply(event)?;
769        }
770        Ok(projection)
771    }
772
773    /// Apply one committed event, enforcing sequence, lifecycle and pairing invariants.
774    pub fn apply(&mut self, envelope: &SessionEvent) -> Result<(), EventError> {
775        if envelope.seq != self.last_seq + 1 {
776            return Err(EventError::Sequence {
777                expected: self.last_seq + 1,
778                actual: envelope.seq,
779            });
780        }
781        let session_id = self
782            .session_id
783            .get_or_insert_with(|| envelope.session_id.clone());
784        if *session_id != envelope.session_id {
785            return Err(EventError::SessionMismatch);
786        }
787        if envelope.format_version() != SESSION_EVENT_FORMAT_VERSION {
788            if envelope.ignorable() {
789                self.last_seq = envelope.seq;
790                return Ok(());
791            }
792            return Err(EventError::UnsupportedFormat(envelope.format_version()));
793        }
794        if self.deleted {
795            return Err(EventError::SessionClosed);
796        }
797        match &envelope.event {
798            Event::SessionCreated {
799                profile_revision_id,
800            } => {
801                if envelope.seq != 1 || self.profile_revision_id.is_some() {
802                    return Err(EventError::DuplicateSession);
803                }
804                self.profile_revision_id = Some(profile_revision_id.clone());
805            }
806            Event::SessionDeleted { .. } => {
807                if let Some(run_id) = self.active_run_id.take() {
808                    self.run_status
809                        .insert(run_id, RunState::Terminal(RunStatus::Cancelled));
810                }
811                for (_, (run_id, _)) in std::mem::take(&mut self.queued_inputs) {
812                    self.run_status
813                        .insert(run_id, RunState::Terminal(RunStatus::Cancelled));
814                }
815                self.waiting_interaction_id = None;
816                self.open_turn = None;
817                self.open_steps.clear();
818                self.open_tool_calls.clear();
819                self.started_tool_calls.clear();
820                self.open_compaction = None;
821                self.deleted = true;
822            }
823            Event::RunStarted { run_id, input_id } => {
824                if self.active_run_id.is_some() {
825                    return Err(EventError::ConcurrentRun);
826                }
827                if self.claimed_inputs.get(input_id) != Some(run_id) {
828                    return Err(EventError::UnclaimedInput(input_id.clone()));
829                }
830                self.active_run_id = Some(run_id.clone());
831                self.next_step = 1;
832                self.run_status.insert(run_id.clone(), RunState::Running);
833            }
834            Event::RunWaiting {
835                run_id,
836                interaction_id,
837            } => {
838                self.require_active(run_id)?;
839                self.waiting_interaction_id = Some(interaction_id.clone());
840                self.run_status
841                    .insert(run_id.clone(), RunState::WaitingForInput);
842            }
843            Event::RunResumed {
844                run_id,
845                interaction_id,
846            } => {
847                self.require_active(run_id)?;
848                if self.waiting_interaction_id.as_ref() != Some(interaction_id) {
849                    return Err(EventError::InteractionMismatch);
850                }
851                self.waiting_interaction_id = None;
852                self.run_status.insert(run_id.clone(), RunState::Running);
853            }
854            Event::RunFinished { run_id, status, .. } => {
855                self.require_active(run_id)?;
856                if !self.open_tool_calls.is_empty()
857                    || !self.open_steps.is_empty()
858                    || self.open_turn.is_some()
859                    || self.open_compaction.is_some()
860                    || self
861                        .queued_inputs
862                        .values()
863                        .any(|(target_run_id, _)| target_run_id == run_id)
864                {
865                    return Err(EventError::OpenLifecycle);
866                }
867                self.run_status
868                    .insert(run_id.clone(), RunState::Terminal(*status));
869                self.active_run_id = None;
870                self.waiting_interaction_id = None;
871            }
872            Event::UserMessage { run_id, content } => self.messages.push(ProjectedMessage {
873                role: "user",
874                run_id: run_id.clone(),
875                content: content.clone(),
876            }),
877            Event::AssistantMessage {
878                run_id, content, ..
879            } => self.messages.push(ProjectedMessage {
880                role: "assistant",
881                run_id: run_id.clone(),
882                content: content.clone(),
883            }),
884            Event::ContextInjected {
885                run_id,
886                step,
887                contribution_id,
888                source,
889                version,
890                authority,
891                form,
892                content,
893            } => {
894                self.require_active(run_id)?;
895                if !self.open_steps.contains(step) {
896                    return Err(EventError::LifecycleMismatch);
897                }
898                self.injected_context.push(ProjectedContext {
899                    run_id: run_id.clone(),
900                    step: *step,
901                    contribution_id: contribution_id.clone(),
902                    source: source.clone(),
903                    version: version.clone(),
904                    authority: authority.clone(),
905                    form: form.clone(),
906                    content: content.clone(),
907                });
908            }
909            Event::InputQueued {
910                input_id,
911                run_id,
912                mode,
913                ..
914            } => {
915                if *mode != DeliveryMode::Followup {
916                    self.require_active(run_id)?;
917                }
918                if self
919                    .queued_inputs
920                    .insert(input_id.clone(), (run_id.clone(), *mode))
921                    .is_some()
922                {
923                    return Err(EventError::DuplicateInput(input_id.clone()));
924                }
925            }
926            Event::InputClaimed { input_id, run_id } => {
927                if self
928                    .queued_inputs
929                    .remove(input_id)
930                    .map(|value| value.0)
931                    .as_ref()
932                    != Some(run_id)
933                    || self
934                        .claimed_inputs
935                        .insert(input_id.clone(), run_id.clone())
936                        .is_some()
937                {
938                    return Err(EventError::UnqueuedInput(input_id.clone()));
939                }
940            }
941            Event::InputCancelled {
942                input_id,
943                run_id,
944                error_code,
945            } => {
946                if self
947                    .queued_inputs
948                    .remove(input_id)
949                    .map(|value| value.0)
950                    .as_ref()
951                    != Some(run_id)
952                {
953                    return Err(EventError::UnqueuedInput(input_id.clone()));
954                }
955                self.run_status.insert(
956                    run_id.clone(),
957                    RunState::Terminal(if error_code == "cancelled" {
958                        RunStatus::Cancelled
959                    } else {
960                        RunStatus::Failed
961                    }),
962                );
963            }
964            Event::TurnStarted { run_id, turn } => {
965                self.require_active(run_id)?;
966                if self.open_turn.replace(*turn).is_some() {
967                    return Err(EventError::ConcurrentTurn);
968                }
969            }
970            Event::TurnFinished { run_id, turn } => {
971                self.require_active(run_id)?;
972                if self.open_turn != Some(*turn)
973                    || !self.open_steps.is_empty()
974                    || !self.open_tool_calls.is_empty()
975                {
976                    return Err(EventError::LifecycleMismatch);
977                }
978                self.open_turn = None;
979            }
980            Event::StepStarted { run_id, step } => {
981                self.require_active(run_id)?;
982                if self.open_turn.is_none()
983                    || !self.open_steps.is_empty()
984                    || *step != self.next_step
985                    || !self.open_steps.insert(*step)
986                {
987                    return Err(EventError::ConcurrentStep);
988                }
989            }
990            Event::StepFinished { run_id, step } => {
991                self.require_active(run_id)?;
992                if self
993                    .open_tool_calls
994                    .values()
995                    .any(|call| call.run_id == *run_id && call.step == *step)
996                    || !self.open_steps.remove(step)
997                {
998                    return Err(EventError::LifecycleMismatch);
999                }
1000                self.next_step = step.saturating_add(1);
1001            }
1002            Event::ToolCall {
1003                run_id,
1004                step,
1005                call_id,
1006                tool,
1007                arguments,
1008            } => {
1009                self.require_active(run_id)?;
1010                if !self.open_steps.contains(step) {
1011                    return Err(EventError::LifecycleMismatch);
1012                }
1013                if !self.seen_tool_calls.insert(call_id.clone())
1014                    || self
1015                        .open_tool_calls
1016                        .insert(
1017                            call_id.clone(),
1018                            OpenToolCall {
1019                                run_id: run_id.clone(),
1020                                step: *step,
1021                                tool: tool.clone(),
1022                                arguments: arguments.clone(),
1023                                source_event_seq: envelope.seq,
1024                            },
1025                        )
1026                        .is_some()
1027                {
1028                    return Err(EventError::DuplicateToolCall(call_id.clone()));
1029                }
1030            }
1031            Event::ToolResult {
1032                run_id,
1033                step,
1034                call_id,
1035                ..
1036            } => {
1037                self.require_active(run_id)?;
1038                if !self.open_steps.contains(step) {
1039                    return Err(EventError::LifecycleMismatch);
1040                }
1041                let Some(call) = self.open_tool_calls.get(call_id) else {
1042                    return Err(EventError::OrphanToolResult(call_id.clone()));
1043                };
1044                if call.run_id != *run_id || call.step != *step {
1045                    return Err(EventError::ToolResultMismatch(call_id.clone()));
1046                }
1047                self.open_tool_calls.remove(call_id);
1048                self.started_tool_calls.remove(call_id);
1049            }
1050            Event::ToolExecutionStarted {
1051                run_id,
1052                step,
1053                call_id,
1054            } => {
1055                self.require_active(run_id)?;
1056                let Some(call) = self.open_tool_calls.get(call_id) else {
1057                    return Err(EventError::OrphanToolResult(call_id.clone()));
1058                };
1059                if call.run_id != *run_id
1060                    || call.step != *step
1061                    || !self.started_tool_calls.insert(call_id.clone())
1062                {
1063                    return Err(EventError::ToolResultMismatch(call_id.clone()));
1064                }
1065            }
1066            Event::ModelRequestPrepared {
1067                run_id,
1068                operation_id,
1069                reserved_prompt_tokens,
1070                reserved_completion_tokens,
1071                ..
1072            } if !operation_id.is_empty() => {
1073                self.require_active(run_id)?;
1074                let key = (run_id.clone(), operation_id.clone());
1075                if !self.usage_operations.contains_key(&key) {
1076                    let reservation = (*reserved_prompt_tokens, *reserved_completion_tokens);
1077                    match self.pending_provider_attempts.get(&key) {
1078                        Some(existing) if *existing != reservation => {
1079                            return Err(EventError::UsageConflict(operation_id.clone()));
1080                        }
1081                        Some(_) => {}
1082                        None => {
1083                            self.pending_provider_attempts.insert(key, reservation);
1084                        }
1085                    }
1086                }
1087            }
1088            Event::UsageRecorded {
1089                run_id,
1090                operation_id,
1091                prompt_tokens,
1092                completion_tokens,
1093                cost_units,
1094            } => {
1095                self.require_active(run_id)?;
1096                let key = (run_id.clone(), operation_id.clone());
1097                let usage = (*prompt_tokens, *completion_tokens, *cost_units);
1098                match self.usage_operations.get(&key) {
1099                    Some(existing) if *existing != usage => {
1100                        return Err(EventError::UsageConflict(operation_id.clone()));
1101                    }
1102                    Some(_) => {}
1103                    None => {
1104                        self.pending_provider_attempts.remove(&key);
1105                        self.usage_operations.insert(key, usage);
1106                    }
1107                }
1108            }
1109            Event::CompactionStarted {
1110                run_id,
1111                compaction_id,
1112                source_through_seq,
1113            } => {
1114                self.require_active(run_id)?;
1115                if self.open_compaction.is_some() {
1116                    return Err(EventError::ConcurrentCompaction);
1117                }
1118                self.open_compaction = Some((compaction_id.clone(), *source_through_seq));
1119            }
1120            Event::CompactionFinished {
1121                run_id,
1122                compaction_id,
1123                ..
1124            } => {
1125                self.require_active(run_id)?;
1126                if self.open_compaction.as_ref().map(|value| value.0.as_str())
1127                    != Some(compaction_id.as_str())
1128                {
1129                    return Err(EventError::CompactionMismatch);
1130                }
1131                self.open_compaction = None;
1132            }
1133            Event::SummaryReplaced { summary, .. } => self.summary = Some(summary.clone()),
1134            Event::Opaque {
1135                event_type,
1136                ignorable: false,
1137                ..
1138            } => return Err(EventError::UnknownRequired(event_type.clone())),
1139            _ => {}
1140        }
1141        self.last_seq = envelope.seq;
1142        Ok(())
1143    }
1144
1145    fn require_active(&self, run_id: &RunId) -> Result<(), EventError> {
1146        if self.active_run_id.as_ref() == Some(run_id) {
1147            Ok(())
1148        } else {
1149            Err(EventError::RunMismatch)
1150        }
1151    }
1152
1153    /// `(prompt, completion)` tokens settled for `run_id`.
1154    pub fn usage_for(&self, run_id: &str) -> (u64, u64) {
1155        self.usage_operations
1156            .iter()
1157            .filter(|((recorded_run_id, _), _)| recorded_run_id.as_str() == run_id)
1158            .fold((0, 0), |total, (_, usage)| {
1159                (total.0 + usage.0, total.1 + usage.1)
1160            })
1161    }
1162
1163    /// Settled plus reserved-but-unsettled units for `run_id`, for conservative billing.
1164    pub fn billable_units_for(&self, run_id: &str) -> u64 {
1165        let recorded = self
1166            .usage_operations
1167            .iter()
1168            .filter(|((recorded_run_id, _), _)| recorded_run_id.as_str() == run_id)
1169            .map(|(_, usage)| usage.0 + usage.1 + usage.2)
1170            .sum::<u64>();
1171        recorded
1172            + self
1173                .pending_provider_attempts
1174                .iter()
1175                .filter(|((recorded_run_id, _), _)| recorded_run_id.as_str() == run_id)
1176                .map(|(_, usage)| usage.0 + usage.1)
1177                .sum::<u64>()
1178    }
1179}
1180
1181/// Invariant violated while applying an event.
1182#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1183pub enum EventError {
1184    /// Unsupported session event format version.
1185    #[error("unsupported session event format version {0}")]
1186    UnsupportedFormat(u32),
1187    /// Unknown required session event type.
1188    #[error("unknown required session event type {0}")]
1189    UnknownRequired(String),
1190    /// Event sequence mismatch: expected `expected`, got `actual`.
1191    #[error("event sequence mismatch: expected {expected}, got {actual}")]
1192    Sequence {
1193        /// Sequence the projection expected next.
1194        expected: u64,
1195        /// Sequence the event carried.
1196        actual: u64,
1197    },
1198    /// Event belongs to another session.
1199    #[error("event belongs to another session")]
1200    SessionMismatch,
1201    /// Session creation must be the first and only creation event.
1202    #[error("session creation must be the first and only creation event")]
1203    DuplicateSession,
1204    /// Session already has an active run.
1205    #[error("session already has an active run")]
1206    ConcurrentRun,
1207    /// Session is closed.
1208    #[error("session is closed")]
1209    SessionClosed,
1210    /// Event does not match the active run.
1211    #[error("event does not match the active run")]
1212    RunMismatch,
1213    /// Interaction does not match the waiting run.
1214    #[error("interaction does not match the waiting run")]
1215    InteractionMismatch,
1216    /// Run cannot finish with an open turn, step or tool call.
1217    #[error("run cannot finish with an open turn, step or tool call")]
1218    OpenLifecycle,
1219    /// Input was queued twice.
1220    #[error("input was queued twice: {0}")]
1221    DuplicateInput(InputId),
1222    /// Input was claimed before it was queued.
1223    #[error("input was claimed before it was queued: {0}")]
1224    UnqueuedInput(InputId),
1225    /// Run started from an unclaimed input.
1226    #[error("run started from an unclaimed input: {0}")]
1227    UnclaimedInput(InputId),
1228    /// Session already has an active turn.
1229    #[error("session already has an active turn")]
1230    ConcurrentTurn,
1231    /// Turn already has this active step.
1232    #[error("turn already has this active step")]
1233    ConcurrentStep,
1234    /// Turn or step lifecycle does not pair.
1235    #[error("turn or step lifecycle does not pair")]
1236    LifecycleMismatch,
1237    /// Duplicate tool call.
1238    #[error("duplicate tool call {0}")]
1239    DuplicateToolCall(ToolCallId),
1240    /// Tool result has no matching call.
1241    #[error("tool result has no matching call {0}")]
1242    OrphanToolResult(ToolCallId),
1243    /// Tool result does not match the call run and step.
1244    #[error("tool result does not match the call run and step: {0}")]
1245    ToolResultMismatch(ToolCallId),
1246    /// Usage operation was recorded with different totals.
1247    #[error("usage operation was recorded with different totals: {0}")]
1248    UsageConflict(String),
1249    /// Session already has an active compaction.
1250    #[error("session already has an active compaction")]
1251    ConcurrentCompaction,
1252    /// Compaction lifecycle does not pair.
1253    #[error("compaction lifecycle does not pair")]
1254    CompactionMismatch,
1255    /// Event store conflict.
1256    #[error("event store conflict: {0}")]
1257    Conflict(String),
1258    /// Event store unavailable.
1259    #[error("event store unavailable: {0}")]
1260    Unavailable(String),
1261}
1262
1263/// Append-only Session event storage.
1264#[async_trait]
1265pub trait SessionEventStore: Send + Sync {
1266    /// Append events atomically after `expected_seq`; returns them with sequences.
1267    async fn append(
1268        &self,
1269        tenant_id: &str,
1270        session_id: &str,
1271        expected_seq: u64,
1272        events: Vec<Event>,
1273    ) -> Result<Vec<SessionEvent>, EventError>;
1274    /// Events after `after_seq`.
1275    async fn load(
1276        &self,
1277        tenant_id: &str,
1278        session_id: &str,
1279        after_seq: u64,
1280    ) -> Result<Vec<SessionEvent>, EventError>;
1281}
1282
1283/// One text content block.
1284pub fn text(value: impl Into<String>) -> Vec<ContentBlock> {
1285    vec![ContentBlock::Text { text: value.into() }]
1286}
1287
1288/// Events that close an interrupted Run as failed with `worker_restarted`, so recovery starts from a clean lifecycle.
1289pub fn recovery_events(projection: &SessionProjection) -> Vec<Event> {
1290    failure_events(projection, "worker_restarted")
1291}
1292
1293/// Events that close an interrupted Run as failed with `error_code`.
1294pub fn failure_events(projection: &SessionProjection, error_code: &str) -> Vec<Event> {
1295    termination_events(projection, RunStatus::Failed, error_code)
1296}
1297
1298/// Events that close an interrupted Run as cancelled.
1299pub fn cancel_events(projection: &SessionProjection) -> Vec<Event> {
1300    termination_events(projection, RunStatus::Cancelled, "cancelled")
1301}
1302
1303/// Events that tombstone a Session and close its Runs.
1304pub fn session_deletion_events(projection: &SessionProjection, reason: &str) -> Vec<Event> {
1305    let active = projection.active_run_id.as_deref();
1306    let mut events = cancel_events(projection);
1307    events.extend(
1308        projection
1309            .queued_inputs
1310            .iter()
1311            .filter(|(_, (run_id, _))| Some(run_id.as_str()) != active)
1312            .map(|(input_id, (run_id, _))| Event::InputCancelled {
1313                input_id: input_id.clone(),
1314                run_id: run_id.clone(),
1315                error_code: "cancelled".into(),
1316            }),
1317    );
1318    events.push(Event::SessionDeleted {
1319        reason: reason.into(),
1320    });
1321    events
1322}
1323
1324fn termination_events(
1325    projection: &SessionProjection,
1326    status: RunStatus,
1327    error_code: &str,
1328) -> Vec<Event> {
1329    let Some(run_id) = &projection.active_run_id else {
1330        return Vec::new();
1331    };
1332    let mut events = projection
1333        .queued_inputs
1334        .iter()
1335        .filter(|(_, (target_run_id, _))| target_run_id == run_id)
1336        .map(|(input_id, _)| Event::InputCancelled {
1337            input_id: input_id.clone(),
1338            run_id: run_id.clone(),
1339            error_code: error_code.into(),
1340        })
1341        .collect::<Vec<_>>();
1342    events.extend(
1343        projection
1344            .open_tool_calls
1345            .iter()
1346            .map(|(call_id, call)| Event::ToolResult {
1347                run_id: run_id.clone(),
1348                step: call.step,
1349                call_id: call_id.clone(),
1350                result: termination_result(error_code),
1351                is_error: true,
1352            }),
1353    );
1354    if let Some((compaction_id, _)) = &projection.open_compaction {
1355        events.push(Event::CompactionFinished {
1356            run_id: run_id.clone(),
1357            compaction_id: compaction_id.clone(),
1358            status: "failed".into(),
1359            error: Some(error_code.into()),
1360        });
1361    }
1362    events.extend(
1363        projection
1364            .open_steps
1365            .iter()
1366            .map(|step| Event::StepFinished {
1367                run_id: run_id.clone(),
1368                step: *step,
1369            }),
1370    );
1371    if let Some(turn) = projection.open_turn {
1372        events.push(Event::TurnFinished {
1373            run_id: run_id.clone(),
1374            turn,
1375        });
1376    }
1377    events.push(Event::RunFinished {
1378        run_id: run_id.clone(),
1379        status,
1380        error_code: Some(error_code.into()),
1381    });
1382    events
1383}
1384
1385fn termination_result(error_code: &str) -> Value {
1386    if error_code == "tool_outcome_unknown" || error_code == "worker_restarted" {
1387        serde_json::json!({
1388            "error": error_code,
1389            "guidance": "The tool outcome is unknown. Verify external state before retrying any operation with side effects; ask the user when verification is unavailable."
1390        })
1391    } else {
1392        serde_json::json!({"error":error_code})
1393    }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398    use super::*;
1399
1400    fn event(seq: u64, event: Event) -> SessionEvent {
1401        SessionEvent {
1402            session_id: "s".parse().unwrap(),
1403            seq,
1404            occurred_at: Utc::now(),
1405            event,
1406        }
1407    }
1408
1409    #[test]
1410    fn replay_enforces_single_run_and_tool_pairs() {
1411        let events = vec![
1412            event(
1413                1,
1414                Event::SessionCreated {
1415                    profile_revision_id: "p1".parse().unwrap(),
1416                },
1417            ),
1418            event(
1419                2,
1420                Event::InputQueued {
1421                    input_id: "i1".parse().unwrap(),
1422                    run_id: "r1".parse().unwrap(),
1423                    mode: DeliveryMode::Followup,
1424                    content: text("hi"),
1425                    explicit_skill: None,
1426                },
1427            ),
1428            event(
1429                3,
1430                Event::InputClaimed {
1431                    input_id: "i1".parse().unwrap(),
1432                    run_id: "r1".parse().unwrap(),
1433                },
1434            ),
1435            event(
1436                4,
1437                Event::RunStarted {
1438                    run_id: "r1".parse().unwrap(),
1439                    input_id: "i1".parse().unwrap(),
1440                },
1441            ),
1442            event(
1443                5,
1444                Event::TurnStarted {
1445                    run_id: "r1".parse().unwrap(),
1446                    turn: 1,
1447                },
1448            ),
1449            event(
1450                6,
1451                Event::StepStarted {
1452                    run_id: "r1".parse().unwrap(),
1453                    step: 1,
1454                },
1455            ),
1456            event(
1457                7,
1458                Event::ToolCall {
1459                    run_id: "r1".parse().unwrap(),
1460                    step: 1,
1461                    call_id: "c1".parse().unwrap(),
1462                    tool: "echo".into(),
1463                    arguments: serde_json::json!({"x":1}),
1464                },
1465            ),
1466            event(
1467                8,
1468                Event::ToolResult {
1469                    run_id: "r1".parse().unwrap(),
1470                    step: 1,
1471                    call_id: "c1".parse().unwrap(),
1472                    result: serde_json::json!({"x":1}),
1473                    is_error: false,
1474                },
1475            ),
1476            event(
1477                9,
1478                Event::StepFinished {
1479                    run_id: "r1".parse().unwrap(),
1480                    step: 1,
1481                },
1482            ),
1483            event(
1484                10,
1485                Event::TurnFinished {
1486                    run_id: "r1".parse().unwrap(),
1487                    turn: 1,
1488                },
1489            ),
1490            event(
1491                11,
1492                Event::RunFinished {
1493                    run_id: "r1".parse().unwrap(),
1494                    status: RunStatus::Completed,
1495                    error_code: None,
1496                },
1497            ),
1498        ];
1499        let projection = SessionProjection::replay(&events).unwrap();
1500        assert_eq!(projection.last_seq, 11);
1501        assert!(projection.active_run_id.is_none());
1502    }
1503
1504    #[test]
1505    fn replay_rejects_orphan_tool_result() {
1506        let events = vec![
1507            event(
1508                1,
1509                Event::SessionCreated {
1510                    profile_revision_id: "p1".parse().unwrap(),
1511                },
1512            ),
1513            event(
1514                2,
1515                Event::InputQueued {
1516                    input_id: "i1".parse().unwrap(),
1517                    run_id: "r1".parse().unwrap(),
1518                    mode: DeliveryMode::Followup,
1519                    content: text("hi"),
1520                    explicit_skill: None,
1521                },
1522            ),
1523            event(
1524                3,
1525                Event::InputClaimed {
1526                    input_id: "i1".parse().unwrap(),
1527                    run_id: "r1".parse().unwrap(),
1528                },
1529            ),
1530            event(
1531                4,
1532                Event::RunStarted {
1533                    run_id: "r1".parse().unwrap(),
1534                    input_id: "i1".parse().unwrap(),
1535                },
1536            ),
1537            event(
1538                5,
1539                Event::TurnStarted {
1540                    run_id: "r1".parse().unwrap(),
1541                    turn: 1,
1542                },
1543            ),
1544            event(
1545                6,
1546                Event::StepStarted {
1547                    run_id: "r1".parse().unwrap(),
1548                    step: 1,
1549                },
1550            ),
1551            event(
1552                7,
1553                Event::ToolResult {
1554                    run_id: "r1".parse().unwrap(),
1555                    step: 1,
1556                    call_id: "missing".parse().unwrap(),
1557                    result: Value::Null,
1558                    is_error: true,
1559                },
1560            ),
1561        ];
1562        assert_eq!(
1563            SessionProjection::replay(&events).unwrap_err(),
1564            EventError::OrphanToolResult("missing".parse().unwrap())
1565        );
1566    }
1567
1568    #[test]
1569    fn queued_input_failure_is_not_projected_as_cancellation() {
1570        let events = vec![
1571            event(
1572                1,
1573                Event::SessionCreated {
1574                    profile_revision_id: "p1".parse().unwrap(),
1575                },
1576            ),
1577            event(
1578                2,
1579                Event::InputQueued {
1580                    input_id: "i1".parse().unwrap(),
1581                    run_id: "r1".parse().unwrap(),
1582                    mode: DeliveryMode::Followup,
1583                    content: text("hi"),
1584                    explicit_skill: None,
1585                },
1586            ),
1587            event(
1588                3,
1589                Event::InputCancelled {
1590                    input_id: "i1".parse().unwrap(),
1591                    run_id: "r1".parse().unwrap(),
1592                    error_code: "profile_not_found".into(),
1593                },
1594            ),
1595        ];
1596        let projection = SessionProjection::replay(&events).unwrap();
1597        assert_eq!(
1598            projection.run_status.get("r1").map(|state| state.as_str()),
1599            Some("failed")
1600        );
1601    }
1602
1603    #[test]
1604    fn session_deletion_closes_active_and_queued_runs_before_tombstone() {
1605        let mut events = vec![
1606            event(
1607                1,
1608                Event::SessionCreated {
1609                    profile_revision_id: "p1".parse().unwrap(),
1610                },
1611            ),
1612            event(
1613                2,
1614                Event::InputQueued {
1615                    input_id: "i1".parse().unwrap(),
1616                    run_id: "r1".parse().unwrap(),
1617                    mode: DeliveryMode::Followup,
1618                    content: text("start"),
1619                    explicit_skill: None,
1620                },
1621            ),
1622            event(
1623                3,
1624                Event::InputClaimed {
1625                    input_id: "i1".parse().unwrap(),
1626                    run_id: "r1".parse().unwrap(),
1627                },
1628            ),
1629            event(
1630                4,
1631                Event::RunStarted {
1632                    run_id: "r1".parse().unwrap(),
1633                    input_id: "i1".parse().unwrap(),
1634                },
1635            ),
1636            event(
1637                5,
1638                Event::TurnStarted {
1639                    run_id: "r1".parse().unwrap(),
1640                    turn: 1,
1641                },
1642            ),
1643            event(
1644                6,
1645                Event::InputQueued {
1646                    input_id: "i2".parse().unwrap(),
1647                    run_id: "r2".parse().unwrap(),
1648                    mode: DeliveryMode::Followup,
1649                    content: text("later"),
1650                    explicit_skill: None,
1651                },
1652            ),
1653        ];
1654        let projection = SessionProjection::replay(&events).unwrap();
1655        for event_value in session_deletion_events(&projection, "api_deleted") {
1656            let seq = events.len() as u64 + 1;
1657            events.push(event(seq, event_value));
1658        }
1659        let deleted = SessionProjection::replay(&events).unwrap();
1660        assert!(deleted.deleted);
1661        assert_eq!(
1662            deleted.run_status.get("r1").map(|state| state.as_str()),
1663            Some("cancelled")
1664        );
1665        assert_eq!(
1666            deleted.run_status.get("r2").map(|state| state.as_str()),
1667            Some("cancelled")
1668        );
1669        assert!(events.iter().any(|event| matches!(
1670            &event.event,
1671            Event::RunFinished { run_id, status: RunStatus::Cancelled, .. } if run_id == "r1"
1672        )));
1673        assert!(matches!(
1674            events.last().map(|event| &event.event),
1675            Some(Event::SessionDeleted { .. })
1676        ));
1677    }
1678
1679    #[test]
1680    fn usage_operations_are_idempotent_and_conflicts_fail_replay() {
1681        let mut events = vec![
1682            event(
1683                1,
1684                Event::SessionCreated {
1685                    profile_revision_id: "p1".parse().unwrap(),
1686                },
1687            ),
1688            event(
1689                2,
1690                Event::InputQueued {
1691                    input_id: "i1".parse().unwrap(),
1692                    run_id: "r1".parse().unwrap(),
1693                    mode: DeliveryMode::Followup,
1694                    content: text("hi"),
1695                    explicit_skill: None,
1696                },
1697            ),
1698            event(
1699                3,
1700                Event::InputClaimed {
1701                    input_id: "i1".parse().unwrap(),
1702                    run_id: "r1".parse().unwrap(),
1703                },
1704            ),
1705            event(
1706                4,
1707                Event::RunStarted {
1708                    run_id: "r1".parse().unwrap(),
1709                    input_id: "i1".parse().unwrap(),
1710                },
1711            ),
1712            event(
1713                5,
1714                Event::UsageRecorded {
1715                    run_id: "r1".parse().unwrap(),
1716                    operation_id: "model:1:attempt:1".into(),
1717                    prompt_tokens: 7,
1718                    completion_tokens: 3,
1719                    cost_units: 5,
1720                },
1721            ),
1722            event(
1723                6,
1724                Event::UsageRecorded {
1725                    run_id: "r1".parse().unwrap(),
1726                    operation_id: "model:1:attempt:1".into(),
1727                    prompt_tokens: 7,
1728                    completion_tokens: 3,
1729                    cost_units: 5,
1730                },
1731            ),
1732        ];
1733        assert_eq!(
1734            SessionProjection::replay(&events).unwrap().usage_for("r1"),
1735            (7, 3)
1736        );
1737        assert_eq!(
1738            SessionProjection::replay(&events)
1739                .unwrap()
1740                .billable_units_for("r1"),
1741            15
1742        );
1743
1744        events.push(event(
1745            7,
1746            Event::UsageRecorded {
1747                run_id: "r1".parse().unwrap(),
1748                operation_id: "model:1:attempt:1".into(),
1749                prompt_tokens: 8,
1750                completion_tokens: 3,
1751                cost_units: 0,
1752            },
1753        ));
1754        assert_eq!(
1755            SessionProjection::replay(&events).unwrap_err(),
1756            EventError::UsageConflict("model:1:attempt:1".into())
1757        );
1758    }
1759
1760    #[test]
1761    fn unresolved_provider_attempt_is_conservatively_billable_and_reconcilable() {
1762        let mut events = vec![
1763            event(
1764                1,
1765                Event::SessionCreated {
1766                    profile_revision_id: "p1".parse().unwrap(),
1767                },
1768            ),
1769            event(
1770                2,
1771                Event::InputQueued {
1772                    input_id: "i1".parse().unwrap(),
1773                    run_id: "r1".parse().unwrap(),
1774                    mode: DeliveryMode::Followup,
1775                    content: text("hi"),
1776                    explicit_skill: None,
1777                },
1778            ),
1779            event(
1780                3,
1781                Event::InputClaimed {
1782                    input_id: "i1".parse().unwrap(),
1783                    run_id: "r1".parse().unwrap(),
1784                },
1785            ),
1786            event(
1787                4,
1788                Event::RunStarted {
1789                    run_id: "r1".parse().unwrap(),
1790                    input_id: "i1".parse().unwrap(),
1791                },
1792            ),
1793            event(
1794                5,
1795                Event::ModelRequestPrepared {
1796                    run_id: "r1".parse().unwrap(),
1797                    step: 1,
1798                    attempt: 1,
1799                    provider_attempt_id: "r1:model:1:attempt:1".into(),
1800                    operation_id: "model:1:attempt:1".into(),
1801                    reserved_prompt_tokens: 7,
1802                    reserved_completion_tokens: 11,
1803                    request: Value::Null,
1804                    prompt_sections: Value::Null,
1805                },
1806            ),
1807        ];
1808        assert_eq!(
1809            SessionProjection::replay(&events)
1810                .unwrap()
1811                .billable_units_for("r1"),
1812            18
1813        );
1814        events.push(event(
1815            6,
1816            Event::UsageRecorded {
1817                run_id: "r1".parse().unwrap(),
1818                operation_id: "model:1:attempt:1".into(),
1819                prompt_tokens: 6,
1820                completion_tokens: 2,
1821                cost_units: 0,
1822            },
1823        ));
1824        assert_eq!(
1825            SessionProjection::replay(&events)
1826                .unwrap()
1827                .billable_units_for("r1"),
1828            8
1829        );
1830    }
1831
1832    fn open_step_events() -> Vec<SessionEvent> {
1833        vec![
1834            event(
1835                1,
1836                Event::SessionCreated {
1837                    profile_revision_id: "p1".parse().unwrap(),
1838                },
1839            ),
1840            event(
1841                2,
1842                Event::InputQueued {
1843                    input_id: "i1".parse().unwrap(),
1844                    run_id: "r1".parse().unwrap(),
1845                    mode: DeliveryMode::Followup,
1846                    content: text("hi"),
1847                    explicit_skill: None,
1848                },
1849            ),
1850            event(
1851                3,
1852                Event::InputClaimed {
1853                    input_id: "i1".parse().unwrap(),
1854                    run_id: "r1".parse().unwrap(),
1855                },
1856            ),
1857            event(
1858                4,
1859                Event::RunStarted {
1860                    run_id: "r1".parse().unwrap(),
1861                    input_id: "i1".parse().unwrap(),
1862                },
1863            ),
1864            event(
1865                5,
1866                Event::TurnStarted {
1867                    run_id: "r1".parse().unwrap(),
1868                    turn: 1,
1869                },
1870            ),
1871            event(
1872                6,
1873                Event::StepStarted {
1874                    run_id: "r1".parse().unwrap(),
1875                    step: 1,
1876                },
1877            ),
1878        ]
1879    }
1880
1881    #[test]
1882    fn replay_rejects_overlapping_or_out_of_order_steps() {
1883        let mut overlapping = open_step_events();
1884        overlapping.push(event(
1885            7,
1886            Event::StepStarted {
1887                run_id: "r1".parse().unwrap(),
1888                step: 2,
1889            },
1890        ));
1891        assert_eq!(
1892            SessionProjection::replay(&overlapping).unwrap_err(),
1893            EventError::ConcurrentStep
1894        );
1895
1896        let mut skipped = open_step_events();
1897        skipped[5] = event(
1898            6,
1899            Event::StepStarted {
1900                run_id: "r1".parse().unwrap(),
1901                step: 2,
1902            },
1903        );
1904        assert_eq!(
1905            SessionProjection::replay(&skipped).unwrap_err(),
1906            EventError::ConcurrentStep
1907        );
1908    }
1909
1910    #[test]
1911    fn replay_rejects_cross_step_results_and_dangling_calls() {
1912        let mut events = open_step_events();
1913        events.push(event(
1914            7,
1915            Event::ToolCall {
1916                run_id: "r1".parse().unwrap(),
1917                step: 1,
1918                call_id: "c1".parse().unwrap(),
1919                tool: "echo".into(),
1920                arguments: Value::Null,
1921            },
1922        ));
1923        events.push(event(
1924            8,
1925            Event::ToolResult {
1926                run_id: "r1".parse().unwrap(),
1927                step: 2,
1928                call_id: "c1".parse().unwrap(),
1929                result: Value::Null,
1930                is_error: false,
1931            },
1932        ));
1933        assert_eq!(
1934            SessionProjection::replay(&events).unwrap_err(),
1935            EventError::LifecycleMismatch
1936        );
1937
1938        let mut dangling = open_step_events();
1939        dangling.push(event(
1940            7,
1941            Event::ToolCall {
1942                run_id: "r1".parse().unwrap(),
1943                step: 1,
1944                call_id: "c1".parse().unwrap(),
1945                tool: "echo".into(),
1946                arguments: Value::Null,
1947            },
1948        ));
1949        dangling.push(event(
1950            8,
1951            Event::StepFinished {
1952                run_id: "r1".parse().unwrap(),
1953                step: 1,
1954            },
1955        ));
1956        assert_eq!(
1957            SessionProjection::replay(&dangling).unwrap_err(),
1958            EventError::LifecycleMismatch
1959        );
1960    }
1961
1962    #[test]
1963    fn replay_rejects_reused_tool_call_ids() {
1964        let mut events = open_step_events();
1965        events.extend([
1966            event(
1967                7,
1968                Event::ToolCall {
1969                    run_id: "r1".parse().unwrap(),
1970                    step: 1,
1971                    call_id: "c1".parse().unwrap(),
1972                    tool: "echo".into(),
1973                    arguments: Value::Null,
1974                },
1975            ),
1976            event(
1977                8,
1978                Event::ToolResult {
1979                    run_id: "r1".parse().unwrap(),
1980                    step: 1,
1981                    call_id: "c1".parse().unwrap(),
1982                    result: Value::Null,
1983                    is_error: false,
1984                },
1985            ),
1986            event(
1987                9,
1988                Event::StepFinished {
1989                    run_id: "r1".parse().unwrap(),
1990                    step: 1,
1991                },
1992            ),
1993            event(
1994                10,
1995                Event::StepStarted {
1996                    run_id: "r1".parse().unwrap(),
1997                    step: 2,
1998                },
1999            ),
2000            event(
2001                11,
2002                Event::ToolCall {
2003                    run_id: "r1".parse().unwrap(),
2004                    step: 2,
2005                    call_id: "c1".parse().unwrap(),
2006                    tool: "echo".into(),
2007                    arguments: Value::Null,
2008                },
2009            ),
2010        ]);
2011        assert_eq!(
2012            SessionProjection::replay(&events).unwrap_err(),
2013            EventError::DuplicateToolCall("c1".parse().unwrap())
2014        );
2015    }
2016
2017    #[test]
2018    fn replay_keeps_injected_context_without_changing_run_state() {
2019        let mut events = open_step_events();
2020        events.push(event(
2021            7,
2022            Event::ContextInjected {
2023                run_id: "r1".parse().unwrap(),
2024                step: 1,
2025                contribution_id: "memory:1".into(),
2026                source: "memory".into(),
2027                version: "v1".into(),
2028                authority: "tenant".into(),
2029                form: "message".into(),
2030                content: text("tenant context"),
2031            },
2032        ));
2033
2034        let projection = SessionProjection::replay(&events).unwrap();
2035        assert_eq!(projection.active_run_id.as_deref(), Some("r1"));
2036        assert_eq!(projection.open_steps, BTreeSet::from([1]));
2037        assert_eq!(
2038            projection.injected_context,
2039            vec![ProjectedContext {
2040                run_id: "r1".parse().unwrap(),
2041                step: 1,
2042                contribution_id: "memory:1".into(),
2043                source: "memory".into(),
2044                version: "v1".into(),
2045                authority: "tenant".into(),
2046                form: "message".into(),
2047                content: text("tenant context"),
2048            }]
2049        );
2050    }
2051
2052    #[test]
2053    fn replay_skips_unknown_ignorable_formats_and_rejects_required_events() {
2054        let optional = event(
2055            1,
2056            Event::Opaque {
2057                format_version: SESSION_EVENT_FORMAT_VERSION,
2058                event_type: "future_optional".into(),
2059                ignorable: true,
2060                payload: serde_json::json!({"answer": 42}),
2061            },
2062        );
2063        let projection = SessionProjection::replay(&[optional]).unwrap();
2064        assert_eq!(projection.last_seq, 1);
2065
2066        let required = event(
2067            1,
2068            Event::Opaque {
2069                format_version: SESSION_EVENT_FORMAT_VERSION,
2070                event_type: "future_required".into(),
2071                ignorable: false,
2072                payload: Value::Null,
2073            },
2074        );
2075        assert_eq!(
2076            SessionProjection::replay(&[required]).unwrap_err(),
2077            EventError::UnknownRequired("future_required".into())
2078        );
2079
2080        let unsupported = event(
2081            1,
2082            Event::Opaque {
2083                format_version: SESSION_EVENT_FORMAT_VERSION + 1,
2084                event_type: "future_optional".into(),
2085                ignorable: true,
2086                payload: Value::Null,
2087            },
2088        );
2089        assert_eq!(
2090            SessionProjection::replay(&[unsupported]).unwrap().last_seq,
2091            1
2092        );
2093
2094        let required_new_format = event(
2095            1,
2096            Event::Opaque {
2097                format_version: SESSION_EVENT_FORMAT_VERSION + 1,
2098                event_type: "future_required".into(),
2099                ignorable: false,
2100                payload: Value::Null,
2101            },
2102        );
2103        assert_eq!(
2104            SessionProjection::replay(&[required_new_format]).unwrap_err(),
2105            EventError::UnsupportedFormat(SESSION_EVENT_FORMAT_VERSION + 1)
2106        );
2107    }
2108}