1#![deny(missing_docs)]
5#![deny(rustdoc::broken_intra_doc_links)]
6
7use af_context::{InputId, InteractionId, ProfileRevisionId, RunId, SessionId, ToolCallId};
8use std::collections::{BTreeMap, BTreeSet};
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15pub const SESSION_EVENT_FORMAT_VERSION: u32 = 1;
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct SessionEvent {
21 pub session_id: SessionId,
23 pub seq: u64,
25 pub occurred_at: DateTime<Utc>,
27 pub event: Event,
29}
30
31impl SessionEvent {
32 pub fn pending(session_id: impl Into<SessionId>, event: Event) -> Self {
34 Self {
35 session_id: session_id.into(),
36 seq: 0,
37 occurred_at: Utc::now(),
38 event,
39 }
40 }
41
42 pub fn format_version(&self) -> u32 {
44 self.event.format_version()
45 }
46
47 pub fn event_type(&self) -> &str {
49 self.event.event_type()
50 }
51
52 pub fn ignorable(&self) -> bool {
54 self.event.ignorable()
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum Event {
62 SessionCreated {
64 profile_revision_id: ProfileRevisionId,
66 },
67 SessionForked {
69 parent_session_id: SessionId,
71 parent_seq: u64,
73 },
74 SessionDeleted {
76 reason: String,
78 },
79 InputQueued {
81 input_id: InputId,
83 run_id: RunId,
85 mode: DeliveryMode,
87 content: Vec<ContentBlock>,
89 explicit_skill: Option<String>,
91 },
92 InputClaimed {
94 input_id: InputId,
96 run_id: RunId,
98 },
99 InputCancelled {
101 input_id: InputId,
103 run_id: RunId,
105 error_code: String,
107 },
108 RunStarted {
110 run_id: RunId,
112 input_id: InputId,
114 },
115 RunWaiting {
117 run_id: RunId,
119 interaction_id: InteractionId,
121 },
122 RunResumed {
124 run_id: RunId,
126 interaction_id: InteractionId,
128 },
129 RunFinished {
131 run_id: RunId,
133 status: RunStatus,
135 error_code: Option<String>,
137 },
138 TurnStarted {
140 run_id: RunId,
142 turn: u32,
144 },
145 TurnFinished {
147 run_id: RunId,
149 turn: u32,
151 },
152 StepStarted {
154 run_id: RunId,
156 step: u32,
158 },
159 StepFinished {
161 run_id: RunId,
163 step: u32,
165 },
166 UserMessage {
168 run_id: RunId,
170 content: Vec<ContentBlock>,
172 },
173 AssistantDelta {
175 run_id: RunId,
177 step: u32,
179 attempt: u32,
181 content: String,
183 },
184 AssistantMessage {
186 run_id: RunId,
188 step: u32,
190 attempt: u32,
192 content: Vec<ContentBlock>,
194 },
195 AssistantToolCalls {
197 run_id: RunId,
199 step: u32,
201 content: Option<String>,
203 calls: Vec<RecordedToolCall>,
205 },
206 ToolCall {
208 run_id: RunId,
210 step: u32,
212 call_id: ToolCallId,
214 tool: String,
216 arguments: Value,
218 },
219 ToolAuthorization {
221 run_id: RunId,
223 step: u32,
225 call_id: ToolCallId,
227 status: ToolAuthorizationStatus,
229 reason: Option<String>,
231 },
232 ToolExecutionStarted {
234 run_id: RunId,
236 step: u32,
238 call_id: ToolCallId,
240 },
241 ToolResult {
243 run_id: RunId,
245 step: u32,
247 call_id: ToolCallId,
249 result: Value,
251 is_error: bool,
253 },
254 UsageRecorded {
256 run_id: RunId,
258 operation_id: String,
260 prompt_tokens: u64,
262 completion_tokens: u64,
264 #[serde(default)]
266 cost_units: u64,
267 },
268 RetryScheduled {
270 run_id: RunId,
272 attempt: u32,
274 delay_ms: u64,
276 reason: String,
278 },
279 ModelRequestPrepared {
281 run_id: RunId,
283 step: u32,
285 attempt: u32,
287 #[serde(default)]
289 provider_attempt_id: String,
290 #[serde(default)]
292 operation_id: String,
293 #[serde(default)]
295 reserved_prompt_tokens: u64,
296 #[serde(default)]
298 reserved_completion_tokens: u64,
299 request: Value,
301 prompt_sections: Value,
303 },
304 ContextInjected {
306 run_id: RunId,
308 step: u32,
310 contribution_id: String,
312 source: String,
314 version: String,
316 authority: String,
318 form: String,
320 content: Vec<ContentBlock>,
322 },
323 ModelAttemptFailed {
325 run_id: RunId,
327 step: u32,
329 attempt: u32,
331 error: String,
333 retryable: bool,
335 },
336 CompactionStarted {
338 run_id: RunId,
340 compaction_id: String,
342 source_through_seq: u64,
344 },
345 ToolResultsPruned {
347 run_id: RunId,
349 call_ids: Vec<ToolCallId>,
351 },
352 SummaryReplaced {
354 run_id: RunId,
356 through_seq: u64,
358 summary: String,
360 compactor: String,
362 model: String,
364 },
365 CompactionFinished {
367 run_id: RunId,
369 compaction_id: String,
371 status: String,
373 error: Option<String>,
375 },
376 InteractionRequested {
378 run_id: RunId,
380 interaction_id: InteractionId,
382 kind: InteractionKind,
384 payload: Value,
386 },
387 InteractionResolved {
389 run_id: RunId,
391 interaction_id: InteractionId,
393 resolution: InteractionResolution,
395 payload: Value,
397 },
398 ChildSessionLinked {
400 run_id: RunId,
402 child_session_id: SessionId,
404 provider: String,
406 },
407 Extension {
409 run_id: RunId,
411 plugin_id: String,
413 event_type: String,
415 payload: Value,
417 },
418 #[serde(skip)]
420 Opaque {
421 format_version: u32,
423 event_type: String,
425 ignorable: bool,
427 payload: Value,
429 },
430}
431
432impl Event {
433 pub fn format_version(&self) -> u32 {
435 match self {
436 Self::Opaque { format_version, .. } => *format_version,
437 _ => SESSION_EVENT_FORMAT_VERSION,
438 }
439 }
440
441 pub fn event_type(&self) -> &str {
443 match self {
444 Self::SessionCreated { .. } => "session_created",
445 Self::SessionForked { .. } => "session_forked",
446 Self::SessionDeleted { .. } => "session_deleted",
447 Self::InputQueued { .. } => "input_queued",
448 Self::InputClaimed { .. } => "input_claimed",
449 Self::InputCancelled { .. } => "input_cancelled",
450 Self::RunStarted { .. } => "run_started",
451 Self::RunWaiting { .. } => "run_waiting",
452 Self::RunResumed { .. } => "run_resumed",
453 Self::RunFinished { .. } => "run_finished",
454 Self::TurnStarted { .. } => "turn_started",
455 Self::TurnFinished { .. } => "turn_finished",
456 Self::StepStarted { .. } => "step_started",
457 Self::StepFinished { .. } => "step_finished",
458 Self::UserMessage { .. } => "user_message",
459 Self::AssistantDelta { .. } => "assistant_delta",
460 Self::AssistantMessage { .. } => "assistant_message",
461 Self::AssistantToolCalls { .. } => "assistant_tool_calls",
462 Self::ToolCall { .. } => "tool_call",
463 Self::ToolAuthorization { .. } => "tool_authorization",
464 Self::ToolExecutionStarted { .. } => "tool_execution_started",
465 Self::ToolResult { .. } => "tool_result",
466 Self::UsageRecorded { .. } => "usage_recorded",
467 Self::RetryScheduled { .. } => "retry_scheduled",
468 Self::ModelRequestPrepared { .. } => "model_request_prepared",
469 Self::ContextInjected { .. } => "context_injected",
470 Self::ModelAttemptFailed { .. } => "model_attempt_failed",
471 Self::CompactionStarted { .. } => "compaction_started",
472 Self::ToolResultsPruned { .. } => "tool_results_pruned",
473 Self::SummaryReplaced { .. } => "summary_replaced",
474 Self::CompactionFinished { .. } => "compaction_finished",
475 Self::InteractionRequested { .. } => "interaction_requested",
476 Self::InteractionResolved { .. } => "interaction_resolved",
477 Self::ChildSessionLinked { .. } => "child_session_linked",
478 Self::Extension { .. } => "extension",
479 Self::Opaque { event_type, .. } => event_type,
480 }
481 }
482
483 pub fn ignorable(&self) -> bool {
485 match self {
486 Self::Opaque { ignorable, .. } => *ignorable,
487 _ => false,
488 }
489 }
490}
491
492#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
494#[serde(rename_all = "snake_case")]
495pub enum DeliveryMode {
496 Followup,
498 Steer,
500 Inject,
502}
503
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
506#[serde(rename_all = "snake_case")]
507pub enum RunStatus {
508 Completed,
510 Failed,
512 Cancelled,
514 MaxStepsReached,
516}
517
518impl RunStatus {
519 pub const fn as_str(self) -> &'static str {
521 match self {
522 Self::Completed => "completed",
523 Self::Failed => "failed",
524 Self::Cancelled => "cancelled",
525 Self::MaxStepsReached => "max_steps_reached",
526 }
527 }
528}
529
530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
532#[serde(rename_all = "snake_case")]
533pub enum InteractionKind {
534 Action,
536 UserQuestion,
538}
539
540impl InteractionKind {
541 pub const fn as_str(self) -> &'static str {
543 match self {
544 Self::Action => "action",
545 Self::UserQuestion => "user_question",
546 }
547 }
548}
549
550#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
552#[serde(rename_all = "snake_case")]
553pub enum InteractionResolution {
554 Confirmed,
556 Rejected,
558 Answered,
560}
561
562impl InteractionResolution {
563 pub const fn as_str(self) -> &'static str {
565 match self {
566 Self::Confirmed => "confirmed",
567 Self::Rejected => "rejected",
568 Self::Answered => "answered",
569 }
570 }
571}
572
573#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
575#[serde(rename_all = "snake_case")]
576pub enum ToolAuthorizationStatus {
577 Allowed,
579 Waiting,
581 Denied,
583}
584
585#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
587pub struct RecordedToolCall {
588 pub call_id: ToolCallId,
590 pub tool: String,
592 pub arguments: Value,
594}
595
596impl ToolAuthorizationStatus {
597 pub const fn as_str(self) -> &'static str {
599 match self {
600 Self::Allowed => "allowed",
601 Self::Waiting => "waiting",
602 Self::Denied => "denied",
603 }
604 }
605}
606
607#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
609#[serde(tag = "type", rename_all = "snake_case")]
610pub enum ContentBlock {
611 Text {
613 text: String,
615 },
616 Resource {
618 resource_id: String,
620 media_type: String,
622 },
623 Data {
625 slot: String,
627 value: Value,
629 },
630 Citation {
632 resource_id: String,
634 label: String,
636 uri: String,
638 excerpt: Option<String>,
640 },
641}
642
643#[derive(Debug, Clone, Default, PartialEq)]
645pub struct SessionProjection {
646 pub session_id: Option<SessionId>,
648 pub profile_revision_id: Option<ProfileRevisionId>,
651 pub deleted: bool,
653 pub last_seq: u64,
655 pub active_run_id: Option<RunId>,
657 pub waiting_interaction_id: Option<InteractionId>,
659 pub messages: Vec<ProjectedMessage>,
661 pub injected_context: Vec<ProjectedContext>,
663 pub run_status: BTreeMap<RunId, RunState>,
665 pub open_tool_calls: BTreeMap<ToolCallId, OpenToolCall>,
667 pub started_tool_calls: BTreeSet<ToolCallId>,
669 pub queued_inputs: BTreeMap<InputId, (RunId, DeliveryMode)>,
671 pub claimed_inputs: BTreeMap<InputId, RunId>,
673 pub open_turn: Option<u32>,
675 pub open_steps: BTreeSet<u32>,
677 pub next_step: u32,
679 pub open_compaction: Option<(String, u64)>,
681 pub summary: Option<String>,
683 usage_operations: BTreeMap<(RunId, String), (u64, u64, u64)>,
684 pending_provider_attempts: BTreeMap<(RunId, String), (u64, u64)>,
685 seen_tool_calls: BTreeSet<ToolCallId>,
686}
687
688#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
690#[serde(rename_all = "snake_case")]
691pub enum RunState {
692 Running,
694 WaitingForInput,
696 Terminal(RunStatus),
698}
699
700impl RunState {
701 pub const fn as_str(self) -> &'static str {
703 match self {
704 Self::Running => "running",
705 Self::WaitingForInput => "waiting_for_input",
706 Self::Terminal(status) => status.as_str(),
707 }
708 }
709
710 pub const fn is_terminal(self) -> bool {
712 matches!(self, Self::Terminal(_))
713 }
714}
715
716#[derive(Debug, Clone, PartialEq)]
718pub struct ProjectedContext {
719 pub run_id: RunId,
721 pub step: u32,
723 pub contribution_id: String,
725 pub source: String,
727 pub version: String,
729 pub authority: String,
731 pub form: String,
733 pub content: Vec<ContentBlock>,
735}
736
737#[derive(Debug, Clone, PartialEq)]
739pub struct ProjectedMessage {
740 pub role: &'static str,
742 pub run_id: RunId,
744 pub content: Vec<ContentBlock>,
746}
747
748#[derive(Debug, Clone, PartialEq)]
750pub struct OpenToolCall {
751 pub run_id: RunId,
753 pub step: u32,
755 pub tool: String,
757 pub arguments: Value,
759 pub source_event_seq: u64,
761}
762
763impl SessionProjection {
764 pub fn replay(events: &[SessionEvent]) -> Result<Self, EventError> {
766 let mut projection = Self::default();
767 for event in events {
768 projection.apply(event)?;
769 }
770 Ok(projection)
771 }
772
773 pub fn apply(&mut self, envelope: &SessionEvent) -> Result<(), EventError> {
775 if envelope.seq != self.last_seq + 1 {
776 return Err(EventError::Sequence {
777 expected: self.last_seq + 1,
778 actual: envelope.seq,
779 });
780 }
781 let session_id = self
782 .session_id
783 .get_or_insert_with(|| envelope.session_id.clone());
784 if *session_id != envelope.session_id {
785 return Err(EventError::SessionMismatch);
786 }
787 if envelope.format_version() != SESSION_EVENT_FORMAT_VERSION {
788 if envelope.ignorable() {
789 self.last_seq = envelope.seq;
790 return Ok(());
791 }
792 return Err(EventError::UnsupportedFormat(envelope.format_version()));
793 }
794 if self.deleted {
795 return Err(EventError::SessionClosed);
796 }
797 match &envelope.event {
798 Event::SessionCreated {
799 profile_revision_id,
800 } => {
801 if envelope.seq != 1 || self.profile_revision_id.is_some() {
802 return Err(EventError::DuplicateSession);
803 }
804 self.profile_revision_id = Some(profile_revision_id.clone());
805 }
806 Event::SessionDeleted { .. } => {
807 if let Some(run_id) = self.active_run_id.take() {
808 self.run_status
809 .insert(run_id, RunState::Terminal(RunStatus::Cancelled));
810 }
811 for (_, (run_id, _)) in std::mem::take(&mut self.queued_inputs) {
812 self.run_status
813 .insert(run_id, RunState::Terminal(RunStatus::Cancelled));
814 }
815 self.waiting_interaction_id = None;
816 self.open_turn = None;
817 self.open_steps.clear();
818 self.open_tool_calls.clear();
819 self.started_tool_calls.clear();
820 self.open_compaction = None;
821 self.deleted = true;
822 }
823 Event::RunStarted { run_id, input_id } => {
824 if self.active_run_id.is_some() {
825 return Err(EventError::ConcurrentRun);
826 }
827 if self.claimed_inputs.get(input_id) != Some(run_id) {
828 return Err(EventError::UnclaimedInput(input_id.clone()));
829 }
830 self.active_run_id = Some(run_id.clone());
831 self.next_step = 1;
832 self.run_status.insert(run_id.clone(), RunState::Running);
833 }
834 Event::RunWaiting {
835 run_id,
836 interaction_id,
837 } => {
838 self.require_active(run_id)?;
839 self.waiting_interaction_id = Some(interaction_id.clone());
840 self.run_status
841 .insert(run_id.clone(), RunState::WaitingForInput);
842 }
843 Event::RunResumed {
844 run_id,
845 interaction_id,
846 } => {
847 self.require_active(run_id)?;
848 if self.waiting_interaction_id.as_ref() != Some(interaction_id) {
849 return Err(EventError::InteractionMismatch);
850 }
851 self.waiting_interaction_id = None;
852 self.run_status.insert(run_id.clone(), RunState::Running);
853 }
854 Event::RunFinished { run_id, status, .. } => {
855 self.require_active(run_id)?;
856 if !self.open_tool_calls.is_empty()
857 || !self.open_steps.is_empty()
858 || self.open_turn.is_some()
859 || self.open_compaction.is_some()
860 || self
861 .queued_inputs
862 .values()
863 .any(|(target_run_id, _)| target_run_id == run_id)
864 {
865 return Err(EventError::OpenLifecycle);
866 }
867 self.run_status
868 .insert(run_id.clone(), RunState::Terminal(*status));
869 self.active_run_id = None;
870 self.waiting_interaction_id = None;
871 }
872 Event::UserMessage { run_id, content } => self.messages.push(ProjectedMessage {
873 role: "user",
874 run_id: run_id.clone(),
875 content: content.clone(),
876 }),
877 Event::AssistantMessage {
878 run_id, content, ..
879 } => self.messages.push(ProjectedMessage {
880 role: "assistant",
881 run_id: run_id.clone(),
882 content: content.clone(),
883 }),
884 Event::ContextInjected {
885 run_id,
886 step,
887 contribution_id,
888 source,
889 version,
890 authority,
891 form,
892 content,
893 } => {
894 self.require_active(run_id)?;
895 if !self.open_steps.contains(step) {
896 return Err(EventError::LifecycleMismatch);
897 }
898 self.injected_context.push(ProjectedContext {
899 run_id: run_id.clone(),
900 step: *step,
901 contribution_id: contribution_id.clone(),
902 source: source.clone(),
903 version: version.clone(),
904 authority: authority.clone(),
905 form: form.clone(),
906 content: content.clone(),
907 });
908 }
909 Event::InputQueued {
910 input_id,
911 run_id,
912 mode,
913 ..
914 } => {
915 if *mode != DeliveryMode::Followup {
916 self.require_active(run_id)?;
917 }
918 if self
919 .queued_inputs
920 .insert(input_id.clone(), (run_id.clone(), *mode))
921 .is_some()
922 {
923 return Err(EventError::DuplicateInput(input_id.clone()));
924 }
925 }
926 Event::InputClaimed { input_id, run_id } => {
927 if self
928 .queued_inputs
929 .remove(input_id)
930 .map(|value| value.0)
931 .as_ref()
932 != Some(run_id)
933 || self
934 .claimed_inputs
935 .insert(input_id.clone(), run_id.clone())
936 .is_some()
937 {
938 return Err(EventError::UnqueuedInput(input_id.clone()));
939 }
940 }
941 Event::InputCancelled {
942 input_id,
943 run_id,
944 error_code,
945 } => {
946 if self
947 .queued_inputs
948 .remove(input_id)
949 .map(|value| value.0)
950 .as_ref()
951 != Some(run_id)
952 {
953 return Err(EventError::UnqueuedInput(input_id.clone()));
954 }
955 self.run_status.insert(
956 run_id.clone(),
957 RunState::Terminal(if error_code == "cancelled" {
958 RunStatus::Cancelled
959 } else {
960 RunStatus::Failed
961 }),
962 );
963 }
964 Event::TurnStarted { run_id, turn } => {
965 self.require_active(run_id)?;
966 if self.open_turn.replace(*turn).is_some() {
967 return Err(EventError::ConcurrentTurn);
968 }
969 }
970 Event::TurnFinished { run_id, turn } => {
971 self.require_active(run_id)?;
972 if self.open_turn != Some(*turn)
973 || !self.open_steps.is_empty()
974 || !self.open_tool_calls.is_empty()
975 {
976 return Err(EventError::LifecycleMismatch);
977 }
978 self.open_turn = None;
979 }
980 Event::StepStarted { run_id, step } => {
981 self.require_active(run_id)?;
982 if self.open_turn.is_none()
983 || !self.open_steps.is_empty()
984 || *step != self.next_step
985 || !self.open_steps.insert(*step)
986 {
987 return Err(EventError::ConcurrentStep);
988 }
989 }
990 Event::StepFinished { run_id, step } => {
991 self.require_active(run_id)?;
992 if self
993 .open_tool_calls
994 .values()
995 .any(|call| call.run_id == *run_id && call.step == *step)
996 || !self.open_steps.remove(step)
997 {
998 return Err(EventError::LifecycleMismatch);
999 }
1000 self.next_step = step.saturating_add(1);
1001 }
1002 Event::ToolCall {
1003 run_id,
1004 step,
1005 call_id,
1006 tool,
1007 arguments,
1008 } => {
1009 self.require_active(run_id)?;
1010 if !self.open_steps.contains(step) {
1011 return Err(EventError::LifecycleMismatch);
1012 }
1013 if !self.seen_tool_calls.insert(call_id.clone())
1014 || self
1015 .open_tool_calls
1016 .insert(
1017 call_id.clone(),
1018 OpenToolCall {
1019 run_id: run_id.clone(),
1020 step: *step,
1021 tool: tool.clone(),
1022 arguments: arguments.clone(),
1023 source_event_seq: envelope.seq,
1024 },
1025 )
1026 .is_some()
1027 {
1028 return Err(EventError::DuplicateToolCall(call_id.clone()));
1029 }
1030 }
1031 Event::ToolResult {
1032 run_id,
1033 step,
1034 call_id,
1035 ..
1036 } => {
1037 self.require_active(run_id)?;
1038 if !self.open_steps.contains(step) {
1039 return Err(EventError::LifecycleMismatch);
1040 }
1041 let Some(call) = self.open_tool_calls.get(call_id) else {
1042 return Err(EventError::OrphanToolResult(call_id.clone()));
1043 };
1044 if call.run_id != *run_id || call.step != *step {
1045 return Err(EventError::ToolResultMismatch(call_id.clone()));
1046 }
1047 self.open_tool_calls.remove(call_id);
1048 self.started_tool_calls.remove(call_id);
1049 }
1050 Event::ToolExecutionStarted {
1051 run_id,
1052 step,
1053 call_id,
1054 } => {
1055 self.require_active(run_id)?;
1056 let Some(call) = self.open_tool_calls.get(call_id) else {
1057 return Err(EventError::OrphanToolResult(call_id.clone()));
1058 };
1059 if call.run_id != *run_id
1060 || call.step != *step
1061 || !self.started_tool_calls.insert(call_id.clone())
1062 {
1063 return Err(EventError::ToolResultMismatch(call_id.clone()));
1064 }
1065 }
1066 Event::ModelRequestPrepared {
1067 run_id,
1068 operation_id,
1069 reserved_prompt_tokens,
1070 reserved_completion_tokens,
1071 ..
1072 } if !operation_id.is_empty() => {
1073 self.require_active(run_id)?;
1074 let key = (run_id.clone(), operation_id.clone());
1075 if !self.usage_operations.contains_key(&key) {
1076 let reservation = (*reserved_prompt_tokens, *reserved_completion_tokens);
1077 match self.pending_provider_attempts.get(&key) {
1078 Some(existing) if *existing != reservation => {
1079 return Err(EventError::UsageConflict(operation_id.clone()));
1080 }
1081 Some(_) => {}
1082 None => {
1083 self.pending_provider_attempts.insert(key, reservation);
1084 }
1085 }
1086 }
1087 }
1088 Event::UsageRecorded {
1089 run_id,
1090 operation_id,
1091 prompt_tokens,
1092 completion_tokens,
1093 cost_units,
1094 } => {
1095 self.require_active(run_id)?;
1096 let key = (run_id.clone(), operation_id.clone());
1097 let usage = (*prompt_tokens, *completion_tokens, *cost_units);
1098 match self.usage_operations.get(&key) {
1099 Some(existing) if *existing != usage => {
1100 return Err(EventError::UsageConflict(operation_id.clone()));
1101 }
1102 Some(_) => {}
1103 None => {
1104 self.pending_provider_attempts.remove(&key);
1105 self.usage_operations.insert(key, usage);
1106 }
1107 }
1108 }
1109 Event::CompactionStarted {
1110 run_id,
1111 compaction_id,
1112 source_through_seq,
1113 } => {
1114 self.require_active(run_id)?;
1115 if self.open_compaction.is_some() {
1116 return Err(EventError::ConcurrentCompaction);
1117 }
1118 self.open_compaction = Some((compaction_id.clone(), *source_through_seq));
1119 }
1120 Event::CompactionFinished {
1121 run_id,
1122 compaction_id,
1123 ..
1124 } => {
1125 self.require_active(run_id)?;
1126 if self.open_compaction.as_ref().map(|value| value.0.as_str())
1127 != Some(compaction_id.as_str())
1128 {
1129 return Err(EventError::CompactionMismatch);
1130 }
1131 self.open_compaction = None;
1132 }
1133 Event::SummaryReplaced { summary, .. } => self.summary = Some(summary.clone()),
1134 Event::Opaque {
1135 event_type,
1136 ignorable: false,
1137 ..
1138 } => return Err(EventError::UnknownRequired(event_type.clone())),
1139 _ => {}
1140 }
1141 self.last_seq = envelope.seq;
1142 Ok(())
1143 }
1144
1145 fn require_active(&self, run_id: &RunId) -> Result<(), EventError> {
1146 if self.active_run_id.as_ref() == Some(run_id) {
1147 Ok(())
1148 } else {
1149 Err(EventError::RunMismatch)
1150 }
1151 }
1152
1153 pub fn usage_for(&self, run_id: &str) -> (u64, u64) {
1155 self.usage_operations
1156 .iter()
1157 .filter(|((recorded_run_id, _), _)| recorded_run_id.as_str() == run_id)
1158 .fold((0, 0), |total, (_, usage)| {
1159 (total.0 + usage.0, total.1 + usage.1)
1160 })
1161 }
1162
1163 pub fn billable_units_for(&self, run_id: &str) -> u64 {
1165 let recorded = self
1166 .usage_operations
1167 .iter()
1168 .filter(|((recorded_run_id, _), _)| recorded_run_id.as_str() == run_id)
1169 .map(|(_, usage)| usage.0 + usage.1 + usage.2)
1170 .sum::<u64>();
1171 recorded
1172 + self
1173 .pending_provider_attempts
1174 .iter()
1175 .filter(|((recorded_run_id, _), _)| recorded_run_id.as_str() == run_id)
1176 .map(|(_, usage)| usage.0 + usage.1)
1177 .sum::<u64>()
1178 }
1179}
1180
1181#[derive(Debug, thiserror::Error, PartialEq, Eq)]
1183pub enum EventError {
1184 #[error("unsupported session event format version {0}")]
1186 UnsupportedFormat(u32),
1187 #[error("unknown required session event type {0}")]
1189 UnknownRequired(String),
1190 #[error("event sequence mismatch: expected {expected}, got {actual}")]
1192 Sequence {
1193 expected: u64,
1195 actual: u64,
1197 },
1198 #[error("event belongs to another session")]
1200 SessionMismatch,
1201 #[error("session creation must be the first and only creation event")]
1203 DuplicateSession,
1204 #[error("session already has an active run")]
1206 ConcurrentRun,
1207 #[error("session is closed")]
1209 SessionClosed,
1210 #[error("event does not match the active run")]
1212 RunMismatch,
1213 #[error("interaction does not match the waiting run")]
1215 InteractionMismatch,
1216 #[error("run cannot finish with an open turn, step or tool call")]
1218 OpenLifecycle,
1219 #[error("input was queued twice: {0}")]
1221 DuplicateInput(InputId),
1222 #[error("input was claimed before it was queued: {0}")]
1224 UnqueuedInput(InputId),
1225 #[error("run started from an unclaimed input: {0}")]
1227 UnclaimedInput(InputId),
1228 #[error("session already has an active turn")]
1230 ConcurrentTurn,
1231 #[error("turn already has this active step")]
1233 ConcurrentStep,
1234 #[error("turn or step lifecycle does not pair")]
1236 LifecycleMismatch,
1237 #[error("duplicate tool call {0}")]
1239 DuplicateToolCall(ToolCallId),
1240 #[error("tool result has no matching call {0}")]
1242 OrphanToolResult(ToolCallId),
1243 #[error("tool result does not match the call run and step: {0}")]
1245 ToolResultMismatch(ToolCallId),
1246 #[error("usage operation was recorded with different totals: {0}")]
1248 UsageConflict(String),
1249 #[error("session already has an active compaction")]
1251 ConcurrentCompaction,
1252 #[error("compaction lifecycle does not pair")]
1254 CompactionMismatch,
1255 #[error("event store conflict: {0}")]
1257 Conflict(String),
1258 #[error("event store unavailable: {0}")]
1260 Unavailable(String),
1261}
1262
1263#[async_trait]
1265pub trait SessionEventStore: Send + Sync {
1266 async fn append(
1268 &self,
1269 tenant_id: &str,
1270 session_id: &str,
1271 expected_seq: u64,
1272 events: Vec<Event>,
1273 ) -> Result<Vec<SessionEvent>, EventError>;
1274 async fn load(
1276 &self,
1277 tenant_id: &str,
1278 session_id: &str,
1279 after_seq: u64,
1280 ) -> Result<Vec<SessionEvent>, EventError>;
1281}
1282
1283pub fn text(value: impl Into<String>) -> Vec<ContentBlock> {
1285 vec![ContentBlock::Text { text: value.into() }]
1286}
1287
1288pub fn recovery_events(projection: &SessionProjection) -> Vec<Event> {
1290 failure_events(projection, "worker_restarted")
1291}
1292
1293pub fn failure_events(projection: &SessionProjection, error_code: &str) -> Vec<Event> {
1295 termination_events(projection, RunStatus::Failed, error_code)
1296}
1297
1298pub fn cancel_events(projection: &SessionProjection) -> Vec<Event> {
1300 termination_events(projection, RunStatus::Cancelled, "cancelled")
1301}
1302
1303pub fn session_deletion_events(projection: &SessionProjection, reason: &str) -> Vec<Event> {
1305 let active = projection.active_run_id.as_deref();
1306 let mut events = cancel_events(projection);
1307 events.extend(
1308 projection
1309 .queued_inputs
1310 .iter()
1311 .filter(|(_, (run_id, _))| Some(run_id.as_str()) != active)
1312 .map(|(input_id, (run_id, _))| Event::InputCancelled {
1313 input_id: input_id.clone(),
1314 run_id: run_id.clone(),
1315 error_code: "cancelled".into(),
1316 }),
1317 );
1318 events.push(Event::SessionDeleted {
1319 reason: reason.into(),
1320 });
1321 events
1322}
1323
1324fn termination_events(
1325 projection: &SessionProjection,
1326 status: RunStatus,
1327 error_code: &str,
1328) -> Vec<Event> {
1329 let Some(run_id) = &projection.active_run_id else {
1330 return Vec::new();
1331 };
1332 let mut events = projection
1333 .queued_inputs
1334 .iter()
1335 .filter(|(_, (target_run_id, _))| target_run_id == run_id)
1336 .map(|(input_id, _)| Event::InputCancelled {
1337 input_id: input_id.clone(),
1338 run_id: run_id.clone(),
1339 error_code: error_code.into(),
1340 })
1341 .collect::<Vec<_>>();
1342 events.extend(
1343 projection
1344 .open_tool_calls
1345 .iter()
1346 .map(|(call_id, call)| Event::ToolResult {
1347 run_id: run_id.clone(),
1348 step: call.step,
1349 call_id: call_id.clone(),
1350 result: termination_result(error_code),
1351 is_error: true,
1352 }),
1353 );
1354 if let Some((compaction_id, _)) = &projection.open_compaction {
1355 events.push(Event::CompactionFinished {
1356 run_id: run_id.clone(),
1357 compaction_id: compaction_id.clone(),
1358 status: "failed".into(),
1359 error: Some(error_code.into()),
1360 });
1361 }
1362 events.extend(
1363 projection
1364 .open_steps
1365 .iter()
1366 .map(|step| Event::StepFinished {
1367 run_id: run_id.clone(),
1368 step: *step,
1369 }),
1370 );
1371 if let Some(turn) = projection.open_turn {
1372 events.push(Event::TurnFinished {
1373 run_id: run_id.clone(),
1374 turn,
1375 });
1376 }
1377 events.push(Event::RunFinished {
1378 run_id: run_id.clone(),
1379 status,
1380 error_code: Some(error_code.into()),
1381 });
1382 events
1383}
1384
1385fn termination_result(error_code: &str) -> Value {
1386 if error_code == "tool_outcome_unknown" || error_code == "worker_restarted" {
1387 serde_json::json!({
1388 "error": error_code,
1389 "guidance": "The tool outcome is unknown. Verify external state before retrying any operation with side effects; ask the user when verification is unavailable."
1390 })
1391 } else {
1392 serde_json::json!({"error":error_code})
1393 }
1394}
1395
1396#[cfg(test)]
1397mod tests {
1398 use super::*;
1399
1400 fn event(seq: u64, event: Event) -> SessionEvent {
1401 SessionEvent {
1402 session_id: "s".parse().unwrap(),
1403 seq,
1404 occurred_at: Utc::now(),
1405 event,
1406 }
1407 }
1408
1409 #[test]
1410 fn replay_enforces_single_run_and_tool_pairs() {
1411 let events = vec![
1412 event(
1413 1,
1414 Event::SessionCreated {
1415 profile_revision_id: "p1".parse().unwrap(),
1416 },
1417 ),
1418 event(
1419 2,
1420 Event::InputQueued {
1421 input_id: "i1".parse().unwrap(),
1422 run_id: "r1".parse().unwrap(),
1423 mode: DeliveryMode::Followup,
1424 content: text("hi"),
1425 explicit_skill: None,
1426 },
1427 ),
1428 event(
1429 3,
1430 Event::InputClaimed {
1431 input_id: "i1".parse().unwrap(),
1432 run_id: "r1".parse().unwrap(),
1433 },
1434 ),
1435 event(
1436 4,
1437 Event::RunStarted {
1438 run_id: "r1".parse().unwrap(),
1439 input_id: "i1".parse().unwrap(),
1440 },
1441 ),
1442 event(
1443 5,
1444 Event::TurnStarted {
1445 run_id: "r1".parse().unwrap(),
1446 turn: 1,
1447 },
1448 ),
1449 event(
1450 6,
1451 Event::StepStarted {
1452 run_id: "r1".parse().unwrap(),
1453 step: 1,
1454 },
1455 ),
1456 event(
1457 7,
1458 Event::ToolCall {
1459 run_id: "r1".parse().unwrap(),
1460 step: 1,
1461 call_id: "c1".parse().unwrap(),
1462 tool: "echo".into(),
1463 arguments: serde_json::json!({"x":1}),
1464 },
1465 ),
1466 event(
1467 8,
1468 Event::ToolResult {
1469 run_id: "r1".parse().unwrap(),
1470 step: 1,
1471 call_id: "c1".parse().unwrap(),
1472 result: serde_json::json!({"x":1}),
1473 is_error: false,
1474 },
1475 ),
1476 event(
1477 9,
1478 Event::StepFinished {
1479 run_id: "r1".parse().unwrap(),
1480 step: 1,
1481 },
1482 ),
1483 event(
1484 10,
1485 Event::TurnFinished {
1486 run_id: "r1".parse().unwrap(),
1487 turn: 1,
1488 },
1489 ),
1490 event(
1491 11,
1492 Event::RunFinished {
1493 run_id: "r1".parse().unwrap(),
1494 status: RunStatus::Completed,
1495 error_code: None,
1496 },
1497 ),
1498 ];
1499 let projection = SessionProjection::replay(&events).unwrap();
1500 assert_eq!(projection.last_seq, 11);
1501 assert!(projection.active_run_id.is_none());
1502 }
1503
1504 #[test]
1505 fn replay_rejects_orphan_tool_result() {
1506 let events = vec![
1507 event(
1508 1,
1509 Event::SessionCreated {
1510 profile_revision_id: "p1".parse().unwrap(),
1511 },
1512 ),
1513 event(
1514 2,
1515 Event::InputQueued {
1516 input_id: "i1".parse().unwrap(),
1517 run_id: "r1".parse().unwrap(),
1518 mode: DeliveryMode::Followup,
1519 content: text("hi"),
1520 explicit_skill: None,
1521 },
1522 ),
1523 event(
1524 3,
1525 Event::InputClaimed {
1526 input_id: "i1".parse().unwrap(),
1527 run_id: "r1".parse().unwrap(),
1528 },
1529 ),
1530 event(
1531 4,
1532 Event::RunStarted {
1533 run_id: "r1".parse().unwrap(),
1534 input_id: "i1".parse().unwrap(),
1535 },
1536 ),
1537 event(
1538 5,
1539 Event::TurnStarted {
1540 run_id: "r1".parse().unwrap(),
1541 turn: 1,
1542 },
1543 ),
1544 event(
1545 6,
1546 Event::StepStarted {
1547 run_id: "r1".parse().unwrap(),
1548 step: 1,
1549 },
1550 ),
1551 event(
1552 7,
1553 Event::ToolResult {
1554 run_id: "r1".parse().unwrap(),
1555 step: 1,
1556 call_id: "missing".parse().unwrap(),
1557 result: Value::Null,
1558 is_error: true,
1559 },
1560 ),
1561 ];
1562 assert_eq!(
1563 SessionProjection::replay(&events).unwrap_err(),
1564 EventError::OrphanToolResult("missing".parse().unwrap())
1565 );
1566 }
1567
1568 #[test]
1569 fn queued_input_failure_is_not_projected_as_cancellation() {
1570 let events = vec![
1571 event(
1572 1,
1573 Event::SessionCreated {
1574 profile_revision_id: "p1".parse().unwrap(),
1575 },
1576 ),
1577 event(
1578 2,
1579 Event::InputQueued {
1580 input_id: "i1".parse().unwrap(),
1581 run_id: "r1".parse().unwrap(),
1582 mode: DeliveryMode::Followup,
1583 content: text("hi"),
1584 explicit_skill: None,
1585 },
1586 ),
1587 event(
1588 3,
1589 Event::InputCancelled {
1590 input_id: "i1".parse().unwrap(),
1591 run_id: "r1".parse().unwrap(),
1592 error_code: "profile_not_found".into(),
1593 },
1594 ),
1595 ];
1596 let projection = SessionProjection::replay(&events).unwrap();
1597 assert_eq!(
1598 projection.run_status.get("r1").map(|state| state.as_str()),
1599 Some("failed")
1600 );
1601 }
1602
1603 #[test]
1604 fn session_deletion_closes_active_and_queued_runs_before_tombstone() {
1605 let mut events = vec![
1606 event(
1607 1,
1608 Event::SessionCreated {
1609 profile_revision_id: "p1".parse().unwrap(),
1610 },
1611 ),
1612 event(
1613 2,
1614 Event::InputQueued {
1615 input_id: "i1".parse().unwrap(),
1616 run_id: "r1".parse().unwrap(),
1617 mode: DeliveryMode::Followup,
1618 content: text("start"),
1619 explicit_skill: None,
1620 },
1621 ),
1622 event(
1623 3,
1624 Event::InputClaimed {
1625 input_id: "i1".parse().unwrap(),
1626 run_id: "r1".parse().unwrap(),
1627 },
1628 ),
1629 event(
1630 4,
1631 Event::RunStarted {
1632 run_id: "r1".parse().unwrap(),
1633 input_id: "i1".parse().unwrap(),
1634 },
1635 ),
1636 event(
1637 5,
1638 Event::TurnStarted {
1639 run_id: "r1".parse().unwrap(),
1640 turn: 1,
1641 },
1642 ),
1643 event(
1644 6,
1645 Event::InputQueued {
1646 input_id: "i2".parse().unwrap(),
1647 run_id: "r2".parse().unwrap(),
1648 mode: DeliveryMode::Followup,
1649 content: text("later"),
1650 explicit_skill: None,
1651 },
1652 ),
1653 ];
1654 let projection = SessionProjection::replay(&events).unwrap();
1655 for event_value in session_deletion_events(&projection, "api_deleted") {
1656 let seq = events.len() as u64 + 1;
1657 events.push(event(seq, event_value));
1658 }
1659 let deleted = SessionProjection::replay(&events).unwrap();
1660 assert!(deleted.deleted);
1661 assert_eq!(
1662 deleted.run_status.get("r1").map(|state| state.as_str()),
1663 Some("cancelled")
1664 );
1665 assert_eq!(
1666 deleted.run_status.get("r2").map(|state| state.as_str()),
1667 Some("cancelled")
1668 );
1669 assert!(events.iter().any(|event| matches!(
1670 &event.event,
1671 Event::RunFinished { run_id, status: RunStatus::Cancelled, .. } if run_id == "r1"
1672 )));
1673 assert!(matches!(
1674 events.last().map(|event| &event.event),
1675 Some(Event::SessionDeleted { .. })
1676 ));
1677 }
1678
1679 #[test]
1680 fn usage_operations_are_idempotent_and_conflicts_fail_replay() {
1681 let mut events = vec![
1682 event(
1683 1,
1684 Event::SessionCreated {
1685 profile_revision_id: "p1".parse().unwrap(),
1686 },
1687 ),
1688 event(
1689 2,
1690 Event::InputQueued {
1691 input_id: "i1".parse().unwrap(),
1692 run_id: "r1".parse().unwrap(),
1693 mode: DeliveryMode::Followup,
1694 content: text("hi"),
1695 explicit_skill: None,
1696 },
1697 ),
1698 event(
1699 3,
1700 Event::InputClaimed {
1701 input_id: "i1".parse().unwrap(),
1702 run_id: "r1".parse().unwrap(),
1703 },
1704 ),
1705 event(
1706 4,
1707 Event::RunStarted {
1708 run_id: "r1".parse().unwrap(),
1709 input_id: "i1".parse().unwrap(),
1710 },
1711 ),
1712 event(
1713 5,
1714 Event::UsageRecorded {
1715 run_id: "r1".parse().unwrap(),
1716 operation_id: "model:1:attempt:1".into(),
1717 prompt_tokens: 7,
1718 completion_tokens: 3,
1719 cost_units: 5,
1720 },
1721 ),
1722 event(
1723 6,
1724 Event::UsageRecorded {
1725 run_id: "r1".parse().unwrap(),
1726 operation_id: "model:1:attempt:1".into(),
1727 prompt_tokens: 7,
1728 completion_tokens: 3,
1729 cost_units: 5,
1730 },
1731 ),
1732 ];
1733 assert_eq!(
1734 SessionProjection::replay(&events).unwrap().usage_for("r1"),
1735 (7, 3)
1736 );
1737 assert_eq!(
1738 SessionProjection::replay(&events)
1739 .unwrap()
1740 .billable_units_for("r1"),
1741 15
1742 );
1743
1744 events.push(event(
1745 7,
1746 Event::UsageRecorded {
1747 run_id: "r1".parse().unwrap(),
1748 operation_id: "model:1:attempt:1".into(),
1749 prompt_tokens: 8,
1750 completion_tokens: 3,
1751 cost_units: 0,
1752 },
1753 ));
1754 assert_eq!(
1755 SessionProjection::replay(&events).unwrap_err(),
1756 EventError::UsageConflict("model:1:attempt:1".into())
1757 );
1758 }
1759
1760 #[test]
1761 fn unresolved_provider_attempt_is_conservatively_billable_and_reconcilable() {
1762 let mut events = vec![
1763 event(
1764 1,
1765 Event::SessionCreated {
1766 profile_revision_id: "p1".parse().unwrap(),
1767 },
1768 ),
1769 event(
1770 2,
1771 Event::InputQueued {
1772 input_id: "i1".parse().unwrap(),
1773 run_id: "r1".parse().unwrap(),
1774 mode: DeliveryMode::Followup,
1775 content: text("hi"),
1776 explicit_skill: None,
1777 },
1778 ),
1779 event(
1780 3,
1781 Event::InputClaimed {
1782 input_id: "i1".parse().unwrap(),
1783 run_id: "r1".parse().unwrap(),
1784 },
1785 ),
1786 event(
1787 4,
1788 Event::RunStarted {
1789 run_id: "r1".parse().unwrap(),
1790 input_id: "i1".parse().unwrap(),
1791 },
1792 ),
1793 event(
1794 5,
1795 Event::ModelRequestPrepared {
1796 run_id: "r1".parse().unwrap(),
1797 step: 1,
1798 attempt: 1,
1799 provider_attempt_id: "r1:model:1:attempt:1".into(),
1800 operation_id: "model:1:attempt:1".into(),
1801 reserved_prompt_tokens: 7,
1802 reserved_completion_tokens: 11,
1803 request: Value::Null,
1804 prompt_sections: Value::Null,
1805 },
1806 ),
1807 ];
1808 assert_eq!(
1809 SessionProjection::replay(&events)
1810 .unwrap()
1811 .billable_units_for("r1"),
1812 18
1813 );
1814 events.push(event(
1815 6,
1816 Event::UsageRecorded {
1817 run_id: "r1".parse().unwrap(),
1818 operation_id: "model:1:attempt:1".into(),
1819 prompt_tokens: 6,
1820 completion_tokens: 2,
1821 cost_units: 0,
1822 },
1823 ));
1824 assert_eq!(
1825 SessionProjection::replay(&events)
1826 .unwrap()
1827 .billable_units_for("r1"),
1828 8
1829 );
1830 }
1831
1832 fn open_step_events() -> Vec<SessionEvent> {
1833 vec![
1834 event(
1835 1,
1836 Event::SessionCreated {
1837 profile_revision_id: "p1".parse().unwrap(),
1838 },
1839 ),
1840 event(
1841 2,
1842 Event::InputQueued {
1843 input_id: "i1".parse().unwrap(),
1844 run_id: "r1".parse().unwrap(),
1845 mode: DeliveryMode::Followup,
1846 content: text("hi"),
1847 explicit_skill: None,
1848 },
1849 ),
1850 event(
1851 3,
1852 Event::InputClaimed {
1853 input_id: "i1".parse().unwrap(),
1854 run_id: "r1".parse().unwrap(),
1855 },
1856 ),
1857 event(
1858 4,
1859 Event::RunStarted {
1860 run_id: "r1".parse().unwrap(),
1861 input_id: "i1".parse().unwrap(),
1862 },
1863 ),
1864 event(
1865 5,
1866 Event::TurnStarted {
1867 run_id: "r1".parse().unwrap(),
1868 turn: 1,
1869 },
1870 ),
1871 event(
1872 6,
1873 Event::StepStarted {
1874 run_id: "r1".parse().unwrap(),
1875 step: 1,
1876 },
1877 ),
1878 ]
1879 }
1880
1881 #[test]
1882 fn replay_rejects_overlapping_or_out_of_order_steps() {
1883 let mut overlapping = open_step_events();
1884 overlapping.push(event(
1885 7,
1886 Event::StepStarted {
1887 run_id: "r1".parse().unwrap(),
1888 step: 2,
1889 },
1890 ));
1891 assert_eq!(
1892 SessionProjection::replay(&overlapping).unwrap_err(),
1893 EventError::ConcurrentStep
1894 );
1895
1896 let mut skipped = open_step_events();
1897 skipped[5] = event(
1898 6,
1899 Event::StepStarted {
1900 run_id: "r1".parse().unwrap(),
1901 step: 2,
1902 },
1903 );
1904 assert_eq!(
1905 SessionProjection::replay(&skipped).unwrap_err(),
1906 EventError::ConcurrentStep
1907 );
1908 }
1909
1910 #[test]
1911 fn replay_rejects_cross_step_results_and_dangling_calls() {
1912 let mut events = open_step_events();
1913 events.push(event(
1914 7,
1915 Event::ToolCall {
1916 run_id: "r1".parse().unwrap(),
1917 step: 1,
1918 call_id: "c1".parse().unwrap(),
1919 tool: "echo".into(),
1920 arguments: Value::Null,
1921 },
1922 ));
1923 events.push(event(
1924 8,
1925 Event::ToolResult {
1926 run_id: "r1".parse().unwrap(),
1927 step: 2,
1928 call_id: "c1".parse().unwrap(),
1929 result: Value::Null,
1930 is_error: false,
1931 },
1932 ));
1933 assert_eq!(
1934 SessionProjection::replay(&events).unwrap_err(),
1935 EventError::LifecycleMismatch
1936 );
1937
1938 let mut dangling = open_step_events();
1939 dangling.push(event(
1940 7,
1941 Event::ToolCall {
1942 run_id: "r1".parse().unwrap(),
1943 step: 1,
1944 call_id: "c1".parse().unwrap(),
1945 tool: "echo".into(),
1946 arguments: Value::Null,
1947 },
1948 ));
1949 dangling.push(event(
1950 8,
1951 Event::StepFinished {
1952 run_id: "r1".parse().unwrap(),
1953 step: 1,
1954 },
1955 ));
1956 assert_eq!(
1957 SessionProjection::replay(&dangling).unwrap_err(),
1958 EventError::LifecycleMismatch
1959 );
1960 }
1961
1962 #[test]
1963 fn replay_rejects_reused_tool_call_ids() {
1964 let mut events = open_step_events();
1965 events.extend([
1966 event(
1967 7,
1968 Event::ToolCall {
1969 run_id: "r1".parse().unwrap(),
1970 step: 1,
1971 call_id: "c1".parse().unwrap(),
1972 tool: "echo".into(),
1973 arguments: Value::Null,
1974 },
1975 ),
1976 event(
1977 8,
1978 Event::ToolResult {
1979 run_id: "r1".parse().unwrap(),
1980 step: 1,
1981 call_id: "c1".parse().unwrap(),
1982 result: Value::Null,
1983 is_error: false,
1984 },
1985 ),
1986 event(
1987 9,
1988 Event::StepFinished {
1989 run_id: "r1".parse().unwrap(),
1990 step: 1,
1991 },
1992 ),
1993 event(
1994 10,
1995 Event::StepStarted {
1996 run_id: "r1".parse().unwrap(),
1997 step: 2,
1998 },
1999 ),
2000 event(
2001 11,
2002 Event::ToolCall {
2003 run_id: "r1".parse().unwrap(),
2004 step: 2,
2005 call_id: "c1".parse().unwrap(),
2006 tool: "echo".into(),
2007 arguments: Value::Null,
2008 },
2009 ),
2010 ]);
2011 assert_eq!(
2012 SessionProjection::replay(&events).unwrap_err(),
2013 EventError::DuplicateToolCall("c1".parse().unwrap())
2014 );
2015 }
2016
2017 #[test]
2018 fn replay_keeps_injected_context_without_changing_run_state() {
2019 let mut events = open_step_events();
2020 events.push(event(
2021 7,
2022 Event::ContextInjected {
2023 run_id: "r1".parse().unwrap(),
2024 step: 1,
2025 contribution_id: "memory:1".into(),
2026 source: "memory".into(),
2027 version: "v1".into(),
2028 authority: "tenant".into(),
2029 form: "message".into(),
2030 content: text("tenant context"),
2031 },
2032 ));
2033
2034 let projection = SessionProjection::replay(&events).unwrap();
2035 assert_eq!(projection.active_run_id.as_deref(), Some("r1"));
2036 assert_eq!(projection.open_steps, BTreeSet::from([1]));
2037 assert_eq!(
2038 projection.injected_context,
2039 vec![ProjectedContext {
2040 run_id: "r1".parse().unwrap(),
2041 step: 1,
2042 contribution_id: "memory:1".into(),
2043 source: "memory".into(),
2044 version: "v1".into(),
2045 authority: "tenant".into(),
2046 form: "message".into(),
2047 content: text("tenant context"),
2048 }]
2049 );
2050 }
2051
2052 #[test]
2053 fn replay_skips_unknown_ignorable_formats_and_rejects_required_events() {
2054 let optional = event(
2055 1,
2056 Event::Opaque {
2057 format_version: SESSION_EVENT_FORMAT_VERSION,
2058 event_type: "future_optional".into(),
2059 ignorable: true,
2060 payload: serde_json::json!({"answer": 42}),
2061 },
2062 );
2063 let projection = SessionProjection::replay(&[optional]).unwrap();
2064 assert_eq!(projection.last_seq, 1);
2065
2066 let required = event(
2067 1,
2068 Event::Opaque {
2069 format_version: SESSION_EVENT_FORMAT_VERSION,
2070 event_type: "future_required".into(),
2071 ignorable: false,
2072 payload: Value::Null,
2073 },
2074 );
2075 assert_eq!(
2076 SessionProjection::replay(&[required]).unwrap_err(),
2077 EventError::UnknownRequired("future_required".into())
2078 );
2079
2080 let unsupported = event(
2081 1,
2082 Event::Opaque {
2083 format_version: SESSION_EVENT_FORMAT_VERSION + 1,
2084 event_type: "future_optional".into(),
2085 ignorable: true,
2086 payload: Value::Null,
2087 },
2088 );
2089 assert_eq!(
2090 SessionProjection::replay(&[unsupported]).unwrap().last_seq,
2091 1
2092 );
2093
2094 let required_new_format = event(
2095 1,
2096 Event::Opaque {
2097 format_version: SESSION_EVENT_FORMAT_VERSION + 1,
2098 event_type: "future_required".into(),
2099 ignorable: false,
2100 payload: Value::Null,
2101 },
2102 );
2103 assert_eq!(
2104 SessionProjection::replay(&[required_new_format]).unwrap_err(),
2105 EventError::UnsupportedFormat(SESSION_EVENT_FORMAT_VERSION + 1)
2106 );
2107 }
2108}