1#![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
21pub const SESSION_EVENT_FORMAT_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(deny_unknown_fields)]
27pub struct SessionMetadata {
28 pub title: String,
30 pub archived: bool,
32 pub version: u64,
34}
35
36impl SessionMetadata {
37 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
48pub struct SessionEvent {
49 pub session_id: SessionId,
51 pub seq: u64,
53 pub occurred_at: DateTime<Utc>,
55 pub event: Event,
57}
58
59impl SessionEvent {
60 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 pub fn format_version(&self) -> u32 {
72 self.event.format_version()
73 }
74
75 pub fn event_type(&self) -> &str {
77 self.event.event_type()
78 }
79
80 pub fn ignorable(&self) -> bool {
82 self.event.ignorable()
83 }
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "type", rename_all = "snake_case")]
89pub enum Event {
90 SessionCreated {
92 profile_revision_id: ProfileRevisionId,
94 },
95 SessionForked {
97 parent_session_id: SessionId,
99 parent_seq: u64,
101 },
102 SessionMetadataUpdated {
104 metadata: SessionMetadata,
106 },
107 SessionDeleted {
109 reason: String,
111 },
112 InputQueued {
114 input_id: InputId,
116 run_id: RunId,
118 mode: DeliveryMode,
120 content: Vec<ContentBlock>,
122 explicit_skill: Option<String>,
124 },
125 InputClaimed {
127 input_id: InputId,
129 run_id: RunId,
131 },
132 InputCancelled {
134 input_id: InputId,
136 run_id: RunId,
138 error_code: String,
140 },
141 RunStarted {
143 run_id: RunId,
145 input_id: InputId,
147 },
148 RunWaiting {
150 run_id: RunId,
152 interaction_id: InteractionId,
154 },
155 RunResumed {
157 run_id: RunId,
159 interaction_id: InteractionId,
161 },
162 RunFinished {
164 run_id: RunId,
166 status: RunStatus,
168 error_code: Option<String>,
170 },
171 TurnStarted {
173 run_id: RunId,
175 turn: u32,
177 },
178 TurnFinished {
180 run_id: RunId,
182 turn: u32,
184 },
185 StepStarted {
187 run_id: RunId,
189 step: u32,
191 },
192 StepFinished {
194 run_id: RunId,
196 step: u32,
198 },
199 UserMessage {
201 run_id: RunId,
203 content: Vec<ContentBlock>,
205 },
206 AssistantDelta {
208 run_id: RunId,
210 step: u32,
212 attempt: u32,
214 content: String,
216 },
217 AssistantMessage {
219 run_id: RunId,
221 step: u32,
223 attempt: u32,
225 content: Vec<ContentBlock>,
227 },
228 AssistantToolCalls {
230 run_id: RunId,
232 step: u32,
234 content: Option<String>,
236 calls: Vec<RecordedToolCall>,
238 },
239 ToolCall {
241 run_id: RunId,
243 step: u32,
245 call_id: ToolCallId,
247 tool: String,
249 arguments: Value,
251 },
252 ToolAuthorization {
254 run_id: RunId,
256 step: u32,
258 call_id: ToolCallId,
260 status: ToolAuthorizationStatus,
262 reason: Option<String>,
264 },
265 ToolExecutionStarted {
267 #[serde(default, skip_serializing_if = "Option::is_none")]
269 metering: Option<MeteringDetails>,
270 #[serde(default)]
272 reserved_cost_units: u64,
273 run_id: RunId,
275 step: u32,
277 call_id: ToolCallId,
279 },
280 ToolResult {
282 run_id: RunId,
284 step: u32,
286 call_id: ToolCallId,
288 result: Value,
290 is_error: bool,
292 },
293 UsageCorrected {
295 correction: MeteringCorrection,
297 actor_id: af_context::SubjectId,
299 },
300 UsageRecorded {
302 #[serde(default, skip_serializing_if = "Option::is_none")]
304 metering: Option<MeteringDetails>,
305 run_id: RunId,
307 operation_id: String,
309 prompt_tokens: u64,
311 completion_tokens: u64,
313 #[serde(default)]
315 cost_units: u64,
316 },
317 RetryScheduled {
319 run_id: RunId,
321 attempt: u32,
323 delay_ms: u64,
325 reason: String,
327 },
328 ModelRequestPrepared {
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 metering: Option<MeteringDetails>,
333 run_id: RunId,
335 step: u32,
337 attempt: u32,
339 #[serde(default)]
341 provider_attempt_id: String,
342 #[serde(default)]
344 operation_id: String,
345 #[serde(default)]
347 reserved_prompt_tokens: u64,
348 #[serde(default)]
350 reserved_completion_tokens: u64,
351 request: Value,
353 prompt_sections: Value,
355 },
356 ContextInjected {
358 run_id: RunId,
360 step: u32,
362 contribution_id: String,
364 source: String,
366 version: String,
368 authority: String,
370 form: String,
372 content: Vec<ContentBlock>,
374 },
375 ModelAttemptFailed {
377 run_id: RunId,
379 step: u32,
381 attempt: u32,
383 error: String,
385 retryable: bool,
387 },
388 CompactionStarted {
390 run_id: RunId,
392 compaction_id: String,
394 source_through_seq: u64,
396 },
397 ToolResultsPruned {
399 run_id: RunId,
401 call_ids: Vec<ToolCallId>,
403 },
404 SummaryReplaced {
406 run_id: RunId,
408 through_seq: u64,
410 summary: String,
412 compactor: String,
414 model: String,
416 },
417 CompactionFinished {
419 run_id: RunId,
421 compaction_id: String,
423 status: String,
425 error: Option<String>,
427 },
428 InteractionRequested {
430 run_id: RunId,
432 interaction_id: InteractionId,
434 kind: InteractionKind,
436 payload: Value,
438 },
439 InteractionResolved {
441 run_id: RunId,
443 interaction_id: InteractionId,
445 resolution: InteractionResolution,
447 payload: Value,
449 },
450 ChildSessionLinked {
452 run_id: RunId,
454 child_session_id: SessionId,
456 provider: String,
458 },
459 Extension {
461 run_id: RunId,
463 plugin_id: String,
465 event_type: String,
467 payload: Value,
469 },
470 #[serde(skip)]
472 Opaque {
473 format_version: u32,
475 event_type: String,
477 ignorable: bool,
479 payload: Value,
481 },
482}
483
484impl Event {
485 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 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 pub fn ignorable(&self) -> bool {
539 match self {
540 Self::Opaque { ignorable, .. } => *ignorable,
541 _ => false,
542 }
543 }
544}
545
546#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
548#[serde(rename_all = "snake_case")]
549pub enum DeliveryMode {
550 Followup,
552 Steer,
554 Inject,
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
560#[serde(rename_all = "snake_case")]
561pub enum RunStatus {
562 Completed,
564 Failed,
566 Cancelled,
568 MaxStepsReached,
570}
571
572impl RunStatus {
573 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
586#[serde(rename_all = "snake_case")]
587pub enum InteractionKind {
588 Action,
590 UserQuestion,
592}
593
594impl InteractionKind {
595 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
606#[serde(rename_all = "snake_case")]
607pub enum InteractionResolution {
608 Confirmed,
610 Rejected,
612 Answered,
614}
615
616impl InteractionResolution {
617 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
629#[serde(rename_all = "snake_case")]
630pub enum ToolAuthorizationStatus {
631 Allowed,
633 Waiting,
635 Denied,
637}
638
639#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
641pub struct RecordedToolCall {
642 pub call_id: ToolCallId,
644 pub tool: String,
646 pub arguments: Value,
648}
649
650impl ToolAuthorizationStatus {
651 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
663#[serde(tag = "type", rename_all = "snake_case")]
664pub enum ContentBlock {
665 Text {
667 text: String,
669 },
670 Resource {
672 resource_id: String,
674 media_type: String,
676 },
677 Data {
679 slot: String,
681 value: Value,
683 },
684 Citation {
686 resource_id: String,
688 label: String,
690 uri: String,
692 excerpt: Option<String>,
694 },
695}
696
697#[derive(Debug, Clone, Default, PartialEq)]
699pub struct SessionProjection {
700 pub metadata: SessionMetadata,
702 pub session_id: Option<SessionId>,
704 pub profile_revision_id: Option<ProfileRevisionId>,
707 pub deleted: bool,
709 pub last_seq: u64,
711 pub active_run_id: Option<RunId>,
713 pub waiting_interaction_id: Option<InteractionId>,
715 pub messages: Vec<ProjectedMessage>,
717 pub injected_context: Vec<ProjectedContext>,
719 pub run_status: BTreeMap<RunId, RunState>,
721 pub open_tool_calls: BTreeMap<ToolCallId, OpenToolCall>,
723 pub started_tool_calls: BTreeSet<ToolCallId>,
725 pub queued_inputs: BTreeMap<InputId, (RunId, DeliveryMode)>,
727 pub claimed_inputs: BTreeMap<InputId, RunId>,
729 pub open_turn: Option<u32>,
731 pub open_steps: BTreeSet<u32>,
733 pub next_step: u32,
735 pub open_compaction: Option<(String, u64)>,
737 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
752#[serde(rename_all = "snake_case")]
753pub enum RunState {
754 Running,
756 WaitingForInput,
758 Terminal(RunStatus),
760}
761
762impl RunState {
763 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 pub const fn is_terminal(self) -> bool {
774 matches!(self, Self::Terminal(_))
775 }
776}
777
778#[derive(Debug, Clone, PartialEq)]
780pub struct ProjectedContext {
781 pub run_id: RunId,
783 pub step: u32,
785 pub contribution_id: String,
787 pub source: String,
789 pub version: String,
791 pub authority: String,
793 pub form: String,
795 pub content: Vec<ContentBlock>,
797}
798
799#[derive(Debug, Clone, PartialEq)]
801pub struct ProjectedMessage {
802 pub role: &'static str,
804 pub run_id: RunId,
806 pub content: Vec<ContentBlock>,
808}
809
810#[derive(Debug, Clone, PartialEq)]
812pub struct OpenToolCall {
813 pub run_id: RunId,
815 pub step: u32,
817 pub tool: String,
819 pub arguments: Value,
821 pub source_event_seq: u64,
823}
824
825impl SessionProjection {
826 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 pub fn apply(&mut self, envelope: &SessionEvent) -> Result<(), EventError> {
837 self.apply_internal(envelope, true)
838 }
839
840 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 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 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 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#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1340pub enum EventError {
1341 #[error("invalid Session metadata or metadata version conflict")]
1343 InvalidSessionMetadata,
1344 #[error("invalid metering attribution")]
1346 InvalidMetering,
1347 #[error("Session is archived")]
1349 SessionArchived,
1350 #[error("unsupported session event format version {0}")]
1352 UnsupportedFormat(u32),
1353 #[error("unknown required session event type {0}")]
1355 UnknownRequired(String),
1356 #[error("event sequence mismatch: expected {expected}, got {actual}")]
1358 Sequence {
1359 expected: u64,
1361 actual: u64,
1363 },
1364 #[error("event belongs to another session")]
1366 SessionMismatch,
1367 #[error("session creation must be the first and only creation event")]
1369 DuplicateSession,
1370 #[error("session already has an active run")]
1372 ConcurrentRun,
1373 #[error("session is closed")]
1375 SessionClosed,
1376 #[error("event does not match the active run")]
1378 RunMismatch,
1379 #[error("interaction does not match the waiting run")]
1381 InteractionMismatch,
1382 #[error("run cannot finish with an open turn, step or tool call")]
1384 OpenLifecycle,
1385 #[error("input was queued twice: {0}")]
1387 DuplicateInput(InputId),
1388 #[error("input was claimed before it was queued: {0}")]
1390 UnqueuedInput(InputId),
1391 #[error("run started from an unclaimed input: {0}")]
1393 UnclaimedInput(InputId),
1394 #[error("session already has an active turn")]
1396 ConcurrentTurn,
1397 #[error("turn already has this active step")]
1399 ConcurrentStep,
1400 #[error("turn or step lifecycle does not pair")]
1402 LifecycleMismatch,
1403 #[error("duplicate tool call {0}")]
1405 DuplicateToolCall(ToolCallId),
1406 #[error("tool result has no matching call {0}")]
1408 OrphanToolResult(ToolCallId),
1409 #[error("tool result does not match the call run and step: {0}")]
1411 ToolResultMismatch(ToolCallId),
1412 #[error("usage operation was recorded with different totals: {0}")]
1414 UsageConflict(String),
1415 #[error("session already has an active compaction")]
1417 ConcurrentCompaction,
1418 #[error("compaction lifecycle does not pair")]
1420 CompactionMismatch,
1421 #[error("event store conflict: {0}")]
1423 Conflict(String),
1424 #[error("event store unavailable: {0}")]
1426 Unavailable(String),
1427}
1428
1429#[async_trait]
1431pub trait SessionEventStore: Send + Sync {
1432 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 async fn load(
1442 &self,
1443 tenant_id: &str,
1444 session_id: &str,
1445 after_seq: u64,
1446 ) -> Result<Vec<SessionEvent>, EventError>;
1447}
1448
1449pub fn text(value: impl Into<String>) -> Vec<ContentBlock> {
1451 vec![ContentBlock::Text { text: value.into() }]
1452}
1453
1454pub fn recovery_events(projection: &SessionProjection) -> Vec<Event> {
1456 failure_events(projection, "worker_restarted")
1457}
1458
1459pub fn failure_events(projection: &SessionProjection, error_code: &str) -> Vec<Event> {
1461 termination_events(projection, RunStatus::Failed, error_code)
1462}
1463
1464pub fn cancel_events(projection: &SessionProjection) -> Vec<Event> {
1466 termination_events(projection, RunStatus::Cancelled, "cancelled")
1467}
1468
1469pub 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}