1use crate::app_server::CURRENT_STANDALONE_CLI_VERSION;
9use serde::{Deserialize, Serialize};
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12use std::collections::{BTreeMap, BTreeSet};
13use std::fmt;
14use std::path::{Component, Path};
15use thiserror::Error;
16
17const MAX_LINE_BYTES: usize = 256 * 1024;
18const MAX_TEXT_BYTES: usize = 64 * 1024;
19const MAX_OUTPUT_BYTES: usize = 256 * 1024;
20const MAX_ID_BYTES: usize = 128;
21const MODEL_PAGE_LIMIT: usize = 50;
22const MAX_MODELS: usize = 256;
23const MAX_PENDING: usize = 64;
24const MAX_THREADS: usize = 64;
25const MAX_TURNS: usize = 64;
26const MAX_ITEMS_PER_TURN: usize = 256;
27const MAX_THREAD_PATH_BYTES: usize = 4 * 1024;
28
29const TOKEN_KEYS: [&str; 9] = [
30 "access_token",
31 "accesstoken",
32 "refresh_token",
33 "refreshtoken",
34 "id_token",
35 "idtoken",
36 "authorization",
37 "api_key",
38 "apikey",
39];
40
41#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43pub enum ReasoningEffort {
44 Minimal,
45 Low,
46 Medium,
47 High,
48 Xhigh,
49 Max,
50 Ultra,
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "lowercase")]
55pub enum CodexRole {
56 Luna,
57 Terra,
58 Sol,
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct CodexRoleProjection {
63 pub role: CodexRole,
64 pub task_class: &'static str,
65 pub escalates_to: Option<CodexRole>,
66 pub escalation_reason: Option<&'static str>,
67}
68
69pub const OPENAI_ROLE_PROJECTIONS: [CodexRoleProjection; 3] = [
70 CodexRoleProjection {
71 role: CodexRole::Luna,
72 task_class: "routine",
73 escalates_to: Some(CodexRole::Terra),
74 escalation_reason: Some("qualifying correctness, format, or capability failure"),
75 },
76 CodexRoleProjection {
77 role: CodexRole::Terra,
78 task_class: "standard",
79 escalates_to: Some(CodexRole::Sol),
80 escalation_reason: Some("qualifying correctness, format, or capability failure"),
81 },
82 CodexRoleProjection {
83 role: CodexRole::Sol,
84 task_class: "complex_or_high_risk",
85 escalates_to: None,
86 escalation_reason: None,
87 },
88];
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
91pub struct ManagedAccountProjection {
92 pub plan_type: Option<String>,
93 pub requires_openai_auth: bool,
94}
95
96#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
97pub struct ModelProjection {
98 pub id: String,
99 pub display_name: String,
100 pub supported_efforts: Vec<ReasoningEffort>,
101}
102
103#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
104pub struct ModelPageProjection {
105 pub models: Vec<ModelProjection>,
106 pub next_cursor: Option<String>,
107}
108
109#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
110pub struct RateLimitWindowProjection {
111 pub used_percent: u8,
112 pub window_duration_minutes: Option<u64>,
113 pub resets_at_unix_seconds: Option<u64>,
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
117pub struct RateLimitsProjection {
118 pub primary: Option<RateLimitWindowProjection>,
119 pub secondary: Option<RateLimitWindowProjection>,
120 pub reached_type: Option<String>,
121}
122
123#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum ReplayDisposition {
125 EligibleBeforeVisibility,
126 DeniedVisibleOutput,
127 DeniedToolOrSideEffect,
128 DeniedModelReroute,
129}
130
131#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
137pub enum CodexToolPolicy {
138 #[default]
139 RejectAll,
140 CanonicalMcpLifecycle,
141}
142
143#[derive(Clone, Copy, Debug, PartialEq, Eq)]
144pub enum ToolCallStatus {
145 Completed,
146 Failed,
147}
148
149#[derive(Clone, Copy, Debug, PartialEq, Eq)]
151pub enum TerminalFailureClass {
152 ContextWindow,
153 UsageLimit,
154 Transport,
155 InvalidRequest,
156 Unauthorized,
157 Sandbox,
158 Internal,
159 Other,
160 Unclassified,
161}
162
163#[derive(Clone, Copy, Debug, PartialEq, Eq)]
164pub enum TerminalStatus {
165 Completed,
166 Failed,
167 Interrupted,
168}
169
170#[derive(Clone, PartialEq, Eq)]
171pub struct TurnCompletionReceipt {
172 pub thread_id: String,
173 pub turn_id: String,
174 pub selected_model: String,
175 pub selected_effort: ReasoningEffort,
176 pub status: TerminalStatus,
177 pub output: Option<String>,
178 pub replay: ReplayDisposition,
179 pub failure: Option<TerminalFailureClass>,
180 pub input_tokens: Option<u64>,
183 pub output_tokens: Option<u64>,
186}
187
188impl fmt::Debug for TurnCompletionReceipt {
189 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
190 formatter
191 .debug_struct("TurnCompletionReceipt")
192 .field("thread_id", &self.thread_id)
193 .field("turn_id", &self.turn_id)
194 .field("selected_model", &self.selected_model)
195 .field("selected_effort", &self.selected_effort)
196 .field("status", &self.status)
197 .field("output", &self.output.as_ref().map(|_| "[REDACTED]"))
198 .field("replay", &self.replay)
199 .field("failure", &self.failure)
200 .field("input_tokens", &self.input_tokens)
201 .field("output_tokens", &self.output_tokens)
202 .finish()
203 }
204}
205
206#[derive(Clone, Debug, PartialEq, Eq)]
207pub enum RuntimeEvent {
208 InitializeAccepted,
209 NonSemanticNotificationIgnored,
212 ManagedAccount(ManagedAccountProjection),
213 ModelPage(ModelPageProjection),
214 RateLimits(RateLimitsProjection),
215 ThreadStarted {
216 thread_id: String,
217 },
218 TurnAccepted {
219 thread_id: String,
220 turn_id: String,
221 },
222 TurnStarted {
223 thread_id: String,
224 turn_id: String,
225 },
226 ItemStarted {
227 thread_id: String,
228 turn_id: String,
229 item_id: String,
230 },
231 ItemDelta {
232 thread_id: String,
233 turn_id: String,
234 item_id: String,
235 },
236 ItemCompleted {
237 thread_id: String,
238 turn_id: String,
239 item_id: String,
240 },
241 ToolCallStarted {
242 thread_id: String,
243 turn_id: String,
244 item_id: String,
245 },
246 ToolCallCompleted {
247 thread_id: String,
248 turn_id: String,
249 item_id: String,
250 status: ToolCallStatus,
251 },
252 ModelReroutedFailure {
253 thread_id: String,
254 turn_id: String,
255 from_model: String,
256 to_model: String,
257 },
258 InterruptAcknowledged {
259 thread_id: String,
260 turn_id: String,
261 },
262 TurnCompleted(TurnCompletionReceipt),
263}
264
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266enum HandshakeState {
267 New,
268 InitializePending,
269 InitializeAccepted,
270 Ready,
271}
272
273#[derive(Clone, Debug)]
274enum PendingRequest {
275 Initialize,
276 AccountRead,
277 ModelList,
278 RateLimitsRead,
279 ThreadStart {
280 model: String,
281 effort: ReasoningEffort,
282 },
283 TurnStart {
284 thread_id: String,
285 model: String,
286 effort: ReasoningEffort,
287 },
288 Interrupt {
289 thread_id: String,
290 turn_id: String,
291 },
292}
293
294#[derive(Clone, Debug)]
295struct ThreadSelection {
296 model: String,
297 effort: ReasoningEffort,
298}
299
300#[derive(Clone, Debug, PartialEq, Eq)]
303struct PendingTurnStarted {
304 thread_id: String,
305 turn_id: String,
306}
307
308#[derive(Clone, Debug)]
309enum ItemKind {
310 AgentMessage,
311 Reasoning,
312 McpToolCall(ToolLifecycleBinding),
313}
314
315#[derive(Clone, Debug)]
316struct ToolLifecycleBinding {
317 item_id: String,
318 server: String,
319 tool: String,
320 arguments_digest: [u8; 32],
321}
322
323#[derive(Clone, Debug)]
324struct ItemState {
325 kind: ItemKind,
326 delta_bytes: usize,
327 completed: Option<String>,
328 lifecycle_complete: bool,
329}
330
331#[derive(Clone, Debug)]
332struct TurnState {
333 model: String,
334 effort: ReasoningEffort,
335 items: BTreeMap<String, ItemState>,
336 final_item: Option<String>,
337 visible_output: bool,
338 tool_or_side_effect: bool,
339 model_rerouted: bool,
340 interrupt_requested: bool,
341 lifecycle_started: bool,
342 output_bytes: usize,
343 usage: Option<TurnUsageCounts>,
344 total_usage: Option<CumulativeTurnUsageCounts>,
349}
350
351#[derive(Clone, Copy, Debug, PartialEq, Eq)]
352struct TurnUsageCounts {
353 input_tokens: u64,
354 output_tokens: u64,
355}
356
357#[derive(Clone, Copy, PartialEq, Eq)]
358struct CumulativeTurnUsageCounts {
359 cached_input_tokens: u64,
360 cache_write_input_tokens: Option<u64>,
361 input_tokens: u64,
362 output_tokens: u64,
363 reasoning_output_tokens: u64,
364 total_tokens: u64,
365}
366
367impl std::fmt::Debug for CumulativeTurnUsageCounts {
368 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
369 formatter.write_str("CumulativeTurnUsageCounts(<redacted>)")
370 }
371}
372
373impl TurnState {
374 fn new(model: String, effort: ReasoningEffort, lifecycle_started: bool) -> Self {
375 Self {
376 model,
377 effort,
378 items: BTreeMap::new(),
379 final_item: None,
380 visible_output: false,
381 tool_or_side_effect: false,
382 model_rerouted: false,
383 interrupt_requested: false,
384 lifecycle_started,
385 output_bytes: 0,
386 usage: None,
387 total_usage: None,
388 }
389 }
390
391 fn replay(&self) -> ReplayDisposition {
392 if self.model_rerouted {
393 ReplayDisposition::DeniedModelReroute
394 } else if self.tool_or_side_effect {
395 ReplayDisposition::DeniedToolOrSideEffect
396 } else if self.visible_output {
397 ReplayDisposition::DeniedVisibleOutput
398 } else {
399 ReplayDisposition::EligibleBeforeVisibility
400 }
401 }
402}
403
404#[derive(Debug, Error, PartialEq, Eq)]
405pub enum RuntimeError {
406 #[error("app-server runtime input is invalid")]
407 InvalidInput,
408 #[error("app-server runtime input contained forbidden sensitive material")]
409 SensitiveInputRejected,
410 #[error("app-server handshake state is invalid")]
411 InvalidHandshake,
412 #[error("app-server runtime is not initialized")]
413 NotInitialized,
414 #[error("app-server pending request capacity is exhausted")]
415 PendingCapacityExceeded,
416 #[error("app-server response correlation is unknown")]
417 UnknownCorrelation,
418 #[error("app-server response was a protocol failure")]
419 ProtocolFailure,
420 #[error("app-server returned a JSON-RPC error response")]
421 JsonRpcRejected,
422 #[error("unsupported app-server notification method: {0}")]
423 UnsupportedNotificationMethod(String),
424 #[error("the account is not managed ChatGPT")]
425 AccountNotManagedChatgpt,
426 #[error("the app-server model page exceeds configured bounds")]
427 ModelPageLimitExceeded,
428 #[error("the requested model is not present in the bounded catalog")]
429 UnknownModel,
430 #[error("the requested reasoning effort is not advertised by the model")]
431 UnsupportedEffort,
432 #[error("the app-server event does not match an active thread or turn")]
433 CorrelationMismatch,
434 #[error("a tool or side-effecting item was rejected")]
435 SideEffectingItemRejected,
436 #[error("the app-server output exceeds configured bounds")]
437 OutputLimitExceeded,
438}
439
440pub struct CodexAgentRuntime {
441 tool_policy: CodexToolPolicy,
442 handshake: HandshakeState,
443 next_id: u64,
444 pending: BTreeMap<u64, PendingRequest>,
445 pending_turn_started: BTreeMap<u64, PendingTurnStarted>,
448 models: BTreeMap<String, ModelProjection>,
449 threads: BTreeMap<String, ThreadSelection>,
450 turns: BTreeMap<(String, String), TurnState>,
451 initialized_server_version: Option<String>,
452}
453
454impl fmt::Debug for CodexAgentRuntime {
455 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
456 formatter
457 .debug_struct("CodexAgentRuntime")
458 .field("tool_policy", &self.tool_policy)
459 .field("handshake", &self.handshake)
460 .field("pending_count", &self.pending.len())
461 .field(
462 "pending_turn_started_count",
463 &self.pending_turn_started.len(),
464 )
465 .field("model_count", &self.models.len())
466 .field("thread_count", &self.threads.len())
467 .field("turn_count", &self.turns.len())
468 .field("transcript", &"[REDACTED]")
469 .finish()
470 }
471}
472
473impl Default for CodexAgentRuntime {
474 fn default() -> Self {
475 Self::new()
476 }
477}
478
479impl CodexAgentRuntime {
480 pub fn new() -> Self {
481 Self::with_tool_policy(CodexToolPolicy::RejectAll)
482 }
483
484 pub fn with_tool_policy(tool_policy: CodexToolPolicy) -> Self {
485 Self {
486 tool_policy,
487 handshake: HandshakeState::New,
488 next_id: 1,
489 pending: BTreeMap::new(),
490 pending_turn_started: BTreeMap::new(),
491 models: BTreeMap::new(),
492 threads: BTreeMap::new(),
493 turns: BTreeMap::new(),
494 initialized_server_version: None,
495 }
496 }
497
498 pub fn initialized_server_version(&self) -> Option<&str> {
500 self.initialized_server_version.as_deref()
501 }
502
503 pub fn initialize_request(&mut self) -> Result<String, RuntimeError> {
504 if self.handshake != HandshakeState::New {
505 return Err(RuntimeError::InvalidHandshake);
506 }
507 let line = self.request(
508 "initialize",
509 json!({
510 "clientInfo":{"name":"steadymux","title":"SteadyMux","version":env!("CARGO_PKG_VERSION")},
511 "capabilities":{"experimentalApi":false}
512 }),
513 PendingRequest::Initialize,
514 )?;
515 self.handshake = HandshakeState::InitializePending;
516 Ok(line)
517 }
518
519 pub fn initialized_notification(&mut self) -> Result<String, RuntimeError> {
520 if self.handshake != HandshakeState::InitializeAccepted {
521 return Err(RuntimeError::InvalidHandshake);
522 }
523 self.handshake = HandshakeState::Ready;
524 encode_line(&json!({"jsonrpc":"2.0","method":"initialized"}))
525 }
526
527 pub fn account_read_request(&mut self) -> Result<String, RuntimeError> {
528 self.ready_request(
529 "account/read",
530 json!({"refreshToken":false}),
531 PendingRequest::AccountRead,
532 )
533 }
534
535 pub fn model_list_request(&mut self, cursor: Option<&str>) -> Result<String, RuntimeError> {
536 let cursor = cursor.map(validated_id).transpose()?;
537 self.ready_request(
538 "model/list",
539 json!({"cursor":cursor,"limit":MODEL_PAGE_LIMIT,"includeHidden":false}),
540 PendingRequest::ModelList,
541 )
542 }
543
544 pub fn rate_limits_read_request(&mut self) -> Result<String, RuntimeError> {
545 self.ready_request(
546 "account/rateLimits/read",
547 json!({}),
548 PendingRequest::RateLimitsRead,
549 )
550 }
551
552 pub fn thread_start_request(
553 &mut self,
554 model: &str,
555 effort: ReasoningEffort,
556 ) -> Result<String, RuntimeError> {
557 self.thread_start_request_with_instructions(model, effort, None, None)
558 }
559
560 pub fn thread_start_request_with_instructions(
561 &mut self,
562 model: &str,
563 effort: ReasoningEffort,
564 base_instructions: Option<&str>,
565 developer_instructions: Option<&str>,
566 ) -> Result<String, RuntimeError> {
567 self.require_model_effort(model, effort)?;
568 let base_instructions = validated_instruction(base_instructions)?;
569 let developer_instructions = validated_instruction(developer_instructions)?;
570 let mut params = json!({
571 "model":model,
572 "modelProvider":"openai",
573 "approvalPolicy":"never",
574 "sandbox":"read-only",
575 "ephemeral":true
576 });
577 let params_object = params.as_object_mut().ok_or(RuntimeError::InvalidInput)?;
578 if let Some(base_instructions) = base_instructions {
579 params_object.insert(
580 "baseInstructions".to_owned(),
581 Value::String(base_instructions),
582 );
583 }
584 if let Some(developer_instructions) = developer_instructions {
585 params_object.insert(
586 "developerInstructions".to_owned(),
587 Value::String(developer_instructions),
588 );
589 }
590 self.ready_request(
591 "thread/start",
592 params,
593 PendingRequest::ThreadStart {
594 model: model.to_owned(),
595 effort,
596 },
597 )
598 }
599
600 pub fn turn_start_request(
601 &mut self,
602 thread_id: &str,
603 client_message_id: &str,
604 text: &str,
605 model: &str,
606 effort: ReasoningEffort,
607 ) -> Result<String, RuntimeError> {
608 let thread_id = validated_id(thread_id)?;
609 let client_message_id = validated_id(client_message_id)?;
610 if text.is_empty() || text.len() > MAX_TEXT_BYTES || text.contains('\0') {
611 return Err(RuntimeError::InvalidInput);
612 }
613 self.require_model_effort(model, effort)?;
614 let selection = self
615 .threads
616 .get(&thread_id)
617 .ok_or(RuntimeError::CorrelationMismatch)?;
618 if selection.model != model || selection.effort != effort {
619 return Err(RuntimeError::CorrelationMismatch);
620 }
621 self.ready_request(
622 "turn/start",
623 json!({
624 "threadId":thread_id,
625 "clientUserMessageId":client_message_id,
626 "input":[{"type":"text","text":text}],
627 "model":model,
628 "effort":effort,
629 "approvalPolicy":"never",
630 "sandboxPolicy":{"type":"readOnly"}
631 }),
632 PendingRequest::TurnStart {
633 thread_id,
634 model: model.to_owned(),
635 effort,
636 },
637 )
638 }
639
640 pub fn turn_interrupt_request(
641 &mut self,
642 thread_id: &str,
643 turn_id: &str,
644 ) -> Result<String, RuntimeError> {
645 let thread_id = validated_id(thread_id)?;
646 let turn_id = validated_id(turn_id)?;
647 let turn = self
648 .turns
649 .get(&(thread_id.clone(), turn_id.clone()))
650 .ok_or(RuntimeError::CorrelationMismatch)?;
651 if turn.interrupt_requested {
652 return Err(RuntimeError::CorrelationMismatch);
653 }
654 let line = self.ready_request(
655 "turn/interrupt",
656 json!({"threadId":thread_id,"turnId":turn_id}),
657 PendingRequest::Interrupt {
658 thread_id: thread_id.clone(),
659 turn_id: turn_id.clone(),
660 },
661 )?;
662 self.turns
663 .get_mut(&(thread_id, turn_id))
664 .ok_or(RuntimeError::CorrelationMismatch)?
665 .interrupt_requested = true;
666 Ok(line)
667 }
668
669 pub fn ingest_line(&mut self, line: &[u8]) -> Result<RuntimeEvent, RuntimeError> {
670 if line.is_empty()
671 || line.len() > MAX_LINE_BYTES
672 || line.contains(&b'\n')
673 || line.contains(&b'\r')
674 {
675 return Err(RuntimeError::InvalidInput);
676 }
677 let value: Value = serde_json::from_slice(line).map_err(|_| RuntimeError::InvalidInput)?;
678 if let Some(id) = value.get("id").and_then(Value::as_u64) {
679 if value.get("error").is_none() && contains_forbidden_sensitive_material(&value) {
680 return Err(RuntimeError::SensitiveInputRejected);
681 }
682 let result = self.ingest_response(id, &value);
683 if matches!(result, Err(RuntimeError::ProtocolFailure)) {
684 eprintln!("steadymux-owned-child: runtime-protocol-source=response");
685 } else if matches!(result, Err(RuntimeError::CorrelationMismatch)) {
686 eprintln!("{}", correlation_mismatch_response_marker());
687 }
688 return result;
689 }
690 if contains_forbidden_sensitive_material(&value) {
691 return Err(RuntimeError::SensitiveInputRejected);
692 }
693 let method = value
694 .get("method")
695 .and_then(Value::as_str)
696 .ok_or(RuntimeError::InvalidInput)?;
697 let params = value.get("params").ok_or(RuntimeError::InvalidInput)?;
698 let result = self.ingest_notification(method, params);
699 if matches!(result, Err(RuntimeError::ProtocolFailure)) {
700 let method_sha256 = Sha256::digest(method.as_bytes())
701 .iter()
702 .map(|byte| format!("{byte:02x}"))
703 .collect::<String>();
704 eprintln!(
705 "steadymux-owned-child: runtime-protocol-source=notification method-sha256={method_sha256}"
706 );
707 } else if matches!(result, Err(RuntimeError::CorrelationMismatch)) {
708 eprintln!("{}", correlation_mismatch_notification_marker(method));
709 }
710 result
711 }
712
713 fn request(
714 &mut self,
715 method: &str,
716 params: Value,
717 pending: PendingRequest,
718 ) -> Result<String, RuntimeError> {
719 if self.pending.len() >= MAX_PENDING {
720 return Err(RuntimeError::PendingCapacityExceeded);
721 }
722 let id = self.next_id;
723 self.next_id = self
724 .next_id
725 .checked_add(1)
726 .ok_or(RuntimeError::PendingCapacityExceeded)?;
727 self.pending.insert(id, pending);
728 encode_line(&json!({"jsonrpc":"2.0","id":id,"method":method,"params":params}))
729 }
730
731 fn ready_request(
732 &mut self,
733 method: &str,
734 params: Value,
735 pending: PendingRequest,
736 ) -> Result<String, RuntimeError> {
737 if self.handshake != HandshakeState::Ready {
738 return Err(RuntimeError::NotInitialized);
739 }
740 self.request(method, params, pending)
741 }
742
743 fn require_model_effort(
744 &self,
745 model: &str,
746 effort: ReasoningEffort,
747 ) -> Result<(), RuntimeError> {
748 let model = self.models.get(model).ok_or(RuntimeError::UnknownModel)?;
749 if !model.supported_efforts.contains(&effort) {
750 return Err(RuntimeError::UnsupportedEffort);
751 }
752 Ok(())
753 }
754
755 fn ingest_response(&mut self, id: u64, value: &Value) -> Result<RuntimeEvent, RuntimeError> {
756 let pending = self
757 .pending
758 .remove(&id)
759 .ok_or(RuntimeError::UnknownCorrelation)?;
760 let pending_turn_started = self.pending_turn_started.remove(&id);
761 if pending_turn_started.is_some() && !matches!(&pending, PendingRequest::TurnStart { .. }) {
762 self.discard_notification_first_turn(pending_turn_started.as_ref());
763 return Err(RuntimeError::CorrelationMismatch);
764 }
765 if value.get("error").is_some() {
766 self.discard_notification_first_turn(pending_turn_started.as_ref());
767 return if valid_json_rpc_error_response(value) {
768 Err(RuntimeError::JsonRpcRejected)
769 } else {
770 Err(RuntimeError::ProtocolFailure)
771 };
772 }
773 let result = match value.get("result") {
774 Some(result) => result,
775 None => {
776 self.discard_notification_first_turn(pending_turn_started.as_ref());
777 return Err(RuntimeError::InvalidInput);
778 }
779 };
780 match pending {
781 PendingRequest::Initialize => {
782 if self.handshake != HandshakeState::InitializePending || !result.is_object() {
783 return Err(RuntimeError::InvalidHandshake);
784 }
785 let user_agent = string_at(result, &["userAgent"])?;
786 self.initialized_server_version =
787 app_server_version_from_user_agent(user_agent).map(str::to_owned);
788 self.handshake = HandshakeState::InitializeAccepted;
789 Ok(RuntimeEvent::InitializeAccepted)
790 }
791 PendingRequest::AccountRead => self.ingest_account(result),
792 PendingRequest::ModelList => self.ingest_models(result),
793 PendingRequest::RateLimitsRead => self.ingest_rate_limits(result),
794 PendingRequest::ThreadStart { model, effort } => {
795 let thread_id = validated_id(string_at(result, &["thread", "id"])?)?;
796 if self.threads.len() >= MAX_THREADS || self.threads.contains_key(&thread_id) {
797 return Err(RuntimeError::CorrelationMismatch);
798 }
799 self.threads
800 .insert(thread_id.clone(), ThreadSelection { model, effort });
801 Ok(RuntimeEvent::ThreadStarted { thread_id })
802 }
803 PendingRequest::TurnStart {
804 thread_id,
805 model,
806 effort,
807 } => {
808 let parsed_response = (|| {
809 let turn_id = validated_id(string_at(result, &["turn", "id"])?)?;
810 if string_at(result, &["turn", "status"])? != "inProgress" {
811 return Err(RuntimeError::InvalidInput);
812 }
813 Ok(turn_id)
814 })();
815 let turn_id = match parsed_response {
816 Ok(turn_id) => turn_id,
817 Err(error) => {
818 self.discard_notification_first_turn(pending_turn_started.as_ref());
819 return Err(error);
820 }
821 };
822 let key = (thread_id.clone(), turn_id.clone());
823 match pending_turn_started {
824 Some(started) => {
825 let exact_match = started.thread_id == thread_id
826 && started.turn_id == turn_id
827 && self.turns.get(&key).is_some_and(|turn| {
828 turn.model == model
829 && turn.effort == effort
830 && turn.lifecycle_started
831 });
832 if !exact_match {
833 self.discard_notification_first_turn(Some(&started));
834 return Err(RuntimeError::CorrelationMismatch);
835 }
836 }
837 None => {
838 if self.turns.len() >= MAX_TURNS || self.turns.contains_key(&key) {
839 return Err(RuntimeError::CorrelationMismatch);
840 }
841 self.turns.insert(key, TurnState::new(model, effort, false));
842 }
843 }
844 Ok(RuntimeEvent::TurnAccepted { thread_id, turn_id })
845 }
846 PendingRequest::Interrupt { thread_id, turn_id } => {
847 if result.as_object().is_none_or(|object| !object.is_empty()) {
848 return Err(RuntimeError::InvalidInput);
849 }
850 Ok(RuntimeEvent::InterruptAcknowledged { thread_id, turn_id })
851 }
852 }
853 }
854
855 fn ingest_account(&self, result: &Value) -> Result<RuntimeEvent, RuntimeError> {
856 let account = result
857 .get("account")
858 .ok_or(RuntimeError::AccountNotManagedChatgpt)?;
859 if account.get("type").and_then(Value::as_str) != Some("chatgpt") {
860 return Err(RuntimeError::AccountNotManagedChatgpt);
861 }
862 let plan_type = account
863 .get("planType")
864 .and_then(Value::as_str)
865 .map(bounded_text)
866 .transpose()?;
867 let requires_openai_auth = result
868 .get("requiresOpenaiAuth")
869 .and_then(Value::as_bool)
870 .ok_or(RuntimeError::InvalidInput)?;
871 Ok(RuntimeEvent::ManagedAccount(ManagedAccountProjection {
872 plan_type,
873 requires_openai_auth,
874 }))
875 }
876
877 fn ingest_models(&mut self, result: &Value) -> Result<RuntimeEvent, RuntimeError> {
878 let data = result
879 .get("data")
880 .and_then(Value::as_array)
881 .ok_or(RuntimeError::InvalidInput)?;
882 if data.len() > MODEL_PAGE_LIMIT
883 || self.models.len().saturating_add(data.len()) > MAX_MODELS
884 {
885 return Err(RuntimeError::ModelPageLimitExceeded);
886 }
887 let mut page = Vec::with_capacity(data.len());
888 let mut page_ids = BTreeSet::new();
889 for raw in data {
890 if raw.get("hidden").and_then(Value::as_bool).unwrap_or(false) {
891 continue;
892 }
893 let id = validated_id(
894 raw.get("id")
895 .and_then(Value::as_str)
896 .ok_or(RuntimeError::InvalidInput)?,
897 )?;
898 if self.models.contains_key(&id) || !page_ids.insert(id.clone()) {
899 return Err(RuntimeError::InvalidInput);
900 }
901 let display_name = bounded_text(
902 raw.get("displayName")
903 .and_then(Value::as_str)
904 .ok_or(RuntimeError::InvalidInput)?,
905 )?;
906 let effort_values = raw
907 .get("supportedReasoningEfforts")
908 .and_then(Value::as_array)
909 .ok_or(RuntimeError::InvalidInput)?;
910 if effort_values.is_empty() || effort_values.len() > 8 {
911 return Err(RuntimeError::InvalidInput);
912 }
913 let mut seen = BTreeSet::new();
914 let mut efforts = Vec::with_capacity(effort_values.len());
915 for effort in effort_values {
916 let effort_value = effort
917 .get("reasoningEffort")
918 .and_then(Value::as_str)
919 .ok_or(RuntimeError::InvalidInput)?;
920 let effort = parse_effort(effort_value)?;
921 if seen.insert(effort) {
922 efforts.push(effort);
923 }
924 }
925 let projection = ModelProjection {
926 id: id.clone(),
927 display_name,
928 supported_efforts: efforts,
929 };
930 page.push(projection);
931 }
932 let next_cursor = result
933 .get("nextCursor")
934 .and_then(Value::as_str)
935 .map(validated_id)
936 .transpose()?;
937 for projection in &page {
938 self.models
939 .insert(projection.id.clone(), projection.clone());
940 }
941 Ok(RuntimeEvent::ModelPage(ModelPageProjection {
942 models: page,
943 next_cursor,
944 }))
945 }
946
947 fn ingest_rate_limits(&self, result: &Value) -> Result<RuntimeEvent, RuntimeError> {
948 let limits = result.get("rateLimits").ok_or(RuntimeError::InvalidInput)?;
949 let primary = parse_window(limits.get("primary"))?;
950 let secondary = parse_window(limits.get("secondary"))?;
951 let reached_type = limits
952 .get("rateLimitReachedType")
953 .and_then(Value::as_str)
954 .map(bounded_text)
955 .transpose()?;
956 Ok(RuntimeEvent::RateLimits(RateLimitsProjection {
957 primary,
958 secondary,
959 reached_type,
960 }))
961 }
962
963 fn ingest_notification(
964 &mut self,
965 method: &str,
966 params: &Value,
967 ) -> Result<RuntimeEvent, RuntimeError> {
968 match method {
969 "remoteControl/status/changed" => {
970 let object = params.as_object().ok_or(RuntimeError::ProtocolFailure)?;
971 let exact_shape = object.len() == 4
972 && object.get("status").and_then(Value::as_str) == Some("disabled")
973 && valid_opaque_control_value(object.get("serverName"))
974 && valid_opaque_control_value(object.get("installationId"))
975 && object.get("environmentId").is_some_and(Value::is_null);
976 if !exact_shape {
977 return Err(RuntimeError::ProtocolFailure);
978 }
979 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
980 }
981 "mcpServer/startupStatus/updated" => self.ingest_mcp_startup_status(params),
982 "warning" => self.ingest_warning(params),
983 "hook/started" | "hook/completed" => Err(RuntimeError::ProtocolFailure),
984 "thread/started" => self.ingest_thread_started(params),
985 "thread/status/changed" => self.ingest_thread_status_changed(params),
986 "thread/tokenUsage/updated" => self.ingest_thread_token_usage_updated(params),
987 "account/rateLimits/updated" => self.ingest_rate_limits_updated(params),
988 "item/reasoning/summaryPartAdded" => self.ingest_reasoning_part_added(params),
989 "item/reasoning/summaryTextDelta" => {
990 self.ingest_reasoning_delta(params, "summaryIndex")
991 }
992 "item/reasoning/textDelta" => self.ingest_reasoning_delta(params, "contentIndex"),
993 "turn/started" => self.ingest_turn_started(params),
994 "item/started" => self.ingest_item_started(params),
995 "item/agentMessage/delta" => self.ingest_delta(params),
996 "item/completed" => self.ingest_item_completed(params),
997 "model/rerouted" => self.ingest_rerouted(params),
998 "turn/completed" => self.ingest_turn_completed(params),
999 _ => match validated_notification_method(method) {
1000 Ok(method) => Err(RuntimeError::UnsupportedNotificationMethod(method)),
1001 Err(_) => Err(RuntimeError::ProtocolFailure),
1002 },
1003 }
1004 }
1005
1006 fn ingest_mcp_startup_status(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1007 const ALLOWED: [&str; 5] = ["threadId", "name", "status", "error", "failureReason"];
1008 let object = params.as_object().ok_or(RuntimeError::ProtocolFailure)?;
1009 let refusal = if !object.contains_key("name")
1010 || !object.contains_key("status")
1011 || !object.keys().all(|key| ALLOWED.contains(&key.as_str()))
1012 {
1013 Some("keys")
1014 } else if !valid_opaque_control_value(object.get("name")) {
1015 Some("name")
1016 } else if !matches!(
1017 object.get("status").and_then(Value::as_str),
1018 Some("starting" | "ready" | "failed" | "cancelled")
1019 ) {
1020 Some("status")
1021 } else if object.get("error").is_some_and(|error| {
1022 !error.is_null()
1023 && error.as_str().is_none_or(|error| {
1024 error.is_empty()
1025 || error.len() > MAX_TEXT_BYTES
1026 || error.chars().any(char::is_control)
1027 })
1028 }) {
1029 Some("error")
1030 } else if object.get("failureReason").is_some_and(|reason| {
1031 !reason.is_null() && reason.as_str() != Some("reauthenticationRequired")
1032 }) {
1033 Some("reason")
1034 } else if object
1035 .get("threadId")
1036 .is_some_and(|thread_id| !thread_id.is_null() && !thread_id.is_string())
1037 {
1038 Some("thread")
1039 } else {
1040 None
1041 };
1042 if let Some(class) = refusal {
1043 eprintln!("steadymux-owned-child: mcp-startup-refused={class}");
1044 return Err(RuntimeError::ProtocolFailure);
1045 }
1046 if let Some(thread_id) = object.get("threadId").and_then(Value::as_str) {
1047 let thread_id = validated_id(thread_id)?;
1048 if !self.threads.contains_key(&thread_id) {
1049 return Err(RuntimeError::CorrelationMismatch);
1050 }
1051 }
1052 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1053 }
1054
1055 fn ingest_warning(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1056 let object = exact_object(params, &["threadId", "message"])?;
1057 let thread_id = object
1058 .get("threadId")
1059 .and_then(Value::as_str)
1060 .filter(|value| {
1061 !value.is_empty() && value.len() <= MAX_ID_BYTES && !value.contains('\0')
1062 })
1063 .ok_or(RuntimeError::ProtocolFailure)?;
1064 if !self.threads.contains_key(thread_id) {
1065 return Err(RuntimeError::CorrelationMismatch);
1066 }
1067 if object
1068 .get("message")
1069 .and_then(Value::as_str)
1070 .is_none_or(|message| {
1071 message.is_empty() || message.len() > MAX_TEXT_BYTES || message.contains('\0')
1072 })
1073 {
1074 return Err(RuntimeError::ProtocolFailure);
1075 }
1076 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1077 }
1078
1079 fn ingest_rate_limits_updated(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1080 let object = exact_object(params, &["rateLimits"])?;
1081 let limits = exact_object(
1082 object
1083 .get("rateLimits")
1084 .ok_or(RuntimeError::ProtocolFailure)?,
1085 &[
1086 "limitId",
1087 "limitName",
1088 "primary",
1089 "secondary",
1090 "credits",
1091 "individualLimit",
1092 "spendControlReached",
1093 "planType",
1094 "rateLimitReachedType",
1095 ],
1096 )?;
1097 let primary = exact_object(
1098 limits.get("primary").ok_or(RuntimeError::ProtocolFailure)?,
1099 &["usedPercent", "windowDurationMins", "resetsAt"],
1100 )?;
1101 let credits = exact_object(
1102 limits.get("credits").ok_or(RuntimeError::ProtocolFailure)?,
1103 &["hasCredits", "unlimited", "balance"],
1104 )?;
1105 let valid = valid_opaque_control_value(limits.get("limitId"))
1106 && limits.get("limitName").is_some_and(Value::is_null)
1107 && limits.get("secondary").is_some_and(Value::is_null)
1108 && limits.get("individualLimit").is_some_and(Value::is_null)
1109 && limits
1110 .get("spendControlReached")
1111 .is_some_and(|value| value.is_null() || value.is_boolean())
1112 && limits.get("rateLimitReachedType").is_some_and(|value| {
1113 value.is_null()
1114 || value
1115 .as_str()
1116 .is_some_and(|text| bounded_text(text).is_ok())
1117 })
1118 && valid_opaque_control_value(limits.get("planType"))
1119 && primary
1120 .get("usedPercent")
1121 .and_then(Value::as_u64)
1122 .is_some_and(|percent| percent <= 100)
1123 && primary
1124 .get("windowDurationMins")
1125 .and_then(Value::as_u64)
1126 .is_some()
1127 && primary.get("resetsAt").and_then(Value::as_u64).is_some()
1128 && credits.get("hasCredits").and_then(Value::as_bool).is_some()
1129 && credits.get("unlimited").and_then(Value::as_bool).is_some()
1130 && credits
1131 .get("balance")
1132 .and_then(Value::as_str)
1133 .is_some_and(|balance| {
1134 !balance.is_empty()
1135 && balance.len() <= MAX_ID_BYTES
1136 && balance
1137 .bytes()
1138 .all(|byte| byte.is_ascii_digit() || byte == b'.')
1139 });
1140 if !valid {
1141 return Err(RuntimeError::ProtocolFailure);
1142 }
1143 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1144 }
1145
1146 fn ingest_thread_started(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1147 let object = exact_object(params, &["thread"])?;
1148 let thread_id = validate_ephemeral_empty_thread(
1149 object.get("thread").ok_or(RuntimeError::ProtocolFailure)?,
1150 )?;
1151 if !self.threads.contains_key(&thread_id) {
1152 return Err(RuntimeError::CorrelationMismatch);
1153 }
1154 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1155 }
1156
1157 fn ingest_turn_started(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1158 let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1159 let turn_id = validated_id(string_at(params, &["turn", "id"])?)?;
1160 if string_at(params, &["turn", "status"])? != "inProgress" {
1161 return Err(RuntimeError::ProtocolFailure);
1162 }
1163 if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1164 if turn.lifecycle_started {
1165 return Err(RuntimeError::CorrelationMismatch);
1166 }
1167 turn.lifecycle_started = true;
1168 return Ok(RuntimeEvent::TurnStarted { thread_id, turn_id });
1169 }
1170
1171 let mut matching_requests =
1172 self.pending
1173 .iter()
1174 .filter_map(|(request_id, request)| match request {
1175 PendingRequest::TurnStart {
1176 thread_id: pending_thread_id,
1177 model,
1178 effort,
1179 } if pending_thread_id == &thread_id => {
1180 Some((*request_id, model.clone(), *effort))
1181 }
1182 _ => None,
1183 });
1184 let (request_id, model, effort) = matching_requests
1185 .next()
1186 .ok_or(RuntimeError::CorrelationMismatch)?;
1187 if matching_requests.next().is_some() || self.pending_turn_started.contains_key(&request_id)
1188 {
1189 return Err(RuntimeError::CorrelationMismatch);
1190 }
1191 if self.turns.len() >= MAX_TURNS {
1192 return Err(RuntimeError::PendingCapacityExceeded);
1193 }
1194 self.pending_turn_started.insert(
1195 request_id,
1196 PendingTurnStarted {
1197 thread_id: thread_id.clone(),
1198 turn_id: turn_id.clone(),
1199 },
1200 );
1201 self.turns.insert(
1202 (thread_id.clone(), turn_id.clone()),
1203 TurnState::new(model, effort, true),
1204 );
1205 Ok(RuntimeEvent::TurnStarted { thread_id, turn_id })
1206 }
1207
1208 fn ingest_thread_status_changed(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1209 let object = exact_object(params, &["threadId", "status"])?;
1210 let thread_id = validated_id(
1211 object
1212 .get("threadId")
1213 .and_then(Value::as_str)
1214 .ok_or(RuntimeError::ProtocolFailure)?,
1215 )?;
1216 if !self.threads.contains_key(&thread_id) {
1217 return Err(RuntimeError::CorrelationMismatch);
1218 }
1219 let status = object.get("status").ok_or(RuntimeError::ProtocolFailure)?;
1220 let status = status.as_object().ok_or(RuntimeError::ProtocolFailure)?;
1221 let allowed = match status.get("type").and_then(Value::as_str) {
1222 Some("idle") => status.len() == 1,
1223 Some("active") => {
1224 status.len() == 2
1225 && status
1226 .get("activeFlags")
1227 .and_then(Value::as_array)
1228 .is_some_and(|flags| {
1229 flags.len() <= 2
1230 && flags.iter().all(|flag| {
1231 matches!(
1232 flag.as_str(),
1233 Some("waitingOnApproval" | "waitingOnUserInput")
1234 )
1235 })
1236 })
1237 }
1238 _ => false,
1239 };
1240 if !allowed {
1241 return Err(RuntimeError::ProtocolFailure);
1242 }
1243 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1244 }
1245
1246 fn ingest_thread_token_usage_updated(
1247 &mut self,
1248 params: &Value,
1249 ) -> Result<RuntimeEvent, RuntimeError> {
1250 let object = exact_object(params, &["threadId", "turnId", "tokenUsage"])
1251 .map_err(|_| token_usage_refusal("params-shape"))?;
1252 let thread_id = object
1253 .get("threadId")
1254 .and_then(Value::as_str)
1255 .ok_or_else(|| token_usage_refusal("ids"))
1256 .and_then(|id| validated_id(id).map_err(|_| token_usage_refusal("ids")))?;
1257 let turn_id = object
1258 .get("turnId")
1259 .and_then(Value::as_str)
1260 .ok_or_else(|| token_usage_refusal("ids"))
1261 .and_then(|id| validated_id(id).map_err(|_| token_usage_refusal("ids")))?;
1262 let usage = validated_last_turn_usage(object.get("tokenUsage"))?;
1263 let total_usage = validated_total_turn_usage(object.get("tokenUsage"))?;
1264 let key = (thread_id.clone(), turn_id.clone());
1265 let correlation_refusal = match self.turns.get(&key) {
1266 None => Some(TokenUsageCorrelationRefusal::MissingPair),
1267 Some(turn) if turn.model_rerouted => Some(TokenUsageCorrelationRefusal::Rerouted),
1268 Some(turn) if turn.usage.is_some() && turn.total_usage.is_none() => {
1269 Some(TokenUsageCorrelationRefusal::PriorUsage)
1270 }
1271 Some(_) => None,
1272 };
1273 if let Some(reason) = correlation_refusal {
1274 emit_token_usage_correlation_marker(reason, &thread_id, &turn_id, &self.turns);
1275 return Err(token_usage_refusal("turn-state/correlation"));
1276 }
1277 if let Some(turn) = self.turns.get(&key)
1278 && let Some(previous_total) = turn.total_usage
1279 {
1280 let total_direction = cumulative_usage_direction(previous_total, total_usage);
1281 if total_direction == PriorUsageDirection::DecreaseOrMixed {
1282 emit_prior_usage_direction_marker(
1283 turn.usage
1284 .map_or(PriorUsageDirection::Unavailable, |previous| {
1285 prior_usage_direction(previous, usage)
1286 }),
1287 );
1288 emit_prior_usage_projection_marker(
1289 turn.usage.map_or(TotalUsageRelativeToLast::Equal, |last| {
1290 total_usage_relative_to_last(last, total_usage)
1291 }),
1292 total_direction,
1293 );
1294 emit_token_usage_correlation_marker(
1295 TokenUsageCorrelationRefusal::PriorUsage,
1296 &thread_id,
1297 &turn_id,
1298 &self.turns,
1299 );
1300 return Err(token_usage_refusal("turn-state/correlation"));
1301 }
1302 }
1303 let turn = self
1304 .turn_mut(&thread_id, &turn_id)
1305 .map_err(|_| token_usage_refusal("turn-state/correlation"))?;
1306 turn.usage = Some(usage);
1315 turn.total_usage = Some(total_usage);
1316 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1317 }
1318
1319 fn ingest_reasoning_part_added(&self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1320 let object = exact_object(params, &["threadId", "turnId", "itemId", "summaryIndex"])?;
1321 let (thread_id, turn_id, item_id) = correlated_reasoning_ids(object, "summaryIndex")?;
1322 self.require_active_reasoning_item(&thread_id, &turn_id, &item_id)?;
1323 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1324 }
1325
1326 fn ingest_reasoning_delta(
1327 &mut self,
1328 params: &Value,
1329 index_key: &str,
1330 ) -> Result<RuntimeEvent, RuntimeError> {
1331 let object = exact_object(
1332 params,
1333 &["threadId", "turnId", "itemId", index_key, "delta"],
1334 )?;
1335 let (thread_id, turn_id, item_id) = correlated_reasoning_ids(object, index_key)?;
1336 let delta = object
1337 .get("delta")
1338 .and_then(Value::as_str)
1339 .filter(|delta| delta.len() <= MAX_TEXT_BYTES)
1340 .ok_or(RuntimeError::ProtocolFailure)?;
1341 let turn = self.turn_mut(&thread_id, &turn_id)?;
1342 if turn.model_rerouted || !turn.lifecycle_started {
1343 return Err(RuntimeError::ProtocolFailure);
1344 }
1345 let item = turn
1346 .items
1347 .get_mut(&item_id)
1348 .filter(|item| matches!(item.kind, ItemKind::Reasoning) && !item.lifecycle_complete)
1349 .ok_or(RuntimeError::CorrelationMismatch)?;
1350 if item.delta_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1351 || turn.output_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1352 {
1353 return Err(RuntimeError::OutputLimitExceeded);
1354 }
1355 item.delta_bytes += delta.len();
1356 turn.output_bytes += delta.len();
1357 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
1358 }
1359
1360 fn require_active_reasoning_item(
1361 &self,
1362 thread_id: &str,
1363 turn_id: &str,
1364 item_id: &str,
1365 ) -> Result<(), RuntimeError> {
1366 let turn = self
1367 .turns
1368 .get(&(thread_id.to_owned(), turn_id.to_owned()))
1369 .ok_or(RuntimeError::CorrelationMismatch)?;
1370 if turn.model_rerouted || !turn.lifecycle_started {
1371 return Err(RuntimeError::ProtocolFailure);
1372 }
1373 turn.items
1374 .get(item_id)
1375 .filter(|item| matches!(item.kind, ItemKind::Reasoning) && !item.lifecycle_complete)
1376 .map(|_| ())
1377 .ok_or(RuntimeError::CorrelationMismatch)
1378 }
1379
1380 fn ingest_item_started(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1381 let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1382 let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1383 let item = params.get("item").ok_or(RuntimeError::InvalidInput)?;
1384 let item_type = string_at(item, &["type"])?;
1385 if item_type == "userMessage" {
1386 self.validate_user_message_notification(params, "startedAtMs")?;
1387 return Ok(RuntimeEvent::NonSemanticNotificationIgnored);
1388 }
1389 let item_id = validated_id(string_at(item, &["id"])?)?;
1390 let item_kind = match item_type {
1391 "agentMessage" => ItemKind::AgentMessage,
1392 "reasoning" => ItemKind::Reasoning,
1393 "mcpToolCall" if self.tool_policy == CodexToolPolicy::CanonicalMcpLifecycle => {
1394 if string_at(item, &["status"])? != "inProgress"
1395 || item.get("arguments").is_none()
1396 || !null_or_absent(item, "result")
1397 || !null_or_absent(item, "error")
1398 {
1399 return Err(RuntimeError::ProtocolFailure);
1400 }
1401 ItemKind::McpToolCall(ToolLifecycleBinding {
1402 item_id: item_id.clone(),
1403 server: validated_tool_name(string_at(item, &["server"])?)?,
1404 tool: validated_tool_name(string_at(item, &["tool"])?)?,
1405 arguments_digest: digest_json(
1406 item.get("arguments").ok_or(RuntimeError::ProtocolFailure)?,
1407 )?,
1408 })
1409 }
1410 "mcpToolCall" => {
1411 emit_mcp_tool_policy_refusal_marker(item, McpToolLifecycle::Started);
1412 if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1413 turn.tool_or_side_effect = true;
1414 }
1415 return Err(RuntimeError::SideEffectingItemRejected);
1416 }
1417 _ => {
1418 emit_side_effecting_item_type_marker(item_type);
1419 if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1420 turn.tool_or_side_effect = true;
1421 }
1422 return Err(RuntimeError::SideEffectingItemRejected);
1423 }
1424 };
1425 let tool_call = matches!(item_kind, ItemKind::McpToolCall(_));
1426 let turn = self.turn_mut(&thread_id, &turn_id)?;
1427 if turn.model_rerouted || !turn.lifecycle_started {
1428 return Err(RuntimeError::ProtocolFailure);
1429 }
1430 if turn.items.len() >= MAX_ITEMS_PER_TURN || turn.items.contains_key(&item_id) {
1431 return Err(RuntimeError::CorrelationMismatch);
1432 }
1433 if tool_call {
1434 turn.tool_or_side_effect = true;
1435 }
1436 turn.items.insert(
1437 item_id.clone(),
1438 ItemState {
1439 kind: item_kind,
1440 delta_bytes: 0,
1441 completed: None,
1442 lifecycle_complete: false,
1443 },
1444 );
1445 if tool_call {
1446 Ok(RuntimeEvent::ToolCallStarted {
1447 thread_id,
1448 turn_id,
1449 item_id,
1450 })
1451 } else {
1452 Ok(RuntimeEvent::ItemStarted {
1453 thread_id,
1454 turn_id,
1455 item_id,
1456 })
1457 }
1458 }
1459
1460 fn ingest_delta(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1461 let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1462 let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1463 let item_id = validated_id(string_at(params, &["itemId"])?)?;
1464 let delta = string_at(params, &["delta"])?;
1465 let turn = self.turn_mut(&thread_id, &turn_id)?;
1466 if turn.model_rerouted || !turn.lifecycle_started {
1467 return Err(RuntimeError::ProtocolFailure);
1468 }
1469 let item = turn
1470 .items
1471 .get_mut(&item_id)
1472 .ok_or(RuntimeError::CorrelationMismatch)?;
1473 if !matches!(item.kind, ItemKind::AgentMessage) {
1474 return Err(RuntimeError::CorrelationMismatch);
1475 }
1476 if item.lifecycle_complete
1477 || item.delta_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1478 || turn.output_bytes.saturating_add(delta.len()) > MAX_OUTPUT_BYTES
1479 {
1480 return Err(RuntimeError::OutputLimitExceeded);
1481 }
1482 item.delta_bytes += delta.len();
1483 turn.output_bytes += delta.len();
1484 if !delta.is_empty() {
1485 turn.visible_output = true;
1486 }
1487 Ok(RuntimeEvent::ItemDelta {
1488 thread_id,
1489 turn_id,
1490 item_id,
1491 })
1492 }
1493
1494 fn ingest_item_completed(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1495 let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1496 let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1497 let item = params.get("item").ok_or(RuntimeError::InvalidInput)?;
1498 let item_type = string_at(item, &["type"])?;
1499 if item_type == "userMessage" {
1500 self.validate_user_message_notification(params, "completedAtMs")?;
1501 return Ok(RuntimeEvent::NonSemanticNotificationIgnored);
1502 }
1503 let item_id = validated_id(string_at(item, &["id"])?)?;
1504 if item_type == "mcpToolCall" {
1505 if self.tool_policy != CodexToolPolicy::CanonicalMcpLifecycle {
1506 emit_mcp_tool_policy_refusal_marker(item, McpToolLifecycle::Completed);
1507 let turn = self.turn_mut(&thread_id, &turn_id)?;
1508 turn.tool_or_side_effect = true;
1509 return Err(RuntimeError::SideEffectingItemRejected);
1510 }
1511 let server = validated_tool_name(string_at(item, &["server"])?)?;
1512 let tool = validated_tool_name(string_at(item, &["tool"])?)?;
1513 let arguments_digest =
1514 digest_json(item.get("arguments").ok_or(RuntimeError::ProtocolFailure)?)?;
1515 let status = match string_at(item, &["status"])? {
1516 "completed" if non_null(item, "result") && null_or_absent(item, "error") => {
1517 ToolCallStatus::Completed
1518 }
1519 "failed" if non_null(item, "result") || non_null(item, "error") => {
1520 ToolCallStatus::Failed
1521 }
1522 _ => return Err(RuntimeError::ProtocolFailure),
1523 };
1524 let turn = self.turn_mut(&thread_id, &turn_id)?;
1525 if turn.model_rerouted || !turn.lifecycle_started {
1526 return Err(RuntimeError::ProtocolFailure);
1527 }
1528 let state = turn
1529 .items
1530 .get_mut(&item_id)
1531 .ok_or(RuntimeError::CorrelationMismatch)?;
1532 match &state.kind {
1533 ItemKind::McpToolCall(binding)
1534 if binding.item_id == item_id
1535 && binding.server == server
1536 && binding.tool == tool
1537 && binding.arguments_digest == arguments_digest => {}
1538 _ => return Err(RuntimeError::CorrelationMismatch),
1539 }
1540 if state.lifecycle_complete {
1541 return Err(RuntimeError::CorrelationMismatch);
1542 }
1543 state.lifecycle_complete = true;
1544 return Ok(RuntimeEvent::ToolCallCompleted {
1545 thread_id,
1546 turn_id,
1547 item_id,
1548 status,
1549 });
1550 }
1551 if !matches!(item_type, "agentMessage" | "reasoning") {
1552 emit_side_effecting_item_type_marker(item_type);
1553 if let Ok(turn) = self.turn_mut(&thread_id, &turn_id) {
1554 turn.tool_or_side_effect = true;
1555 }
1556 return Err(RuntimeError::SideEffectingItemRejected);
1557 }
1558 let text = if item_type == "agentMessage" {
1559 Some(bounded_output(string_at(item, &["text"])?)?)
1560 } else {
1561 None
1562 };
1563 let turn = self.turn_mut(&thread_id, &turn_id)?;
1564 if turn.model_rerouted || !turn.lifecycle_started {
1565 return Err(RuntimeError::ProtocolFailure);
1566 }
1567 let item = turn
1568 .items
1569 .get_mut(&item_id)
1570 .ok_or(RuntimeError::CorrelationMismatch)?;
1571 let expected_kind = if item_type == "agentMessage" {
1572 matches!(item.kind, ItemKind::AgentMessage)
1573 } else {
1574 matches!(item.kind, ItemKind::Reasoning)
1575 };
1576 if !expected_kind || item.lifecycle_complete {
1577 return Err(RuntimeError::CorrelationMismatch);
1578 }
1579 let text_len = text.as_ref().map_or(0, String::len);
1580 if turn.output_bytes.saturating_add(text_len) > MAX_OUTPUT_BYTES {
1581 return Err(RuntimeError::OutputLimitExceeded);
1582 }
1583 turn.output_bytes += text_len;
1584 item.completed = text;
1585 item.lifecycle_complete = true;
1586 if item_type == "agentMessage" {
1587 turn.final_item = Some(item_id.clone());
1588 turn.visible_output = true;
1589 }
1590 Ok(RuntimeEvent::ItemCompleted {
1591 thread_id,
1592 turn_id,
1593 item_id,
1594 })
1595 }
1596
1597 fn validate_user_message_notification(
1598 &self,
1599 params: &Value,
1600 timestamp_key: &str,
1601 ) -> Result<(), RuntimeError> {
1602 let object = exact_object(params, &["item", "threadId", "turnId", timestamp_key])?;
1603 let thread_id = validated_id(
1604 object
1605 .get("threadId")
1606 .and_then(Value::as_str)
1607 .ok_or(RuntimeError::ProtocolFailure)?,
1608 )?;
1609 let turn_id = validated_id(
1610 object
1611 .get("turnId")
1612 .and_then(Value::as_str)
1613 .ok_or(RuntimeError::ProtocolFailure)?,
1614 )?;
1615 if object.get(timestamp_key).and_then(Value::as_u64).is_none() {
1616 return Err(RuntimeError::ProtocolFailure);
1617 }
1618 let item = exact_object(
1619 object.get("item").ok_or(RuntimeError::ProtocolFailure)?,
1620 &["type", "id", "clientId", "content"],
1621 )?;
1622 if item.get("type").and_then(Value::as_str) != Some("userMessage") {
1623 return Err(RuntimeError::ProtocolFailure);
1624 }
1625 validated_id(
1626 item.get("id")
1627 .and_then(Value::as_str)
1628 .ok_or(RuntimeError::ProtocolFailure)?,
1629 )?;
1630 validated_id(
1631 item.get("clientId")
1632 .and_then(Value::as_str)
1633 .ok_or(RuntimeError::ProtocolFailure)?,
1634 )?;
1635 let content = item
1636 .get("content")
1637 .and_then(Value::as_array)
1638 .filter(|content| content.len() == 1)
1639 .ok_or(RuntimeError::ProtocolFailure)?;
1640 let text = exact_object(&content[0], &["type", "text", "text_elements"])?;
1641 if text.get("type").and_then(Value::as_str) != Some("text")
1642 || text
1643 .get("text")
1644 .and_then(Value::as_str)
1645 .is_none_or(|text| text.len() > MAX_TEXT_BYTES || text.contains('\0'))
1646 || text
1647 .get("text_elements")
1648 .and_then(Value::as_array)
1649 .is_none_or(|elements| !elements.is_empty())
1650 {
1651 return Err(RuntimeError::ProtocolFailure);
1652 }
1653 let turn = self
1654 .turns
1655 .get(&(thread_id, turn_id))
1656 .ok_or(RuntimeError::CorrelationMismatch)?;
1657 if !turn.lifecycle_started || turn.model_rerouted {
1658 return Err(RuntimeError::ProtocolFailure);
1659 }
1660 Ok(())
1661 }
1662
1663 fn ingest_rerouted(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1664 let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1665 let turn_id = validated_id(string_at(params, &["turnId"])?)?;
1666 let from_model = validated_id(string_at(params, &["fromModel"])?)?;
1667 let to_model = validated_id(string_at(params, &["toModel"])?)?;
1668 let turn = self.turn_mut(&thread_id, &turn_id)?;
1669 if turn.model != from_model {
1670 return Err(RuntimeError::CorrelationMismatch);
1671 }
1672 turn.model_rerouted = true;
1673 Ok(RuntimeEvent::ModelReroutedFailure {
1674 thread_id,
1675 turn_id,
1676 from_model,
1677 to_model,
1678 })
1679 }
1680
1681 fn ingest_turn_completed(&mut self, params: &Value) -> Result<RuntimeEvent, RuntimeError> {
1682 let thread_id = validated_id(string_at(params, &["threadId"])?)?;
1683 let turn_id = validated_id(string_at(params, &["turn", "id"])?)?;
1684 let turn_value = params.get("turn").ok_or(RuntimeError::InvalidInput)?;
1685 let (status, failure) = match string_at(turn_value, &["status"])? {
1686 "completed" if null_or_absent(turn_value, "error") => (TerminalStatus::Completed, None),
1687 "failed" => {
1688 let error = turn_value
1689 .get("error")
1690 .filter(|error| error.is_object())
1691 .ok_or(RuntimeError::ProtocolFailure)?;
1692 (TerminalStatus::Failed, Some(parse_terminal_failure(error)))
1693 }
1694 "interrupted" if null_or_absent(turn_value, "error") => {
1695 (TerminalStatus::Interrupted, None)
1696 }
1697 _ => return Err(RuntimeError::InvalidInput),
1698 };
1699 let turn = self
1700 .turns
1701 .remove(&(thread_id.clone(), turn_id.clone()))
1702 .ok_or(RuntimeError::CorrelationMismatch)?;
1703 if !turn.lifecycle_started || turn.items.values().any(|item| !item.lifecycle_complete) {
1704 return Err(RuntimeError::ProtocolFailure);
1705 }
1706 if turn.model_rerouted && status != TerminalStatus::Failed {
1707 return Err(RuntimeError::ProtocolFailure);
1708 }
1709 if status == TerminalStatus::Interrupted && !turn.interrupt_requested {
1710 return Err(RuntimeError::CorrelationMismatch);
1711 }
1712 let usage = match status {
1713 TerminalStatus::Completed => {
1714 turn.total_usage.ok_or(RuntimeError::ProtocolFailure)?;
1719 Some(turn.usage.ok_or(RuntimeError::ProtocolFailure)?)
1720 }
1721 TerminalStatus::Failed | TerminalStatus::Interrupted => turn.usage,
1722 };
1723 let output = turn
1724 .final_item
1725 .as_ref()
1726 .and_then(|item_id| turn.items.get(item_id))
1727 .and_then(|item| item.completed.clone());
1728 Ok(RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
1729 thread_id,
1730 turn_id,
1731 selected_model: turn.model.clone(),
1732 selected_effort: turn.effort,
1733 status,
1734 output,
1735 replay: turn.replay(),
1736 failure,
1737 input_tokens: usage.map(|usage| usage.input_tokens),
1738 output_tokens: usage.map(|usage| usage.output_tokens),
1739 }))
1740 }
1741
1742 fn turn_mut(&mut self, thread_id: &str, turn_id: &str) -> Result<&mut TurnState, RuntimeError> {
1743 self.turns
1744 .get_mut(&(thread_id.to_owned(), turn_id.to_owned()))
1745 .ok_or(RuntimeError::CorrelationMismatch)
1746 }
1747
1748 fn discard_notification_first_turn(&mut self, started: Option<&PendingTurnStarted>) {
1749 if let Some(started) = started {
1750 self.turns
1751 .remove(&(started.thread_id.clone(), started.turn_id.clone()));
1752 }
1753 }
1754}
1755
1756fn side_effecting_item_type_marker(item_type: &str) -> String {
1757 let item_type_sha256 = Sha256::digest(item_type.as_bytes())
1758 .iter()
1759 .map(|byte| format!("{byte:02x}"))
1760 .collect::<String>();
1761 format!("steadymux-owned-child: side-effecting-item-type-sha256={item_type_sha256}")
1762}
1763
1764fn emit_side_effecting_item_type_marker(item_type: &str) {
1765 eprintln!("{}", side_effecting_item_type_marker(item_type));
1766}
1767
1768#[derive(Clone, Copy)]
1769enum McpToolLifecycle {
1770 Started,
1771 Completed,
1772}
1773
1774fn mcp_tool_policy_refusal_marker(item: &Value, lifecycle: McpToolLifecycle) -> String {
1775 let lifecycle = match lifecycle {
1776 McpToolLifecycle::Started => "started",
1777 McpToolLifecycle::Completed => "completed",
1778 };
1779 let status = match item.get("status").and_then(Value::as_str) {
1780 Some("inProgress") => "in-progress",
1781 Some("completed") => "completed",
1782 Some("failed") => "failed",
1783 _ => "unknown",
1784 };
1785 let server = mcp_tool_identity_commitment(item.get("server").and_then(Value::as_str));
1786 let tool = mcp_tool_identity_commitment(item.get("tool").and_then(Value::as_str));
1787 format!(
1788 "steadymux-owned-child: side-effecting-item-refused=mcp-tool-policy \
1789 lifecycle={lifecycle} status={status} server-sha256={server} tool-sha256={tool}"
1790 )
1791}
1792
1793fn mcp_tool_identity_commitment(value: Option<&str>) -> String {
1794 let Some(value) = value else {
1795 return "missing-or-invalid".to_owned();
1796 };
1797 if validated_tool_name(value).is_err() {
1798 return "missing-or-invalid".to_owned();
1799 }
1800 Sha256::digest(value.as_bytes())
1801 .iter()
1802 .map(|byte| format!("{byte:02x}"))
1803 .collect()
1804}
1805
1806fn emit_mcp_tool_policy_refusal_marker(item: &Value, lifecycle: McpToolLifecycle) {
1807 eprintln!("{}", mcp_tool_policy_refusal_marker(item, lifecycle));
1808}
1809
1810fn encode_line(value: &Value) -> Result<String, RuntimeError> {
1811 let mut encoded = serde_json::to_string(value).map_err(|_| RuntimeError::InvalidInput)?;
1812 encoded.push('\n');
1813 Ok(encoded)
1814}
1815
1816fn validated_id(value: &str) -> Result<String, RuntimeError> {
1817 if value.is_empty()
1818 || value.len() > MAX_ID_BYTES
1819 || !value
1820 .bytes()
1821 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b':'))
1822 {
1823 return Err(RuntimeError::InvalidInput);
1824 }
1825 Ok(value.to_owned())
1826}
1827
1828fn bounded_text(value: &str) -> Result<String, RuntimeError> {
1829 if value.len() > MAX_ID_BYTES || value.chars().any(char::is_control) {
1830 return Err(RuntimeError::InvalidInput);
1831 }
1832 Ok(value.to_owned())
1833}
1834
1835fn bounded_output(value: &str) -> Result<String, RuntimeError> {
1836 if value.len() > MAX_OUTPUT_BYTES {
1837 return Err(RuntimeError::OutputLimitExceeded);
1838 }
1839 Ok(value.to_owned())
1840}
1841
1842fn validated_instruction(value: Option<&str>) -> Result<Option<String>, RuntimeError> {
1843 let Some(value) = value else {
1844 return Ok(None);
1845 };
1846 if value.len() > MAX_TEXT_BYTES || value.contains('\0') {
1847 return Err(RuntimeError::InvalidInput);
1848 }
1849 if looks_like_secret(value) {
1850 return Err(RuntimeError::SensitiveInputRejected);
1851 }
1852 Ok(Some(value.to_owned()))
1853}
1854
1855fn validated_tool_name(value: &str) -> Result<String, RuntimeError> {
1856 if value.is_empty() || value.len() > MAX_ID_BYTES || value.chars().any(char::is_control) {
1857 return Err(RuntimeError::InvalidInput);
1858 }
1859 Ok(value.to_owned())
1860}
1861
1862fn valid_opaque_control_value(value: Option<&Value>) -> bool {
1863 value.and_then(Value::as_str).is_some_and(|value| {
1864 !value.is_empty() && value.len() <= MAX_ID_BYTES && !value.chars().any(char::is_control)
1865 })
1866}
1867
1868fn validate_ephemeral_empty_thread(value: &Value) -> Result<String, RuntimeError> {
1869 const REQUIRED: [&str; 12] = [
1870 "cliVersion",
1871 "createdAt",
1872 "cwd",
1873 "ephemeral",
1874 "id",
1875 "modelProvider",
1876 "preview",
1877 "sessionId",
1878 "source",
1879 "status",
1880 "turns",
1881 "updatedAt",
1882 ];
1883 const OPTIONAL: [&str; 14] = [
1884 "agentNickname",
1885 "agentRole",
1886 "canAcceptDirectInput",
1887 "extra",
1888 "forkedFromId",
1889 "gitInfo",
1890 "historyMode",
1891 "name",
1892 "parentThreadId",
1893 "path",
1894 "recencyAt",
1895 "section",
1896 "sectionEnteredAt",
1897 "threadSource",
1898 ];
1899 const NULL_ONLY: [&str; 11] = [
1900 "agentNickname",
1901 "agentRole",
1902 "extra",
1903 "forkedFromId",
1904 "gitInfo",
1905 "name",
1906 "parentThreadId",
1907 "path",
1908 "section",
1909 "sectionEnteredAt",
1910 "threadSource",
1911 ];
1912
1913 let thread = value.as_object().ok_or(RuntimeError::ProtocolFailure)?;
1914 let missing_keys = REQUIRED
1915 .iter()
1916 .filter(|key| !thread.contains_key(**key))
1917 .copied()
1918 .collect::<Vec<_>>();
1919 let unknown_keys = thread
1920 .keys()
1921 .filter(|key| !REQUIRED.contains(&key.as_str()) && !OPTIONAL.contains(&key.as_str()))
1922 .map(String::as_str)
1923 .collect::<Vec<_>>();
1924 let refusal = if !missing_keys.is_empty() || !unknown_keys.is_empty() {
1925 for key in missing_keys.iter().chain(unknown_keys.iter()) {
1926 let key_sha256 = Sha256::digest(key.as_bytes())
1927 .iter()
1928 .map(|byte| format!("{byte:02x}"))
1929 .collect::<String>();
1930 eprintln!("steadymux-owned-child: thread-started-key-sha256={key_sha256}");
1931 }
1932 eprintln!(
1933 "steadymux-owned-child: thread-started-key-counts=missing-{}-unknown-{}",
1934 missing_keys.len(),
1935 unknown_keys.len()
1936 );
1937 Some("keys")
1938 } else if NULL_ONLY
1939 .iter()
1940 .any(|key| thread.get(*key).is_some_and(|value| !value.is_null()))
1941 {
1942 Some("null-only")
1943 } else if thread.get("ephemeral").and_then(Value::as_bool) != Some(true)
1944 || thread.get("modelProvider").and_then(Value::as_str) != Some("openai")
1945 || !matches!(
1946 thread.get("source").and_then(Value::as_str),
1947 Some("appServer" | "vscode")
1948 )
1949 {
1950 Some("core")
1951 } else if thread.get("cliVersion").and_then(Value::as_str)
1952 != Some(CURRENT_STANDALONE_CLI_VERSION)
1953 {
1954 Some("version")
1955 } else if !valid_idle_thread_status(thread.get("status")) {
1956 Some("status")
1957 } else if thread
1958 .get("turns")
1959 .and_then(Value::as_array)
1960 .is_none_or(|turns| !turns.is_empty())
1961 {
1962 Some("turns")
1963 } else if thread
1964 .get("preview")
1965 .and_then(Value::as_str)
1966 .is_none_or(|preview| preview.len() > MAX_TEXT_BYTES)
1967 {
1968 Some("preview")
1969 } else if !valid_thread_path(thread.get("cwd")) {
1970 Some("cwd")
1971 } else if thread.get("recencyAt").is_some_and(|value| {
1972 !value.is_null() && value.as_i64().is_none_or(|timestamp| timestamp < 0)
1973 }) || thread
1974 .get("canAcceptDirectInput")
1975 .is_some_and(|value| value.as_bool() != Some(true))
1976 || thread
1977 .get("historyMode")
1978 .is_some_and(|value| !matches!(value.as_str(), Some("legacy" | "paginated")))
1979 {
1980 Some("optional")
1981 } else {
1982 None
1983 };
1984 if let Some(class) = refusal {
1985 eprintln!("steadymux-owned-child: thread-started-refused={class}");
1986 return Err(RuntimeError::ProtocolFailure);
1987 }
1988
1989 let created_at = thread
1990 .get("createdAt")
1991 .and_then(Value::as_i64)
1992 .filter(|timestamp| *timestamp >= 0)
1993 .ok_or_else(|| {
1994 eprintln!("steadymux-owned-child: thread-started-refused=timestamps");
1995 RuntimeError::ProtocolFailure
1996 })?;
1997 thread
1998 .get("updatedAt")
1999 .and_then(Value::as_i64)
2000 .filter(|timestamp| *timestamp >= created_at)
2001 .ok_or_else(|| {
2002 eprintln!("steadymux-owned-child: thread-started-refused=timestamps");
2003 RuntimeError::ProtocolFailure
2004 })?;
2005 validated_id(
2006 thread
2007 .get("sessionId")
2008 .and_then(Value::as_str)
2009 .ok_or_else(|| {
2010 eprintln!("steadymux-owned-child: thread-started-refused=ids");
2011 RuntimeError::ProtocolFailure
2012 })?,
2013 )?;
2014 validated_id(thread.get("id").and_then(Value::as_str).ok_or_else(|| {
2015 eprintln!("steadymux-owned-child: thread-started-refused=ids");
2016 RuntimeError::ProtocolFailure
2017 })?)
2018}
2019
2020fn valid_idle_thread_status(value: Option<&Value>) -> bool {
2021 value.and_then(Value::as_object).is_some_and(|status| {
2022 status.len() == 1 && status.get("type").and_then(Value::as_str) == Some("idle")
2023 })
2024}
2025
2026fn valid_thread_path(value: Option<&Value>) -> bool {
2027 let Some(value) = value.and_then(Value::as_str) else {
2028 return false;
2029 };
2030 if value.is_empty()
2031 || value.len() > MAX_THREAD_PATH_BYTES
2032 || value.chars().any(char::is_control)
2033 {
2034 return false;
2035 }
2036 let path = Path::new(value);
2037 path.is_absolute()
2038 && path
2039 .components()
2040 .all(|component| matches!(component, Component::RootDir | Component::Normal(_)))
2041}
2042
2043fn valid_json_rpc_error_response(value: &Value) -> bool {
2044 let Some(response) = value.as_object() else {
2045 return false;
2046 };
2047 if !(response.len() == 2 || response.len() == 3)
2048 || !response.contains_key("id")
2049 || !response.contains_key("error")
2050 || !response
2051 .keys()
2052 .all(|key| matches!(key.as_str(), "id" | "error" | "jsonrpc"))
2053 || response
2054 .get("jsonrpc")
2055 .is_some_and(|version| version.as_str() != Some("2.0"))
2056 {
2057 return false;
2058 }
2059 let Some(error) = response.get("error").and_then(Value::as_object) else {
2060 return false;
2061 };
2062 (error.len() == 2 || error.len() == 3)
2063 && error.get("code").and_then(Value::as_i64).is_some()
2064 && error.get("message").and_then(Value::as_str).is_some()
2065 && error
2066 .keys()
2067 .all(|key| matches!(key.as_str(), "code" | "message" | "data"))
2068}
2069
2070fn exact_object<'a>(
2071 value: &'a Value,
2072 expected_keys: &[&str],
2073) -> Result<&'a serde_json::Map<String, Value>, RuntimeError> {
2074 let object = value.as_object().ok_or(RuntimeError::ProtocolFailure)?;
2075 if object.len() != expected_keys.len()
2076 || expected_keys.iter().any(|key| !object.contains_key(*key))
2077 {
2078 return Err(RuntimeError::ProtocolFailure);
2079 }
2080 Ok(object)
2081}
2082
2083fn correlated_reasoning_ids(
2084 object: &serde_json::Map<String, Value>,
2085 index_key: &str,
2086) -> Result<(String, String, String), RuntimeError> {
2087 let thread_id = validated_id(
2088 object
2089 .get("threadId")
2090 .and_then(Value::as_str)
2091 .ok_or(RuntimeError::ProtocolFailure)?,
2092 )?;
2093 let turn_id = validated_id(
2094 object
2095 .get("turnId")
2096 .and_then(Value::as_str)
2097 .ok_or(RuntimeError::ProtocolFailure)?,
2098 )?;
2099 let item_id = validated_id(
2100 object
2101 .get("itemId")
2102 .and_then(Value::as_str)
2103 .ok_or(RuntimeError::ProtocolFailure)?,
2104 )?;
2105 let valid_index = object
2106 .get(index_key)
2107 .and_then(Value::as_u64)
2108 .is_some_and(|index| index < MAX_ITEMS_PER_TURN as u64);
2109 if !valid_index {
2110 return Err(RuntimeError::ProtocolFailure);
2111 }
2112 Ok((thread_id, turn_id, item_id))
2113}
2114
2115fn token_usage_refusal(class: &'static str) -> RuntimeError {
2116 eprintln!("steadymux-owned-child: token-usage-refused={class}");
2117 RuntimeError::ProtocolFailure
2118}
2119
2120#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2121enum TokenUsageCorrelationRefusal {
2122 MissingPair,
2123 Rerouted,
2124 PriorUsage,
2125}
2126
2127#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2128enum PriorUsageDirection {
2129 Unavailable,
2130 Same,
2131 MonotonicNondecreasing,
2132 DecreaseOrMixed,
2133}
2134
2135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2136enum TotalUsageRelativeToLast {
2137 Equal,
2138 TotalNondecreasing,
2139 TotalDecreaseOrMixed,
2140}
2141
2142impl TotalUsageRelativeToLast {
2143 const fn as_str(self) -> &'static str {
2144 match self {
2145 Self::Equal => "equal",
2146 Self::TotalNondecreasing => "total-nondecreasing",
2147 Self::TotalDecreaseOrMixed => "total-decrease-or-mixed",
2148 }
2149 }
2150}
2151
2152impl PriorUsageDirection {
2153 const fn as_str(self) -> &'static str {
2154 match self {
2155 Self::Unavailable => "unavailable",
2156 Self::Same => "same",
2157 Self::MonotonicNondecreasing => "monotonic-nondecreasing",
2158 Self::DecreaseOrMixed => "decrease-or-mixed",
2159 }
2160 }
2161}
2162
2163fn prior_usage_direction(
2164 previous: TurnUsageCounts,
2165 incoming: TurnUsageCounts,
2166) -> PriorUsageDirection {
2167 if previous == incoming {
2168 PriorUsageDirection::Same
2169 } else if incoming.input_tokens >= previous.input_tokens
2170 && incoming.output_tokens >= previous.output_tokens
2171 {
2172 PriorUsageDirection::MonotonicNondecreasing
2173 } else {
2174 PriorUsageDirection::DecreaseOrMixed
2175 }
2176}
2177
2178fn prior_usage_direction_marker(direction: PriorUsageDirection) -> String {
2179 format!(
2180 "steadymux-owned-child: token-usage-prior-direction={}",
2181 direction.as_str()
2182 )
2183}
2184
2185fn emit_prior_usage_direction_marker(direction: PriorUsageDirection) {
2186 eprintln!("{}", prior_usage_direction_marker(direction));
2187}
2188
2189fn total_usage_relative_to_last(
2190 last: TurnUsageCounts,
2191 total: CumulativeTurnUsageCounts,
2192) -> TotalUsageRelativeToLast {
2193 if total.input_tokens == last.input_tokens && total.output_tokens == last.output_tokens {
2194 TotalUsageRelativeToLast::Equal
2195 } else if total.input_tokens >= last.input_tokens && total.output_tokens >= last.output_tokens {
2196 TotalUsageRelativeToLast::TotalNondecreasing
2197 } else {
2198 TotalUsageRelativeToLast::TotalDecreaseOrMixed
2199 }
2200}
2201
2202fn cumulative_usage_direction(
2203 previous: CumulativeTurnUsageCounts,
2204 incoming: CumulativeTurnUsageCounts,
2205) -> PriorUsageDirection {
2206 if previous == incoming {
2207 PriorUsageDirection::Same
2208 } else if incoming.cached_input_tokens >= previous.cached_input_tokens
2209 && cache_write_non_decreasing(
2210 previous.cache_write_input_tokens,
2211 incoming.cache_write_input_tokens,
2212 )
2213 && incoming.input_tokens >= previous.input_tokens
2214 && incoming.output_tokens >= previous.output_tokens
2215 && incoming.reasoning_output_tokens >= previous.reasoning_output_tokens
2216 && incoming.total_tokens >= previous.total_tokens
2217 {
2218 PriorUsageDirection::MonotonicNondecreasing
2219 } else {
2220 PriorUsageDirection::DecreaseOrMixed
2221 }
2222}
2223
2224fn cache_write_non_decreasing(previous: Option<u64>, incoming: Option<u64>) -> bool {
2228 match (previous, incoming) {
2229 (Some(previous), Some(incoming)) => incoming >= previous,
2230 _ => true,
2231 }
2232}
2233
2234fn prior_usage_projection_marker(
2235 total_relative_to_last: TotalUsageRelativeToLast,
2236 total_direction: PriorUsageDirection,
2237) -> String {
2238 format!(
2239 "steadymux-owned-child: token-usage-prior-projections total-vs-last={} total-vs-prior={}",
2240 total_relative_to_last.as_str(),
2241 total_direction.as_str(),
2242 )
2243}
2244
2245fn emit_prior_usage_projection_marker(
2246 total_relative_to_last: TotalUsageRelativeToLast,
2247 total_direction: PriorUsageDirection,
2248) {
2249 eprintln!(
2250 "{}",
2251 prior_usage_projection_marker(total_relative_to_last, total_direction)
2252 );
2253}
2254
2255impl TokenUsageCorrelationRefusal {
2256 const fn as_str(self) -> &'static str {
2257 match self {
2258 Self::MissingPair => "missing-pair",
2259 Self::Rerouted => "rerouted",
2260 Self::PriorUsage => "prior-usage",
2261 }
2262 }
2263}
2264
2265fn emit_token_usage_correlation_marker(
2266 reason: TokenUsageCorrelationRefusal,
2267 incoming_thread_id: &str,
2268 incoming_turn_id: &str,
2269 turns: &BTreeMap<(String, String), TurnState>,
2270) {
2271 eprintln!(
2272 "{}",
2273 token_usage_correlation_marker(reason, incoming_thread_id, incoming_turn_id, turns)
2274 );
2275}
2276
2277fn token_usage_correlation_marker(
2278 reason: TokenUsageCorrelationRefusal,
2279 incoming_thread_id: &str,
2280 incoming_turn_id: &str,
2281 turns: &BTreeMap<(String, String), TurnState>,
2282) -> String {
2283 fn sha256_hex(value: &[u8]) -> String {
2284 Sha256::digest(value)
2285 .iter()
2286 .map(|byte| format!("{byte:02x}"))
2287 .collect()
2288 }
2289
2290 fn update_id(hasher: &mut Sha256, id: &str) {
2291 hasher.update((id.len() as u64).to_be_bytes());
2292 hasher.update(id.as_bytes());
2293 }
2294
2295 fn id_commitment(domain: &[u8], ids: impl IntoIterator<Item = String>) -> String {
2296 let mut digest = Sha256::new();
2297 digest.update(domain);
2298 for id in ids {
2299 update_id(&mut digest, &id);
2300 }
2301 digest
2302 .finalize()
2303 .iter()
2304 .map(|byte| format!("{byte:02x}"))
2305 .collect()
2306 }
2307
2308 let mut known_pairs = Sha256::new();
2309 known_pairs.update(b"steadymux.token-usage-known-pairs.v1\0");
2310 for (thread_id, turn_id) in turns.keys() {
2311 update_id(&mut known_pairs, thread_id);
2312 update_id(&mut known_pairs, turn_id);
2313 }
2314 let known_pairs_sha256 = known_pairs
2315 .finalize()
2316 .iter()
2317 .map(|byte| format!("{byte:02x}"))
2318 .collect::<String>();
2319 let known_active_threads_sha256 = id_commitment(
2320 b"steadymux.token-usage-known-active-threads.v1\0",
2321 turns
2322 .keys()
2323 .map(|(thread_id, _)| thread_id.clone())
2324 .collect::<BTreeSet<_>>(),
2325 );
2326 let known_active_turns_sha256 = id_commitment(
2327 b"steadymux.token-usage-known-active-turns.v1\0",
2328 turns
2329 .keys()
2330 .map(|(_, turn_id)| turn_id.clone())
2331 .collect::<BTreeSet<_>>(),
2332 );
2333
2334 format!(
2335 "steadymux-owned-child: token-usage-correlation reason={} incoming-thread-sha256={} incoming-turn-sha256={} known-active-threads-sha256={known_active_threads_sha256} known-active-turns-sha256={known_active_turns_sha256} known-active-pairs-sha256={known_pairs_sha256} known-active-pair-count={}",
2336 reason.as_str(),
2337 sha256_hex(incoming_thread_id.as_bytes()),
2338 sha256_hex(incoming_turn_id.as_bytes()),
2339 turns.len(),
2340 )
2341}
2342
2343#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2344enum TokenBreakdownValidation {
2345 Valid,
2346 Shape,
2347 Keys,
2348 Counts,
2349}
2350
2351fn validated_last_turn_usage(value: Option<&Value>) -> Result<TurnUsageCounts, RuntimeError> {
2352 let Some(usage) = value.and_then(Value::as_object) else {
2353 return Err(token_usage_refusal("usage-keys"));
2354 };
2355 if !(usage.len() == 2 || usage.len() == 3)
2356 || !usage.contains_key("last")
2357 || !usage.contains_key("total")
2358 || !usage
2359 .keys()
2360 .all(|key| matches!(key.as_str(), "last" | "total" | "modelContextWindow"))
2361 {
2362 return Err(token_usage_refusal("usage-keys"));
2363 }
2364 match valid_token_breakdown(usage.get("last")) {
2365 TokenBreakdownValidation::Valid => {}
2366 TokenBreakdownValidation::Shape => return Err(token_usage_refusal("last-shape")),
2367 TokenBreakdownValidation::Keys => {
2368 emit_token_breakdown_key_diagnostics(usage.get("last"));
2369 return Err(token_usage_refusal("last-counts"));
2370 }
2371 TokenBreakdownValidation::Counts => return Err(token_usage_refusal("last-counts")),
2372 }
2373 match valid_token_breakdown(usage.get("total")) {
2374 TokenBreakdownValidation::Valid => {}
2375 TokenBreakdownValidation::Keys => {
2376 emit_token_breakdown_key_diagnostics(usage.get("total"));
2377 return Err(token_usage_refusal("total-shape"));
2378 }
2379 TokenBreakdownValidation::Shape | TokenBreakdownValidation::Counts => {
2380 return Err(token_usage_refusal("total-shape"));
2381 }
2382 }
2383 if !usage
2384 .get("modelContextWindow")
2385 .is_none_or(|value| value.is_null() || value.as_i64().is_some_and(|window| window >= 0))
2386 {
2387 return Err(token_usage_refusal("context-window"));
2388 }
2389 let last = usage
2390 .get("last")
2391 .and_then(Value::as_object)
2392 .ok_or_else(|| token_usage_refusal("last-shape"))?;
2393 let input_tokens = last
2394 .get("inputTokens")
2395 .and_then(Value::as_i64)
2396 .and_then(|tokens| u64::try_from(tokens).ok())
2397 .ok_or_else(|| token_usage_refusal("last-counts"))?;
2398 let output_tokens = last
2399 .get("outputTokens")
2400 .and_then(Value::as_i64)
2401 .and_then(|tokens| u64::try_from(tokens).ok())
2402 .ok_or_else(|| token_usage_refusal("last-counts"))?;
2403 Ok(TurnUsageCounts {
2404 input_tokens,
2405 output_tokens,
2406 })
2407}
2408
2409fn validated_total_turn_usage(
2413 value: Option<&Value>,
2414) -> Result<CumulativeTurnUsageCounts, RuntimeError> {
2415 let total = value
2416 .and_then(Value::as_object)
2417 .and_then(|usage| usage.get("total"))
2418 .and_then(Value::as_object)
2419 .ok_or_else(|| token_usage_refusal("total-shape"))?;
2420 let count = |key| {
2421 total
2422 .get(key)
2423 .and_then(Value::as_i64)
2424 .and_then(|tokens| u64::try_from(tokens).ok())
2425 .ok_or_else(|| token_usage_refusal("total-shape"))
2426 };
2427 Ok(CumulativeTurnUsageCounts {
2428 cached_input_tokens: count("cachedInputTokens")?,
2429 cache_write_input_tokens: total
2430 .get("cacheWriteInputTokens")
2431 .map(|value| {
2432 value
2433 .as_i64()
2434 .and_then(|tokens| u64::try_from(tokens).ok())
2435 .ok_or_else(|| token_usage_refusal("total-shape"))
2436 })
2437 .transpose()?,
2438 input_tokens: count("inputTokens")?,
2439 output_tokens: count("outputTokens")?,
2440 reasoning_output_tokens: count("reasoningOutputTokens")?,
2441 total_tokens: count("totalTokens")?,
2442 })
2443}
2444
2445fn valid_token_breakdown(value: Option<&Value>) -> TokenBreakdownValidation {
2446 const REQUIRED: [&str; 5] = [
2447 "cachedInputTokens",
2448 "inputTokens",
2449 "outputTokens",
2450 "reasoningOutputTokens",
2451 "totalTokens",
2452 ];
2453 let Some(breakdown) = value.and_then(Value::as_object) else {
2454 return TokenBreakdownValidation::Shape;
2455 };
2456 if !(breakdown.len() == REQUIRED.len() || breakdown.len() == REQUIRED.len() + 1) {
2457 return TokenBreakdownValidation::Keys;
2458 }
2459 if !breakdown
2460 .keys()
2461 .all(|key| REQUIRED.contains(&key.as_str()) || key.as_str() == "cacheWriteInputTokens")
2462 {
2463 return TokenBreakdownValidation::Keys;
2464 }
2465 if REQUIRED.iter().any(|key| {
2466 breakdown
2467 .get(*key)
2468 .and_then(Value::as_i64)
2469 .is_none_or(|tokens| tokens < 0)
2470 }) {
2471 return TokenBreakdownValidation::Counts;
2472 }
2473 if !breakdown
2474 .get("cacheWriteInputTokens")
2475 .is_none_or(|value| value.as_i64().is_some_and(|tokens| tokens >= 0))
2476 {
2477 return TokenBreakdownValidation::Counts;
2478 }
2479 TokenBreakdownValidation::Valid
2480}
2481
2482fn emit_token_breakdown_key_diagnostics(value: Option<&Value>) {
2483 const REQUIRED: [&str; 5] = [
2484 "cachedInputTokens",
2485 "inputTokens",
2486 "outputTokens",
2487 "reasoningOutputTokens",
2488 "totalTokens",
2489 ];
2490 let Some(breakdown) = value.and_then(Value::as_object) else {
2491 return;
2492 };
2493 let required = REQUIRED.into_iter().collect::<BTreeSet<_>>();
2494 let allowed = required
2495 .iter()
2496 .copied()
2497 .chain(std::iter::once("cacheWriteInputTokens"))
2498 .collect::<BTreeSet<_>>();
2499 let actual = breakdown
2500 .keys()
2501 .map(String::as_str)
2502 .collect::<BTreeSet<_>>();
2503 let missing = required.difference(&actual).copied().collect::<Vec<_>>();
2504 let unknown = actual.difference(&allowed).copied().collect::<Vec<_>>();
2505 for key in missing.iter().chain(unknown.iter()) {
2506 let key_sha256 = Sha256::digest(key.as_bytes())
2507 .iter()
2508 .map(|byte| format!("{byte:02x}"))
2509 .collect::<String>();
2510 eprintln!("steadymux-owned-child: token-usage-breakdown-key-sha256={key_sha256}");
2511 }
2512 eprintln!(
2513 "steadymux-owned-child: token-usage-breakdown-key-counts=missing-{}-unknown-{}",
2514 missing.len(),
2515 unknown.len()
2516 );
2517}
2518
2519fn validated_notification_method(value: &str) -> Result<String, RuntimeError> {
2520 if value.is_empty()
2521 || value.len() > MAX_ID_BYTES
2522 || !value.bytes().all(|byte| {
2523 byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'-' | b'_' | b'.' | b':')
2524 })
2525 {
2526 return Err(RuntimeError::ProtocolFailure);
2527 }
2528 Ok(value.to_owned())
2529}
2530
2531fn correlation_mismatch_response_marker() -> &'static str {
2535 "steadymux-owned-child: correlation-mismatch source=response class=correlation"
2536}
2537
2538fn correlation_mismatch_notification_marker(method: &str) -> String {
2542 let handler = match method {
2543 "thread/tokenUsage/updated" => "usage",
2544 "thread/started" | "thread/status/changed" => "thread",
2545 "turn/started" | "turn/completed" => "turn",
2546 method if method.starts_with("item/") => "item",
2547 method if method.starts_with("hook/") => "hook",
2548 method if method.starts_with("mcpServer/") => "mcp",
2549 _ => "control",
2550 };
2551 let method_sha256 = Sha256::digest(method.as_bytes())
2552 .iter()
2553 .map(|byte| format!("{byte:02x}"))
2554 .collect::<String>();
2555 format!(
2556 "steadymux-owned-child: correlation-mismatch source=notification handler={handler} mismatch=correlation method-sha256={method_sha256}"
2557 )
2558}
2559
2560fn digest_json(value: &Value) -> Result<[u8; 32], RuntimeError> {
2561 let encoded = serde_json::to_vec(value).map_err(|_| RuntimeError::InvalidInput)?;
2562 Ok(Sha256::digest(encoded).into())
2563}
2564
2565fn app_server_version_from_user_agent(user_agent: &str) -> Option<&str> {
2566 let identity = user_agent.split_ascii_whitespace().next()?;
2567 let (originator, version) = identity.split_once('/')?;
2568 if originator != "steadymux"
2569 || version.is_empty()
2570 || version.len() > MAX_ID_BYTES
2571 || version.chars().any(char::is_control)
2572 {
2573 return None;
2574 }
2575 Some(version)
2576}
2577
2578fn null_or_absent(value: &Value, key: &str) -> bool {
2579 value.get(key).is_none_or(Value::is_null)
2580}
2581
2582fn non_null(value: &Value, key: &str) -> bool {
2583 value.get(key).is_some_and(|field| !field.is_null())
2584}
2585
2586fn parse_terminal_failure(error: &Value) -> TerminalFailureClass {
2587 let Some(info) = error.get("codexErrorInfo") else {
2588 return TerminalFailureClass::Unclassified;
2589 };
2590 let discriminator = match info {
2591 Value::String(value) => Some(value.as_str()),
2592 Value::Object(object) => object
2593 .get("type")
2594 .and_then(Value::as_str)
2595 .or_else(|| object.keys().next().map(String::as_str)),
2596 _ => None,
2597 };
2598 let Some(discriminator) = discriminator else {
2599 return TerminalFailureClass::Other;
2600 };
2601 let normalized = discriminator.to_ascii_lowercase();
2602 if normalized.contains("contextwindow") {
2603 TerminalFailureClass::ContextWindow
2604 } else if normalized.contains("usage")
2605 || normalized.contains("budget")
2606 || normalized.contains("ratelimit")
2607 {
2608 TerminalFailureClass::UsageLimit
2609 } else if normalized.contains("connection")
2610 || normalized.contains("stream")
2611 || normalized.contains("http")
2612 {
2613 TerminalFailureClass::Transport
2614 } else if normalized.contains("badrequest") || normalized.contains("invalidrequest") {
2615 TerminalFailureClass::InvalidRequest
2616 } else if normalized.contains("unauthorized") || normalized.contains("authentication") {
2617 TerminalFailureClass::Unauthorized
2618 } else if normalized.contains("sandbox") || normalized.contains("permission") {
2619 TerminalFailureClass::Sandbox
2620 } else if normalized.contains("internal") {
2621 TerminalFailureClass::Internal
2622 } else {
2623 TerminalFailureClass::Other
2624 }
2625}
2626
2627fn string_at<'a>(value: &'a Value, path: &[&str]) -> Result<&'a str, RuntimeError> {
2628 let mut cursor = value;
2629 for key in path {
2630 cursor = cursor.get(*key).ok_or(RuntimeError::InvalidInput)?;
2631 }
2632 cursor.as_str().ok_or(RuntimeError::InvalidInput)
2633}
2634
2635fn parse_effort(value: &str) -> Result<ReasoningEffort, RuntimeError> {
2636 match value {
2637 "minimal" => Ok(ReasoningEffort::Minimal),
2638 "low" => Ok(ReasoningEffort::Low),
2639 "medium" => Ok(ReasoningEffort::Medium),
2640 "high" => Ok(ReasoningEffort::High),
2641 "xhigh" => Ok(ReasoningEffort::Xhigh),
2642 "max" => Ok(ReasoningEffort::Max),
2643 "ultra" => Ok(ReasoningEffort::Ultra),
2644 _ => Err(RuntimeError::InvalidInput),
2645 }
2646}
2647
2648fn parse_window(value: Option<&Value>) -> Result<Option<RateLimitWindowProjection>, RuntimeError> {
2649 let Some(value) = value else { return Ok(None) };
2650 if value.is_null() {
2651 return Ok(None);
2652 }
2653 let used_percent = value
2654 .get("usedPercent")
2655 .and_then(Value::as_u64)
2656 .ok_or(RuntimeError::InvalidInput)?;
2657 if used_percent > 100 {
2658 return Err(RuntimeError::InvalidInput);
2659 }
2660 Ok(Some(RateLimitWindowProjection {
2661 used_percent: used_percent as u8,
2662 window_duration_minutes: value.get("windowDurationMins").and_then(Value::as_u64),
2663 resets_at_unix_seconds: value.get("resetsAt").and_then(Value::as_u64),
2664 }))
2665}
2666
2667fn contains_forbidden_sensitive_material(value: &Value) -> bool {
2668 match value {
2669 Value::Object(object) => object.iter().any(|(key, value)| {
2670 let normalized = key.to_ascii_lowercase();
2671 TOKEN_KEYS
2672 .iter()
2673 .any(|forbidden| normalized.contains(forbidden))
2674 || contains_forbidden_sensitive_material(value)
2675 }),
2676 Value::Array(values) => values.iter().any(contains_forbidden_sensitive_material),
2677 Value::String(value) => looks_like_secret(value),
2678 _ => false,
2679 }
2680}
2681
2682fn looks_like_secret(value: &str) -> bool {
2683 let lower = value.to_ascii_lowercase();
2684 lower.contains("bearer ")
2685 || lower.contains("-----begin private key-----")
2686 || lower.contains("-----begin rsa private key-----")
2687 || lower.contains("github_pat_")
2688 || lower.contains("xoxb-")
2689 || lower.contains("xoxp-")
2690 || value
2691 .split(|character: char| character.is_whitespace() || matches!(character, '"' | '\''))
2692 .any(|token| {
2693 (token.starts_with("sk-") && token.len() >= 20)
2694 || (token.starts_with("AKIA")
2695 && token.len() == 20
2696 && token
2697 .bytes()
2698 .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()))
2699 })
2700}
2701
2702#[cfg(test)]
2703mod notification_tests {
2704 use super::*;
2705 use serde_json::json;
2706
2707 fn ingest(runtime: &mut CodexAgentRuntime, value: Value) -> Result<RuntimeEvent, RuntimeError> {
2708 runtime.ingest_line(
2709 &serde_json::to_vec(&value).expect("serialize app-server notification fixture"),
2710 )
2711 }
2712
2713 fn active_reasoning_runtime() -> CodexAgentRuntime {
2714 let thread_id = "thr-notification".to_owned();
2715 let turn_id = "turn-notification".to_owned();
2716 let item_id = "reasoning-notification".to_owned();
2717 let mut threads = BTreeMap::new();
2718 threads.insert(
2719 thread_id.clone(),
2720 ThreadSelection {
2721 model: "gpt-5.6-luna".to_owned(),
2722 effort: ReasoningEffort::Low,
2723 },
2724 );
2725 let mut items = BTreeMap::new();
2726 items.insert(
2727 item_id,
2728 ItemState {
2729 kind: ItemKind::Reasoning,
2730 delta_bytes: 0,
2731 completed: None,
2732 lifecycle_complete: false,
2733 },
2734 );
2735 let mut turns = BTreeMap::new();
2736 turns.insert(
2737 (thread_id, turn_id),
2738 TurnState {
2739 model: "gpt-5.6-luna".to_owned(),
2740 effort: ReasoningEffort::Low,
2741 items,
2742 final_item: None,
2743 visible_output: false,
2744 tool_or_side_effect: false,
2745 model_rerouted: false,
2746 interrupt_requested: false,
2747 lifecycle_started: true,
2748 output_bytes: 0,
2749 usage: None,
2750 total_usage: None,
2751 },
2752 );
2753 CodexAgentRuntime {
2754 tool_policy: CodexToolPolicy::RejectAll,
2755 handshake: HandshakeState::Ready,
2756 next_id: 1,
2757 pending: BTreeMap::new(),
2758 pending_turn_started: BTreeMap::new(),
2759 models: BTreeMap::new(),
2760 threads,
2761 turns,
2762 initialized_server_version: None,
2763 }
2764 }
2765
2766 fn pending_turn_runtime() -> CodexAgentRuntime {
2767 let mut runtime = active_reasoning_runtime();
2768 runtime.pending.insert(
2769 1,
2770 PendingRequest::TurnStart {
2771 thread_id: "thr-notification".to_owned(),
2772 model: "gpt-5.6-luna".to_owned(),
2773 effort: ReasoningEffort::Low,
2774 },
2775 );
2776 runtime
2777 }
2778
2779 fn notification_first_runtime() -> CodexAgentRuntime {
2780 let mut runtime = pending_turn_runtime();
2781 runtime.turns.clear();
2782 runtime
2783 }
2784
2785 fn completable_runtime() -> CodexAgentRuntime {
2786 let mut runtime = active_reasoning_runtime();
2787 let turn = runtime
2788 .turns
2789 .get_mut(&(
2790 "thr-notification".to_owned(),
2791 "turn-notification".to_owned(),
2792 ))
2793 .expect("active turn fixture");
2794 turn.items.clear();
2795 runtime
2796 }
2797
2798 fn token_usage_notification(input_tokens: i64, output_tokens: i64) -> Value {
2799 token_usage_notification_with_total(input_tokens, output_tokens, 123456781, 123456782)
2800 }
2801
2802 fn token_usage_notification_with_total(
2803 input_tokens: i64,
2804 output_tokens: i64,
2805 total_input_tokens: i64,
2806 total_output_tokens: i64,
2807 ) -> Value {
2808 json!({
2809 "method": "thread/tokenUsage/updated",
2810 "params": {
2811 "threadId": "thr-notification",
2812 "turnId": "turn-notification",
2813 "tokenUsage": {
2814 "last": {
2815 "cachedInputTokens": 3,
2816 "inputTokens": input_tokens,
2817 "outputTokens": output_tokens,
2818 "reasoningOutputTokens": 5,
2819 "totalTokens": input_tokens + output_tokens
2820 },
2821 "total": {
2822 "cachedInputTokens": 123456780,
2823 "inputTokens": total_input_tokens,
2824 "outputTokens": total_output_tokens,
2825 "reasoningOutputTokens": 123456783,
2826 "totalTokens": 987654321
2827 },
2828 "modelContextWindow": 128000
2829 }
2830 }
2831 })
2832 }
2833
2834 fn cumulative_usage(input_tokens: u64, output_tokens: u64) -> CumulativeTurnUsageCounts {
2835 CumulativeTurnUsageCounts {
2836 cached_input_tokens: 100,
2837 cache_write_input_tokens: Some(0),
2838 input_tokens,
2839 output_tokens,
2840 reasoning_output_tokens: 5,
2841 total_tokens: input_tokens + output_tokens,
2842 }
2843 }
2844
2845 fn realistic_thread_started(thread_id: &str) -> Value {
2846 json!({
2847 "method": "thread/started",
2848 "params": {
2849 "thread": {
2850 "id": thread_id,
2851 "preview": "private-preview-canary",
2852 "ephemeral": true,
2853 "modelProvider": "openai",
2854 "createdAt": 1,
2855 "updatedAt": 1,
2856 "status": {"type": "idle"},
2857 "cwd": "/private-cwd-canary",
2858 "cliVersion": CURRENT_STANDALONE_CLI_VERSION,
2859 "source": "appServer",
2860 "sessionId": "private-session-canary",
2861 "turns": [],
2862 "agentNickname": null,
2863 "agentRole": null,
2864 "canAcceptDirectInput": true,
2865 "extra": null,
2866 "forkedFromId": null,
2867 "gitInfo": null,
2868 "historyMode": "legacy",
2869 "name": null,
2870 "parentThreadId": null,
2871 "path": null,
2872 "recencyAt": 1,
2873 "section": null,
2874 "sectionEnteredAt": null,
2875 "threadSource": null
2876 }
2877 }
2878 })
2879 }
2880
2881 fn turn_started(thread_id: &str, turn_id: &str) -> Value {
2882 json!({
2883 "method": "turn/started",
2884 "params": {
2885 "threadId": thread_id,
2886 "turn": {
2887 "id": turn_id,
2888 "status": "inProgress",
2889 "items": [],
2890 "error": null
2891 }
2892 }
2893 })
2894 }
2895
2896 fn turn_start_response(request_id: u64, turn_id: &str) -> Value {
2897 json!({
2898 "id": request_id,
2899 "result": {
2900 "turn": {
2901 "id": turn_id,
2902 "status": "inProgress",
2903 "items": [],
2904 "error": null
2905 }
2906 }
2907 })
2908 }
2909
2910 #[test]
2911 fn correlation_mismatch_provenance_is_fixed_class_and_content_free() {
2912 assert_eq!(
2913 correlation_mismatch_response_marker(),
2914 "steadymux-owned-child: correlation-mismatch source=response class=correlation"
2915 );
2916
2917 let method = "thread/tokenUsage/updated";
2918 let marker = correlation_mismatch_notification_marker(method);
2919 let expected_sha256 = Sha256::digest(method.as_bytes())
2920 .iter()
2921 .map(|byte| format!("{byte:02x}"))
2922 .collect::<String>();
2923 assert_eq!(
2924 marker,
2925 format!(
2926 "steadymux-owned-child: correlation-mismatch source=notification handler=usage mismatch=correlation method-sha256={expected_sha256}"
2927 )
2928 );
2929 assert!(!marker.contains(method));
2930 assert!(!marker.contains("thr-notification"));
2931 assert!(!marker.contains("turn-notification"));
2932
2933 assert!(correlation_mismatch_notification_marker("item/started").contains("handler=item"));
2934 assert!(
2935 correlation_mismatch_notification_marker("mcpServer/startupStatus/updated")
2936 .contains("handler=mcp")
2937 );
2938 assert!(correlation_mismatch_notification_marker("hook/started").contains("handler=hook"));
2939 assert!(
2940 correlation_mismatch_notification_marker("remoteControl/status/changed")
2941 .contains("handler=control")
2942 );
2943 }
2944
2945 #[test]
2946 fn crossed_notification_correlation_refuses_without_mutating_turn_state() {
2947 let mut runtime = notification_first_runtime();
2948 let before = format!("{runtime:?}");
2949
2950 assert_eq!(
2951 ingest(&mut runtime, turn_started("thr-other", "turn-other")),
2952 Err(RuntimeError::CorrelationMismatch)
2953 );
2954 assert_eq!(format!("{runtime:?}"), before);
2955 }
2956
2957 #[test]
2958 fn notification_first_turn_start_reconciles_exact_response_once() {
2959 let mut runtime = notification_first_runtime();
2960 assert_eq!(
2961 ingest(
2962 &mut runtime,
2963 turn_started("thr-notification", "turn-notification")
2964 ),
2965 Ok(RuntimeEvent::TurnStarted {
2966 thread_id: "thr-notification".to_owned(),
2967 turn_id: "turn-notification".to_owned()
2968 })
2969 );
2970 assert_eq!(runtime.pending_turn_started.len(), 1);
2971 assert!(
2972 runtime
2973 .turns
2974 .get(&(
2975 "thr-notification".to_owned(),
2976 "turn-notification".to_owned()
2977 ))
2978 .is_some_and(|turn| {
2979 turn.model == "gpt-5.6-luna"
2980 && turn.effort == ReasoningEffort::Low
2981 && turn.lifecycle_started
2982 })
2983 );
2984
2985 assert_eq!(
2986 ingest(&mut runtime, turn_start_response(1, "turn-notification")),
2987 Ok(RuntimeEvent::TurnAccepted {
2988 thread_id: "thr-notification".to_owned(),
2989 turn_id: "turn-notification".to_owned()
2990 })
2991 );
2992 assert!(runtime.pending_turn_started.is_empty());
2993 assert!(
2994 runtime
2995 .turns
2996 .get(&(
2997 "thr-notification".to_owned(),
2998 "turn-notification".to_owned()
2999 ))
3000 .is_some_and(|turn| turn.lifecycle_started)
3001 );
3002 assert_eq!(
3003 ingest(
3004 &mut runtime,
3005 turn_started("thr-notification", "turn-notification")
3006 ),
3007 Err(RuntimeError::CorrelationMismatch)
3008 );
3009 }
3010
3011 #[test]
3012 fn response_first_turn_start_remains_valid() {
3013 let mut runtime = notification_first_runtime();
3014 assert!(matches!(
3015 ingest(&mut runtime, turn_start_response(1, "turn-notification")),
3016 Ok(RuntimeEvent::TurnAccepted { .. })
3017 ));
3018 assert!(runtime.pending_turn_started.is_empty());
3019 assert_eq!(
3020 ingest(
3021 &mut runtime,
3022 turn_started("thr-notification", "turn-notification")
3023 ),
3024 Ok(RuntimeEvent::TurnStarted {
3025 thread_id: "thr-notification".to_owned(),
3026 turn_id: "turn-notification".to_owned()
3027 })
3028 );
3029 }
3030
3031 #[test]
3032 fn response_first_usage_before_turn_started_completes_exactly_once() {
3033 let mut runtime = notification_first_runtime();
3034 assert!(matches!(
3035 ingest(&mut runtime, turn_start_response(1, "turn-notification")),
3036 Ok(RuntimeEvent::TurnAccepted { .. })
3037 ));
3038 assert_eq!(
3039 ingest(&mut runtime, token_usage_notification(12, 34)),
3040 Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3041 "the accepted response creates the only record usage may update"
3042 );
3043 assert_eq!(
3044 ingest(
3045 &mut runtime,
3046 turn_started("thr-notification", "turn-notification")
3047 ),
3048 Ok(RuntimeEvent::TurnStarted {
3049 thread_id: "thr-notification".to_owned(),
3050 turn_id: "turn-notification".to_owned(),
3051 })
3052 );
3053 let terminal = ingest(
3054 &mut runtime,
3055 json!({
3056 "method": "turn/completed",
3057 "params": {
3058 "threadId": "thr-notification",
3059 "turn": {
3060 "id": "turn-notification",
3061 "status": "completed",
3062 "items": [],
3063 "error": null
3064 }
3065 }
3066 }),
3067 )
3068 .expect("response-first usage becomes terminal only after turn/started");
3069 assert!(matches!(
3070 terminal,
3071 RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3072 input_tokens: Some(12),
3073 output_tokens: Some(34),
3074 ..
3075 })
3076 ));
3077 }
3078
3079 #[test]
3080 fn response_first_usage_accepts_monotonic_totals_but_refuses_mismatched_turns() {
3081 let mut before_accepted = notification_first_runtime();
3082 assert_eq!(
3083 ingest(&mut before_accepted, token_usage_notification(12, 34)),
3084 Err(RuntimeError::ProtocolFailure),
3085 "a notification cannot create a turn"
3086 );
3087
3088 let mut runtime = notification_first_runtime();
3089 ingest(&mut runtime, turn_start_response(1, "turn-notification"))
3090 .expect("accepted turn response");
3091 assert_eq!(
3092 ingest(&mut runtime, token_usage_notification(12, 34)),
3093 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3094 );
3095 assert_eq!(
3096 ingest(&mut runtime, token_usage_notification(12, 34)),
3097 Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3098 "an identical authoritative duplicate is idempotent"
3099 );
3100 assert_eq!(
3101 ingest(
3102 &mut runtime,
3103 token_usage_notification_with_total(13, 34, 123456782, 123456783),
3104 ),
3105 Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3106 "a monotonic cumulative projection accepts the latest per-turn usage even before lifecycle start"
3107 );
3108
3109 let mut mismatched = notification_first_runtime();
3110 ingest(&mut mismatched, turn_start_response(1, "turn-notification"))
3111 .expect("accepted turn response");
3112 let mut wrong_ids = token_usage_notification(12, 34);
3113 wrong_ids["params"]["turnId"] = json!("turn-other");
3114 assert_eq!(
3115 ingest(&mut mismatched, wrong_ids),
3116 Err(RuntimeError::ProtocolFailure),
3117 "usage must bind to the accepted response turn"
3118 );
3119 assert_eq!(
3120 ingest(&mut mismatched, token_usage_notification(12, 34)),
3121 Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3122 "a mismatched update cannot consume the accepted turn's usage slot"
3123 );
3124 }
3125
3126 #[test]
3127 fn duplicate_notification_first_turn_start_is_rejected() {
3128 let mut runtime = notification_first_runtime();
3129 ingest(
3130 &mut runtime,
3131 turn_started("thr-notification", "turn-notification"),
3132 )
3133 .expect("first exactly correlated notification");
3134 assert_eq!(
3135 ingest(
3136 &mut runtime,
3137 turn_started("thr-notification", "turn-notification")
3138 ),
3139 Err(RuntimeError::CorrelationMismatch)
3140 );
3141 assert_eq!(runtime.pending_turn_started.len(), 1);
3142 }
3143
3144 #[test]
3145 fn notification_first_requires_exactly_one_pending_turn_start() {
3146 let mut runtime = notification_first_runtime();
3147 runtime.pending.insert(
3148 2,
3149 PendingRequest::TurnStart {
3150 thread_id: "thr-notification".to_owned(),
3151 model: "gpt-5.6-luna".to_owned(),
3152 effort: ReasoningEffort::Low,
3153 },
3154 );
3155 assert_eq!(
3156 ingest(
3157 &mut runtime,
3158 turn_started("thr-notification", "turn-notification")
3159 ),
3160 Err(RuntimeError::CorrelationMismatch)
3161 );
3162 assert!(runtime.pending_turn_started.is_empty());
3163 }
3164
3165 #[test]
3166 fn notification_first_rejects_unmatched_thread() {
3167 let mut runtime = notification_first_runtime();
3168 assert_eq!(
3169 ingest(
3170 &mut runtime,
3171 turn_started("thr-unmatched", "turn-notification")
3172 ),
3173 Err(RuntimeError::CorrelationMismatch)
3174 );
3175 assert!(runtime.pending_turn_started.is_empty());
3176 }
3177
3178 #[test]
3179 fn notification_first_mismatched_response_consumes_marker_and_fails_closed() {
3180 let mut runtime = notification_first_runtime();
3181 ingest(
3182 &mut runtime,
3183 turn_started("thr-notification", "turn-notification"),
3184 )
3185 .expect("first exactly correlated notification");
3186 assert_eq!(
3187 ingest(&mut runtime, turn_start_response(1, "turn-mismatch")),
3188 Err(RuntimeError::CorrelationMismatch)
3189 );
3190 assert!(runtime.pending_turn_started.is_empty());
3191 assert!(runtime.turns.is_empty());
3192 }
3193
3194 #[test]
3195 fn notification_first_turn_start_capacity_is_bounded() {
3196 let mut runtime = pending_turn_runtime();
3197 let turn = runtime
3198 .turns
3199 .values()
3200 .next()
3201 .expect("turn state fixture")
3202 .clone();
3203 runtime.turns.clear();
3204 for index in 0..MAX_TURNS {
3205 runtime.turns.insert(
3206 (
3207 format!("thr-capacity-{index}"),
3208 format!("turn-capacity-{index}"),
3209 ),
3210 turn.clone(),
3211 );
3212 }
3213 assert_eq!(
3214 ingest(
3215 &mut runtime,
3216 turn_started("thr-notification", "turn-notification")
3217 ),
3218 Err(RuntimeError::PendingCapacityExceeded)
3219 );
3220 assert!(runtime.pending_turn_started.is_empty());
3221 }
3222
3223 #[test]
3224 fn exact_disabled_remote_control_status_is_content_free() {
3225 let mut runtime = CodexAgentRuntime::new();
3226 let event = ingest(
3227 &mut runtime,
3228 json!({
3229 "method": "remoteControl/status/changed",
3230 "params": {
3231 "status": "disabled",
3232 "serverName": "private-server-canary",
3233 "installationId": "private-installation-canary",
3234 "environmentId": null
3235 },
3236 "emittedAtMs": 1
3237 }),
3238 )
3239 .expect("exact reviewed control notification");
3240
3241 assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3242 let debug = format!("{event:?}");
3243 assert!(!debug.contains("private-server-canary"));
3244 assert!(!debug.contains("private-installation-canary"));
3245 }
3246
3247 #[test]
3248 fn json_rpc_rejection_discards_message_and_data_content() {
3249 let mut runtime = pending_turn_runtime();
3250 let error = ingest(
3251 &mut runtime,
3252 json!({
3253 "jsonrpc": "2.0",
3254 "id": 1,
3255 "error": {
3256 "code": -32603,
3257 "message": "private-json-rpc-message-canary",
3258 "data": {"content": "private-json-rpc-data-canary"}
3259 }
3260 }),
3261 )
3262 .expect_err("JSON-RPC error response must reject the request");
3263
3264 assert_eq!(error, RuntimeError::JsonRpcRejected);
3265 let debug = format!("{error:?} {error} {runtime:?}");
3266 assert!(!debug.contains("private-json-rpc-message-canary"));
3267 assert!(!debug.contains("private-json-rpc-data-canary"));
3268 }
3269
3270 #[test]
3271 fn malformed_json_rpc_error_is_a_protocol_failure() {
3272 let mut runtime = pending_turn_runtime();
3273
3274 assert_eq!(
3275 ingest(
3276 &mut runtime,
3277 json!({"id": 1, "error": {"message": "private-malformed-canary"}}),
3278 ),
3279 Err(RuntimeError::ProtocolFailure)
3280 );
3281 assert!(!format!("{runtime:?}").contains("private-malformed-canary"));
3282 }
3283
3284 #[test]
3285 fn completed_turn_exposes_only_authoritative_last_turn_usage() {
3286 let mut runtime = completable_runtime();
3287 assert_eq!(
3288 ingest(&mut runtime, token_usage_notification(12, 34)),
3289 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3290 );
3291 let terminal = ingest(
3292 &mut runtime,
3293 json!({
3294 "method": "turn/completed",
3295 "params": {
3296 "threadId": "thr-notification",
3297 "turn": {
3298 "id": "turn-notification",
3299 "status": "completed",
3300 "items": [],
3301 "error": null
3302 }
3303 }
3304 }),
3305 )
3306 .expect("completed turn with one usage update");
3307 let RuntimeEvent::TurnCompleted(receipt) = terminal else {
3308 panic!("expected terminal receipt");
3309 };
3310 assert_eq!(receipt.input_tokens, Some(12));
3311 assert_eq!(receipt.output_tokens, Some(34));
3312 let debug = format!("{receipt:?} {runtime:?}");
3313 assert!(!debug.contains("987654321"));
3314 assert!(!debug.contains("123456781"));
3315 }
3316
3317 #[test]
3318 fn progressive_cumulative_usage_projects_the_latest_per_turn_snapshot_at_completion() {
3319 let mut runtime = completable_runtime();
3320 ingest(
3321 &mut runtime,
3322 token_usage_notification_with_total(12, 34, 1_000, 2_000),
3323 )
3324 .expect("initial complete cumulative projection");
3325 ingest(
3326 &mut runtime,
3327 token_usage_notification_with_total(7, 41, 1_010, 2_010),
3328 )
3329 .expect("later cumulative projection may replace a varying last snapshot");
3330 ingest(
3331 &mut runtime,
3332 token_usage_notification_with_total(7, 41, 1_010, 2_010),
3333 )
3334 .expect("an identical cumulative projection is idempotent");
3335
3336 let terminal = ingest(
3337 &mut runtime,
3338 json!({
3339 "method": "turn/completed",
3340 "params": {
3341 "threadId": "thr-notification",
3342 "turn": {
3343 "id": "turn-notification",
3344 "status": "completed",
3345 "items": [],
3346 "error": null
3347 }
3348 }
3349 }),
3350 )
3351 .expect("a turn with lifecycle and cumulative usage completes");
3352 assert!(matches!(
3353 terminal,
3354 RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3355 input_tokens: Some(7),
3356 output_tokens: Some(41),
3357 ..
3358 })
3359 ));
3360 let debug = format!("{runtime:?}");
3361 for forbidden in ["1000", "1010", "2000", "2010"] {
3362 assert!(
3363 !debug.contains(forbidden),
3364 "cumulative values must remain private"
3365 );
3366 }
3367 }
3368
3369 #[test]
3370 fn nonmonotonic_cumulative_usage_refuses_without_mutating_the_latest_snapshot() {
3371 let mut runtime = completable_runtime();
3372 ingest(
3373 &mut runtime,
3374 token_usage_notification_with_total(12, 34, 1_000, 2_000),
3375 )
3376 .expect("initial complete cumulative projection");
3377 let before = runtime.turns.values().next().expect("turn fixture").clone();
3378 assert_eq!(
3379 ingest(
3380 &mut runtime,
3381 token_usage_notification_with_total(7, 41, 999, 2_010),
3382 ),
3383 Err(RuntimeError::ProtocolFailure),
3384 "a decrease in a validated cumulative projection remains fail closed"
3385 );
3386 let after = runtime.turns.values().next().expect("turn fixture");
3387 assert_eq!(after.usage, before.usage);
3388 assert_eq!(after.total_usage, before.total_usage);
3389 }
3390
3391 #[test]
3392 fn completed_turn_requires_authoritative_usage_but_ignores_identical_duplicate() {
3393 let mut missing = completable_runtime();
3394 assert_eq!(
3395 ingest(
3396 &mut missing,
3397 json!({"method":"turn/completed","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"completed","items":[],"error":null}}}),
3398 ),
3399 Err(RuntimeError::ProtocolFailure)
3400 );
3401
3402 let mut duplicate = completable_runtime();
3403 ingest(&mut duplicate, token_usage_notification(12, 34)).expect("first usage update");
3404 assert_eq!(
3405 ingest(&mut duplicate, token_usage_notification(12, 34)),
3406 Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3407 "the app-server may repeat an unchanged authoritative update"
3408 );
3409 }
3410
3411 #[test]
3412 fn interrupted_or_failed_turn_usage_is_optional_but_never_invented() {
3413 let mut failed = completable_runtime();
3414 let failed = ingest(
3415 &mut failed,
3416 json!({"method":"turn/completed","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"failed","items":[],"error":{"codexErrorInfo":"other"}}}}),
3417 )
3418 .expect("failed turn without usage remains representable");
3419 assert!(matches!(
3420 failed,
3421 RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3422 input_tokens: None,
3423 output_tokens: None,
3424 ..
3425 })
3426 ));
3427
3428 let mut interrupted = completable_runtime();
3429 interrupted
3430 .turns
3431 .get_mut(&(
3432 "thr-notification".to_owned(),
3433 "turn-notification".to_owned(),
3434 ))
3435 .expect("interrupt turn fixture")
3436 .interrupt_requested = true;
3437 ingest(&mut interrupted, token_usage_notification(7, 9))
3438 .expect("single interrupted usage update");
3439 let interrupted = ingest(
3440 &mut interrupted,
3441 json!({"method":"turn/completed","params":{"threadId":"thr-notification","turn":{"id":"turn-notification","status":"interrupted","items":[],"error":null}}}),
3442 )
3443 .expect("interrupted turn with usage remains representable");
3444 assert!(matches!(
3445 interrupted,
3446 RuntimeEvent::TurnCompleted(TurnCompletionReceipt {
3447 input_tokens: Some(7),
3448 output_tokens: Some(9),
3449 ..
3450 })
3451 ));
3452 }
3453
3454 #[test]
3455 #[cfg(unix)]
3456 fn exact_ephemeral_empty_thread_started_is_content_free() {
3457 let mut runtime = active_reasoning_runtime();
3458 let event = ingest(&mut runtime, realistic_thread_started("thr-notification"))
3459 .expect("reviewed ephemeral thread notification");
3460
3461 assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3462 let debug = format!("{event:?} {runtime:?}");
3463 for canary in [
3464 "private-preview-canary",
3465 "private-cwd-canary",
3466 "private-session-canary",
3467 "thr-notification",
3468 ] {
3469 assert!(!debug.contains(canary));
3470 }
3471 }
3472
3473 #[test]
3474 #[cfg(unix)]
3475 fn thread_started_requires_registered_correlation() {
3476 let mut runtime = active_reasoning_runtime();
3477 assert_eq!(
3478 ingest(&mut runtime, realistic_thread_started("thr-unregistered")),
3479 Err(RuntimeError::CorrelationMismatch)
3480 );
3481 }
3482
3483 #[test]
3484 fn malformed_extended_or_side_effecting_thread_started_fails_closed() {
3485 let mut malformed = Vec::new();
3486
3487 let mut missing = realistic_thread_started("thr-notification");
3488 missing["params"]["thread"]
3489 .as_object_mut()
3490 .expect("thread fixture object")
3491 .remove("ephemeral");
3492 malformed.push(missing);
3493
3494 let mut extended = realistic_thread_started("thr-notification");
3495 extended["params"]["thread"]["future"] = json!(true);
3496 malformed.push(extended);
3497
3498 let mut persistent = realistic_thread_started("thr-notification");
3499 persistent["params"]["thread"]["ephemeral"] = json!(false);
3500 malformed.push(persistent);
3501
3502 let mut materialized = realistic_thread_started("thr-notification");
3503 materialized["params"]["thread"]["path"] = json!("/private/thread.jsonl");
3504 malformed.push(materialized);
3505
3506 let mut with_turn = realistic_thread_started("thr-notification");
3507 with_turn["params"]["thread"]["turns"] = json!([{"id":"turn-existing"}]);
3508 malformed.push(with_turn);
3509
3510 let mut subagent = realistic_thread_started("thr-notification");
3511 subagent["params"]["thread"]["source"] = json!({"subAgent":"review"});
3512 malformed.push(subagent);
3513
3514 let mut git = realistic_thread_started("thr-notification");
3515 git["params"]["thread"]["gitInfo"] = json!({"branch":"private-branch"});
3516 malformed.push(git);
3517
3518 let mut extra = realistic_thread_started("thr-notification");
3519 extra["params"]["thread"]["extra"] = json!({"private":"payload"});
3520 malformed.push(extra);
3521
3522 let mut section = realistic_thread_started("thr-notification");
3523 section["params"]["thread"]["section"] = json!({"id":"private-section"});
3524 malformed.push(section);
3525
3526 let mut section_entered = realistic_thread_started("thr-notification");
3527 section_entered["params"]["thread"]["sectionEnteredAt"] = json!(1);
3528 malformed.push(section_entered);
3529
3530 let mut direct_input = realistic_thread_started("thr-notification");
3531 direct_input["params"]["thread"]["canAcceptDirectInput"] = json!(false);
3532 malformed.push(direct_input);
3533
3534 let mut history = realistic_thread_started("thr-notification");
3535 history["params"]["thread"]["historyMode"] = json!("future");
3536 malformed.push(history);
3537
3538 let mut oversized = realistic_thread_started("thr-notification");
3539 oversized["params"]["thread"]["preview"] = json!("x".repeat(MAX_TEXT_BYTES + 1));
3540 malformed.push(oversized);
3541
3542 let mut extended_params = realistic_thread_started("thr-notification");
3543 extended_params["params"]["future"] = json!(true);
3544 malformed.push(extended_params);
3545
3546 for (index, fixture) in malformed.into_iter().enumerate() {
3547 let mut runtime = active_reasoning_runtime();
3548 assert_eq!(
3549 ingest(&mut runtime, fixture),
3550 Err(RuntimeError::ProtocolFailure),
3551 "malformed notification fixture {index}"
3552 );
3553 }
3554 }
3555
3556 #[test]
3557 fn exact_no_tools_non_semantic_notifications_are_content_free() {
3558 let fixtures = [
3559 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":[]}}}),
3560 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["waitingOnUserInput"]}}}),
3561 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"idle"}}}),
3562 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cacheWriteInputTokens":0,"cachedInputTokens":11,"inputTokens":12,"outputTokens":13,"reasoningOutputTokens":14,"totalTokens":50},"modelContextWindow":128000}}}),
3563 json!({"method":"item/reasoning/summaryPartAdded","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0}}),
3564 json!({"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0,"delta":"private-summary-canary"}}),
3565 json!({"method":"item/reasoning/textDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","contentIndex":0,"delta":"private-reasoning-canary"}}),
3566 ];
3567 let mut runtime = active_reasoning_runtime();
3568 for fixture in fixtures {
3569 let event = ingest(&mut runtime, fixture).expect("reviewed non-semantic notification");
3570 assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3571 let debug = format!("{event:?}");
3572 assert!(!debug.contains("private-summary-canary"));
3573 assert!(!debug.contains("private-reasoning-canary"));
3574 assert!(!debug.contains("thr-notification"));
3575 assert!(!debug.contains("turn-notification"));
3576 assert!(!debug.contains("reasoning-notification"));
3577 }
3578 }
3579
3580 #[test]
3581 fn thread_status_active_flags_match_the_installed_protocol_enum() {
3582 let mut runtime = active_reasoning_runtime();
3583 for invalid in [
3584 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["unknown"]}}}),
3585 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["waitingOnApproval","waitingOnUserInput","waitingOnApproval"]}}}),
3586 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":[1]}}}),
3587 ] {
3588 assert!(matches!(
3589 ingest(&mut runtime, invalid),
3590 Err(RuntimeError::ProtocolFailure)
3591 ));
3592 }
3593 }
3594
3595 #[test]
3596 fn installed_mcp_startup_notifications_are_content_free() {
3597 let fixtures = [
3598 json!({
3599 "method": "mcpServer/startupStatus/updated",
3600 "params": {
3601 "threadId": "thr-notification",
3602 "name": "openai-api-key-local-confirmation",
3603 "status": "starting",
3604 "error": null,
3605 "failureReason": null
3606 },
3607 "emittedAtMs": 1_786_054_396_424_u64
3608 }),
3609 json!({
3610 "method": "mcpServer/startupStatus/updated",
3611 "params": {
3612 "threadId": "thr-notification",
3613 "name": "context7",
3614 "status": "ready",
3615 "error": null,
3616 "failureReason": null
3617 },
3618 "emittedAtMs": 1_786_054_397_661_u64
3619 }),
3620 json!({
3621 "method": "mcpServer/startupStatus/updated",
3622 "params": {
3623 "name": "context7",
3624 "status": "starting"
3625 },
3626 "emittedAtMs": 1_786_054_397_662_u64
3627 }),
3628 json!({
3629 "method": "mcpServer/startupStatus/updated",
3630 "params": {
3631 "threadId": null,
3632 "name": "context7",
3633 "status": "cancelled",
3634 "error": null,
3635 "failureReason": null
3636 },
3637 "emittedAtMs": 1_786_054_397_663_u64
3638 }),
3639 json!({
3640 "method": "mcpServer/startupStatus/updated",
3641 "params": {
3642 "threadId": "thr-notification",
3643 "name": "context7",
3644 "status": "failed",
3645 "error": null,
3646 "failureReason": "reauthenticationRequired"
3647 },
3648 "emittedAtMs": 1_786_054_397_664_u64
3649 }),
3650 json!({
3651 "method": "mcpServer/startupStatus/updated",
3652 "params": {
3653 "threadId": "thr-notification",
3654 "name": "context7",
3655 "status": "failed",
3656 "error": "MCP startup failed",
3657 "failureReason": null
3658 },
3659 "emittedAtMs": 1_786_054_397_665_u64
3660 }),
3661 ];
3662 let mut runtime = active_reasoning_runtime();
3663
3664 for fixture in fixtures {
3665 let event = ingest(&mut runtime, fixture).expect("live-shaped MCP startup status");
3666 assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3667 let debug = format!("{event:?} {runtime:?}");
3668 assert!(!debug.contains("openai-api-key-local-confirmation"));
3669 assert!(!debug.contains("context7"));
3670 assert!(!debug.contains("thr-notification"));
3671 assert!(!debug.contains("MCP startup failed"));
3672 }
3673 }
3674
3675 #[test]
3676 fn malformed_extended_or_diagnostic_mcp_startup_notifications_fail_closed() {
3677 let malformed = [
3678 json!({"threadId":"thr-notification","name":"context7","status":"ready","error":"","failureReason":null}),
3679 json!({"threadId":"thr-notification","name":"context7","status":"ready","error":"startup\nfailed","failureReason":null}),
3680 json!({"threadId":"thr-notification","name":"context7","status":"ready","error":1,"failureReason":null}),
3681 json!({"threadId":"thr-notification","name":"context7","status":"failed","error":null,"failureReason":"future"}),
3682 json!({"threadId":"thr-notification","name":"","status":"ready","error":null,"failureReason":null}),
3683 json!({"threadId":"thr-notification","name":"context7"}),
3684 json!({"name":"context7","status":"future"}),
3685 json!({"threadId":1,"name":"context7","status":"ready"}),
3686 json!({"threadId":"thr-notification","name":"context7","status":"ready","error":null,"failureReason":null,"future":true}),
3687 ];
3688
3689 for params in malformed {
3690 let mut runtime = active_reasoning_runtime();
3691 assert!(
3692 ingest(
3693 &mut runtime,
3694 json!({"method":"mcpServer/startupStatus/updated","params":params,"emittedAtMs":1}),
3695 )
3696 .is_err()
3697 );
3698 }
3699
3700 let mut unregistered = active_reasoning_runtime();
3701 assert_eq!(
3702 ingest(
3703 &mut unregistered,
3704 json!({"method":"mcpServer/startupStatus/updated","params":{"threadId":"thr-unregistered","name":"context7","status":"ready","error":null,"failureReason":null},"emittedAtMs":1}),
3705 ),
3706 Err(RuntimeError::CorrelationMismatch)
3707 );
3708
3709 let secret = "sk-private-mcp-startup-canary";
3710 let mut sensitive = active_reasoning_runtime();
3711 let error = ingest(
3712 &mut sensitive,
3713 json!({"method":"mcpServer/startupStatus/updated","params":{"threadId":"thr-notification","name":"context7","status":"ready","error":secret,"failureReason":null},"emittedAtMs":1}),
3714 )
3715 .expect_err("secret-shaped diagnostic must fail closed");
3716 assert_eq!(error, RuntimeError::SensitiveInputRejected);
3717 assert!(!format!("{error:?} {error} {sensitive:?}").contains(secret));
3718 }
3719
3720 #[test]
3721 fn live_user_message_lifecycle_is_correlated_and_not_retained() {
3722 let item = json!({
3723 "type": "userMessage",
3724 "id": "msg-user-live",
3725 "clientId": "steadymux-live-canary",
3726 "content": [{
3727 "type": "text",
3728 "text": "Return the single token STEADYMUX_CANARY.",
3729 "text_elements": []
3730 }]
3731 });
3732 let mut runtime = active_reasoning_runtime();
3733 let item_count = runtime
3734 .turns
3735 .values()
3736 .next()
3737 .expect("active turn")
3738 .items
3739 .len();
3740
3741 for (method, timestamp_key) in [
3742 ("item/started", "startedAtMs"),
3743 ("item/completed", "completedAtMs"),
3744 ] {
3745 let event = ingest(
3746 &mut runtime,
3747 json!({
3748 "method": method,
3749 "params": {
3750 "item": item.clone(),
3751 "threadId": "thr-notification",
3752 "turnId": "turn-notification",
3753 timestamp_key: 1_786_054_552_017_u64
3754 },
3755 "emittedAtMs": 1_786_054_552_018_u64
3756 }),
3757 )
3758 .expect("live-shaped user message lifecycle");
3759 assert_eq!(event, RuntimeEvent::NonSemanticNotificationIgnored);
3760 }
3761 assert_eq!(
3762 runtime
3763 .turns
3764 .values()
3765 .next()
3766 .expect("active turn")
3767 .items
3768 .len(),
3769 item_count
3770 );
3771 let debug = format!("{runtime:?}");
3772 assert!(!debug.contains("STEADYMUX_CANARY"));
3773 assert!(!debug.contains("steadymux-live-canary"));
3774 assert!(!debug.contains("msg-user-live"));
3775 }
3776
3777 #[test]
3778 #[cfg(unix)]
3779 fn live_warning_and_rate_notifications_are_content_free_while_hooks_refuse() {
3780 let fixtures = [
3781 json!({"method":"warning","params":{"threadId":"thr-notification","message":"Skill descriptions were shortened to fit the bounded skills context budget."},"emittedAtMs":1}),
3782 json!({"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":95,"windowDurationMins":10080,"resetsAt":1_786_323_626_u64},"secondary":null,"credits":{"hasCredits":false,"unlimited":false,"balance":"0"},"individualLimit":null,"spendControlReached":null,"planType":"pro","rateLimitReachedType":null}},"emittedAtMs":1}),
3783 ];
3784 let mut runtime = active_reasoning_runtime();
3785
3786 for fixture in fixtures {
3787 assert_eq!(
3788 ingest(&mut runtime, fixture),
3789 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3790 );
3791 }
3792 for method in ["hook/started", "hook/completed"] {
3793 assert_eq!(
3794 ingest(
3795 &mut runtime,
3796 json!({"method":method,"params":{"private":"hook payload"},"emittedAtMs":1}),
3797 ),
3798 Err(RuntimeError::ProtocolFailure)
3799 );
3800 }
3801 let debug = format!("{runtime:?}");
3802 assert!(!debug.contains("hook payload"));
3803 assert!(!debug.contains("usedPercent"));
3804 }
3805
3806 #[test]
3807 #[cfg(unix)]
3808 fn exhausted_account_rate_notification_is_not_a_protocol_failure() {
3809 let exhausted = json!({"method":"account/rateLimits/updated","params":{"rateLimits":{"limitId":"codex","limitName":null,"primary":{"usedPercent":100,"windowDurationMins":10080,"resetsAt":1_786_323_626_u64},"secondary":null,"credits":{"hasCredits":false,"unlimited":false,"balance":"0"},"individualLimit":null,"spendControlReached":true,"planType":"pro","rateLimitReachedType":"spend_control"}},"emittedAtMs":1});
3815 let mut runtime = active_reasoning_runtime();
3816 assert_eq!(
3817 ingest(&mut runtime, exhausted),
3818 Ok(RuntimeEvent::NonSemanticNotificationIgnored)
3819 );
3820 let debug = format!("{runtime:?}");
3821 assert!(!debug.contains("spend_control"));
3822 assert!(!debug.contains("usedPercent"));
3823 }
3824
3825 #[test]
3826 fn malformed_or_extended_no_tools_notifications_fail_closed() {
3827 let malformed = [
3828 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"active","activeFlags":["unknown"]}}}),
3829 json!({"method":"thread/status/changed","params":{"threadId":"thr-notification","status":{"type":"idle"},"future":true}}),
3830 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{},"total":{}}}}),
3831 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10}},"future":true}}),
3832 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"future":true}}}),
3833 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10,"future":0},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10}}}}),
3834 json!({"method":"item/reasoning/summaryPartAdded","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":-1}}),
3835 json!({"method":"item/reasoning/summaryPartAdded","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0,"future":true}}),
3836 json!({"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0}}),
3837 json!({"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","summaryIndex":0,"delta":"value","future":true}}),
3838 json!({"method":"item/reasoning/textDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","contentIndex":-1,"delta":"value"}}),
3839 json!({"method":"item/reasoning/textDelta","params":{"threadId":"thr-notification","turnId":"turn-notification","itemId":"reasoning-notification","contentIndex":0,"delta":"value","future":true}}),
3840 ];
3841 for (index, fixture) in malformed.into_iter().enumerate() {
3842 let mut runtime = active_reasoning_runtime();
3843 assert_eq!(
3844 ingest(&mut runtime, fixture),
3845 Err(RuntimeError::ProtocolFailure),
3846 "malformed no-tools notification fixture {index}"
3847 );
3848 }
3849 }
3850
3851 #[test]
3852 fn token_usage_breakdown_validation_accepts_exact_counts_and_refuses_shape_keys_and_values() {
3853 let exact = json!({
3854 "cachedInputTokens": 1,
3855 "inputTokens": 2,
3856 "outputTokens": 3,
3857 "reasoningOutputTokens": 4,
3858 "totalTokens": 10,
3859 });
3860 assert_eq!(
3861 valid_token_breakdown(Some(&exact)),
3862 TokenBreakdownValidation::Valid
3863 );
3864 assert_eq!(
3865 valid_token_breakdown(Some(&json!(null))),
3866 TokenBreakdownValidation::Shape
3867 );
3868 assert_eq!(
3869 valid_token_breakdown(Some(&json!({"inputTokens": 2, "future": 0}))),
3870 TokenBreakdownValidation::Keys
3871 );
3872 assert_eq!(
3873 valid_token_breakdown(Some(&json!({
3874 "cachedInputTokens": 1,
3875 "inputTokens": -2,
3876 "outputTokens": 3,
3877 "reasoningOutputTokens": 4,
3878 "totalTokens": 10,
3879 }))),
3880 TokenBreakdownValidation::Counts
3881 );
3882 }
3883
3884 #[test]
3885 fn token_usage_refusals_preserve_turn_usage_state() {
3886 let invalid_notifications = [
3887 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification"}}),
3888 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10},"modelContextWindow":-1}}}),
3889 json!({"method":"thread/tokenUsage/updated","params":{"threadId":"thr-notification","turnId":"turn-notification","tokenUsage":{"last":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10,"future":0},"total":{"cachedInputTokens":1,"inputTokens":2,"outputTokens":3,"reasoningOutputTokens":4,"totalTokens":10}}}}),
3890 ];
3891 for (index, notification) in invalid_notifications.into_iter().enumerate() {
3892 let mut runtime = completable_runtime();
3893 assert_eq!(
3894 ingest(&mut runtime, notification),
3895 Err(RuntimeError::ProtocolFailure),
3896 "token usage refusal {index}"
3897 );
3898 assert!(
3899 runtime
3900 .turns
3901 .get(&(
3902 String::from("thr-notification"),
3903 String::from("turn-notification")
3904 ))
3905 .is_some_and(|turn| turn.usage.is_none())
3906 );
3907 }
3908 }
3909
3910 #[test]
3911 fn token_usage_correlation_markers_commit_only_ids_and_preserve_refusal_state() {
3912 let runtime = completable_runtime();
3913 let incoming_thread_id = "thread-private-canary";
3914 let incoming_turn_id = "turn-private-canary";
3915 let marker = token_usage_correlation_marker(
3916 TokenUsageCorrelationRefusal::MissingPair,
3917 incoming_thread_id,
3918 incoming_turn_id,
3919 &runtime.turns,
3920 );
3921
3922 assert!(marker.starts_with(concat!(
3923 "steadymux-owned-child: token-usage-correlation reason=missing-pair incoming-thread-sha256=",
3924 "39b23b6f58c848f74d5936de5a9bd933f4b53a58cef823f75c3eef10a27073ce"
3925 )));
3926 assert!(marker.contains(concat!(
3927 " incoming-turn-sha256=",
3928 "f048ecdb852620d8ac2aed4f4b73b6b087ad0bbf515943205eba3a2bc94a9a13"
3929 )));
3930 assert!(marker.contains(" known-active-threads-sha256="));
3931 assert!(marker.contains(" known-active-turns-sha256="));
3932 assert!(marker.contains(" known-active-pairs-sha256="));
3933 assert!(marker.ends_with(" known-active-pair-count=1"));
3934 for forbidden in [
3935 incoming_thread_id,
3936 incoming_turn_id,
3937 "thr-notification",
3938 "turn-notification",
3939 "private",
3940 "canary",
3941 ] {
3942 assert!(!marker.contains(forbidden), "marker leaked {forbidden}");
3943 }
3944
3945 let mut runtime = completable_runtime();
3946 let mut mismatched = token_usage_notification(12, 34);
3947 mismatched["params"]["threadId"] = json!(incoming_thread_id);
3948 mismatched["params"]["turnId"] = json!(incoming_turn_id);
3949 assert_eq!(
3950 ingest(&mut runtime, mismatched),
3951 Err(RuntimeError::ProtocolFailure),
3952 "the diagnostic must not weaken correlation admission"
3953 );
3954 assert!(
3955 runtime
3956 .turns
3957 .get(&(
3958 String::from("thr-notification"),
3959 String::from("turn-notification")
3960 ))
3961 .is_some_and(|turn| turn.usage.is_none()),
3962 "a mismatched update must not consume the active turn usage slot"
3963 );
3964
3965 let mut rerouted = completable_runtime();
3966 rerouted
3967 .turns
3968 .get_mut(&(
3969 String::from("thr-notification"),
3970 String::from("turn-notification"),
3971 ))
3972 .expect("active turn fixture")
3973 .model_rerouted = true;
3974 assert_eq!(
3975 ingest(&mut rerouted, token_usage_notification(12, 34)),
3976 Err(RuntimeError::ProtocolFailure),
3977 "rerouted turns remain ineligible for usage updates"
3978 );
3979 assert!(rerouted.turns.values().all(|turn| turn.usage.is_none()));
3980
3981 let mut duplicate = completable_runtime();
3982 ingest(&mut duplicate, token_usage_notification(12, 34)).expect("first usage update");
3983 let usage_before = duplicate
3984 .turns
3985 .values()
3986 .next()
3987 .expect("active turn fixture")
3988 .usage;
3989 assert_eq!(
3990 ingest(&mut duplicate, token_usage_notification(13, 34)),
3991 Ok(RuntimeEvent::NonSemanticNotificationIgnored),
3992 "a monotonic cumulative projection may replace the per-turn snapshot"
3993 );
3994 assert_eq!(
3995 duplicate
3996 .turns
3997 .values()
3998 .next()
3999 .expect("active turn fixture")
4000 .usage,
4001 Some(TurnUsageCounts {
4002 input_tokens: 13,
4003 output_tokens: 34,
4004 }),
4005 "the latest per-turn snapshot is retained after cumulative admission"
4006 );
4007 assert_ne!(
4008 duplicate
4009 .turns
4010 .values()
4011 .next()
4012 .expect("active turn fixture")
4013 .usage,
4014 usage_before,
4015 );
4016
4017 for reason in [
4018 TokenUsageCorrelationRefusal::MissingPair,
4019 TokenUsageCorrelationRefusal::Rerouted,
4020 TokenUsageCorrelationRefusal::PriorUsage,
4021 ] {
4022 let marker = token_usage_correlation_marker(
4023 reason,
4024 incoming_thread_id,
4025 incoming_turn_id,
4026 &runtime.turns,
4027 );
4028 assert!(marker.contains(&format!(" reason={}", reason.as_str())));
4029 }
4030 }
4031
4032 #[test]
4033 fn cumulative_usage_admission_is_monotonic_and_never_exposes_token_values() {
4034 let baseline = TurnUsageCounts {
4035 input_tokens: 12,
4036 output_tokens: 34,
4037 };
4038 assert_eq!(
4039 prior_usage_direction(
4040 baseline,
4041 TurnUsageCounts {
4042 input_tokens: 13,
4043 output_tokens: 34,
4044 },
4045 ),
4046 PriorUsageDirection::MonotonicNondecreasing
4047 );
4048 assert_eq!(
4049 prior_usage_direction(
4050 baseline,
4051 TurnUsageCounts {
4052 input_tokens: 11,
4053 output_tokens: 34,
4054 },
4055 ),
4056 PriorUsageDirection::DecreaseOrMixed
4057 );
4058 assert_eq!(
4059 prior_usage_direction(
4060 baseline,
4061 TurnUsageCounts {
4062 input_tokens: 12,
4063 output_tokens: 34,
4064 },
4065 ),
4066 PriorUsageDirection::Same
4067 );
4068 assert_eq!(
4069 prior_usage_direction(
4070 baseline,
4071 TurnUsageCounts {
4072 input_tokens: 13,
4073 output_tokens: 33,
4074 },
4075 ),
4076 PriorUsageDirection::DecreaseOrMixed
4077 );
4078
4079 assert_eq!(
4080 total_usage_relative_to_last(baseline, cumulative_usage(12, 34),),
4081 TotalUsageRelativeToLast::Equal
4082 );
4083 assert_eq!(
4084 total_usage_relative_to_last(baseline, cumulative_usage(13, 35),),
4085 TotalUsageRelativeToLast::TotalNondecreasing
4086 );
4087 assert_eq!(
4088 total_usage_relative_to_last(baseline, cumulative_usage(11, 35),),
4089 TotalUsageRelativeToLast::TotalDecreaseOrMixed
4090 );
4091 assert_eq!(
4092 cumulative_usage_direction(cumulative_usage(12, 34), cumulative_usage(13, 35)),
4093 PriorUsageDirection::MonotonicNondecreasing
4094 );
4095 assert_eq!(
4096 cumulative_usage_direction(cumulative_usage(12, 34), cumulative_usage(11, 35)),
4097 PriorUsageDirection::DecreaseOrMixed
4098 );
4099 let mut cache_write_decrease = cumulative_usage(13, 35);
4100 cache_write_decrease.cache_write_input_tokens = Some(1);
4101 let mut cache_write_regression = cache_write_decrease;
4102 cache_write_regression.cache_write_input_tokens = Some(0);
4103 assert_eq!(
4104 cumulative_usage_direction(cache_write_decrease, cache_write_regression),
4105 PriorUsageDirection::DecreaseOrMixed,
4106 "all validated cumulative counters participate in the admission guard"
4107 );
4108 let mut cache_write_omitted = cache_write_decrease;
4111 cache_write_omitted.cache_write_input_tokens = None;
4112 assert_eq!(
4113 cumulative_usage_direction(cache_write_decrease, cache_write_omitted),
4114 PriorUsageDirection::MonotonicNondecreasing,
4115 "omitted optional cache-write count must not reset a positive prior"
4116 );
4117
4118 let marker = prior_usage_direction_marker(PriorUsageDirection::DecreaseOrMixed);
4119 assert_eq!(
4120 marker,
4121 "steadymux-owned-child: token-usage-prior-direction=decrease-or-mixed"
4122 );
4123 for forbidden in ["12", "13", "33", "34", "input", "output"] {
4124 assert!(!marker.contains(forbidden), "marker leaked {forbidden}");
4125 }
4126 let projection_marker = prior_usage_projection_marker(
4127 TotalUsageRelativeToLast::TotalNondecreasing,
4128 PriorUsageDirection::MonotonicNondecreasing,
4129 );
4130 assert_eq!(
4131 projection_marker,
4132 "steadymux-owned-child: token-usage-prior-projections total-vs-last=total-nondecreasing total-vs-prior=monotonic-nondecreasing"
4133 );
4134 for forbidden in ["12", "13", "33", "34", "input", "output", "cached"] {
4135 assert!(
4136 !projection_marker.contains(forbidden),
4137 "projection marker leaked {forbidden}"
4138 );
4139 }
4140
4141 let mut runtime = completable_runtime();
4142 ingest(
4143 &mut runtime,
4144 token_usage_notification_with_total(12, 34, 1_000, 2_000),
4145 )
4146 .expect("baseline usage");
4147 let before = runtime.turns.values().next().expect("turn state").usage;
4148 assert_eq!(
4149 ingest(
4150 &mut runtime,
4151 token_usage_notification_with_total(13, 34, 1_010, 2_010),
4152 ),
4153 Ok(RuntimeEvent::NonSemanticNotificationIgnored),
4154 "a complete monotonic total admits the latest per-turn projection"
4155 );
4156 assert_eq!(
4157 runtime.turns.values().next().expect("turn state").usage,
4158 Some(TurnUsageCounts {
4159 input_tokens: 13,
4160 output_tokens: 34,
4161 })
4162 );
4163 assert_ne!(
4164 runtime.turns.values().next().expect("turn state").usage,
4165 before
4166 );
4167 assert_eq!(
4168 ingest(
4169 &mut runtime,
4170 token_usage_notification_with_total(11, 34, 1_009, 2_011),
4171 ),
4172 Err(RuntimeError::ProtocolFailure),
4173 "a decrease in any persisted cumulative counter remains fail closed"
4174 );
4175 assert_eq!(
4176 runtime.turns.values().next().expect("turn state").usage,
4177 Some(TurnUsageCounts {
4178 input_tokens: 13,
4179 output_tokens: 34,
4180 }),
4181 "a refused cumulative projection preserves the last accepted receipt delta"
4182 );
4183 let debug = format!("{runtime:?}");
4184 for forbidden in ["1000", "1010", "2000", "2010"] {
4185 assert!(
4186 !debug.contains(forbidden),
4187 "runtime debug leaked {forbidden}"
4188 );
4189 }
4190
4191 let mut rerouted = completable_runtime();
4192 ingest(&mut rerouted, token_usage_notification(12, 34)).expect("baseline usage");
4193 rerouted
4194 .turns
4195 .values_mut()
4196 .next()
4197 .expect("turn state")
4198 .model_rerouted = true;
4199 let before = rerouted.turns.values().next().expect("turn state").usage;
4200 assert_eq!(
4201 ingest(&mut rerouted, token_usage_notification(12, 34)),
4202 Err(RuntimeError::ProtocolFailure),
4203 "rerouted turns refuse even an otherwise-idempotent usage update"
4204 );
4205 assert_eq!(
4206 rerouted.turns.values().next().expect("turn state").usage,
4207 before,
4208 "rerouted refusal preserves previously admitted usage"
4209 );
4210 }
4211
4212 #[test]
4213 fn malformed_remote_control_status_notifications_fail_closed() {
4214 let malformed = [
4215 json!({"status":"enabled","serverName":"server","installationId":"install","environmentId":null}),
4216 json!({"status":"disabled","serverName":"","installationId":"install","environmentId":null}),
4217 json!({"status":"disabled","serverName":"server","installationId":"","environmentId":null}),
4218 json!({"status":"disabled","serverName":"server","installationId":"install","environmentId":"environment"}),
4219 json!({"status":"disabled","serverName":"server","installationId":"install","environmentId":null,"future":true}),
4220 ];
4221
4222 for params in malformed {
4223 let mut runtime = CodexAgentRuntime::new();
4224 assert_eq!(
4225 ingest(
4226 &mut runtime,
4227 json!({"method":"remoteControl/status/changed","params":params,"emittedAtMs":1}),
4228 ),
4229 Err(RuntimeError::ProtocolFailure)
4230 );
4231 }
4232 }
4233
4234 #[test]
4235 fn unknown_notifications_still_fail_closed() {
4236 let mut runtime = CodexAgentRuntime::new();
4237 let canary = "private-payload-canary";
4238 let error = ingest(
4239 &mut runtime,
4240 json!({"method":"thread/future","params":{"content":canary},"emittedAtMs":1}),
4241 )
4242 .expect_err("unknown notification must remain fail closed");
4243 let RuntimeError::UnsupportedNotificationMethod(method) = error else {
4244 panic!("unknown notification must return a method-only diagnostic");
4245 };
4246 assert_eq!(method.as_str(), "thread/future");
4247 assert!(!format!("{method:?} {method}").contains(canary));
4248 }
4249
4250 #[test]
4251 fn side_effecting_item_refusal_markers_are_content_free_and_policy_specific() {
4252 let item_type = "futureToolWithPrivatePayload";
4253 let type_marker = side_effecting_item_type_marker(item_type);
4254 assert_eq!(
4255 type_marker,
4256 concat!(
4257 "steadymux-owned-child: side-effecting-item-type-sha256=",
4258 "b13049c568e4b6f1440b283578562022dc3e6a51c357527f133126acd750ee75"
4259 )
4260 );
4261 let type_marker = side_effecting_item_type_marker(item_type);
4262 assert!(!type_marker.contains(item_type));
4263 assert!(!type_marker.contains("PrivatePayload"));
4264 let mcp_item = json!({
4265 "status": "inProgress",
4266 "server": "mcp-server-private-canary",
4267 "tool": "mcp-tool-private-canary",
4268 "arguments": {"content": "NO_RETENTION_MCP_ARGUMENT_CANARY"},
4269 });
4270 assert_eq!(
4271 mcp_tool_policy_refusal_marker(&mcp_item, McpToolLifecycle::Started),
4272 concat!(
4273 "steadymux-owned-child: side-effecting-item-refused=mcp-tool-policy ",
4274 "lifecycle=started status=in-progress ",
4275 "server-sha256=424957af3b15be998a0143b68e0adc27ec7d06e8521b4045f03525fcbae5765d ",
4276 "tool-sha256=0cec75c422c7b03194148b7e50165b45a60a0868d5960d449b9b2e5376d923a6"
4277 )
4278 );
4279 let marker = mcp_tool_policy_refusal_marker(&mcp_item, McpToolLifecycle::Started);
4280 assert!(!marker.contains("mcp-server-private-canary"));
4281 assert!(!marker.contains("mcp-tool-private-canary"));
4282 assert!(!marker.contains("NO_RETENTION_MCP_ARGUMENT_CANARY"));
4283 assert_eq!(
4284 mcp_tool_policy_refusal_marker(&json!({}), McpToolLifecycle::Completed),
4285 concat!(
4286 "steadymux-owned-child: side-effecting-item-refused=mcp-tool-policy ",
4287 "lifecycle=completed status=unknown ",
4288 "server-sha256=missing-or-invalid tool-sha256=missing-or-invalid"
4289 )
4290 );
4291 }
4292
4293 #[test]
4294 fn rejected_item_types_remain_unretained_and_fail_closed() {
4295 let canary = "NO_RETENTION_ITEM_CANARY";
4296 for (method, item_type) in [
4297 ("item/started", "futureToolWithPrivatePayload"),
4298 ("item/completed", "futureCompletedToolWithPrivatePayload"),
4299 ] {
4300 let mut runtime = active_reasoning_runtime();
4301 assert_eq!(
4302 ingest(
4303 &mut runtime,
4304 json!({
4305 "method": method,
4306 "params": {
4307 "threadId": "thr-notification",
4308 "turnId": "turn-notification",
4309 "item": {
4310 "type": item_type,
4311 "id": "side-effecting-item",
4312 "private": canary,
4313 },
4314 },
4315 }),
4316 ),
4317 Err(RuntimeError::SideEffectingItemRejected),
4318 "{method} must remain fail closed"
4319 );
4320 let turn = runtime
4321 .turns
4322 .values()
4323 .next()
4324 .expect("active turn remains available for terminal receipt");
4325 assert!(turn.tool_or_side_effect);
4326 assert_eq!(turn.items.len(), 1);
4327 let debug = format!("{runtime:?}");
4328 assert!(!debug.contains(item_type));
4329 assert!(!debug.contains(canary));
4330 }
4331 }
4332
4333 #[test]
4334 fn web_search_items_remain_fail_closed_even_if_a_child_flag_is_omitted() {
4335 let canary = "NO_RETENTION_WEB_SEARCH_CANARY";
4336 for (method, item_type) in [
4337 ("item/started", "webSearch"),
4338 ("item/completed", "webSearch"),
4339 ] {
4340 let mut runtime = active_reasoning_runtime();
4341 assert_eq!(
4342 ingest(
4343 &mut runtime,
4344 json!({
4345 "method": method,
4346 "params": {
4347 "threadId": "thr-notification",
4348 "turnId": "turn-notification",
4349 "item": {
4350 "type": item_type,
4351 "id": "web-search-item",
4352 "private": canary,
4353 },
4354 },
4355 }),
4356 ),
4357 Err(RuntimeError::SideEffectingItemRejected),
4358 "{method} web-search item must remain fail closed"
4359 );
4360 let turn = runtime
4361 .turns
4362 .values()
4363 .next()
4364 .expect("active turn remains available for terminal receipt");
4365 assert!(turn.tool_or_side_effect);
4366 assert!(!format!("{runtime:?}").contains(canary));
4367 }
4368 }
4369
4370 #[test]
4371 fn no_tools_mcp_refusal_remains_fail_closed_with_digest_only_identity_markers() {
4372 let canary = "NO_RETENTION_MCP_CANARY";
4373 for (method, status, lifecycle) in [
4374 ("item/started", "inProgress", McpToolLifecycle::Started),
4375 ("item/completed", "completed", McpToolLifecycle::Completed),
4376 ] {
4377 let mut runtime = active_reasoning_runtime();
4378 let item = json!({
4379 "type": "mcpToolCall",
4380 "id": "mcp-tool-item",
4381 "status": status,
4382 "server": "mcp-server-private-canary",
4383 "tool": "mcp-tool-private-canary",
4384 "arguments": {"canary": canary},
4385 "result": {"content": "NO_RETENTION_MCP_RESULT_CANARY"},
4386 "error": null,
4387 });
4388 assert_eq!(
4389 ingest(
4390 &mut runtime,
4391 json!({
4392 "method": method,
4393 "params": {
4394 "threadId": "thr-notification",
4395 "turnId": "turn-notification",
4396 "item": item,
4397 },
4398 }),
4399 ),
4400 Err(RuntimeError::SideEffectingItemRejected),
4401 "{method} must reject all MCP lifecycle events"
4402 );
4403 let turn = runtime
4404 .turns
4405 .values()
4406 .next()
4407 .expect("active turn remains available");
4408 assert!(turn.tool_or_side_effect);
4409 assert!(!format!("{runtime:?}").contains(canary));
4410 let marker = mcp_tool_policy_refusal_marker(
4411 &json!({
4412 "status": status,
4413 "server": "mcp-server-private-canary",
4414 "tool": "mcp-tool-private-canary",
4415 "arguments": {"canary": canary},
4416 }),
4417 lifecycle,
4418 );
4419 assert!(!marker.contains(canary));
4420 assert!(!marker.contains("mcp-server-private-canary"));
4421 assert!(!marker.contains("mcp-tool-private-canary"));
4422 }
4423 }
4424}