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