1use crate::{
2 AdapterBinding, AdapterFamily, AgentId, CapabilityModelSummary, CapabilityProbeFailure,
3 CapabilityProbeRequest, CapabilitySnapshot, HistoryCandidateSummary, HistoryOperation,
4 HistoryQuery, HistorySessionRecord, HistorySnapshot, InputAction, InputPrepareError,
5 PreparedInput, PreparedInputKind, ResumeAuthorityTarget, ResumeLaunchRequest,
6 ResumeSessionSummary, ResumeSnapshot, ResumeTarget, SessionOptionSelection,
7};
8use serde::{Deserialize, Deserializer, Serialize};
9use thiserror::Error;
10
11pub const CONTROL_SESSIONS_MAX: usize = 512;
12pub const CONTROL_INSTANCE_IDENTITIES_CAPACITY: u32 = 4_096;
13pub const CONTROL_INSTANCE_IDENTITIES_MAX: usize = CONTROL_INSTANCE_IDENTITIES_CAPACITY as usize;
14pub const TERMINAL_ROWS_MAX: u16 = 1_000;
15pub const TERMINAL_COLUMNS_MAX: u16 = 1_000;
16pub const WORKING_DIRECTORY_MAX_BYTES: usize = 32_768;
17pub const PROVIDER_INGRESS_EVENTS_MAX: usize = 32;
18pub const PROVIDER_EVENT_TEXT_MAX_BYTES: usize = 262_144;
19pub const PROVIDER_EVENT_ID_MAX_BYTES: usize = 512;
20pub const PROVIDER_EVENT_TOOLS_MAX: usize = 256;
21pub const PROVIDER_INTERACTIONS_MAX: usize = 64;
22pub const PROVIDER_INTERACTION_RESPONSE_MAX_BYTES: usize = 32_768;
23pub const PROVIDER_INTERACTION_FAILURE_MAX_BYTES: usize = 4_096;
24pub const PROVIDER_INTERACTION_OPTIONS_MAX: usize = 8;
30pub const PROVIDER_SUBAGENTS_MAX: usize = 64;
31pub const PROVIDER_SESSION_LOCATOR_MAX_BYTES: usize = 32_768;
32pub const PROVIDER_PLAN_STEPS_MAX: usize = 256;
33pub const PROVIDER_AVAILABLE_COMMANDS_MAX: usize = 256;
34pub const PROVIDER_CONFIG_OPTIONS_MAX: usize = 256;
35pub const PROVIDER_CONFIG_OPTION_CHOICES_MAX: usize = 256;
36pub const PROVIDER_MODE_CATALOG_MAX: usize = 256;
37
38#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
39#[serde(transparent)]
40pub struct AgentInstanceId(pub u64);
41
42#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
43#[serde(transparent)]
44pub struct CommandId(pub u64);
45
46#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
47#[serde(transparent)]
48pub struct OperationId(pub u64);
49
50#[derive(
51 Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize,
52)]
53#[serde(transparent)]
54pub struct SessionGeneration(pub u64);
55
56#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
70#[serde(rename_all = "kebab-case")]
71pub enum ApprovalLevel {
72 #[default]
78 FullAuto,
79 Moderate,
85 ReadOnly,
90 Unmanaged,
93}
94
95#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
96pub struct StartRequest {
97 pub working_directory: String,
98 pub terminal_size: TerminalSize,
99 #[serde(default)]
100 pub initial_prompt: Option<String>,
101 #[serde(default)]
102 pub session_options: Option<SessionOptionSelection>,
103 #[serde(default)]
109 pub approval_level: ApprovalLevel,
110}
111
112#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
113pub struct ProviderRuntimePolicy {
114 pub raw_pty_lifecycle: bool,
115 pub semantic_readiness: bool,
116 pub structured_prompt: bool,
117 pub provider_session_identity: bool,
118 pub semantic_resume: bool,
119 pub hook_semantics: bool,
145}
146
147impl ProviderRuntimePolicy {
148 pub fn new(
149 raw_pty_lifecycle: bool,
150 semantic_readiness: bool,
151 structured_prompt: bool,
152 provider_session_identity: bool,
153 semantic_resume: bool,
154 hook_semantics: bool,
155 ) -> Result<Self, ProviderRuntimePolicyError> {
156 let policy = Self {
157 raw_pty_lifecycle,
158 semantic_readiness,
159 structured_prompt,
160 provider_session_identity,
161 semantic_resume,
162 hook_semantics,
163 };
164 policy.validate()?;
165 Ok(policy)
166 }
167
168 pub const fn raw_pty() -> Self {
169 Self {
170 raw_pty_lifecycle: true,
171 semantic_readiness: false,
172 structured_prompt: false,
173 provider_session_identity: false,
174 semantic_resume: false,
175 hook_semantics: false,
176 }
177 }
178
179 pub const fn none() -> Self {
185 Self {
186 raw_pty_lifecycle: false,
187 semantic_readiness: false,
188 structured_prompt: false,
189 provider_session_identity: false,
190 semantic_resume: false,
191 hook_semantics: false,
192 }
193 }
194
195 pub fn validate(self) -> Result<(), ProviderRuntimePolicyError> {
228 if (self.semantic_resume || self.hook_semantics) && !self.raw_pty_lifecycle {
229 return Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty);
230 }
231 if self.structured_prompt && !self.semantic_readiness {
232 return Err(ProviderRuntimePolicyError::StructuredPromptRequiresReadiness);
233 }
234 if self.semantic_resume && !self.provider_session_identity {
235 return Err(ProviderRuntimePolicyError::ResumeRequiresSessionIdentity);
236 }
237 Ok(())
238 }
239
240 pub const fn admits(self, capability: ProviderRuntimeCapability) -> bool {
241 match capability {
242 ProviderRuntimeCapability::RawPtyLifecycle => self.raw_pty_lifecycle,
243 ProviderRuntimeCapability::SemanticReadiness => self.semantic_readiness,
244 ProviderRuntimeCapability::StructuredPrompt => self.structured_prompt,
245 ProviderRuntimeCapability::ProviderSessionIdentity => {
246 self.provider_session_identity
247 }
248 ProviderRuntimeCapability::SemanticResume => self.semantic_resume,
249 ProviderRuntimeCapability::HookSemantics => self.hook_semantics,
250 }
251 }
252}
253
254impl<'de> Deserialize<'de> for ProviderRuntimePolicy {
255 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256 where
257 D: Deserializer<'de>,
258 {
259 #[derive(Deserialize)]
260 struct WirePolicy {
261 raw_pty_lifecycle: bool,
262 semantic_readiness: bool,
263 structured_prompt: bool,
264 provider_session_identity: bool,
265 semantic_resume: bool,
266 hook_semantics: bool,
267 }
268
269 let wire = WirePolicy::deserialize(deserializer)?;
270 Self::new(
271 wire.raw_pty_lifecycle,
272 wire.semantic_readiness,
273 wire.structured_prompt,
274 wire.provider_session_identity,
275 wire.semantic_resume,
276 wire.hook_semantics,
277 )
278 .map_err(serde::de::Error::custom)
279 }
280}
281
282#[derive(Clone, Copy, Debug, Eq, Error, PartialEq, Serialize, Deserialize)]
283#[serde(rename_all = "kebab-case")]
284pub enum ProviderRuntimePolicyError {
285 #[error("semantic resume and hook semantics require the raw PTY lifecycle")]
286 SemanticCapabilityRequiresRawPty,
287 #[error("structured prompts require semantic readiness")]
288 StructuredPromptRequiresReadiness,
289 #[error("semantic resume requires provider session identity")]
290 ResumeRequiresSessionIdentity,
291}
292
293#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
294#[serde(rename_all = "kebab-case")]
295pub enum ProviderRuntimeCapability {
296 RawPtyLifecycle,
297 SemanticReadiness,
298 StructuredPrompt,
299 ProviderSessionIdentity,
300 SemanticResume,
301 HookSemantics,
305}
306
307#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
308pub struct TerminalSize {
309 pub rows: u16,
310 pub columns: u16,
311}
312
313#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
314#[serde(rename_all = "kebab-case")]
315pub enum TerminalMouseProtocolEncoding {
316 #[default]
317 Default,
318 Utf8,
319 Sgr,
320}
321
322#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
323pub struct TerminalFrame {
324 pub sequence: u64,
325 pub size: TerminalSize,
326 pub cursor_row: u16,
327 pub cursor_column: u16,
328 pub contents: String,
329 pub formatted: Vec<u8>,
330 #[serde(default)]
331 pub scrollback_formatted: Vec<Vec<u8>>,
332 #[serde(default)]
333 pub alternate_screen: bool,
334 #[serde(default)]
335 pub mouse_protocol_enabled: bool,
336 #[serde(default)]
337 pub mouse_protocol_encoding: TerminalMouseProtocolEncoding,
338 #[serde(default)]
352 pub produced_at_unix_ms: u64,
353 #[serde(default)]
362 pub screen_state: PtyScreenState,
363 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub bracketed_paste: Option<bool>,
379}
380
381pub const FOREGROUND_PROCESS_NAME_MAX_BYTES: usize = 512;
382pub const PTY_SCREEN_GATE_NAME_MAX_BYTES: usize = 128;
383pub const OPERATOR_GATE_OPTIONS_MAX: usize = 8;
387pub const OPERATOR_GATE_OPTION_TEXT_MAX_BYTES: usize = 128;
391pub const OPERATOR_GATE_PATH_MAX_BYTES: usize = 32_768;
395
396#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
402#[serde(rename_all = "kebab-case")]
403pub enum OperatorGateKind {
404 WorkspaceTrust,
407 HookTrust,
413 Authentication,
415 VendorUpdate,
419 Onboarding,
422 TerminalAppearance,
424 ConfigurationMigration,
427}
428
429impl OperatorGateKind {
430 pub fn label(&self) -> &'static str {
435 match self {
436 Self::WorkspaceTrust => "workspace trust",
437 Self::HookTrust => "hook trust review",
438 Self::Authentication => "authentication",
439 Self::VendorUpdate => "vendor update",
440 Self::Onboarding => "onboarding",
441 Self::TerminalAppearance => "terminal appearance setup",
442 Self::ConfigurationMigration => "configuration migration",
443 }
444 }
445}
446
447#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
456#[serde(tag = "kind", rename_all = "kebab-case")]
457pub enum OperatorGateSubject {
458 Directory { path: Option<String> },
462 Hooks { count: Option<u32> },
466 McpServers,
468 Account,
470 ApiKey,
472 Appearance,
474 Unknown,
476}
477
478#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
481#[serde(rename_all = "kebab-case")]
482pub enum OperatorGateInput {
483 NumberedList,
486 ArrowList,
489 PressEnter,
491 TextEntry,
494 Unknown,
496}
497
498#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
503#[serde(rename_all = "kebab-case")]
504pub enum OperatorGateOptionSemantics {
505 Accept,
507 Decline,
510 Inspect,
513 Exit,
515 Unknown,
517}
518
519#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
521pub struct OperatorGateOption {
522 pub text: String,
527 pub semantics: OperatorGateOptionSemantics,
528 pub selected: bool,
534}
535
536impl OperatorGateOption {
537 fn is_valid(&self) -> bool {
538 !self.text.trim().is_empty()
539 && self.text.len() <= OPERATOR_GATE_OPTION_TEXT_MAX_BYTES
540 && !self.text.chars().any(char::is_control)
541 }
542}
543
544#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
551pub struct OperatorGateState {
552 pub kind: OperatorGateKind,
553 pub subject: OperatorGateSubject,
554 pub input: OperatorGateInput,
555 pub options: Vec<OperatorGateOption>,
560}
561
562impl OperatorGateState {
563 pub fn new(kind: OperatorGateKind) -> Self {
569 Self {
570 kind,
571 subject: OperatorGateSubject::Unknown,
572 input: OperatorGateInput::Unknown,
573 options: Vec::new(),
574 }
575 }
576
577 pub fn with_subject(mut self, subject: OperatorGateSubject) -> Self {
579 self.subject = subject;
580 self
581 }
582
583 pub fn with_options(mut self, input: OperatorGateInput, options: Vec<OperatorGateOption>) -> Self {
588 self.input = input;
589 self.options = options;
590 self
591 }
592
593 pub fn is_valid(&self) -> bool {
599 let subject_valid = match &self.subject {
600 OperatorGateSubject::Directory { path: Some(path) } => {
601 !path.trim().is_empty()
602 && path.len() <= OPERATOR_GATE_PATH_MAX_BYTES
603 && !path.chars().any(char::is_control)
604 }
605 OperatorGateSubject::Directory { path: None }
606 | OperatorGateSubject::Hooks { .. }
607 | OperatorGateSubject::McpServers
608 | OperatorGateSubject::Account
609 | OperatorGateSubject::ApiKey
610 | OperatorGateSubject::Appearance
611 | OperatorGateSubject::Unknown => true,
612 };
613 subject_valid
614 && self.options.len() <= OPERATOR_GATE_OPTIONS_MAX
615 && self.options.iter().all(OperatorGateOption::is_valid)
616 }
617}
618
619impl std::fmt::Display for OperatorGateState {
620 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
625 f.write_str(self.kind.label())
626 }
627}
628
629impl OperatorGateState {
630 pub fn describe(&self) -> String {
636 if self.options.is_empty() {
637 return self.kind.label().to_owned();
638 }
639 let options = self
640 .options
641 .iter()
642 .map(|option| {
643 if option.selected {
644 format!("*{}", option.text)
645 } else {
646 option.text.clone()
647 }
648 })
649 .collect::<Vec<_>>()
650 .join(", ");
651 format!("{} [{options}]", self.kind.label())
652 }
653}
654
655#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
656#[serde(tag = "kind", rename_all = "kebab-case")]
657pub enum ForegroundProcessKind {
658 Agent { agent_id: AgentId },
659 Shell,
660 Other,
661}
662
663#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
664pub struct ForegroundProcess {
665 pub root_process_id: u32,
666 pub process_id: u32,
667 pub process_name: String,
668 pub kind: ForegroundProcessKind,
669}
670
671impl ForegroundProcess {
672 pub fn is_valid_for(&self, session_agent_id: &AgentId) -> bool {
673 self.root_process_id > 0
674 && self.process_id > 0
675 && !self.process_name.trim().is_empty()
676 && self.process_name.len() <= FOREGROUND_PROCESS_NAME_MAX_BYTES
677 && !self.process_name.chars().any(char::is_control)
678 && match &self.kind {
679 ForegroundProcessKind::Agent { agent_id } => agent_id == session_agent_id,
680 ForegroundProcessKind::Shell | ForegroundProcessKind::Other => true,
681 }
682 }
683}
684
685#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
686#[serde(rename_all = "kebab-case")]
687pub enum ForegroundAuthority {
688 #[default]
689 Unknown,
690 Confirmed,
691 Stale,
692}
693
694#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
695pub struct ForegroundSnapshot {
696 pub authority: ForegroundAuthority,
697 pub process: Option<ForegroundProcess>,
698 pub stale_reason: Option<String>,
699}
700
701#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
710#[serde(tag = "kind", rename_all = "kebab-case")]
711pub enum PtyScreenState {
712 #[default]
717 Unknown,
718 NotAgent { observed_process: String },
723 OperatorGate { gate: OperatorGateState },
734 Failing { reason: String },
751 Ready,
762}
763
764impl PtyScreenState {
765 pub fn admits_blind_write(&self) -> bool {
785 !matches!(
786 self,
787 Self::OperatorGate { .. } | Self::NotAgent { .. } | Self::Failing { .. }
788 )
789 }
790
791 pub fn is_valid(&self) -> bool {
796 match self {
797 Self::Unknown | Self::Ready => true,
798 Self::NotAgent { observed_process } => {
799 !observed_process.trim().is_empty()
800 && observed_process.len() <= FOREGROUND_PROCESS_NAME_MAX_BYTES
801 && !observed_process.chars().any(char::is_control)
802 }
803 Self::OperatorGate { gate } => gate.is_valid(),
804 Self::Failing { reason } => {
805 !reason.trim().is_empty()
806 && reason.len() <= PTY_SCREEN_GATE_NAME_MAX_BYTES
807 && !reason.chars().any(char::is_control)
808 }
809 }
810 }
811}
812
813impl TerminalSize {
814 pub fn is_valid(self) -> bool {
815 (1..=TERMINAL_ROWS_MAX).contains(&self.rows)
816 && (1..=TERMINAL_COLUMNS_MAX).contains(&self.columns)
817 }
818}
819
820#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
821#[serde(rename_all = "kebab-case")]
822pub enum TransportKind {
823 Pty,
824 Pipe,
825 Acp,
826}
827
828#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
829pub struct CommandEnvelope {
830 pub id: CommandId,
831 pub command: ControlCommand,
832}
833
834#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
835#[serde(tag = "kind", rename_all = "kebab-case")]
836pub enum ControlCommand {
837 Register {
838 instance_id: AgentInstanceId,
839 agent_id: AgentId,
840 transport: TransportKind,
841 },
842 Start {
843 instance_id: AgentInstanceId,
844 runtime_policy: ProviderRuntimePolicy,
845 request: StartRequest,
846 },
847 Stop {
848 instance_id: AgentInstanceId,
849 force: bool,
850 },
851 SendInput {
852 instance_id: AgentInstanceId,
853 action: InputAction,
854 },
855 Resize {
856 instance_id: AgentInstanceId,
857 size: TerminalSize,
858 },
859 RefreshForeground {
860 instance_id: AgentInstanceId,
861 },
862 ProbeCapabilities {
863 instance_id: AgentInstanceId,
864 request: CapabilityProbeRequest,
865 },
866 DiscoverHistory {
867 instance_id: AgentInstanceId,
868 query: HistoryQuery,
869 },
870 LoadHistory {
871 instance_id: AgentInstanceId,
872 candidate_id: String,
873 },
874 Resume {
875 instance_id: AgentInstanceId,
876 target: ResumeTarget,
877 runtime_policy: ProviderRuntimePolicy,
878 request: ResumeLaunchRequest,
879 },
880 ResolveInteraction {
881 instance_id: AgentInstanceId,
882 generation: SessionGeneration,
883 interaction_id: ProviderInteractionId,
884 response: ProviderInteractionResponse,
885 },
886 SetSessionMode {
889 instance_id: AgentInstanceId,
890 mode_id: String,
891 },
892 SetSessionConfigOption {
898 instance_id: AgentInstanceId,
899 option_id: String,
900 value_json: String,
901 },
902 SetSessionModel {
909 instance_id: AgentInstanceId,
910 model_id: String,
911 },
912 IngestProvider {
913 instance_id: AgentInstanceId,
914 generation: SessionGeneration,
915 source: ProviderSource,
916 source_sequence: u64,
917 events: Vec<ProviderEvent>,
918 },
919 Remove {
920 instance_id: AgentInstanceId,
921 },
922}
923
924impl ControlCommand {
925 pub fn instance_id(&self) -> AgentInstanceId {
926 match self {
927 Self::Register { instance_id, .. }
928 | Self::Start { instance_id, .. }
929 | Self::Stop { instance_id, .. }
930 | Self::SendInput { instance_id, .. }
931 | Self::Resize { instance_id, .. }
932 | Self::RefreshForeground { instance_id }
933 | Self::ProbeCapabilities { instance_id, .. }
934 | Self::DiscoverHistory { instance_id, .. }
935 | Self::LoadHistory { instance_id, .. }
936 | Self::Resume { instance_id, .. }
937 | Self::ResolveInteraction { instance_id, .. }
938 | Self::SetSessionMode { instance_id, .. }
939 | Self::SetSessionConfigOption { instance_id, .. }
940 | Self::SetSessionModel { instance_id, .. }
941 | Self::IngestProvider { instance_id, .. }
942 | Self::Remove { instance_id } => *instance_id,
943 }
944 }
945}
946
947#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
948pub struct EffectEnvelope {
949 pub operation_id: OperationId,
950 pub instance_id: AgentInstanceId,
951 pub generation: SessionGeneration,
952 pub effect: ControlEffect,
953}
954
955#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
960#[serde(tag = "kind", rename_all = "kebab-case")]
961pub enum ForegroundRequirement {
962 Any,
964 Agent { agent_id: AgentId },
966 Shell,
968}
969
970#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
971#[serde(tag = "kind", rename_all = "kebab-case")]
972pub enum ControlEffect {
973 Spawn {
974 agent_id: AgentId,
975 transport: TransportKind,
976 runtime_policy: ProviderRuntimePolicy,
977 request: StartRequest,
978 },
979 Stop {
980 force: bool,
981 },
982 WriteInput {
983 input: PreparedInput,
984 required_foreground: ForegroundRequirement,
985 },
986 SubmitPrompt {
987 prompt: String,
988 },
989 Interrupt,
990 Resize {
991 size: TerminalSize,
992 },
993 ObserveForeground,
994 ProbeCapabilities {
995 agent_id: AgentId,
996 request: CapabilityProbeRequest,
997 },
998 DiscoverHistory {
999 agent_id: AgentId,
1000 query: HistoryQuery,
1001 },
1002 LoadHistory {
1003 agent_id: AgentId,
1004 candidate_id: String,
1005 },
1006 AuthorizeResume {
1007 agent_id: AgentId,
1008 target: ResumeAuthorityTarget,
1009 request: ResumeLaunchRequest,
1010 },
1011 SpawnResume {
1012 agent_id: AgentId,
1013 transport: TransportKind,
1014 provider_session: ProviderSessionIdentity,
1015 runtime_policy: ProviderRuntimePolicy,
1016 request: ResumeLaunchRequest,
1017 },
1018 ResolveInteraction {
1019 target: ProviderInteractionTarget,
1020 response: ProviderInteractionResponse,
1021 },
1022 SetSessionMode {
1023 mode_id: String,
1024 },
1025 SetSessionConfigOption {
1026 option_id: String,
1027 value_json: String,
1028 },
1029 SetSessionModel {
1030 model_id: String,
1031 },
1032}
1033
1034#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1035pub struct ObservationEnvelope {
1036 pub operation_id: Option<OperationId>,
1037 pub instance_id: AgentInstanceId,
1038 pub generation: SessionGeneration,
1039 pub observation: ControlObservation,
1040}
1041
1042#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1043#[serde(tag = "kind", rename_all = "kebab-case")]
1044pub enum ControlObservation {
1045 Spawned {
1046 process_id: Option<u32>,
1047 },
1048 SpawnFailed {
1049 message: String,
1050 },
1051 ProcessExited {
1052 exit_code: Option<i32>,
1053 final_terminal: Option<TerminalFrame>,
1054 },
1055 StopCompleted {
1056 forced: bool,
1057 exit_code: Option<i32>,
1058 final_terminal: Option<TerminalFrame>,
1059 },
1060 StopFailed {
1061 message: String,
1062 },
1063 InputCompleted,
1064 InputFailed {
1065 message: String,
1066 },
1067 ResizeCompleted {
1068 size: TerminalSize,
1069 },
1070 ResizeFailed {
1071 message: String,
1072 },
1073 ForegroundObserved {
1074 process: ForegroundProcess,
1075 },
1076 ForegroundFailed {
1077 message: String,
1078 },
1079 CapabilitiesProbed {
1080 session_option_models: Vec<CapabilityModelSummary>,
1081 },
1082 CapabilityProbeFailed {
1083 failure: CapabilityProbeFailure,
1084 },
1085 HistoryDiscovered {
1086 candidates: Vec<HistoryCandidateSummary>,
1087 },
1088 HistoryLoaded {
1089 session: HistorySessionRecord,
1090 },
1091 HistoryFailed {
1092 message: String,
1093 },
1094 ResumeAuthorized {
1095 provider_session: ProviderSessionIdentity,
1096 },
1097 ResumeDenied {
1098 reason: String,
1099 },
1100 ResumeFailed {
1101 message: String,
1102 },
1103 InteractionResolutionCompleted {
1104 interaction_id: ProviderInteractionId,
1105 },
1106 InteractionResolutionFailed {
1107 interaction_id: ProviderInteractionId,
1108 message: String,
1109 },
1110 SessionModeSet {
1114 mode_id: String,
1115 },
1116 SessionModeSetFailed {
1117 message: String,
1118 },
1119 SessionConfigOptionSet {
1120 option_id: String,
1121 },
1122 SessionConfigOptionSetFailed {
1123 message: String,
1124 },
1125 SessionModelSet {
1126 model_id: String,
1127 },
1128 SessionModelSetFailed {
1129 message: String,
1130 },
1131 TerminalFrame {
1132 frame: TerminalFrame,
1133 },
1134 TerminalStale {
1135 message: String,
1136 },
1137 ScreenState {
1146 state: PtyScreenState,
1147 },
1148 ProviderEvent {
1149 source: ProviderSource,
1150 sequence: u64,
1151 event: ProviderEvent,
1152 },
1153 ProviderGap {
1154 source: ProviderSource,
1155 source_sequence: u64,
1156 missed: u64,
1157 },
1158}
1159
1160impl ControlObservation {
1161 pub fn requires_operation_id(&self) -> bool {
1162 !matches!(
1163 self,
1164 Self::ProcessExited { .. }
1165 | Self::TerminalFrame { .. }
1166 | Self::TerminalStale { .. }
1167 | Self::ScreenState { .. }
1168 | Self::ProviderEvent { .. }
1169 | Self::ProviderGap { .. }
1170 )
1171 }
1172}
1173
1174#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1175#[serde(tag = "kind", rename_all = "kebab-case")]
1176pub enum SessionStatus {
1177 Registered,
1178 Starting,
1179 Running,
1180 Stopping,
1181 Exited { exit_code: Option<i32> },
1182 Failed { message: String },
1183}
1184
1185impl SessionStatus {
1186 pub fn allows_remove(&self) -> bool {
1201 match self {
1202 Self::Starting | Self::Running | Self::Stopping => false,
1203 Self::Registered | Self::Exited { .. } | Self::Failed { .. } => true,
1204 }
1205 }
1206}
1207
1208#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1209pub struct SessionSnapshot {
1210 pub instance_id: AgentInstanceId,
1211 pub agent_id: AgentId,
1212 pub transport: TransportKind,
1213 pub generation: SessionGeneration,
1214 pub status: SessionStatus,
1215 pub pending_operation: Option<OperationId>,
1216 pub pending_input: Option<PreparedInputKind>,
1217 pub process_id: Option<u32>,
1218 pub terminal_size: Option<TerminalSize>,
1219 pub terminal_frame: Option<TerminalFrame>,
1220 pub terminal_stale: Option<String>,
1221 pub session_options: Option<SessionOptionSelection>,
1222 pub capabilities: CapabilitySnapshot,
1223 pub history: HistorySnapshot,
1224 pub resume: ResumeSnapshot,
1225 pub foreground: ForegroundSnapshot,
1226 pub provider: ProviderSnapshot,
1227 #[serde(default)]
1234 pub screen_state: PtyScreenState,
1235}
1236
1237#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
1238pub struct TokenUsage {
1239 pub input_tokens: u64,
1240 pub output_tokens: u64,
1241 pub cache_read_tokens: u64,
1242 pub cache_write_tokens: u64,
1243 pub reasoning_tokens: u64,
1244 pub context_window: Option<u64>,
1245}
1246
1247#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1248pub struct ContextWindowUsage {
1249 pub uncached_input_tokens: u64,
1250 pub cache_read_tokens: u64,
1251 pub cache_write_tokens: u64,
1252 pub output_tokens: u64,
1253 pub unattributed_tokens: u64,
1254 pub used_tokens: u64,
1255 pub capacity_tokens: u64,
1256}
1257
1258impl ContextWindowUsage {
1259 pub fn validate(&self) -> Result<(), ProviderEventValidationError> {
1260 if self.capacity_tokens == 0 {
1261 return Err(ProviderEventValidationError::ZeroContextWindowCapacity);
1262 }
1263 let segment_sum = self
1264 .uncached_input_tokens
1265 .checked_add(self.cache_read_tokens)
1266 .and_then(|sum| sum.checked_add(self.cache_write_tokens))
1267 .and_then(|sum| sum.checked_add(self.output_tokens))
1268 .and_then(|sum| sum.checked_add(self.unattributed_tokens))
1269 .ok_or(ProviderEventValidationError::ContextWindowSegmentsOverflow)?;
1270 if segment_sum != self.used_tokens {
1271 return Err(ProviderEventValidationError::ContextWindowSegmentsMismatch {
1272 segment_sum,
1273 used_tokens: self.used_tokens,
1274 });
1275 }
1276 Ok(())
1277 }
1278}
1279
1280#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1281#[serde(rename_all = "kebab-case")]
1282pub enum ProviderInteractionKind {
1283 Approval,
1284 Question,
1285}
1286
1287#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1294#[serde(rename_all = "kebab-case")]
1295pub enum ProviderRateLimitKind {
1296 Session,
1298 Daily,
1300 Weekly,
1302 Unknown,
1304}
1305
1306#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1307#[serde(rename_all = "kebab-case")]
1308pub enum ProviderInteractionOutcome {
1309 Approved,
1310 Answered,
1311 Denied,
1312 Interrupted,
1313 TurnEnded,
1314 Superseded,
1315}
1316
1317#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1318#[serde(rename_all = "kebab-case")]
1319pub enum ProviderInteractionResponseKind {
1320 ApproveOnce,
1321 Deny,
1322 Answer,
1323}
1324
1325#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1326#[serde(tag = "kind", rename_all = "kebab-case")]
1327pub enum ProviderInteractionResponse {
1328 ApproveOnce,
1329 Deny,
1330 Answer { text: String },
1331}
1332
1333impl ProviderInteractionResponse {
1334 pub fn kind(&self) -> ProviderInteractionResponseKind {
1335 match self {
1336 Self::ApproveOnce => ProviderInteractionResponseKind::ApproveOnce,
1337 Self::Deny => ProviderInteractionResponseKind::Deny,
1338 Self::Answer { .. } => ProviderInteractionResponseKind::Answer,
1339 }
1340 }
1341
1342 pub fn outcome(&self) -> ProviderInteractionOutcome {
1343 match self {
1344 Self::ApproveOnce => ProviderInteractionOutcome::Approved,
1345 Self::Deny => ProviderInteractionOutcome::Denied,
1346 Self::Answer { .. } => ProviderInteractionOutcome::Answered,
1347 }
1348 }
1349
1350 pub fn validate_for(
1351 &self,
1352 interaction_kind: ProviderInteractionKind,
1353 ) -> Result<(), ProviderInteractionResponseError> {
1354 match (interaction_kind, self) {
1355 (ProviderInteractionKind::Approval, Self::ApproveOnce)
1356 | (ProviderInteractionKind::Approval, Self::Deny)
1357 | (ProviderInteractionKind::Question, Self::Deny) => Ok(()),
1358 (ProviderInteractionKind::Question, Self::Answer { text }) => {
1359 if text.trim().is_empty() {
1360 return Err(ProviderInteractionResponseError::EmptyAnswer);
1361 }
1362 let has_unsafe_control = text.chars().any(|character| {
1363 character.is_control() && !matches!(character, '\n' | '\r' | '\t')
1364 });
1365 if text.len() > PROVIDER_INTERACTION_RESPONSE_MAX_BYTES || has_unsafe_control {
1366 return Err(ProviderInteractionResponseError::InvalidAnswer {
1367 max: PROVIDER_INTERACTION_RESPONSE_MAX_BYTES,
1368 });
1369 }
1370 Ok(())
1371 }
1372 (ProviderInteractionKind::Approval, Self::Answer { .. }) => {
1373 Err(ProviderInteractionResponseError::AnswerRequiresQuestion)
1374 }
1375 (ProviderInteractionKind::Question, Self::ApproveOnce) => {
1376 Err(ProviderInteractionResponseError::ApprovalRequiresApproval)
1377 }
1378 }
1379 }
1380}
1381
1382#[derive(Clone, Debug, Eq, Error, PartialEq)]
1383pub enum ProviderInteractionResponseError {
1384 #[error("interaction answer is required")]
1385 EmptyAnswer,
1386 #[error("interaction answer contains controls or exceeds {max} bytes")]
1387 InvalidAnswer { max: usize },
1388 #[error("an answer response requires a question interaction")]
1389 AnswerRequiresQuestion,
1390 #[error("an approve-once response requires an approval interaction")]
1391 ApprovalRequiresApproval,
1392}
1393
1394#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1395#[serde(rename_all = "kebab-case")]
1396pub enum ProviderSessionKey {
1397 SessionId,
1398 ConversationId,
1399}
1400
1401#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1402pub struct ProviderSessionIdentity {
1403 pub key: ProviderSessionKey,
1404 pub id: String,
1405 pub transcript_path: Option<String>,
1406}
1407
1408impl ProviderSessionIdentity {
1409 pub fn validate(&self) -> Result<(), ProviderEventValidationError> {
1410 validate_required("provider session id", &self.id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1411 if self.id.starts_with('-') {
1412 return Err(ProviderEventValidationError::InvalidField {
1413 field: "provider session id",
1414 max: PROVIDER_EVENT_ID_MAX_BYTES,
1415 });
1416 }
1417 if let Some(path) = &self.transcript_path {
1418 validate_required(
1419 "provider transcript path",
1420 path,
1421 PROVIDER_SESSION_LOCATOR_MAX_BYTES,
1422 )?;
1423 }
1424 Ok(())
1425 }
1426}
1427
1428#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1429pub struct ProviderSubagent {
1430 pub source: ProviderSource,
1431 pub provider_agent_id: String,
1432 pub agent_type: Option<String>,
1433 pub description: Option<String>,
1434}
1435
1436#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1439#[serde(rename_all = "kebab-case")]
1440pub enum ProviderPlanPriority {
1441 High,
1442 Medium,
1443 Low,
1444}
1445
1446#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1449#[serde(rename_all = "kebab-case")]
1450pub enum ProviderPlanStatus {
1451 Pending,
1452 InProgress,
1453 Completed,
1454}
1455
1456#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1460pub struct ProviderPlanStep {
1461 pub content: String,
1462 pub priority: ProviderPlanPriority,
1463 pub status: ProviderPlanStatus,
1464}
1465
1466#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1469pub struct ProviderAvailableCommand {
1470 pub name: String,
1471 pub description: String,
1472 pub input_hint: Option<String>,
1473}
1474
1475#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1482pub struct ProviderModeInfo {
1483 pub id: String,
1484 pub name: String,
1485 pub description: Option<String>,
1486}
1487
1488#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1492#[serde(rename_all = "kebab-case")]
1493pub enum ProviderConfigOptionKind {
1494 Select,
1495 Boolean,
1496 Unknown,
1497}
1498
1499#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1505pub struct ProviderConfigChoice {
1506 pub value_json: String,
1507 pub label: Option<String>,
1508}
1509
1510#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1515pub struct ProviderConfigOption {
1516 pub id: String,
1517 pub name: String,
1518 pub description: Option<String>,
1519 pub category: Option<String>,
1520 pub kind: ProviderConfigOptionKind,
1521 pub value_json: String,
1522 pub choices: Vec<ProviderConfigChoice>,
1523}
1524
1525#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1531#[serde(rename_all = "kebab-case")]
1532pub enum HostDecisionAuthority {
1533 Gate,
1537 Policy,
1541 Operator,
1544 DeadlinePolicy,
1553}
1554
1555#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
1560#[serde(tag = "kind", rename_all = "kebab-case")]
1561pub enum HostRequestDecision {
1562 Granted { by: HostDecisionAuthority },
1564 Denied { by: HostDecisionAuthority },
1566 Deferred,
1570}
1571
1572#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1582#[serde(tag = "kind", rename_all = "kebab-case")]
1583pub enum HostRequestOutcome {
1584 Executed,
1587 Failed { error: String },
1593}
1594
1595impl Default for HostRequestOutcome {
1596 fn default() -> Self {
1602 Self::Executed
1603 }
1604}
1605
1606#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1623pub struct ProviderInteractionOption {
1624 pub option_id: String,
1625 pub name: String,
1626 pub kind: String,
1627}
1628
1629#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1639#[serde(tag = "kind", rename_all = "kebab-case")]
1640pub enum ProviderStopReason {
1641 EndTurn,
1642 MaxTokens,
1643 MaxTurnRequests,
1644 Refusal,
1645 Cancelled,
1646 Other { value: String },
1647 ProviderError {
1648 code: i32,
1649 message: String,
1650 vendor_code: Option<String>,
1651 },
1652}
1653
1654impl ProviderStopReason {
1655 fn validate_ingress(&self) -> Result<(), ProviderEventValidationError> {
1656 match self {
1657 Self::EndTurn | Self::MaxTokens | Self::MaxTurnRequests | Self::Refusal | Self::Cancelled => {
1658 Ok(())
1659 }
1660 Self::Other { value } => validate_text("stop reason", value, PROVIDER_EVENT_ID_MAX_BYTES),
1661 Self::ProviderError { message, vendor_code, .. } => {
1662 validate_text("provider error message", message, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1663 if let Some(code) = vendor_code {
1664 validate_text("provider error vendor code", code, PROVIDER_EVENT_ID_MAX_BYTES)?;
1665 }
1666 Ok(())
1667 }
1668 }
1669 }
1670}
1671
1672#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1673#[serde(tag = "kind", rename_all = "kebab-case")]
1674pub enum ProviderEvent {
1675 SessionStarted {
1676 session_id: String,
1677 model: String,
1678 tools: Vec<String>,
1679 },
1680 SessionIdentityObserved {
1681 identity: ProviderSessionIdentity,
1682 },
1683 TurnStarted {
1684 prompt: Option<String>,
1685 },
1686 WorkingObserved,
1687 Text {
1688 text: String,
1689 is_delta: bool,
1690 },
1691 Thinking {
1692 text: String,
1693 },
1694 ToolStarted {
1695 id: String,
1696 name: String,
1697 input_json: String,
1698 agent_id: Option<String>,
1699 },
1700 ToolCompleted {
1701 id: String,
1702 output: String,
1703 is_error: bool,
1704 duration_ms: Option<u64>,
1705 agent_id: Option<String>,
1706 #[serde(default)]
1715 non_execution_kind: Option<String>,
1716 },
1717 TurnCompleted {
1718 usage: TokenUsage,
1719 is_cumulative: bool,
1720 },
1721 ContextWindowUsage {
1722 usage: ContextWindowUsage,
1723 },
1724 TurnInterrupted,
1725 SessionEnded {
1726 result: String,
1727 cost_usd: Option<String>,
1728 is_error: bool,
1729 #[serde(default)]
1737 stop_reason: Option<ProviderStopReason>,
1738 },
1739 Error {
1740 message: String,
1741 },
1742 Ready,
1743 InteractionRequested {
1744 request_id: Option<String>,
1745 interaction_kind: ProviderInteractionKind,
1746 tool_name: String,
1747 title: Option<String>,
1754 prompt: String,
1755 options: Vec<ProviderInteractionOption>,
1762 agent_id: Option<String>,
1763 },
1764 InteractionResolved {
1765 request_id: String,
1766 outcome: ProviderInteractionOutcome,
1767 },
1768 SubagentStarted {
1769 agent_id: String,
1770 agent_type: Option<String>,
1771 description: Option<String>,
1772 },
1773 SubagentStopped {
1774 agent_id: String,
1775 },
1776 RateLimited {
1777 limit_type: ProviderRateLimitKind,
1778 resets_at: Option<String>,
1779 usage_percent: Option<String>,
1780 raw_message: String,
1781 },
1782 HostRequestObserved {
1800 method: String,
1801 params_json: String,
1802 decision: HostRequestDecision,
1803 #[serde(default)]
1811 outcome: HostRequestOutcome,
1812 reason: Option<String>,
1824 },
1825 UnrecognizedNotification {
1833 method: String,
1834 payload_json: String,
1835 },
1836 UserMessage {
1839 text: String,
1840 is_delta: bool,
1841 },
1842 Plan {
1845 steps: Vec<ProviderPlanStep>,
1846 },
1847 AvailableCommandsUpdated {
1850 commands: Vec<ProviderAvailableCommand>,
1851 },
1852 ModeChanged {
1863 mode_id: String,
1864 available: Vec<ProviderModeInfo>,
1865 },
1866 SessionInfoUpdated {
1869 title: Option<String>,
1870 },
1871 UsageUpdated {
1876 used_tokens: Option<u64>,
1877 context_window: Option<u64>,
1878 cost_amount: Option<String>,
1879 cost_currency: Option<String>,
1880 },
1881 ConfigOptionsUpdated {
1884 options: Vec<ProviderConfigOption>,
1885 },
1886}
1887
1888impl ProviderEvent {
1889 pub fn validate_ingress(&self) -> Result<(), ProviderEventValidationError> {
1890 match self {
1891 Self::SessionStarted {
1892 session_id,
1893 model,
1894 tools,
1895 } => {
1896 validate_required("session_id", session_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1897 validate_identifier("model", model, PROVIDER_EVENT_ID_MAX_BYTES)?;
1898 if tools.len() > PROVIDER_EVENT_TOOLS_MAX {
1899 return Err(ProviderEventValidationError::TooManyTools {
1900 count: tools.len(),
1901 max: PROVIDER_EVENT_TOOLS_MAX,
1902 });
1903 }
1904 for tool in tools {
1905 validate_required("tool", tool, PROVIDER_EVENT_ID_MAX_BYTES)?;
1906 }
1907 }
1908 Self::SessionIdentityObserved { identity } => {
1909 identity.validate()?;
1910 }
1911 Self::TurnStarted { prompt } => {
1912 if let Some(prompt) = prompt {
1913 validate_text("prompt", prompt, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1914 }
1915 }
1916 Self::Text { text, .. } | Self::Thinking { text } => {
1917 validate_text("text", text, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1918 }
1919 Self::ToolStarted {
1920 id,
1921 name,
1922 input_json,
1923 agent_id,
1924 } => {
1925 validate_required("tool id", id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1926 validate_required("tool name", name, PROVIDER_EVENT_ID_MAX_BYTES)?;
1927 validate_text("tool input", input_json, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1928 validate_optional_agent_id(agent_id)?;
1929 }
1930 Self::ToolCompleted {
1931 id,
1932 output,
1933 agent_id,
1934 non_execution_kind,
1935 ..
1936 } => {
1937 validate_required("tool id", id, PROVIDER_EVENT_ID_MAX_BYTES)?;
1938 validate_text("tool output", output, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1939 validate_optional_agent_id(agent_id)?;
1940 if let Some(kind) = non_execution_kind {
1941 validate_text("tool non-execution kind", kind, PROVIDER_EVENT_ID_MAX_BYTES)?;
1942 }
1943 }
1944 Self::SessionEnded {
1945 result,
1946 cost_usd,
1947 stop_reason,
1948 ..
1949 } => {
1950 validate_text("session result", result, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1951 if let Some(cost) = cost_usd {
1952 validate_identifier("cost", cost, PROVIDER_EVENT_ID_MAX_BYTES)?;
1953 }
1954 if let Some(stop_reason) = stop_reason {
1955 stop_reason.validate_ingress()?;
1956 }
1957 }
1958 Self::Error { message } => {
1959 validate_required_text("error", message, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1960 }
1961 Self::InteractionRequested {
1962 request_id,
1963 interaction_kind,
1964 tool_name,
1965 title,
1966 prompt,
1967 options,
1968 agent_id,
1969 } => {
1970 if let Some(request_id) = request_id {
1971 validate_required(
1972 "interaction request id",
1973 request_id,
1974 PROVIDER_EVENT_ID_MAX_BYTES,
1975 )?;
1976 }
1977 validate_required("interaction tool", tool_name, PROVIDER_EVENT_ID_MAX_BYTES)?;
1978 if let Some(title) = title {
1979 validate_text("interaction title", title, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1980 }
1981 if *interaction_kind == ProviderInteractionKind::Question {
1982 validate_required_text(
1983 "interaction prompt",
1984 prompt,
1985 PROVIDER_EVENT_TEXT_MAX_BYTES,
1986 )?;
1987 } else {
1988 validate_text("interaction prompt", prompt, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
1989 }
1990 if options.len() > PROVIDER_INTERACTION_OPTIONS_MAX {
1991 return Err(ProviderEventValidationError::TooManyInteractionOptions {
1992 count: options.len(),
1993 max: PROVIDER_INTERACTION_OPTIONS_MAX,
1994 });
1995 }
1996 for option in options {
1997 validate_required(
1998 "interaction option id",
1999 &option.option_id,
2000 PROVIDER_EVENT_ID_MAX_BYTES,
2001 )?;
2002 validate_required(
2003 "interaction option name",
2004 &option.name,
2005 PROVIDER_EVENT_ID_MAX_BYTES,
2006 )?;
2007 validate_required(
2008 "interaction option kind",
2009 &option.kind,
2010 PROVIDER_EVENT_ID_MAX_BYTES,
2011 )?;
2012 }
2013 validate_optional_agent_id(agent_id)?;
2014 }
2015 Self::InteractionResolved {
2016 request_id,
2017 outcome,
2018 } => {
2019 validate_required(
2020 "interaction request id",
2021 request_id,
2022 PROVIDER_EVENT_ID_MAX_BYTES,
2023 )?;
2024 if !matches!(
2025 outcome,
2026 ProviderInteractionOutcome::Approved | ProviderInteractionOutcome::Denied
2027 ) {
2028 return Err(
2029 ProviderEventValidationError::InvalidInteractionResolutionOutcome {
2030 outcome: *outcome,
2031 },
2032 );
2033 }
2034 }
2035 Self::SubagentStarted {
2036 agent_id,
2037 agent_type,
2038 description,
2039 } => {
2040 validate_required("subagent id", agent_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2041 if let Some(agent_type) = agent_type {
2042 validate_identifier("subagent type", agent_type, PROVIDER_EVENT_ID_MAX_BYTES)?;
2043 }
2044 if let Some(description) = description {
2045 validate_text(
2046 "subagent description",
2047 description,
2048 PROVIDER_EVENT_TEXT_MAX_BYTES,
2049 )?;
2050 }
2051 }
2052 Self::SubagentStopped { agent_id } => {
2053 validate_required("subagent id", agent_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2054 }
2055 Self::RateLimited {
2056 limit_type: _,
2057 resets_at,
2058 usage_percent,
2059 raw_message,
2060 } => {
2061 for (field, value) in [
2064 ("reset time", resets_at.as_deref()),
2065 ("usage percent", usage_percent.as_deref()),
2066 ] {
2067 if let Some(value) = value {
2068 validate_identifier(field, value, PROVIDER_EVENT_ID_MAX_BYTES)?;
2069 }
2070 }
2071 validate_text(
2072 "rate limit message",
2073 raw_message,
2074 PROVIDER_EVENT_TEXT_MAX_BYTES,
2075 )?;
2076 }
2077 Self::HostRequestObserved {
2078 method,
2079 params_json,
2080 reason,
2081 outcome,
2082 ..
2083 } => {
2084 validate_required("host request method", method, PROVIDER_EVENT_ID_MAX_BYTES)?;
2085 validate_text(
2086 "host request params",
2087 params_json,
2088 PROVIDER_EVENT_TEXT_MAX_BYTES,
2089 )?;
2090 if let Some(reason) = reason {
2091 validate_text("host request reason", reason, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2092 }
2093 if let HostRequestOutcome::Failed { error } = outcome {
2094 validate_text(
2095 "host request outcome error",
2096 error,
2097 PROVIDER_EVENT_TEXT_MAX_BYTES,
2098 )?;
2099 }
2100 }
2101 Self::UnrecognizedNotification {
2102 method,
2103 payload_json,
2104 } => {
2105 validate_required(
2106 "unrecognized notification method",
2107 method,
2108 PROVIDER_EVENT_ID_MAX_BYTES,
2109 )?;
2110 validate_text(
2111 "unrecognized notification payload",
2112 payload_json,
2113 PROVIDER_EVENT_TEXT_MAX_BYTES,
2114 )?;
2115 }
2116 Self::UserMessage { text, .. } => {
2117 validate_text("text", text, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2118 }
2119 Self::Plan { steps } => {
2120 if steps.len() > PROVIDER_PLAN_STEPS_MAX {
2121 return Err(ProviderEventValidationError::TooManyPlanSteps {
2122 count: steps.len(),
2123 max: PROVIDER_PLAN_STEPS_MAX,
2124 });
2125 }
2126 for step in steps {
2127 validate_required_text(
2128 "plan step content",
2129 &step.content,
2130 PROVIDER_EVENT_TEXT_MAX_BYTES,
2131 )?;
2132 }
2133 }
2134 Self::AvailableCommandsUpdated { commands } => {
2135 if commands.len() > PROVIDER_AVAILABLE_COMMANDS_MAX {
2136 return Err(ProviderEventValidationError::TooManyAvailableCommands {
2137 count: commands.len(),
2138 max: PROVIDER_AVAILABLE_COMMANDS_MAX,
2139 });
2140 }
2141 for command in commands {
2142 validate_required("command name", &command.name, PROVIDER_EVENT_ID_MAX_BYTES)?;
2143 validate_text(
2144 "command description",
2145 &command.description,
2146 PROVIDER_EVENT_TEXT_MAX_BYTES,
2147 )?;
2148 if let Some(hint) = &command.input_hint {
2149 validate_text("command input hint", hint, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2150 }
2151 }
2152 }
2153 Self::ModeChanged { mode_id, available } => {
2154 validate_required("mode id", mode_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2155 if available.len() > PROVIDER_MODE_CATALOG_MAX {
2156 return Err(ProviderEventValidationError::TooManyModes {
2157 count: available.len(),
2158 max: PROVIDER_MODE_CATALOG_MAX,
2159 });
2160 }
2161 for mode in available {
2162 validate_required("available mode id", &mode.id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2163 validate_required(
2164 "available mode name",
2165 &mode.name,
2166 PROVIDER_EVENT_ID_MAX_BYTES,
2167 )?;
2168 if let Some(description) = &mode.description {
2169 validate_text(
2170 "available mode description",
2171 description,
2172 PROVIDER_EVENT_TEXT_MAX_BYTES,
2173 )?;
2174 }
2175 }
2176 }
2177 Self::SessionInfoUpdated { title } => {
2178 if let Some(title) = title {
2179 validate_text("session title", title, PROVIDER_EVENT_TEXT_MAX_BYTES)?;
2180 }
2181 }
2182 Self::UsageUpdated {
2183 cost_amount,
2184 cost_currency,
2185 ..
2186 } => {
2187 if let Some(amount) = cost_amount {
2188 validate_identifier("usage cost amount", amount, PROVIDER_EVENT_ID_MAX_BYTES)?;
2189 }
2190 if let Some(currency) = cost_currency {
2191 validate_identifier(
2192 "usage cost currency",
2193 currency,
2194 PROVIDER_EVENT_ID_MAX_BYTES,
2195 )?;
2196 }
2197 }
2198 Self::ConfigOptionsUpdated { options } => {
2199 if options.len() > PROVIDER_CONFIG_OPTIONS_MAX {
2200 return Err(ProviderEventValidationError::TooManyConfigOptions {
2201 count: options.len(),
2202 max: PROVIDER_CONFIG_OPTIONS_MAX,
2203 });
2204 }
2205 for option in options {
2206 validate_required("config option id", &option.id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2207 validate_required(
2208 "config option name",
2209 &option.name,
2210 PROVIDER_EVENT_ID_MAX_BYTES,
2211 )?;
2212 if let Some(description) = &option.description {
2213 validate_text(
2214 "config option description",
2215 description,
2216 PROVIDER_EVENT_TEXT_MAX_BYTES,
2217 )?;
2218 }
2219 if let Some(category) = &option.category {
2220 validate_identifier(
2221 "config option category",
2222 category,
2223 PROVIDER_EVENT_ID_MAX_BYTES,
2224 )?;
2225 }
2226 validate_text(
2227 "config option value",
2228 &option.value_json,
2229 PROVIDER_EVENT_TEXT_MAX_BYTES,
2230 )?;
2231 if option.choices.len() > PROVIDER_CONFIG_OPTION_CHOICES_MAX {
2232 return Err(ProviderEventValidationError::TooManyConfigOptionChoices {
2233 count: option.choices.len(),
2234 max: PROVIDER_CONFIG_OPTION_CHOICES_MAX,
2235 });
2236 }
2237 for choice in &option.choices {
2238 validate_text(
2239 "config option choice value",
2240 &choice.value_json,
2241 PROVIDER_EVENT_TEXT_MAX_BYTES,
2242 )?;
2243 if let Some(label) = &choice.label {
2244 validate_text(
2245 "config option choice label",
2246 label,
2247 PROVIDER_EVENT_TEXT_MAX_BYTES,
2248 )?;
2249 }
2250 }
2251 }
2252 }
2253 Self::WorkingObserved
2254 | Self::TurnCompleted { .. }
2255 | Self::TurnInterrupted
2256 | Self::Ready => {}
2257 Self::ContextWindowUsage { usage } => usage.validate()?,
2258 }
2259 Ok(())
2260 }
2261}
2262
2263fn validate_required(
2264 field: &'static str,
2265 value: &str,
2266 max: usize,
2267) -> Result<(), ProviderEventValidationError> {
2268 if value.trim().is_empty() {
2269 return Err(ProviderEventValidationError::Empty { field });
2270 }
2271 validate_identifier(field, value, max)
2272}
2273
2274fn validate_required_text(
2275 field: &'static str,
2276 value: &str,
2277 max: usize,
2278) -> Result<(), ProviderEventValidationError> {
2279 if value.trim().is_empty() {
2280 return Err(ProviderEventValidationError::Empty { field });
2281 }
2282 validate_text(field, value, max)
2283}
2284
2285fn validate_identifier(
2286 field: &'static str,
2287 value: &str,
2288 max: usize,
2289) -> Result<(), ProviderEventValidationError> {
2290 if value.len() > max || value.chars().any(char::is_control) {
2291 return Err(ProviderEventValidationError::InvalidField { field, max });
2292 }
2293 Ok(())
2294}
2295
2296fn validate_optional_agent_id(
2297 agent_id: &Option<String>,
2298) -> Result<(), ProviderEventValidationError> {
2299 if let Some(agent_id) = agent_id {
2300 validate_required("provider agent id", agent_id, PROVIDER_EVENT_ID_MAX_BYTES)?;
2301 }
2302 Ok(())
2303}
2304
2305fn validate_text(
2306 field: &'static str,
2307 value: &str,
2308 max: usize,
2309) -> Result<(), ProviderEventValidationError> {
2310 let has_unsafe_control = value
2311 .chars()
2312 .any(|character| character.is_control() && !matches!(character, '\n' | '\r' | '\t'));
2313 if value.len() > max || has_unsafe_control {
2314 return Err(ProviderEventValidationError::InvalidField { field, max });
2315 }
2316 Ok(())
2317}
2318
2319pub fn validate_session_control_id(
2330 field: &'static str,
2331 value: &str,
2332) -> Result<(), ProviderEventValidationError> {
2333 validate_required(field, value, PROVIDER_EVENT_ID_MAX_BYTES)
2334}
2335
2336pub fn validate_session_config_value_json(
2346 value: &str,
2347) -> Result<(), ProviderEventValidationError> {
2348 validate_required_text(
2349 "session config option value",
2350 value,
2351 PROVIDER_EVENT_TEXT_MAX_BYTES,
2352 )
2353}
2354
2355#[derive(Clone, Debug, Error, Eq, PartialEq)]
2356pub enum ProviderEventValidationError {
2357 #[error("provider event field '{field}' is required")]
2358 Empty { field: &'static str },
2359 #[error("provider event field '{field}' contains controls or exceeds {max} bytes")]
2360 InvalidField { field: &'static str, max: usize },
2361 #[error("provider event tool count {count} exceeds {max}")]
2362 TooManyTools { count: usize, max: usize },
2363 #[error("provider interaction resolution outcome {outcome:?} is not exact")]
2364 InvalidInteractionResolutionOutcome { outcome: ProviderInteractionOutcome },
2365 #[error("context-window capacity must be non-zero")]
2366 ZeroContextWindowCapacity,
2367 #[error("context-window token segments overflow u64")]
2368 ContextWindowSegmentsOverflow,
2369 #[error("context-window token segments sum to {segment_sum}, not used_tokens {used_tokens}")]
2370 ContextWindowSegmentsMismatch { segment_sum: u64, used_tokens: u64 },
2371 #[error("provider plan step count {count} exceeds {max}")]
2372 TooManyPlanSteps { count: usize, max: usize },
2373 #[error("provider available command count {count} exceeds {max}")]
2374 TooManyAvailableCommands { count: usize, max: usize },
2375 #[error("provider config option count {count} exceeds {max}")]
2376 TooManyConfigOptions { count: usize, max: usize },
2377 #[error("provider config option choice count {count} exceeds {max}")]
2378 TooManyConfigOptionChoices { count: usize, max: usize },
2379 #[error("provider mode catalog count {count} exceeds {max}")]
2380 TooManyModes { count: usize, max: usize },
2381 #[error("provider interaction option count {count} exceeds {max}")]
2382 TooManyInteractionOptions { count: usize, max: usize },
2383}
2384
2385#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
2386pub struct ProviderSource {
2387 pub family: AdapterFamily,
2388 pub binding: AdapterBinding,
2389}
2390
2391#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2392pub struct ProviderSourceCursor {
2393 pub source: ProviderSource,
2394 pub sequence: u64,
2395 pub gap_count: u64,
2396 pub stale: bool,
2397}
2398
2399#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize, Deserialize)]
2400#[serde(transparent)]
2401pub struct ProviderInteractionId(pub u64);
2402
2403#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2404pub struct ProviderInteractionTarget {
2405 pub interaction_id: ProviderInteractionId,
2406 pub source: ProviderSource,
2407 pub provider_request_id: Option<String>,
2408 pub interaction_kind: ProviderInteractionKind,
2409 pub tool_name: String,
2410 pub agent_id: Option<String>,
2411}
2412
2413#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2414#[serde(tag = "kind", rename_all = "kebab-case")]
2415pub enum ProviderInteractionStatus {
2416 Pending,
2417 Resolving {
2418 operation_id: OperationId,
2419 response_kind: ProviderInteractionResponseKind,
2420 },
2421 Resolved {
2422 outcome: ProviderInteractionOutcome,
2423 },
2424}
2425
2426#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2427pub struct ProviderInteraction {
2428 pub id: ProviderInteractionId,
2429 pub source: ProviderSource,
2430 pub provider_request_id: Option<String>,
2431 pub interaction_kind: ProviderInteractionKind,
2432 pub tool_name: String,
2433 pub prompt: String,
2434 pub agent_id: Option<String>,
2435 pub resume_lead_activity: Option<ProviderActivity>,
2436 pub status: ProviderInteractionStatus,
2437}
2438
2439#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2440#[serde(rename_all = "kebab-case")]
2441pub enum ProviderActivity {
2442 #[default]
2443 Idle,
2444 Working,
2445 WaitingForInput,
2446 Blocked,
2447}
2448
2449#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2450pub struct ActiveProviderTool {
2451 pub id: String,
2452 pub name: String,
2453 pub input_json: String,
2454}
2455
2456#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
2457pub struct ProviderSnapshot {
2458 pub sequence: u64,
2459 pub session: Option<ProviderSessionIdentity>,
2460 pub model: Option<String>,
2461 pub tools: Vec<String>,
2462 pub completed_turns: u64,
2463 pub usage: TokenUsage,
2464 pub lead_activity: ProviderActivity,
2465 pub activity: ProviderActivity,
2466 pub current_prompt: Option<String>,
2467 pub active_tools: Vec<ActiveProviderTool>,
2468 pub interactions: Vec<ProviderInteraction>,
2469 pub subagents: Vec<ProviderSubagent>,
2470 pub sources: Vec<ProviderSourceCursor>,
2471 pub last_event: Option<ProviderEvent>,
2472 pub gap_count: u64,
2473 pub stale: bool,
2474}
2475
2476#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2477pub struct ControlSnapshot {
2478 pub revision: u64,
2479 pub health: ControlHealth,
2480 pub sessions: Vec<SessionSnapshot>,
2481}
2482
2483#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2484pub struct ControlHealth {
2485 pub operation_id_exhausted: bool,
2486 pub event_sequence_exhausted: bool,
2487 pub revision_exhausted: bool,
2488 pub provider_sequence_exhausted_sessions: u32,
2489 pub retained_instance_identities: u32,
2490 pub retained_instance_identity_capacity: u32,
2491}
2492
2493impl Default for ControlHealth {
2494 fn default() -> Self {
2495 Self {
2496 operation_id_exhausted: false,
2497 event_sequence_exhausted: false,
2498 revision_exhausted: false,
2499 provider_sequence_exhausted_sessions: 0,
2500 retained_instance_identities: 0,
2501 retained_instance_identity_capacity: CONTROL_INSTANCE_IDENTITIES_CAPACITY,
2502 }
2503 }
2504}
2505
2506#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2507pub struct ControlEvent {
2508 pub sequence: u64,
2509 pub command_id: Option<CommandId>,
2510 pub instance_id: AgentInstanceId,
2511 pub generation: SessionGeneration,
2512 pub event: ControlEventKind,
2513}
2514
2515#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
2516#[serde(tag = "kind", rename_all = "kebab-case")]
2517pub enum ControlEventKind {
2518 CommandRejected {
2519 message: String,
2520 },
2521 Registered,
2522 StartRequested {
2523 operation_id: OperationId,
2524 },
2525 Running {
2526 process_id: Option<u32>,
2527 },
2528 StopRequested {
2529 operation_id: OperationId,
2530 force: bool,
2531 },
2532 InputRequested {
2533 operation_id: OperationId,
2534 input_kind: PreparedInputKind,
2535 },
2536 InputCompleted {
2537 input_kind: PreparedInputKind,
2538 },
2539 InputFailed {
2540 input_kind: PreparedInputKind,
2541 message: String,
2542 },
2543 ResizeRequested {
2544 operation_id: OperationId,
2545 size: TerminalSize,
2546 },
2547 Resized {
2548 size: TerminalSize,
2549 },
2550 ResizeFailed {
2551 message: String,
2552 },
2553 ForegroundRefreshRequested {
2554 operation_id: OperationId,
2555 },
2556 ForegroundObserved {
2557 process: ForegroundProcess,
2558 },
2559 ForegroundFailed {
2560 message: String,
2561 },
2562 CapabilityProbeRequested {
2563 operation_id: OperationId,
2564 },
2565 CapabilitiesProbed {
2566 count: usize,
2567 },
2568 CapabilityProbeFailed {
2569 failure: CapabilityProbeFailure,
2570 },
2571 HistoryRequested {
2572 operation_id: OperationId,
2573 operation: HistoryOperation,
2574 },
2575 HistoryDiscovered {
2576 count: usize,
2577 },
2578 HistoryLoaded {
2579 session_id: String,
2580 },
2581 HistoryFailed {
2582 message: String,
2583 },
2584 ResumeRequested {
2585 operation_id: OperationId,
2586 target: ResumeTarget,
2587 },
2588 ResumeAuthorized {
2589 session: ResumeSessionSummary,
2590 },
2591 Resumed {
2592 session: ResumeSessionSummary,
2593 process_id: Option<u32>,
2594 },
2595 ResumeDenied {
2596 reason: String,
2597 },
2598 ResumeFailed {
2599 message: String,
2600 },
2601 TerminalStale {
2602 message: String,
2603 },
2604 ProviderEvent {
2605 sequence: u64,
2606 source: ProviderSource,
2607 source_sequence: u64,
2608 event: ProviderEvent,
2609 },
2610 ProviderGap {
2611 sequence: u64,
2612 source: ProviderSource,
2613 source_sequence: u64,
2614 missed: u64,
2615 },
2616 InteractionRequested {
2617 interaction: ProviderInteraction,
2618 },
2619 InteractionResolutionRequested {
2620 operation_id: OperationId,
2621 interaction_id: ProviderInteractionId,
2622 response_kind: ProviderInteractionResponseKind,
2623 },
2624 InteractionResolutionFailed {
2625 interaction_id: ProviderInteractionId,
2626 message: String,
2627 },
2628 InteractionResolved {
2629 interaction_id: ProviderInteractionId,
2630 outcome: ProviderInteractionOutcome,
2631 },
2632 SessionModeSetRequested {
2633 operation_id: OperationId,
2634 mode_id: String,
2635 },
2636 SessionModeSet {
2637 mode_id: String,
2638 },
2639 SessionModeSetFailed {
2640 message: String,
2641 },
2642 SessionConfigOptionSetRequested {
2643 operation_id: OperationId,
2644 option_id: String,
2645 },
2646 SessionConfigOptionSet {
2647 option_id: String,
2648 },
2649 SessionConfigOptionSetFailed {
2650 message: String,
2651 },
2652 SessionModelSetRequested {
2653 operation_id: OperationId,
2654 model_id: String,
2655 },
2656 SessionModelSet {
2657 model_id: String,
2658 },
2659 SessionModelSetFailed {
2660 message: String,
2661 },
2662 Exited {
2663 exit_code: Option<i32>,
2664 forced: bool,
2665 },
2666 Failed {
2667 message: String,
2668 },
2669 Removed,
2670 ObservationIgnored {
2671 reason: ObservationIgnoredReason,
2672 },
2673}
2674
2675#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
2676#[serde(rename_all = "kebab-case")]
2677pub enum ObservationIgnoredReason {
2678 UnknownInstance,
2679 StaleGeneration,
2680 GenerationExhausted,
2681 MissingOperation,
2682 OperationMismatch,
2683 InvalidState,
2684 StaleTerminalFrame,
2685 StaleProviderEvent,
2686 InvalidForegroundObservation,
2687 InvalidCapabilityObservation,
2688 InvalidHistoryObservation,
2689 InvalidResumeObservation,
2690 InvalidInteractionObservation,
2691 ProviderRuntimePolicyDenied {
2692 capability: ProviderRuntimeCapability,
2693 },
2694}
2695
2696#[derive(Clone, Debug, Eq, Error, PartialEq, Serialize, Deserialize)]
2697#[serde(tag = "kind", rename_all = "kebab-case")]
2698pub enum ControlError {
2699 #[error("agent instance {instance_id:?} is already registered")]
2700 DuplicateInstance { instance_id: AgentInstanceId },
2701 #[error(
2702 "cannot register agent instance {instance_id:?}: live session capacity {max} is exhausted"
2703 )]
2704 SessionCapacityExceeded {
2705 instance_id: AgentInstanceId,
2706 max: usize,
2707 },
2708 #[error(
2709 "cannot register agent instance {instance_id:?}: retained identity capacity {max} is exhausted"
2710 )]
2711 InstanceIdentityCapacityExceeded {
2712 instance_id: AgentInstanceId,
2713 max: usize,
2714 },
2715 #[error("agent instance {instance_id:?} is not registered")]
2716 UnknownInstance { instance_id: AgentInstanceId },
2717 #[error("agent instance {instance_id:?} exhausted session generation {generation:?}")]
2718 GenerationExhausted {
2719 instance_id: AgentInstanceId,
2720 generation: SessionGeneration,
2721 },
2722 #[error("control operation identifiers are exhausted")]
2723 OperationIdExhausted,
2724 #[error("control event sequences are exhausted")]
2725 EventSequenceExhausted,
2726 #[error("control snapshot revisions are exhausted")]
2727 RevisionExhausted,
2728 #[error(
2729 "agent instance {instance_id:?} generation {generation:?} exhausted provider event sequences"
2730 )]
2731 ProviderSequenceExhausted {
2732 instance_id: AgentInstanceId,
2733 generation: SessionGeneration,
2734 },
2735 #[error(
2736 "agent instance {instance_id:?} generation {generation:?} exhausted source sequence for {provider_source:?}"
2737 )]
2738 ProviderSourceSequenceExhausted {
2739 instance_id: AgentInstanceId,
2740 generation: SessionGeneration,
2741 provider_source: ProviderSource,
2742 },
2743 #[error("agent instance {instance_id:?} already has pending operation {operation_id:?}")]
2744 OperationPending {
2745 instance_id: AgentInstanceId,
2746 operation_id: OperationId,
2747 },
2748 #[error("agent input was rejected: {error}")]
2749 InputRejected { error: InputPrepareError },
2750 #[error("provider runtime policy is invalid: {error}")]
2751 InvalidProviderRuntimePolicy { error: ProviderRuntimePolicyError },
2752 #[error("provider runtime capability {capability:?} is not admitted")]
2753 ProviderRuntimePolicyDenied {
2754 capability: ProviderRuntimeCapability,
2755 },
2756 #[error("terminal size is outside the supported bounded range")]
2757 InvalidTerminalSize,
2758 #[error("working directory is empty, too large, or contains a NUL byte")]
2759 InvalidWorkingDirectory,
2760 #[error("pipe transport requires a non-empty initial prompt")]
2761 MissingInitialPrompt,
2762 #[error("session options are invalid: {message}")]
2763 InvalidSessionOptions { message: String },
2764 #[error("capability probe request is invalid: {message}")]
2765 InvalidCapabilityProbeRequest { message: String },
2766 #[error("capability probe operation {operation_id:?} is already pending")]
2767 CapabilityProbeOperationPending { operation_id: OperationId },
2768 #[error("capability probe already settled for this agent instance")]
2769 CapabilityProbeSettled,
2770 #[error("history request is invalid: {message}")]
2771 InvalidHistoryRequest { message: String },
2772 #[error("history operation {operation_id:?} is already pending")]
2773 HistoryOperationPending { operation_id: OperationId },
2774 #[error("history candidate is not present in the current discovery snapshot")]
2775 UnknownHistoryCandidate,
2776 #[error("resume request is invalid: {message}")]
2777 InvalidResumeRequest { message: String },
2778 #[error("resume requires a canonical provider session identity")]
2779 MissingProviderSession,
2780 #[error("resume history candidate must be the currently loaded candidate")]
2781 HistoryCandidateNotLoaded,
2782 #[error("transport {transport:?} does not support {action}")]
2783 UnsupportedTransportOperation {
2784 transport: TransportKind,
2785 action: String,
2786 },
2787 #[error("agent instance {instance_id:?} cannot {action} while in state {status:?}")]
2788 InvalidTransition {
2789 instance_id: AgentInstanceId,
2790 action: String,
2791 status: SessionStatus,
2792 },
2793 #[error("provider ingress generation {actual:?} is stale; expected {expected:?}")]
2794 StaleProviderGeneration {
2795 expected: SessionGeneration,
2796 actual: SessionGeneration,
2797 },
2798 #[error("provider ingress source sequence must be greater than the current sequence")]
2799 StaleProviderSequence,
2800 #[error("provider ingress batch must contain between 1 and {max} events")]
2801 InvalidProviderBatch { max: usize },
2802 #[error("invalid provider ingress event: {message}")]
2803 InvalidProviderEvent { message: String },
2804 #[error("provider interaction generation {actual:?} is stale; expected {expected:?}")]
2805 StaleProviderInteractionGeneration {
2806 expected: SessionGeneration,
2807 actual: SessionGeneration,
2808 },
2809 #[error("provider interaction {interaction_id:?} is unknown")]
2810 UnknownProviderInteraction {
2811 interaction_id: ProviderInteractionId,
2812 },
2813 #[error("provider interaction {interaction_id:?} is not pending")]
2814 ProviderInteractionNotPending {
2815 interaction_id: ProviderInteractionId,
2816 },
2817 #[error("provider interaction response is invalid: {message}")]
2818 InvalidProviderInteractionResponse { message: String },
2819 #[error("session mode request is invalid: {message}")]
2820 InvalidSessionModeRequest { message: String },
2821 #[error("session config option request is invalid: {message}")]
2822 InvalidSessionConfigOptionRequest { message: String },
2823 #[error("session model request is invalid: {message}")]
2824 InvalidSessionModelRequest { message: String },
2825}
2826
2827impl Default for ControlSnapshot {
2828 fn default() -> Self {
2829 Self {
2830 revision: 0,
2831 health: ControlHealth::default(),
2832 sessions: Vec::new(),
2833 }
2834 }
2835}
2836
2837#[cfg(test)]
2838mod tests {
2839
2840 #[test]
2851 fn only_a_settled_session_admits_a_remove() {
2852 use crate::SessionStatus;
2853
2854 for live in [
2855 SessionStatus::Starting,
2856 SessionStatus::Running,
2857 SessionStatus::Stopping,
2858 ] {
2859 assert!(
2860 !live.allows_remove(),
2861 "{live:?} still owns a process; a Remove for it is rejected",
2862 );
2863 }
2864 for settled in [
2865 SessionStatus::Registered,
2866 SessionStatus::Exited { exit_code: Some(0) },
2867 SessionStatus::Exited { exit_code: None },
2868 SessionStatus::Failed { message: "boom".to_owned() },
2869 ] {
2870 assert!(
2871 settled.allows_remove(),
2872 "{settled:?} is settled; a Remove for it must be accepted",
2873 );
2874 }
2875 }
2876
2877 use crate::AgentId;
2878 use super::{
2879 AgentInstanceId, CapabilitySnapshot, ContextWindowUsage, ForegroundProcess,
2880 ForegroundProcessKind, ForegroundSnapshot, HistorySnapshot, HostDecisionAuthority,
2881 HostRequestDecision, HostRequestOutcome, OperatorGateInput,
2882 OperatorGateKind, OperatorGateOption, OperatorGateOptionSemantics, OperatorGateState,
2883 OperatorGateSubject, ProviderAvailableCommand, ProviderConfigChoice, ProviderConfigOption,
2884 ProviderConfigOptionKind, ProviderEvent,
2885 ProviderEventValidationError,
2886 ProviderInteractionKind, ProviderInteractionOption, ProviderInteractionOutcome,
2887 ProviderInteractionResponse,
2888 ProviderInteractionResponseError, ProviderModeInfo, ProviderPlanPriority,
2889 ProviderPlanStatus,
2890 ProviderPlanStep, ProviderRuntimeCapability, ProviderRuntimePolicy,
2891 ProviderRuntimePolicyError,
2892 ProviderSessionIdentity, ProviderSessionKey, ProviderSnapshot, PtyScreenState,
2893 ResumeSnapshot, SessionGeneration, SessionSnapshot, SessionStatus, TerminalFrame,
2894 TerminalMouseProtocolEncoding, TransportKind,
2895 FOREGROUND_PROCESS_NAME_MAX_BYTES, OPERATOR_GATE_OPTIONS_MAX,
2896 OPERATOR_GATE_OPTION_TEXT_MAX_BYTES, OPERATOR_GATE_PATH_MAX_BYTES,
2897 PROVIDER_AVAILABLE_COMMANDS_MAX,
2898 PROVIDER_EVENT_ID_MAX_BYTES, PROVIDER_EVENT_TEXT_MAX_BYTES,
2899 PROVIDER_INTERACTION_OPTIONS_MAX, PROVIDER_INTERACTION_RESPONSE_MAX_BYTES,
2900 PROVIDER_MODE_CATALOG_MAX,
2901 PROVIDER_PLAN_STEPS_MAX,
2902 PTY_SCREEN_GATE_NAME_MAX_BYTES,
2903 };
2904
2905 fn sample_gate() -> OperatorGateState {
2912 OperatorGateState::new(OperatorGateKind::HookTrust)
2913 .with_subject(OperatorGateSubject::Hooks { count: Some(6) })
2914 .with_options(
2915 OperatorGateInput::NumberedList,
2916 vec![
2917 OperatorGateOption {
2918 text: "Trust all and continue".to_owned(),
2919 semantics: OperatorGateOptionSemantics::Accept,
2920 selected: false,
2921 },
2922 OperatorGateOption {
2923 text: "Continue without trusting".to_owned(),
2924 semantics: OperatorGateOptionSemantics::Decline,
2925 selected: true,
2926 },
2927 ],
2928 )
2929 }
2930
2931 #[test]
2932 fn context_window_usage_ingress_requires_exact_bounded_segments() {
2933 let event = |usage| ProviderEvent::ContextWindowUsage { usage };
2934 let valid = ContextWindowUsage {
2935 uncached_input_tokens: 70,
2936 cache_read_tokens: 20,
2937 cache_write_tokens: 0,
2938 output_tokens: 10,
2939 unattributed_tokens: 5,
2940 used_tokens: 105,
2941 capacity_tokens: 100,
2942 };
2943 assert_eq!(event(valid).validate_ingress(), Ok(()));
2944 assert_eq!(
2945 event(ContextWindowUsage { capacity_tokens: 0, ..valid }).validate_ingress(),
2946 Err(ProviderEventValidationError::ZeroContextWindowCapacity)
2947 );
2948 assert_eq!(
2949 event(ContextWindowUsage { used_tokens: 104, ..valid }).validate_ingress(),
2950 Err(ProviderEventValidationError::ContextWindowSegmentsMismatch {
2951 segment_sum: 105,
2952 used_tokens: 104,
2953 })
2954 );
2955 assert_eq!(
2956 event(ContextWindowUsage {
2957 uncached_input_tokens: u64::MAX,
2958 cache_read_tokens: 1,
2959 cache_write_tokens: 0,
2960 output_tokens: 0,
2961 unattributed_tokens: 0,
2962 used_tokens: u64::MAX,
2963 capacity_tokens: 1,
2964 })
2965 .validate_ingress(),
2966 Err(ProviderEventValidationError::ContextWindowSegmentsOverflow)
2967 );
2968 }
2969
2970 #[test]
2971 fn provider_runtime_policy_enforces_semantic_invariants() {
2972 let raw = ProviderRuntimePolicy::raw_pty();
2973 assert!(raw.admits(ProviderRuntimeCapability::RawPtyLifecycle));
2974 assert!(!raw.admits(ProviderRuntimeCapability::SemanticReadiness));
2975 assert!(!raw.admits(ProviderRuntimeCapability::HookSemantics));
2976 assert_eq!(raw.validate(), Ok(()));
2977
2978 let none = ProviderRuntimePolicy::none();
2979 assert!(!none.admits(ProviderRuntimeCapability::RawPtyLifecycle));
2980 assert!(!none.admits(ProviderRuntimeCapability::SemanticReadiness));
2981 assert_eq!(none.validate(), Ok(()));
2982
2983 assert_eq!(
2991 ProviderRuntimePolicy::new(false, true, true, true, false, false),
2992 Ok(ProviderRuntimePolicy {
2993 raw_pty_lifecycle: false,
2994 semantic_readiness: true,
2995 structured_prompt: true,
2996 provider_session_identity: true,
2997 semantic_resume: false,
2998 hook_semantics: false,
2999 }),
3000 );
3001 assert_eq!(
3005 ProviderRuntimePolicy::new(false, false, true, false, false, false),
3006 Err(ProviderRuntimePolicyError::StructuredPromptRequiresReadiness),
3007 );
3008 assert_eq!(
3009 ProviderRuntimePolicy::new(true, false, true, false, false, false),
3010 Err(ProviderRuntimePolicyError::StructuredPromptRequiresReadiness),
3011 );
3012 assert_eq!(
3013 ProviderRuntimePolicy::new(true, true, true, false, true, false),
3014 Err(ProviderRuntimePolicyError::ResumeRequiresSessionIdentity),
3015 );
3016 assert_eq!(
3017 ProviderRuntimePolicy::new(false, false, false, false, false, true),
3018 Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty),
3019 );
3020 assert_eq!(
3026 ProviderRuntimePolicy::new(false, true, true, true, true, false),
3027 Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty),
3028 );
3029 assert_eq!(
3030 ProviderRuntimePolicy::new(false, true, true, true, false, true),
3031 Err(ProviderRuntimePolicyError::SemanticCapabilityRequiresRawPty),
3032 );
3033 assert!(ProviderRuntimePolicy::new(true, true, true, true, true, true).is_ok());
3034 }
3035
3036 #[test]
3041 fn hook_semantics_and_semantic_readiness_are_independently_grantable() {
3042 let hook_only = ProviderRuntimePolicy::new(true, false, false, false, false, true)
3043 .expect("hook semantics alone requires only the raw PTY lifecycle");
3044 assert!(hook_only.admits(ProviderRuntimeCapability::HookSemantics));
3045 assert!(!hook_only.admits(ProviderRuntimeCapability::SemanticReadiness));
3046
3047 let semantic_only = ProviderRuntimePolicy::new(true, true, false, false, false, false)
3048 .expect("semantic readiness alone requires only the raw PTY lifecycle");
3049 assert!(semantic_only.admits(ProviderRuntimeCapability::SemanticReadiness));
3050 assert!(!semantic_only.admits(ProviderRuntimeCapability::HookSemantics));
3051 }
3052
3053 #[test]
3054 fn provider_runtime_policy_serde_requires_every_field_and_revalidates() {
3055 let raw = ProviderRuntimePolicy::raw_pty();
3056 let encoded = serde_json::to_string(&raw).unwrap();
3057 assert_eq!(
3058 encoded,
3059 r#"{"raw_pty_lifecycle":true,"semantic_readiness":false,"structured_prompt":false,"provider_session_identity":false,"semantic_resume":false,"hook_semantics":false}"#,
3060 );
3061 assert_eq!(
3062 serde_json::from_str::<ProviderRuntimePolicy>(&encoded).unwrap(),
3063 raw,
3064 );
3065 assert!(serde_json::from_str::<ProviderRuntimePolicy>(
3066 r#"{"raw_pty_lifecycle":true,"semantic_readiness":false,"structured_prompt":false,"provider_session_identity":false,"semantic_resume":false}"#,
3067 )
3068 .is_err());
3069 assert!(serde_json::from_str::<ProviderRuntimePolicy>(
3070 r#"{"raw_pty_lifecycle":true,"semantic_readiness":false,"structured_prompt":true,"provider_session_identity":false,"semantic_resume":false,"hook_semantics":false}"#,
3071 )
3072 .is_err());
3073 assert!(serde_json::from_str::<super::ControlCommand>(
3074 r#"{"kind":"start","instance_id":1,"request":{"working_directory":"C:\\repo","terminal_size":{"rows":24,"columns":80}}}"#,
3075 )
3076 .is_err());
3077 assert!(serde_json::from_str::<super::ControlEffect>(
3078 r#"{"kind":"spawn","agent_id":"claude","transport":"pty","request":{"working_directory":"C:\\repo","terminal_size":{"rows":24,"columns":80}}}"#,
3079 )
3080 .is_err());
3081 }
3082
3083 #[test]
3084 fn foreground_process_is_bounded_and_agent_bound() {
3085 let claude = AgentId::new("claude").unwrap();
3086 let process = ForegroundProcess {
3087 root_process_id: 1,
3088 process_id: 2,
3089 process_name: "claude".to_owned(),
3090 kind: ForegroundProcessKind::Agent {
3091 agent_id: claude.clone(),
3092 },
3093 };
3094 assert!(process.is_valid_for(&claude));
3095 assert!(!process.is_valid_for(&AgentId::new("codex").unwrap()));
3096 assert!(!ForegroundProcess {
3097 process_name: "x".repeat(FOREGROUND_PROCESS_NAME_MAX_BYTES + 1),
3098 ..process
3099 }
3100 .is_valid_for(&claude));
3101 }
3102
3103 #[test]
3104 fn terminal_frame_metadata_defaults_for_older_serialized_frames() {
3105 let frame: TerminalFrame = serde_json::from_str(
3106 r#"{"sequence":1,"size":{"rows":24,"columns":80},"cursor_row":0,"cursor_column":0,"contents":"ready","formatted":[114]}"#,
3107 )
3108 .expect("legacy terminal frame");
3109
3110 assert!(frame.scrollback_formatted.is_empty());
3111 assert!(!frame.alternate_screen);
3112 assert!(!frame.mouse_protocol_enabled);
3113 assert_eq!(frame.mouse_protocol_encoding, TerminalMouseProtocolEncoding::Default);
3114 assert_eq!(frame.produced_at_unix_ms, 0);
3115 }
3116
3117 #[test]
3118 fn a_terminal_frame_that_omits_screen_state_decodes_as_unknown_not_ready() {
3119 let frame: TerminalFrame = serde_json::from_str(
3120 r#"{"sequence":1,"size":{"rows":24,"columns":80},"cursor_row":0,"cursor_column":0,"contents":"ready","formatted":[114]}"#,
3121 )
3122 .expect("legacy terminal frame");
3123
3124 assert_eq!(frame.screen_state, PtyScreenState::Unknown);
3125 assert_ne!(frame.screen_state, PtyScreenState::Ready);
3129 }
3130
3131 #[test]
3132 fn a_session_snapshot_that_omits_screen_state_decodes_as_unknown_not_ready() {
3133 let populated = SessionSnapshot {
3134 instance_id: AgentInstanceId(1),
3135 agent_id: AgentId::new("claude").unwrap(),
3136 transport: TransportKind::Pty,
3137 generation: SessionGeneration(1),
3138 status: SessionStatus::Running,
3139 pending_operation: None,
3140 pending_input: None,
3141 process_id: None,
3142 terminal_size: None,
3143 terminal_frame: None,
3144 terminal_stale: None,
3145 session_options: None,
3146 capabilities: CapabilitySnapshot::default(),
3147 history: HistorySnapshot::default(),
3148 resume: ResumeSnapshot::default(),
3149 foreground: ForegroundSnapshot::default(),
3150 provider: ProviderSnapshot::default(),
3151 screen_state: PtyScreenState::Ready,
3152 };
3153 let mut wire = serde_json::to_value(&populated).unwrap();
3154 wire.as_object_mut().unwrap().remove("screen_state");
3155 let decoded: SessionSnapshot = serde_json::from_value(wire).unwrap();
3156
3157 assert_eq!(decoded.screen_state, PtyScreenState::Unknown);
3158 assert_ne!(decoded.screen_state, PtyScreenState::Ready);
3161 }
3162
3163 #[test]
3164 fn every_pty_screen_state_variant_round_trips_through_serde() {
3165 let variants = [
3166 PtyScreenState::Unknown,
3167 PtyScreenState::NotAgent {
3168 observed_process: "npm".to_owned(),
3169 },
3170 PtyScreenState::OperatorGate { gate: sample_gate() },
3171 PtyScreenState::Failing {
3172 reason: "startup-crash".to_owned(),
3173 },
3174 PtyScreenState::Ready,
3175 ];
3176 for variant in variants {
3177 let json = serde_json::to_string(&variant).unwrap();
3178 assert_eq!(
3179 serde_json::from_str::<PtyScreenState>(&json).unwrap(),
3180 variant,
3181 );
3182 }
3183 }
3184
3185 #[test]
3192 fn admits_blind_write_refuses_a_finding_and_admits_the_absence_of_one() {
3193 assert!(PtyScreenState::Ready.admits_blind_write());
3194 assert!(PtyScreenState::Unknown.admits_blind_write());
3195 assert!(!PtyScreenState::NotAgent {
3196 observed_process: "npm".to_owned(),
3197 }
3198 .admits_blind_write());
3199 assert!(!PtyScreenState::OperatorGate { gate: sample_gate() }.admits_blind_write());
3200 assert!(!PtyScreenState::Failing {
3201 reason: "startup-crash".to_owned(),
3202 }
3203 .admits_blind_write());
3204 }
3205
3206 #[test]
3207 fn pty_screen_state_is_valid_rejects_oversized_empty_and_control_carrying_fields() {
3208 assert!(PtyScreenState::NotAgent {
3209 observed_process: "npm install".to_owned(),
3210 }
3211 .is_valid());
3212 assert!(!PtyScreenState::NotAgent {
3213 observed_process: "x".repeat(FOREGROUND_PROCESS_NAME_MAX_BYTES + 1),
3214 }
3215 .is_valid());
3216 assert!(!PtyScreenState::NotAgent {
3217 observed_process: String::new(),
3218 }
3219 .is_valid());
3220 assert!(!PtyScreenState::NotAgent {
3221 observed_process: "bad\u{0000}process".to_owned(),
3222 }
3223 .is_valid());
3224
3225 assert!(PtyScreenState::OperatorGate { gate: sample_gate() }.is_valid());
3226 assert!(!PtyScreenState::OperatorGate {
3227 gate: sample_gate().with_subject(OperatorGateSubject::Directory {
3228 path: Some("x".repeat(OPERATOR_GATE_PATH_MAX_BYTES + 1)),
3229 }),
3230 }
3231 .is_valid());
3232 assert!(!PtyScreenState::OperatorGate {
3233 gate: sample_gate().with_subject(OperatorGateSubject::Directory {
3234 path: Some(String::new()),
3235 }),
3236 }
3237 .is_valid());
3238 assert!(!PtyScreenState::OperatorGate {
3239 gate: sample_gate().with_subject(OperatorGateSubject::Directory {
3240 path: Some("bad\u{0000}path".to_owned()),
3241 }),
3242 }
3243 .is_valid());
3244 assert!(!PtyScreenState::OperatorGate {
3245 gate: sample_gate().with_options(
3246 OperatorGateInput::NumberedList,
3247 vec![OperatorGateOption {
3248 text: "x".repeat(OPERATOR_GATE_OPTION_TEXT_MAX_BYTES + 1),
3249 semantics: OperatorGateOptionSemantics::Accept,
3250 selected: false,
3251 }],
3252 ),
3253 }
3254 .is_valid());
3255 assert!(!PtyScreenState::OperatorGate {
3256 gate: sample_gate().with_options(
3257 OperatorGateInput::NumberedList,
3258 vec![OperatorGateOption {
3259 text: String::new(),
3260 semantics: OperatorGateOptionSemantics::Accept,
3261 selected: false,
3262 }],
3263 ),
3264 }
3265 .is_valid());
3266 assert!(!PtyScreenState::OperatorGate {
3267 gate: sample_gate().with_options(
3268 OperatorGateInput::NumberedList,
3269 vec![OperatorGateOption {
3270 text: "bad\u{0000}option".to_owned(),
3271 semantics: OperatorGateOptionSemantics::Accept,
3272 selected: false,
3273 }],
3274 ),
3275 }
3276 .is_valid());
3277 assert!(!PtyScreenState::OperatorGate {
3278 gate: sample_gate().with_options(
3279 OperatorGateInput::NumberedList,
3280 (0..=OPERATOR_GATE_OPTIONS_MAX)
3281 .map(|index| OperatorGateOption {
3282 text: format!("option {index}"),
3283 semantics: OperatorGateOptionSemantics::Unknown,
3284 selected: false,
3285 })
3286 .collect(),
3287 ),
3288 }
3289 .is_valid());
3290
3291 assert!(PtyScreenState::Failing {
3292 reason: "startup-crash".to_owned(),
3293 }
3294 .is_valid());
3295 assert!(!PtyScreenState::Failing {
3296 reason: "x".repeat(PTY_SCREEN_GATE_NAME_MAX_BYTES + 1),
3297 }
3298 .is_valid());
3299 assert!(!PtyScreenState::Failing {
3300 reason: String::new(),
3301 }
3302 .is_valid());
3303 assert!(!PtyScreenState::Failing {
3304 reason: "bad\u{0000}reason".to_owned(),
3305 }
3306 .is_valid());
3307
3308 assert!(PtyScreenState::Unknown.is_valid());
3309 assert!(PtyScreenState::Ready.is_valid());
3310 }
3311
3312 #[test]
3313 fn provider_interactions_require_bounded_identity_and_question_payloads() {
3314 let question = ProviderEvent::InteractionRequested {
3315 request_id: Some("question-1".to_owned()),
3316 interaction_kind: ProviderInteractionKind::Question,
3317 tool_name: "AskUserQuestion".to_owned(),
3318 title: Some("Continue?".to_owned()),
3319 prompt: "{\"question\":\"Continue?\"}".to_owned(),
3320 options: vec![ProviderInteractionOption {
3321 option_id: "yes".to_owned(),
3322 name: "Yes".to_owned(),
3323 kind: "allow_once".to_owned(),
3324 }],
3325 agent_id: Some("child-1".to_owned()),
3326 };
3327 assert_eq!(question.validate_ingress(), Ok(()));
3328
3329 assert!(matches!(
3330 ProviderEvent::InteractionRequested {
3331 request_id: Some("bad\nrequest".to_owned()),
3332 interaction_kind: ProviderInteractionKind::Approval,
3333 tool_name: "shell".to_owned(),
3334 title: None,
3335 prompt: String::new(),
3336 options: Vec::new(),
3337 agent_id: None,
3338 }
3339 .validate_ingress(),
3340 Err(ProviderEventValidationError::InvalidField {
3341 field: "interaction request id",
3342 ..
3343 })
3344 ));
3345 assert!(matches!(
3346 ProviderEvent::InteractionRequested {
3347 request_id: None,
3348 interaction_kind: ProviderInteractionKind::Question,
3349 tool_name: "AskUserQuestion".to_owned(),
3350 title: None,
3351 prompt: String::new(),
3352 options: Vec::new(),
3353 agent_id: None,
3354 }
3355 .validate_ingress(),
3356 Err(ProviderEventValidationError::Empty {
3357 field: "interaction prompt"
3358 })
3359 ));
3360 assert!(matches!(
3361 ProviderEvent::InteractionRequested {
3362 request_id: None,
3363 interaction_kind: ProviderInteractionKind::Approval,
3364 tool_name: "shell".to_owned(),
3365 title: None,
3366 prompt: String::new(),
3367 options: (0..PROVIDER_INTERACTION_OPTIONS_MAX + 1)
3368 .map(|index| ProviderInteractionOption {
3369 option_id: format!("option-{index}"),
3370 name: format!("Option {index}"),
3371 kind: "allow_once".to_owned(),
3372 })
3373 .collect(),
3374 agent_id: None,
3375 }
3376 .validate_ingress(),
3377 Err(ProviderEventValidationError::TooManyInteractionOptions {
3378 max: PROVIDER_INTERACTION_OPTIONS_MAX,
3379 ..
3380 })
3381 ));
3382
3383 for outcome in [
3384 ProviderInteractionOutcome::Approved,
3385 ProviderInteractionOutcome::Denied,
3386 ] {
3387 assert_eq!(
3388 ProviderEvent::InteractionResolved {
3389 request_id: "approval-1".to_owned(),
3390 outcome,
3391 }
3392 .validate_ingress(),
3393 Ok(())
3394 );
3395 }
3396 assert!(matches!(
3397 ProviderEvent::InteractionResolved {
3398 request_id: "bad\nrequest".to_owned(),
3399 outcome: ProviderInteractionOutcome::Approved,
3400 }
3401 .validate_ingress(),
3402 Err(ProviderEventValidationError::InvalidField {
3403 field: "interaction request id",
3404 ..
3405 })
3406 ));
3407 assert_eq!(
3408 ProviderEvent::InteractionResolved {
3409 request_id: "approval-1".to_owned(),
3410 outcome: ProviderInteractionOutcome::TurnEnded,
3411 }
3412 .validate_ingress(),
3413 Err(
3414 ProviderEventValidationError::InvalidInteractionResolutionOutcome {
3415 outcome: ProviderInteractionOutcome::TurnEnded,
3416 }
3417 )
3418 );
3419 }
3420
3421 #[test]
3422 fn provider_interaction_responses_are_kind_checked_and_bounded() {
3423 assert_eq!(
3424 ProviderInteractionResponse::ApproveOnce
3425 .validate_for(ProviderInteractionKind::Approval),
3426 Ok(())
3427 );
3428 assert_eq!(
3429 ProviderInteractionResponse::Deny.validate_for(ProviderInteractionKind::Question),
3430 Ok(())
3431 );
3432 assert_eq!(
3433 ProviderInteractionResponse::Answer {
3434 text: "continue".to_owned(),
3435 }
3436 .validate_for(ProviderInteractionKind::Question),
3437 Ok(())
3438 );
3439 assert_eq!(
3440 ProviderInteractionResponse::ApproveOnce
3441 .validate_for(ProviderInteractionKind::Question),
3442 Err(ProviderInteractionResponseError::ApprovalRequiresApproval)
3443 );
3444 assert_eq!(
3445 ProviderInteractionResponse::Answer {
3446 text: String::new(),
3447 }
3448 .validate_for(ProviderInteractionKind::Question),
3449 Err(ProviderInteractionResponseError::EmptyAnswer)
3450 );
3451 assert_eq!(
3452 ProviderInteractionResponse::Answer {
3453 text: "x".repeat(PROVIDER_INTERACTION_RESPONSE_MAX_BYTES + 1),
3454 }
3455 .validate_for(ProviderInteractionKind::Question),
3456 Err(ProviderInteractionResponseError::InvalidAnswer {
3457 max: PROVIDER_INTERACTION_RESPONSE_MAX_BYTES,
3458 })
3459 );
3460 }
3461
3462 #[test]
3463 fn provider_ingress_allows_multiline_text_but_rejects_control_bytes() {
3464 ProviderEvent::Text {
3465 text: "first line\n\tsecond line".to_owned(),
3466 is_delta: false,
3467 }
3468 .validate_ingress()
3469 .unwrap();
3470
3471 assert!(matches!(
3472 ProviderEvent::Text {
3473 text: "unsafe\u{0000}text".to_owned(),
3474 is_delta: false,
3475 }
3476 .validate_ingress(),
3477 Err(ProviderEventValidationError::InvalidField { field: "text", .. })
3478 ));
3479 assert!(ProviderEvent::SessionStarted {
3480 session_id: "session\nother".to_owned(),
3481 model: "model".to_owned(),
3482 tools: Vec::new(),
3483 }
3484 .validate_ingress()
3485 .is_err());
3486 }
3487
3488 #[test]
3489 fn provider_session_identity_is_typed_and_bounded_at_ingress() {
3490 let valid = ProviderEvent::SessionIdentityObserved {
3491 identity: ProviderSessionIdentity {
3492 key: ProviderSessionKey::ConversationId,
3493 id: "conversation-1".to_owned(),
3494 transcript_path: Some("C:/sessions/conversation-1.jsonl".to_owned()),
3495 },
3496 };
3497 assert_eq!(valid.validate_ingress(), Ok(()));
3498
3499 for identity in [
3500 ProviderSessionIdentity {
3501 key: ProviderSessionKey::SessionId,
3502 id: "--help".to_owned(),
3503 transcript_path: None,
3504 },
3505 ProviderSessionIdentity {
3506 key: ProviderSessionKey::SessionId,
3507 id: "session-1".to_owned(),
3508 transcript_path: Some("bad\npath".to_owned()),
3509 },
3510 ] {
3511 assert!(ProviderEvent::SessionIdentityObserved { identity }
3512 .validate_ingress()
3513 .is_err());
3514 }
3515 }
3516
3517 #[test]
3522 fn host_request_observed_is_bounded_at_ingress() {
3523 let valid = ProviderEvent::HostRequestObserved {
3524 method: "session/request_permission".to_owned(),
3525 params_json: "{\"toolName\":\"bash\"}".to_owned(),
3526 decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Policy },
3527 outcome: HostRequestOutcome::Executed,
3528 reason: Some("session/request_permission denied by host policy".to_owned()),
3529 };
3530 assert_eq!(valid.validate_ingress(), Ok(()));
3531
3532 assert!(matches!(
3533 ProviderEvent::HostRequestObserved {
3534 method: String::new(),
3535 params_json: String::new(),
3536 decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Policy },
3537 outcome: HostRequestOutcome::Executed,
3538 reason: None,
3539 }
3540 .validate_ingress(),
3541 Err(ProviderEventValidationError::Empty { field: "host request method" })
3542 ));
3543
3544 let oversized_method = "m".repeat(PROVIDER_EVENT_ID_MAX_BYTES + 1);
3545 assert!(matches!(
3546 ProviderEvent::HostRequestObserved {
3547 method: oversized_method,
3548 params_json: String::new(),
3549 decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3550 outcome: HostRequestOutcome::Executed,
3551 reason: None,
3552 }
3553 .validate_ingress(),
3554 Err(ProviderEventValidationError::InvalidField {
3555 field: "host request method",
3556 ..
3557 })
3558 ));
3559
3560 let oversized_params = "p".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3561 assert!(matches!(
3562 ProviderEvent::HostRequestObserved {
3563 method: "fs/read_text_file".to_owned(),
3564 params_json: oversized_params,
3565 decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3566 outcome: HostRequestOutcome::Executed,
3567 reason: None,
3568 }
3569 .validate_ingress(),
3570 Err(ProviderEventValidationError::InvalidField {
3571 field: "host request params",
3572 ..
3573 })
3574 ));
3575
3576 let oversized_reason = "r".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3577 assert!(matches!(
3578 ProviderEvent::HostRequestObserved {
3579 method: "terminal/create".to_owned(),
3580 params_json: String::new(),
3581 decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Gate },
3582 outcome: HostRequestOutcome::Executed,
3583 reason: Some(oversized_reason),
3584 }
3585 .validate_ingress(),
3586 Err(ProviderEventValidationError::InvalidField {
3587 field: "host request reason",
3588 ..
3589 })
3590 ));
3591
3592 let oversized_error = "e".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3593 assert!(matches!(
3594 ProviderEvent::HostRequestObserved {
3595 method: "terminal/create".to_owned(),
3596 params_json: String::new(),
3597 decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3598 outcome: HostRequestOutcome::Failed { error: oversized_error },
3599 reason: None,
3600 }
3601 .validate_ingress(),
3602 Err(ProviderEventValidationError::InvalidField {
3603 field: "host request outcome error",
3604 ..
3605 })
3606 ));
3607 }
3608
3609 #[test]
3614 fn host_request_execution_failure_is_a_distinct_bounded_outcome_not_a_denial() {
3615 let spawn_error = "terminal/create spawn failed: os error 3";
3616 let granted_but_failed = ProviderEvent::HostRequestObserved {
3617 method: "terminal/create".to_owned(),
3618 params_json: String::new(),
3619 decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3620 outcome: HostRequestOutcome::Failed { error: spawn_error.to_owned() },
3621 reason: None,
3622 };
3623 assert_eq!(granted_but_failed.validate_ingress(), Ok(()));
3624 assert!(matches!(
3625 granted_but_failed,
3626 ProviderEvent::HostRequestObserved {
3627 decision: HostRequestDecision::Granted { .. },
3628 outcome: HostRequestOutcome::Failed { .. },
3629 ..
3630 }
3631 ));
3632 }
3633
3634 #[test]
3640 fn host_request_observed_outcome_defaults_to_executed_for_pre_k1d_json() {
3641 let pre_k1d = serde_json::json!({
3642 "kind": "host-request-observed",
3643 "method": "session/request_permission",
3644 "params_json": "{\"toolName\":\"bash\"}",
3645 "decision": { "kind": "granted", "by": "policy" },
3646 "reason": null,
3647 });
3648 let decoded: ProviderEvent = serde_json::from_value(pre_k1d)
3649 .expect("pre-K1d shape without outcome must still deserialize");
3650 assert!(matches!(
3651 decoded,
3652 ProviderEvent::HostRequestObserved {
3653 decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3654 outcome: HostRequestOutcome::Executed,
3655 ..
3656 }
3657 ));
3658 }
3659
3660 #[test]
3667 fn host_request_observed_reason_is_bounded_free_text_and_optional() {
3668 let gate_text =
3669 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
3670 let with_reason = ProviderEvent::HostRequestObserved {
3671 method: "terminal/create".to_owned(),
3672 params_json: String::new(),
3673 decision: HostRequestDecision::Denied { by: HostDecisionAuthority::Gate },
3674 outcome: HostRequestOutcome::Executed,
3675 reason: Some(gate_text.to_owned()),
3676 };
3677 assert_eq!(with_reason.validate_ingress(), Ok(()));
3678
3679 let without_reason = ProviderEvent::HostRequestObserved {
3680 method: "terminal/create".to_owned(),
3681 params_json: String::new(),
3682 decision: HostRequestDecision::Granted { by: HostDecisionAuthority::Policy },
3683 outcome: HostRequestOutcome::Executed,
3684 reason: None,
3685 };
3686 assert_eq!(without_reason.validate_ingress(), Ok(()));
3687 }
3688
3689 #[test]
3694 fn unrecognized_notification_is_bounded_at_ingress() {
3695 let valid = ProviderEvent::UnrecognizedNotification {
3696 method: "session/some_future_update".to_owned(),
3697 payload_json: "{\"unknown\":true}".to_owned(),
3698 };
3699 assert_eq!(valid.validate_ingress(), Ok(()));
3700
3701 assert!(matches!(
3702 ProviderEvent::UnrecognizedNotification {
3703 method: String::new(),
3704 payload_json: String::new(),
3705 }
3706 .validate_ingress(),
3707 Err(ProviderEventValidationError::Empty {
3708 field: "unrecognized notification method"
3709 })
3710 ));
3711
3712 let oversized_payload = "p".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
3713 assert!(matches!(
3714 ProviderEvent::UnrecognizedNotification {
3715 method: "session/update".to_owned(),
3716 payload_json: oversized_payload,
3717 }
3718 .validate_ingress(),
3719 Err(ProviderEventValidationError::InvalidField {
3720 field: "unrecognized notification payload",
3721 ..
3722 })
3723 ));
3724 }
3725
3726 #[test]
3733 fn provider_user_message_is_bounded_at_ingress_like_text() {
3734 let valid = ProviderEvent::UserMessage { text: "hi".to_owned(), is_delta: true };
3735 assert_eq!(valid.validate_ingress(), Ok(()));
3736
3737 assert!(matches!(
3738 ProviderEvent::UserMessage {
3739 text: "unsafe\u{0000}text".to_owned(),
3740 is_delta: true,
3741 }
3742 .validate_ingress(),
3743 Err(ProviderEventValidationError::InvalidField { field: "text", .. })
3744 ));
3745 }
3746
3747 #[test]
3748 fn provider_plan_accepts_a_valid_snapshot_and_rejects_empty_step_content() {
3749 let valid = ProviderEvent::Plan {
3750 steps: vec![ProviderPlanStep {
3751 content: "read the file".to_owned(),
3752 priority: ProviderPlanPriority::High,
3753 status: ProviderPlanStatus::Completed,
3754 }],
3755 };
3756 assert_eq!(valid.validate_ingress(), Ok(()));
3757
3758 assert!(matches!(
3759 ProviderEvent::Plan {
3760 steps: vec![ProviderPlanStep {
3761 content: String::new(),
3762 priority: ProviderPlanPriority::Low,
3763 status: ProviderPlanStatus::Pending,
3764 }],
3765 }
3766 .validate_ingress(),
3767 Err(ProviderEventValidationError::Empty { field: "plan step content" })
3768 ));
3769 }
3770
3771 #[test]
3772 fn provider_plan_rejects_too_many_steps() {
3773 let steps = (0..=PROVIDER_PLAN_STEPS_MAX)
3774 .map(|i| ProviderPlanStep {
3775 content: format!("step {i}"),
3776 priority: ProviderPlanPriority::Medium,
3777 status: ProviderPlanStatus::Pending,
3778 })
3779 .collect();
3780 assert!(matches!(
3781 ProviderEvent::Plan { steps }.validate_ingress(),
3782 Err(ProviderEventValidationError::TooManyPlanSteps {
3783 max: PROVIDER_PLAN_STEPS_MAX,
3784 ..
3785 })
3786 ));
3787 }
3788
3789 #[test]
3790 fn provider_available_commands_updated_is_bounded_at_ingress() {
3791 let valid = ProviderEvent::AvailableCommandsUpdated {
3792 commands: vec![ProviderAvailableCommand {
3793 name: "review".to_owned(),
3794 description: "Review the diff".to_owned(),
3795 input_hint: Some("<file>".to_owned()),
3796 }],
3797 };
3798 assert_eq!(valid.validate_ingress(), Ok(()));
3799
3800 assert!(matches!(
3801 ProviderEvent::AvailableCommandsUpdated {
3802 commands: vec![ProviderAvailableCommand {
3803 name: String::new(),
3804 description: String::new(),
3805 input_hint: None,
3806 }],
3807 }
3808 .validate_ingress(),
3809 Err(ProviderEventValidationError::Empty { field: "command name" })
3810 ));
3811
3812 let too_many = (0..=PROVIDER_AVAILABLE_COMMANDS_MAX)
3813 .map(|i| ProviderAvailableCommand {
3814 name: format!("cmd{i}"),
3815 description: String::new(),
3816 input_hint: None,
3817 })
3818 .collect();
3819 assert!(matches!(
3820 ProviderEvent::AvailableCommandsUpdated { commands: too_many }.validate_ingress(),
3821 Err(ProviderEventValidationError::TooManyAvailableCommands {
3822 max: PROVIDER_AVAILABLE_COMMANDS_MAX,
3823 ..
3824 })
3825 ));
3826 }
3827
3828 #[test]
3829 fn provider_mode_changed_requires_a_mode_id() {
3830 assert_eq!(
3831 ProviderEvent::ModeChanged {
3832 mode_id: "architect".to_owned(),
3833 available: Vec::new(),
3834 }
3835 .validate_ingress(),
3836 Ok(())
3837 );
3838 assert!(matches!(
3839 ProviderEvent::ModeChanged { mode_id: String::new(), available: Vec::new() }
3840 .validate_ingress(),
3841 Err(ProviderEventValidationError::Empty { field: "mode id" })
3842 ));
3843 }
3844
3845 #[test]
3846 fn provider_mode_changed_available_catalog_is_bounded_at_ingress() {
3847 let valid = ProviderEvent::ModeChanged {
3848 mode_id: "architect".to_owned(),
3849 available: vec![ProviderModeInfo {
3850 id: "architect".to_owned(),
3851 name: "Architect".to_owned(),
3852 description: Some("Plans before it edits".to_owned()),
3853 }],
3854 };
3855 assert_eq!(valid.validate_ingress(), Ok(()));
3856
3857 assert!(matches!(
3858 ProviderEvent::ModeChanged {
3859 mode_id: "architect".to_owned(),
3860 available: vec![ProviderModeInfo {
3861 id: String::new(),
3862 name: "Architect".to_owned(),
3863 description: None,
3864 }],
3865 }
3866 .validate_ingress(),
3867 Err(ProviderEventValidationError::Empty { field: "available mode id" })
3868 ));
3869
3870 let too_many = (0..=PROVIDER_MODE_CATALOG_MAX)
3871 .map(|i| ProviderModeInfo {
3872 id: format!("mode{i}"),
3873 name: format!("Mode {i}"),
3874 description: None,
3875 })
3876 .collect();
3877 assert!(matches!(
3878 ProviderEvent::ModeChanged {
3879 mode_id: "architect".to_owned(),
3880 available: too_many,
3881 }
3882 .validate_ingress(),
3883 Err(ProviderEventValidationError::TooManyModes {
3884 max: PROVIDER_MODE_CATALOG_MAX,
3885 ..
3886 })
3887 ));
3888 }
3889
3890 #[test]
3891 fn provider_session_info_updated_allows_no_title_and_rejects_control_bytes() {
3892 assert_eq!(
3893 ProviderEvent::SessionInfoUpdated { title: None }.validate_ingress(),
3894 Ok(())
3895 );
3896 assert!(matches!(
3897 ProviderEvent::SessionInfoUpdated {
3898 title: Some("bad\u{0000}title".to_owned()),
3899 }
3900 .validate_ingress(),
3901 Err(ProviderEventValidationError::InvalidField { field: "session title", .. })
3902 ));
3903 }
3904
3905 #[test]
3906 fn provider_usage_updated_accepts_optional_cost_and_bounds_currency() {
3907 let valid = ProviderEvent::UsageUpdated {
3908 used_tokens: Some(100),
3909 context_window: Some(200_000),
3910 cost_amount: Some("0.42".to_owned()),
3911 cost_currency: Some("USD".to_owned()),
3912 };
3913 assert_eq!(valid.validate_ingress(), Ok(()));
3914
3915 let no_cost = ProviderEvent::UsageUpdated {
3916 used_tokens: None,
3917 context_window: None,
3918 cost_amount: None,
3919 cost_currency: None,
3920 };
3921 assert_eq!(no_cost.validate_ingress(), Ok(()));
3922
3923 assert!(matches!(
3924 ProviderEvent::UsageUpdated {
3925 used_tokens: None,
3926 context_window: None,
3927 cost_amount: None,
3928 cost_currency: Some("bad\ncurrency".to_owned()),
3929 }
3930 .validate_ingress(),
3931 Err(ProviderEventValidationError::InvalidField { field: "usage cost currency", .. })
3932 ));
3933 }
3934
3935 #[test]
3936 fn provider_config_options_updated_is_typed_and_bounded_at_ingress() {
3937 let valid = ProviderEvent::ConfigOptionsUpdated {
3938 options: vec![ProviderConfigOption {
3939 id: "model".to_owned(),
3940 name: "Model".to_owned(),
3941 description: Some("Which model to use".to_owned()),
3942 category: Some("generation".to_owned()),
3943 kind: ProviderConfigOptionKind::Select,
3944 value_json: "\"opus\"".to_owned(),
3945 choices: vec![ProviderConfigChoice {
3946 value_json: "\"opus\"".to_owned(),
3947 label: Some("Opus".to_owned()),
3948 }],
3949 }],
3950 };
3951 assert_eq!(valid.validate_ingress(), Ok(()));
3952
3953 assert!(matches!(
3954 ProviderEvent::ConfigOptionsUpdated {
3955 options: vec![ProviderConfigOption {
3956 id: String::new(),
3957 name: "Model".to_owned(),
3958 description: None,
3959 category: None,
3960 kind: ProviderConfigOptionKind::Boolean,
3961 value_json: "true".to_owned(),
3962 choices: Vec::new(),
3963 }],
3964 }
3965 .validate_ingress(),
3966 Err(ProviderEventValidationError::Empty { field: "config option id" })
3967 ));
3968 }
3969
3970 #[test]
3971 fn tool_completed_non_execution_kind_is_optional_and_bounded() {
3972 let none = ProviderEvent::ToolCompleted {
3973 id: "t1".to_owned(),
3974 output: "denied".to_owned(),
3975 is_error: true,
3976 duration_ms: None,
3977 agent_id: None,
3978 non_execution_kind: None,
3979 };
3980 assert_eq!(none.validate_ingress(), Ok(()));
3981
3982 let typed = ProviderEvent::ToolCompleted {
3983 id: "t1".to_owned(),
3984 output: "denied".to_owned(),
3985 is_error: true,
3986 duration_ms: None,
3987 agent_id: None,
3988 non_execution_kind: Some("permission-rule".to_owned()),
3989 };
3990 assert_eq!(typed.validate_ingress(), Ok(()));
3991
3992 let oversized = "k".repeat(PROVIDER_EVENT_ID_MAX_BYTES + 1);
3993 assert!(matches!(
3994 ProviderEvent::ToolCompleted {
3995 id: "t1".to_owned(),
3996 output: "denied".to_owned(),
3997 is_error: true,
3998 duration_ms: None,
3999 agent_id: None,
4000 non_execution_kind: Some(oversized),
4001 }
4002 .validate_ingress(),
4003 Err(ProviderEventValidationError::InvalidField {
4004 field: "tool non-execution kind",
4005 ..
4006 })
4007 ));
4008 }
4009
4010 #[test]
4011 fn session_ended_stop_reason_is_optional_and_each_variant_validates() {
4012 use super::ProviderStopReason;
4013 for stop_reason in [
4014 None,
4015 Some(ProviderStopReason::EndTurn),
4016 Some(ProviderStopReason::MaxTokens),
4017 Some(ProviderStopReason::MaxTurnRequests),
4018 Some(ProviderStopReason::Refusal),
4019 Some(ProviderStopReason::Cancelled),
4020 Some(ProviderStopReason::Other { value: "some-vendor-code".to_owned() }),
4021 Some(ProviderStopReason::ProviderError {
4022 code: -32603,
4023 message: "You've hit your usage limit.".to_owned(),
4024 vendor_code: Some("usageLimitExceeded".to_owned()),
4025 }),
4026 ] {
4027 let event = ProviderEvent::SessionEnded {
4028 result: "end_turn".to_owned(),
4029 cost_usd: None,
4030 is_error: false,
4031 stop_reason,
4032 };
4033 assert_eq!(event.validate_ingress(), Ok(()));
4034 let encoded = serde_json::to_vec(&event).expect("serialize");
4035 let decoded: ProviderEvent = serde_json::from_slice(&encoded).expect("deserialize");
4036 assert_eq!(decoded, event);
4037 }
4038 }
4039
4040 #[test]
4041 fn session_ended_provider_error_message_and_vendor_code_are_bounded() {
4042 use super::ProviderStopReason;
4043 let oversized_message = "m".repeat(PROVIDER_EVENT_TEXT_MAX_BYTES + 1);
4044 assert!(matches!(
4045 ProviderEvent::SessionEnded {
4046 result: "provider_error".to_owned(),
4047 cost_usd: None,
4048 is_error: true,
4049 stop_reason: Some(ProviderStopReason::ProviderError {
4050 code: -32603,
4051 message: oversized_message,
4052 vendor_code: None,
4053 }),
4054 }
4055 .validate_ingress(),
4056 Err(ProviderEventValidationError::InvalidField {
4057 field: "provider error message",
4058 ..
4059 })
4060 ));
4061
4062 let oversized_code = "c".repeat(PROVIDER_EVENT_ID_MAX_BYTES + 1);
4063 assert!(matches!(
4064 ProviderEvent::SessionEnded {
4065 result: "provider_error".to_owned(),
4066 cost_usd: None,
4067 is_error: true,
4068 stop_reason: Some(ProviderStopReason::ProviderError {
4069 code: -32603,
4070 message: "usage limit exceeded".to_owned(),
4071 vendor_code: Some(oversized_code),
4072 }),
4073 }
4074 .validate_ingress(),
4075 Err(ProviderEventValidationError::InvalidField {
4076 field: "provider error vendor code",
4077 ..
4078 })
4079 ));
4080 }
4081}