1#![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
23pub const SESSION_EVENT_FORMAT_VERSION: u32 = 1;
25
26#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(deny_unknown_fields)]
29pub struct SessionMetadata {
30 pub title: String,
32 pub archived: bool,
34 pub version: u64,
36}
37
38impl SessionMetadata {
39 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
50pub struct SessionEvent {
51 pub session_id: SessionId,
53 pub seq: u64,
55 pub occurred_at: DateTime<Utc>,
57 pub event: Event,
59}
60
61impl SessionEvent {
62 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 pub fn format_version(&self) -> u32 {
74 self.event.format_version()
75 }
76
77 pub fn event_type(&self) -> &str {
79 self.event.event_type()
80 }
81
82 pub fn ignorable(&self) -> bool {
84 self.event.ignorable()
85 }
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90#[serde(tag = "type", rename_all = "snake_case")]
91pub enum Event {
92 SessionCreated {
94 profile_revision_id: ProfileRevisionId,
96 },
97 SessionForked {
99 parent_session_id: SessionId,
101 parent_seq: u64,
103 },
104 SessionMetadataUpdated {
106 metadata: SessionMetadata,
108 },
109 SessionDeleted {
111 reason: String,
113 },
114 InputQueued {
116 input_id: InputId,
118 run_id: RunId,
120 mode: DeliveryMode,
122 content: Vec<ContentBlock>,
124 explicit_skill: Option<String>,
126 },
127 InputClaimed {
129 input_id: InputId,
131 run_id: RunId,
133 },
134 InputCancelled {
136 input_id: InputId,
138 run_id: RunId,
140 error_code: String,
142 },
143 RunStarted {
145 run_id: RunId,
147 input_id: InputId,
149 },
150 RunWaiting {
152 run_id: RunId,
154 interaction_id: InteractionId,
156 },
157 RunResumed {
159 run_id: RunId,
161 interaction_id: InteractionId,
163 },
164 RunFinished {
166 run_id: RunId,
168 status: RunStatus,
170 error_code: Option<String>,
172 },
173 TurnStarted {
175 run_id: RunId,
177 turn: u32,
179 },
180 TurnFinished {
182 run_id: RunId,
184 turn: u32,
186 },
187 StepStarted {
189 run_id: RunId,
191 step: u32,
193 },
194 StepFinished {
196 run_id: RunId,
198 step: u32,
200 },
201 UserMessage {
203 run_id: RunId,
205 content: Vec<ContentBlock>,
207 },
208 AssistantDelta {
210 run_id: RunId,
212 step: u32,
214 attempt: u32,
216 content: String,
218 },
219 AssistantMessage {
221 run_id: RunId,
223 step: u32,
225 attempt: u32,
227 content: Vec<ContentBlock>,
229 },
230 AssistantToolCalls {
232 run_id: RunId,
234 step: u32,
236 content: Option<String>,
238 calls: Vec<RecordedToolCall>,
240 },
241 ToolCall {
243 run_id: RunId,
245 step: u32,
247 call_id: ToolCallId,
249 tool: String,
251 arguments: Value,
253 },
254 ToolAuthorization {
256 run_id: RunId,
258 step: u32,
260 call_id: ToolCallId,
262 status: ToolAuthorizationStatus,
264 reason: Option<String>,
266 },
267 ToolExecutionStarted {
269 #[serde(default, skip_serializing_if = "Option::is_none")]
271 metering: Option<MeteringDetails>,
272 #[serde(default)]
274 reserved_cost_units: u64,
275 run_id: RunId,
277 step: u32,
279 call_id: ToolCallId,
281 },
282 ToolResult {
284 run_id: RunId,
286 step: u32,
288 call_id: ToolCallId,
290 result: Value,
292 is_error: bool,
294 },
295 UsageCorrected {
297 correction: MeteringCorrection,
299 actor_id: af_context::SubjectId,
301 },
302 UsageRecorded {
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 metering: Option<MeteringDetails>,
307 run_id: RunId,
309 operation_id: String,
311 prompt_tokens: u64,
313 completion_tokens: u64,
315 #[serde(default)]
317 cost_units: u64,
318 },
319 RetryScheduled {
321 run_id: RunId,
323 attempt: u32,
325 delay_ms: u64,
327 reason: String,
329 },
330 ModelRequestPrepared {
332 #[serde(default, skip_serializing_if = "Option::is_none")]
334 metering: Option<MeteringDetails>,
335 run_id: RunId,
337 step: u32,
339 attempt: u32,
341 #[serde(default)]
343 provider_attempt_id: String,
344 #[serde(default)]
346 operation_id: String,
347 #[serde(default)]
349 reserved_prompt_tokens: u64,
350 #[serde(default)]
352 reserved_completion_tokens: u64,
353 request: Value,
355 prompt_sections: Value,
357 },
358 ContextInjected {
360 run_id: RunId,
362 step: u32,
364 contribution_id: String,
366 source: String,
368 version: String,
370 authority: String,
372 form: String,
374 content: Vec<ContentBlock>,
376 },
377 ModelAttemptFailed {
379 run_id: RunId,
381 step: u32,
383 attempt: u32,
385 error: String,
387 retryable: bool,
389 },
390 CompactionStarted {
392 run_id: RunId,
394 compaction_id: String,
396 source_through_seq: u64,
398 },
399 ToolResultsPruned {
401 run_id: RunId,
403 call_ids: Vec<ToolCallId>,
405 },
406 SummaryReplaced {
408 run_id: RunId,
410 through_seq: u64,
412 summary: String,
414 compactor: String,
416 model: String,
418 },
419 CompactionFinished {
421 run_id: RunId,
423 compaction_id: String,
425 status: String,
427 error: Option<String>,
429 },
430 InteractionRequested {
432 run_id: RunId,
434 interaction_id: InteractionId,
436 kind: InteractionKind,
438 payload: Value,
440 },
441 InteractionResolved {
443 run_id: RunId,
445 interaction_id: InteractionId,
447 resolution: InteractionResolution,
449 payload: Value,
451 },
452 ChildSessionLinked {
454 run_id: RunId,
456 child_session_id: SessionId,
458 provider: String,
460 },
461 Extension {
463 run_id: RunId,
465 plugin_id: String,
467 event_type: String,
469 payload: Value,
471 },
472 #[serde(skip)]
474 Opaque {
475 format_version: u32,
477 event_type: String,
479 ignorable: bool,
481 payload: Value,
483 },
484}
485
486impl Event {
487 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 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 pub fn ignorable(&self) -> bool {
541 match self {
542 Self::Opaque { ignorable, .. } => *ignorable,
543 _ => false,
544 }
545 }
546}
547
548#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
550#[serde(rename_all = "snake_case")]
551pub enum DeliveryMode {
552 Followup,
554 Steer,
556 Inject,
558}
559
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
562#[serde(rename_all = "snake_case")]
563pub enum RunStatus {
564 Completed,
566 Failed,
568 Cancelled,
570 MaxStepsReached,
572}
573
574impl RunStatus {
575 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
588#[serde(rename_all = "snake_case")]
589pub enum InteractionKind {
590 Action,
592 UserQuestion,
594}
595
596impl InteractionKind {
597 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
608#[serde(rename_all = "snake_case")]
609pub enum InteractionResolution {
610 Confirmed,
612 Rejected,
614 Answered,
616}
617
618impl InteractionResolution {
619 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
631#[serde(rename_all = "snake_case")]
632pub enum ToolAuthorizationStatus {
633 Allowed,
635 Waiting,
637 Denied,
639}
640
641#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
643pub struct RecordedToolCall {
644 pub call_id: ToolCallId,
646 pub tool: String,
648 pub arguments: Value,
650}
651
652impl ToolAuthorizationStatus {
653 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
665#[serde(tag = "type", rename_all = "snake_case")]
666pub enum ContentBlock {
667 Text {
669 text: String,
671 },
672 Resource {
674 resource_id: String,
676 media_type: String,
678 },
679 Data {
681 slot: String,
683 value: Value,
685 },
686 Citation {
688 resource_id: String,
690 label: String,
692 uri: String,
694 excerpt: Option<String>,
696 },
697}
698
699#[derive(Debug, Clone, Default, PartialEq)]
701pub struct SessionProjection {
702 pub metadata: SessionMetadata,
704 pub session_id: Option<SessionId>,
706 pub profile_revision_id: Option<ProfileRevisionId>,
709 pub deleted: bool,
711 pub last_seq: u64,
713 pub active_run_id: Option<RunId>,
715 pub waiting_interaction_id: Option<InteractionId>,
717 pub messages: Vec<ProjectedMessage>,
719 pub injected_context: Vec<ProjectedContext>,
721 pub run_status: BTreeMap<RunId, RunState>,
723 pub open_tool_calls: BTreeMap<ToolCallId, OpenToolCall>,
725 pub started_tool_calls: BTreeSet<ToolCallId>,
727 pub queued_inputs: BTreeMap<InputId, (RunId, DeliveryMode)>,
729 pub claimed_inputs: BTreeMap<InputId, RunId>,
731 pub open_turn: Option<u32>,
733 pub open_steps: BTreeSet<u32>,
735 pub next_step: u32,
737 pub open_compaction: Option<(String, u64)>,
739 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
754#[serde(rename_all = "snake_case")]
755pub enum RunState {
756 Running,
758 WaitingForInput,
760 Terminal(RunStatus),
762}
763
764impl RunState {
765 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 pub const fn is_terminal(self) -> bool {
776 matches!(self, Self::Terminal(_))
777 }
778}
779
780#[derive(Debug, Clone, PartialEq)]
782pub struct ProjectedContext {
783 pub run_id: RunId,
785 pub step: u32,
787 pub contribution_id: String,
789 pub source: String,
791 pub version: String,
793 pub authority: String,
795 pub form: String,
797 pub content: Vec<ContentBlock>,
799}
800
801#[derive(Debug, Clone, PartialEq)]
803pub struct ProjectedMessage {
804 pub role: &'static str,
806 pub run_id: RunId,
808 pub content: Vec<ContentBlock>,
810}
811
812#[derive(Debug, Clone, PartialEq)]
814pub struct OpenToolCall {
815 pub run_id: RunId,
817 pub step: u32,
819 pub tool: String,
821 pub arguments: Value,
823 pub source_event_seq: u64,
825}
826
827impl SessionProjection {
828 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 pub fn apply(&mut self, envelope: &SessionEvent) -> Result<(), EventError> {
839 self.apply_internal(envelope, true)
840 }
841
842 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 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 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 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#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1342pub enum EventError {
1343 #[error("invalid Session metadata or metadata version conflict")]
1345 InvalidSessionMetadata,
1346 #[error("invalid metering attribution")]
1348 InvalidMetering,
1349 #[error("Session is archived")]
1351 SessionArchived,
1352 #[error("unsupported session event format version {0}")]
1354 UnsupportedFormat(u32),
1355 #[error("unknown required session event type {0}")]
1357 UnknownRequired(String),
1358 #[error("event sequence mismatch: expected {expected}, got {actual}")]
1360 Sequence {
1361 expected: u64,
1363 actual: u64,
1365 },
1366 #[error("event belongs to another session")]
1368 SessionMismatch,
1369 #[error("session creation must be the first and only creation event")]
1371 DuplicateSession,
1372 #[error("session already has an active run")]
1374 ConcurrentRun,
1375 #[error("session is closed")]
1377 SessionClosed,
1378 #[error("event does not match the active run")]
1380 RunMismatch,
1381 #[error("interaction does not match the waiting run")]
1383 InteractionMismatch,
1384 #[error("run cannot finish with an open turn, step or tool call")]
1386 OpenLifecycle,
1387 #[error("input was queued twice: {0}")]
1389 DuplicateInput(InputId),
1390 #[error("input was claimed before it was queued: {0}")]
1392 UnqueuedInput(InputId),
1393 #[error("run started from an unclaimed input: {0}")]
1395 UnclaimedInput(InputId),
1396 #[error("session already has an active turn")]
1398 ConcurrentTurn,
1399 #[error("turn already has this active step")]
1401 ConcurrentStep,
1402 #[error("turn or step lifecycle does not pair")]
1404 LifecycleMismatch,
1405 #[error("duplicate tool call {0}")]
1407 DuplicateToolCall(ToolCallId),
1408 #[error("tool result has no matching call {0}")]
1410 OrphanToolResult(ToolCallId),
1411 #[error("tool result does not match the call run and step: {0}")]
1413 ToolResultMismatch(ToolCallId),
1414 #[error("usage operation was recorded with different totals: {0}")]
1416 UsageConflict(String),
1417 #[error("session already has an active compaction")]
1419 ConcurrentCompaction,
1420 #[error("compaction lifecycle does not pair")]
1422 CompactionMismatch,
1423 #[error("event store conflict: {0}")]
1425 Conflict(String),
1426 #[error("event store unavailable: {0}")]
1428 Unavailable(String),
1429}
1430
1431#[async_trait]
1433pub trait SessionEventStore: Send + Sync {
1434 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 async fn load(
1444 &self,
1445 tenant_id: &str,
1446 session_id: &str,
1447 after_seq: u64,
1448 ) -> Result<Vec<SessionEvent>, EventError>;
1449}
1450
1451pub fn text(value: impl Into<String>) -> Vec<ContentBlock> {
1453 vec![ContentBlock::Text { text: value.into() }]
1454}
1455
1456pub fn recovery_events(projection: &SessionProjection) -> Vec<Event> {
1458 failure_events(projection, "worker_restarted")
1459}
1460
1461pub fn failure_events(projection: &SessionProjection, error_code: &str) -> Vec<Event> {
1463 termination_events(projection, RunStatus::Failed, error_code)
1464}
1465
1466pub fn cancel_events(projection: &SessionProjection) -> Vec<Event> {
1468 termination_events(projection, RunStatus::Cancelled, "cancelled")
1469}
1470
1471pub 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}