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