1use gate4agent_node_protocol::correlation::{
20 interaction_correlation, process_correlation, subagent_correlation, tool_class_label,
21 tool_correlation,
22};
23use gate4agent_node_protocol::{
24 AgentStreamChunkKindV1, AgentStreamChunkV1, BlockAuthorityV1 as NodeBlockAuthorityV1,
25 SessionHistorySummaryV1,
26};
27use gate4agent_types::{
28 AdapterFamily, ControlEvent, ControlEventKind,
29 HostDecisionAuthority as ProviderHostDecisionAuthority,
30 HostRequestDecision as ProviderHostRequestDecision,
31 HostRequestOutcome as ProviderHostRequestOutcome, ProviderEvent, ProviderInteractionKind,
32 ProviderPlanStatus,
33};
34use hatchery_observation_protocol::{
35 truncate_observation_text, BlockAuthorityV1, HostDecisionAuthorityV1, HostRequestDecisionV1,
36 HostRequestOutcomeV1, ObservationCapabilitiesV1, ObservationEvidenceV1,
37 ObservationInteractionOutcomeV1, ObservationKindV1, ObservationSourceFamilyV1,
38 ObservationTodoItemV1, ObservationTodoStateV1, ObservationV1,
39 OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES,
40};
41
42fn observation_error_detail(message: &str) -> String {
52 let message = message.trim().to_ascii_lowercase();
53 if message.starts_with("provider events rejected") {
54 return "provider-events-rejected".to_owned();
55 }
56 "provider-error".to_owned()
57}
58
59fn observation_evidence(family: AdapterFamily) -> Option<ObservationEvidenceV1> {
60 match family {
61 AdapterFamily::PtySemantic | AdapterFamily::Hook | AdapterFamily::ManagedHook => {
68 Some(ObservationEvidenceV1::PtyHint)
69 }
70 AdapterFamily::Pipe | AdapterFamily::OneShot | AdapterFamily::Acp => {
71 Some(ObservationEvidenceV1::StructuredProvider)
72 }
73 AdapterFamily::History
74 | AdapterFamily::Resume
75 | AdapterFamily::SessionOptions
76 | AdapterFamily::CapabilityProbe => None,
77 }
78}
79
80fn observation_source_capabilities(
81 source: &gate4agent_types::ProviderSource,
82) -> Option<(ObservationSourceFamilyV1, ObservationCapabilitiesV1)> {
83 let adapter = source.binding.id.as_str();
84 Some(match source.family {
85 AdapterFamily::PtySemantic => (
86 ObservationSourceFamilyV1::PtySemantic,
87 ObservationCapabilitiesV1::default(),
88 ),
89 AdapterFamily::Pipe => (
90 ObservationSourceFamilyV1::Pipe,
91 ObservationCapabilitiesV1 {
92 tools: matches!(adapter, "claude-code" | "codex" | "kimi"),
93 usage: matches!(adapter, "claude-code" | "codex"),
94 ..ObservationCapabilitiesV1::default()
95 },
96 ),
97 AdapterFamily::Hook => (
100 ObservationSourceFamilyV1::Hook,
101 ObservationCapabilitiesV1::default(),
102 ),
103 AdapterFamily::ManagedHook => (
104 ObservationSourceFamilyV1::ManagedHook,
105 ObservationCapabilitiesV1::default(),
106 ),
107 AdapterFamily::OneShot => (
108 ObservationSourceFamilyV1::OneShot,
109 ObservationCapabilitiesV1::default(),
110 ),
111 AdapterFamily::Acp => (
112 ObservationSourceFamilyV1::Acp,
113 ObservationCapabilitiesV1 {
114 tools: true,
115 usage: true,
116 ..ObservationCapabilitiesV1::default()
117 },
118 ),
119 AdapterFamily::History
120 | AdapterFamily::Resume
121 | AdapterFamily::SessionOptions
122 | AdapterFamily::CapabilityProbe => return None,
123 })
124}
125
126fn token_usage_is_observed(usage: &gate4agent_types::TokenUsage) -> bool {
127 usage.input_tokens != 0
128 || usage.output_tokens != 0
129 || usage.cache_read_tokens != 0
130 || usage.cache_write_tokens != 0
131 || usage.reasoning_tokens != 0
132 || usage.context_window.is_some()
133}
134
135fn source_capabilities_observation(
136 source: &gate4agent_types::ProviderSource,
137 source_sequence: u64,
138 evidence: ObservationEvidenceV1,
139) -> Option<ObservationV1> {
140 let (source_family, capabilities) = observation_source_capabilities(source)?;
141 Some(ObservationV1 {
142 source_sequence,
143 observed_at_unix_ms: None,
144 evidence,
145 kind: ObservationKindV1::SourceCapabilities {
146 source_family,
147 source_adapter: source.binding.id.as_str().to_owned(),
148 capabilities,
149 },
150 truncated: false,
151 })
152}
153
154fn observation_host_request_decision(decision: &ProviderHostRequestDecision) -> HostRequestDecisionV1 {
162 match decision {
163 ProviderHostRequestDecision::Granted { by } => {
164 HostRequestDecisionV1::Granted { by: observation_host_decision_authority(*by) }
165 }
166 ProviderHostRequestDecision::Denied { by } => {
167 HostRequestDecisionV1::Denied { by: observation_host_decision_authority(*by) }
168 }
169 ProviderHostRequestDecision::Deferred => HostRequestDecisionV1::Deferred,
170 }
171}
172
173fn observation_host_request_outcome(outcome: &ProviderHostRequestOutcome) -> HostRequestOutcomeV1 {
182 match outcome {
183 ProviderHostRequestOutcome::Executed => HostRequestOutcomeV1::Executed,
184 ProviderHostRequestOutcome::Failed { error } => {
185 let (error, _) =
186 truncate_observation_text(error, OBSERVATION_ACTION_BLOCKED_REASON_MAX_BYTES);
187 HostRequestOutcomeV1::Failed { error }
188 }
189 }
190}
191
192fn observation_host_decision_authority(by: ProviderHostDecisionAuthority) -> HostDecisionAuthorityV1 {
193 match by {
194 ProviderHostDecisionAuthority::Gate => HostDecisionAuthorityV1::Gate,
195 ProviderHostDecisionAuthority::Policy => HostDecisionAuthorityV1::Policy,
196 ProviderHostDecisionAuthority::Operator => HostDecisionAuthorityV1::Operator,
197 ProviderHostDecisionAuthority::DeadlinePolicy => HostDecisionAuthorityV1::DeadlinePolicy,
198 }
199}
200
201fn observation_interaction_outcome(
202 outcome: gate4agent_types::ProviderInteractionOutcome,
203) -> ObservationInteractionOutcomeV1 {
204 match outcome {
205 gate4agent_types::ProviderInteractionOutcome::Approved => {
206 ObservationInteractionOutcomeV1::Approved
207 }
208 gate4agent_types::ProviderInteractionOutcome::Answered => {
209 ObservationInteractionOutcomeV1::Answered
210 }
211 gate4agent_types::ProviderInteractionOutcome::Denied => {
212 ObservationInteractionOutcomeV1::Denied
213 }
214 gate4agent_types::ProviderInteractionOutcome::Interrupted => {
215 ObservationInteractionOutcomeV1::Interrupted
216 }
217 gate4agent_types::ProviderInteractionOutcome::TurnEnded => {
218 ObservationInteractionOutcomeV1::TurnEnded
219 }
220 gate4agent_types::ProviderInteractionOutcome::Superseded => {
221 ObservationInteractionOutcomeV1::Superseded
222 }
223 }
224}
225
226fn observation_todo_state(status: ProviderPlanStatus) -> ObservationTodoStateV1 {
231 match status {
232 ProviderPlanStatus::Pending => ObservationTodoStateV1::Pending,
233 ProviderPlanStatus::InProgress => ObservationTodoStateV1::InProgress,
234 ProviderPlanStatus::Completed => ObservationTodoStateV1::Completed,
235 }
236}
237
238pub fn control_event_observations(event: &ControlEvent) -> Vec<ObservationV1> {
248 let (source, source_sequence, provider_sequence, provider_event) = match &event.event {
249 ControlEventKind::ProviderEvent {
250 sequence,
251 source,
252 source_sequence,
253 event,
254 } => (source, *source_sequence, *sequence, event),
255 ControlEventKind::ProviderGap {
256 source,
257 source_sequence,
258 missed,
259 ..
260 } => {
261 let Some(evidence) = observation_evidence(source.family) else {
262 return Vec::new();
263 };
264 let mut observations = Vec::with_capacity(2);
265 observations.extend(source_capabilities_observation(
266 source,
267 *source_sequence,
268 evidence,
269 ));
270 observations.push(ObservationV1 {
271 source_sequence: *source_sequence,
272 observed_at_unix_ms: None,
273 evidence,
274 kind: ObservationKindV1::Gap { missed: *missed },
275 truncated: false,
276 });
277 return observations;
278 }
279 ControlEventKind::InteractionResolved {
280 interaction_id,
281 outcome,
282 } => {
283 return vec![ObservationV1 {
284 source_sequence: event.sequence,
285 observed_at_unix_ms: None,
286 evidence: ObservationEvidenceV1::NodeLifecycle,
287 kind: ObservationKindV1::InteractionResolved {
288 correlation_id: interaction_correlation(
289 event.instance_id,
290 event.generation,
291 interaction_id.0,
292 ),
293 outcome: observation_interaction_outcome(*outcome),
294 },
295 truncated: false,
296 }];
297 }
298 _ => return node_lifecycle_observations(event),
299 };
300 let Some(evidence) = observation_evidence(source.family) else {
301 return Vec::new();
302 };
303 let is_pty_hint = evidence == ObservationEvidenceV1::PtyHint;
304 let mut kinds = Vec::with_capacity(2);
305 match provider_event {
306 ProviderEvent::SessionStarted { .. } => kinds.push(ObservationKindV1::SessionStarted),
307 ProviderEvent::TurnStarted { .. } => kinds.push(ObservationKindV1::TurnStarted),
308 ProviderEvent::WorkingObserved => kinds.push(ObservationKindV1::Working),
309 ProviderEvent::Thinking { .. } => kinds.push(ObservationKindV1::Working),
310 ProviderEvent::ToolStarted { id, name, .. } => kinds.push(ObservationKindV1::ToolStarted {
311 correlation_id: tool_correlation(event, source, id),
312 class: tool_class_label(name),
313 }),
314 ProviderEvent::ToolCompleted { id, is_error, duration_ms, .. } if !is_pty_hint => {
315 kinds.push(ObservationKindV1::ToolCompleted {
316 correlation_id: tool_correlation(event, source, id),
317 class: "Tool".to_owned(),
318 success: !is_error,
319 duration_ms: *duration_ms,
320 });
321 }
322 ProviderEvent::TurnCompleted { usage, is_cumulative } if !is_pty_hint => {
323 kinds.push(ObservationKindV1::TurnCompleted);
324 if token_usage_is_observed(usage) {
325 kinds.push(ObservationKindV1::Usage {
326 input_tokens: usage.input_tokens,
327 output_tokens: usage.output_tokens,
328 cache_read_tokens: usage.cache_read_tokens,
329 cache_write_tokens: usage.cache_write_tokens,
330 reasoning_tokens: usage.reasoning_tokens,
331 context_window: usage.context_window,
332 is_cumulative: *is_cumulative,
333 });
334 }
335 }
336 ProviderEvent::ContextWindowUsage { usage }
337 if evidence == ObservationEvidenceV1::StructuredProvider =>
338 {
339 kinds.push(ObservationKindV1::ContextWindowUsage {
340 uncached_input_tokens: Some(usage.uncached_input_tokens),
341 cache_read_tokens: Some(usage.cache_read_tokens),
342 cache_write_tokens: Some(usage.cache_write_tokens),
343 output_tokens: Some(usage.output_tokens),
344 unattributed_tokens: Some(usage.unattributed_tokens),
345 used_tokens: usage.used_tokens,
346 capacity_tokens: usage.capacity_tokens,
347 });
348 }
349 ProviderEvent::UsageUpdated {
358 used_tokens,
359 context_window,
360 ..
361 } if evidence == ObservationEvidenceV1::StructuredProvider => {
362 if let (Some(used_tokens), Some(capacity_tokens)) = (*used_tokens, *context_window) {
363 kinds.push(ObservationKindV1::ContextWindowUsage {
364 uncached_input_tokens: None,
365 cache_read_tokens: None,
366 cache_write_tokens: None,
367 output_tokens: None,
368 unattributed_tokens: None,
369 used_tokens,
370 capacity_tokens,
371 });
372 }
373 }
374 ProviderEvent::Plan { steps } if !is_pty_hint => {
382 kinds.push(ObservationKindV1::TodoSnapshot {
383 revision: source_sequence,
384 items: steps
385 .iter()
386 .map(|step| ObservationTodoItemV1 {
387 id: None,
388 text: step.content.clone(),
389 state: observation_todo_state(step.status),
390 })
391 .collect(),
392 complete: !steps.is_empty()
398 && steps
399 .iter()
400 .all(|step| step.status == ProviderPlanStatus::Completed),
401 });
402 }
403 ProviderEvent::TurnInterrupted => kinds.push(ObservationKindV1::TurnInterrupted),
404 ProviderEvent::SessionEnded { is_error, .. } => {
405 kinds.push(ObservationKindV1::Exited { success: Some(!is_error) });
406 }
407 ProviderEvent::Error { message } => kinds.push(ObservationKindV1::Error {
416 detail: observation_error_detail(message),
417 }),
418 ProviderEvent::Ready => kinds.push(ObservationKindV1::Ready),
419 ProviderEvent::InteractionRequested {
420 interaction_kind,
421 tool_name,
422 ..
423 } => kinds.push(match interaction_kind {
424 ProviderInteractionKind::Approval => ObservationKindV1::ApprovalRequested {
425 correlation_id: interaction_correlation(
426 event.instance_id,
427 event.generation,
428 provider_sequence,
429 ),
430 tool_class: tool_class_label(tool_name),
431 },
432 ProviderInteractionKind::Question => ObservationKindV1::QuestionRequested {
433 correlation_id: interaction_correlation(
434 event.instance_id,
435 event.generation,
436 provider_sequence,
437 ),
438 tool_class: tool_class_label(tool_name),
439 },
440 }),
441 ProviderEvent::SubagentStarted { agent_id, agent_type, .. } => {
442 kinds.push(ObservationKindV1::SubagentStarted {
443 correlation_id: subagent_correlation(
444 event.instance_id,
445 event.generation,
446 source,
447 agent_id,
448 ),
449 class: agent_type
450 .as_deref()
451 .map(tool_class_label)
452 .unwrap_or_else(|| "Task".to_owned()),
453 });
454 }
455 ProviderEvent::SubagentStopped { agent_id } if !is_pty_hint => {
456 kinds.push(ObservationKindV1::SubagentCompleted {
457 correlation_id: subagent_correlation(
458 event.instance_id,
459 event.generation,
460 source,
461 agent_id,
462 ),
463 success: None,
464 });
465 }
466 ProviderEvent::RateLimited { .. } => kinds.push(ObservationKindV1::RateLimited),
467 ProviderEvent::HostRequestObserved { method, decision, outcome, .. } => {
468 kinds.push(ObservationKindV1::HostRequestObserved {
469 class: tool_class_label(method),
470 decision: observation_host_request_decision(decision),
471 outcome: observation_host_request_outcome(outcome),
472 });
473 }
474 ProviderEvent::UnrecognizedNotification { method, .. } => {
475 kinds.push(ObservationKindV1::UnrecognizedNotification {
476 method: method.clone(),
477 });
478 }
479 ProviderEvent::SessionIdentityObserved { .. }
480 | ProviderEvent::Text { .. }
481 | ProviderEvent::InteractionResolved { .. }
482 | ProviderEvent::ToolCompleted { .. }
483 | ProviderEvent::TurnCompleted { .. }
484 | ProviderEvent::ContextWindowUsage { .. }
485 | ProviderEvent::SubagentStopped { .. }
486 | ProviderEvent::UsageUpdated { .. }
487 | ProviderEvent::Plan { .. }
488 | ProviderEvent::UserMessage { .. }
493 | ProviderEvent::AvailableCommandsUpdated { .. }
494 | ProviderEvent::ModeChanged { .. }
495 | ProviderEvent::SessionInfoUpdated { .. }
496 | ProviderEvent::ConfigOptionsUpdated { .. } => {}
497 }
498 let reports_capabilities = source_sequence == 1
499 || matches!(provider_event, ProviderEvent::SessionStarted { .. });
500 let mut observations = kinds.into_iter().map(|kind| ObservationV1 {
501 source_sequence,
502 observed_at_unix_ms: None,
503 evidence,
504 kind,
505 truncated: false,
506 })
507 .filter(|observation| observation.validate().is_ok())
508 .collect::<Vec<_>>();
509 if reports_capabilities {
513 if let Some(capabilities) =
514 source_capabilities_observation(source, source_sequence, evidence)
515 {
516 observations.insert(0, capabilities);
517 }
518 }
519 observations
520}
521
522fn node_lifecycle_observations(event: &ControlEvent) -> Vec<ObservationV1> {
523 let correlation_id = process_correlation(event.instance_id, event.generation);
524 let kinds = match &event.event {
525 ControlEventKind::Running { .. } => vec![
526 ObservationKindV1::SessionStarted,
527 ObservationKindV1::OwnedProcessStarted {
528 correlation_id,
529 class: "provider-session".to_owned(),
530 },
531 ],
532 ControlEventKind::Exited { exit_code, .. } => vec![
533 ObservationKindV1::OwnedProcessExited {
534 correlation_id,
535 success: exit_code.map(|code| code == 0),
536 exit_code: *exit_code,
537 },
538 ObservationKindV1::Exited {
539 success: exit_code.map(|code| code == 0),
540 },
541 ],
542 ControlEventKind::Failed { .. } => vec![
543 ObservationKindV1::OwnedProcessExited {
544 correlation_id,
545 success: Some(false),
546 exit_code: None,
547 },
548 ObservationKindV1::Error {
549 detail: "session-failed".to_owned(),
550 },
551 ],
552 ControlEventKind::Removed => vec![ObservationKindV1::Stopped],
553 _ => return Vec::new(),
554 };
555 std::iter::once(ObservationV1 {
556 source_sequence: event.sequence,
557 observed_at_unix_ms: None,
558 evidence: ObservationEvidenceV1::NodeLifecycle,
559 kind: ObservationKindV1::SourceCapabilities {
560 source_family: ObservationSourceFamilyV1::NodeLifecycle,
561 source_adapter: "node".to_owned(),
562 capabilities: ObservationCapabilitiesV1 {
563 owned_processes: true,
564 ..ObservationCapabilitiesV1::default()
565 },
566 },
567 truncated: false,
568 })
569 .chain(kinds.into_iter().map(|kind| ObservationV1 {
570 source_sequence: event.sequence,
571 observed_at_unix_ms: None,
572 evidence: ObservationEvidenceV1::NodeLifecycle,
573 kind,
574 truncated: false,
575 }))
576 .filter(|observation| observation.validate().is_ok())
577 .collect()
578}
579
580pub fn history_summary_observations(summary: &SessionHistorySummaryV1) -> [ObservationV1; 2] {
586 let source_sequence = summary.modified_at_unix_ms.unwrap_or(1).max(1);
587 [
588 ObservationV1 {
589 source_sequence,
590 observed_at_unix_ms: None,
591 evidence: ObservationEvidenceV1::HistoryProjection,
592 kind: ObservationKindV1::SourceCapabilities {
593 source_family: ObservationSourceFamilyV1::History,
594 source_adapter: "native-history".to_owned(),
595 capabilities: ObservationCapabilitiesV1 {
596 history_summary: true,
597 ..ObservationCapabilitiesV1::default()
598 },
599 },
600 truncated: false,
601 },
602 ObservationV1 {
603 source_sequence,
604 observed_at_unix_ms: None,
605 evidence: ObservationEvidenceV1::HistoryProjection,
606 kind: ObservationKindV1::HistorySnapshot {
607 message_count: summary.message_count,
608 message_count_exact: summary.message_count_exact,
609 completed_turn_count: summary.completed_turn_count,
610 total_tokens: summary.total_tokens,
611 },
612 truncated: false,
616 },
617 ]
618}
619
620pub fn blocked_chunk_observation(chunk: &AgentStreamChunkV1) -> Option<ObservationV1> {
629 let AgentStreamChunkKindV1::Blocked {
630 correlation_id,
631 tool_class,
632 authority,
633 reason_kind,
634 reason,
635 help,
636 } = &chunk.kind
637 else {
638 return None;
639 };
640 let observation = ObservationV1 {
641 source_sequence: chunk.source_sequence,
642 observed_at_unix_ms: None,
643 evidence: ObservationEvidenceV1::StructuredProvider,
644 kind: ObservationKindV1::ActionBlocked {
645 correlation_id: correlation_id.clone(),
646 tool_class: tool_class.clone(),
647 authority: block_authority(*authority),
648 reason_kind: reason_kind.clone(),
649 reason: reason.clone(),
650 help: help.clone(),
651 },
652 truncated: false,
653 };
654 observation.validate().is_ok().then_some(observation)
655}
656
657fn block_authority(authority: NodeBlockAuthorityV1) -> BlockAuthorityV1 {
660 match authority {
661 NodeBlockAuthorityV1::HostGate => BlockAuthorityV1::HarnessGate,
662 NodeBlockAuthorityV1::HostPolicy => BlockAuthorityV1::HarnessPolicy,
663 NodeBlockAuthorityV1::HostDeadline => BlockAuthorityV1::HarnessDeadline,
664 NodeBlockAuthorityV1::Operator => BlockAuthorityV1::Operator,
665 NodeBlockAuthorityV1::ProviderClassifier => BlockAuthorityV1::ProviderClassifier,
666 NodeBlockAuthorityV1::ProviderPermissionRule => BlockAuthorityV1::ProviderPermissionRule,
667 NodeBlockAuthorityV1::ProviderSandbox => BlockAuthorityV1::ProviderSandbox,
668 NodeBlockAuthorityV1::ProviderRefusal => BlockAuthorityV1::ProviderRefusal,
669 NodeBlockAuthorityV1::UserRejected => BlockAuthorityV1::UserRejected,
670 NodeBlockAuthorityV1::ProviderQuota => BlockAuthorityV1::ProviderQuota,
671 NodeBlockAuthorityV1::Unknown => BlockAuthorityV1::Unknown,
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use gate4agent_types::{
679 AdapterBinding, AdapterId, AdapterVerification, AgentInstanceId, ContextWindowUsage,
680 ProviderPlanPriority, ProviderPlanStep, ProviderSessionIdentity, ProviderSessionKey,
681 ProviderSource, SessionGeneration, TokenUsage,
682 };
683
684 fn provider_control_event(
685 family: AdapterFamily,
686 provider_event: ProviderEvent,
687 ) -> ControlEvent {
688 provider_control_event_at(family, "fixture", 9, provider_event)
689 }
690
691 fn provider_control_event_at(
692 family: AdapterFamily,
693 adapter: &str,
694 source_sequence: u64,
695 provider_event: ProviderEvent,
696 ) -> ControlEvent {
697 ControlEvent {
698 sequence: 41,
699 command_id: None,
700 instance_id: AgentInstanceId(7),
701 generation: SessionGeneration(3),
702 event: ControlEventKind::ProviderEvent {
703 sequence: 8,
704 source: provider_source(family, adapter),
705 source_sequence,
706 event: provider_event,
707 },
708 }
709 }
710
711 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
712 ProviderSource {
713 family,
714 binding: AdapterBinding::new(
715 AdapterId::new(adapter).unwrap(),
716 "fixture/v1",
717 AdapterVerification::SyntheticFixture,
718 )
719 .unwrap(),
720 }
721 }
722
723 fn timeline_observations(observations: &[ObservationV1]) -> Vec<&ObservationV1> {
724 observations
725 .iter()
726 .filter(|observation| {
727 !matches!(observation.kind, ObservationKindV1::SourceCapabilities { .. })
728 })
729 .collect()
730 }
731
732 #[test]
733 fn source_capabilities_match_what_each_family_can_report() {
734 let claims = |family, adapter| {
735 observation_source_capabilities(&provider_source(family, adapter)).unwrap()
736 };
737 assert_eq!(
738 claims(AdapterFamily::PtySemantic, "codex"),
739 (ObservationSourceFamilyV1::PtySemantic, ObservationCapabilitiesV1::default()),
740 );
741 let (family, pipe) = claims(AdapterFamily::Pipe, "codex");
742 assert_eq!(family, ObservationSourceFamilyV1::Pipe);
743 assert!(pipe.tools && pipe.usage);
744 assert!(!pipe.attention && !pipe.subagents && !pipe.todo && !pipe.file_changes);
745 let (_, kimi_pipe) = claims(AdapterFamily::Pipe, "kimi");
746 assert!(kimi_pipe.tools);
747 assert!(!kimi_pipe.usage);
748 let (family, acp) = claims(AdapterFamily::Acp, "codex");
749 assert_eq!(family, ObservationSourceFamilyV1::Acp);
750 assert!(acp.tools && acp.usage);
751 assert!(!acp.attention && !acp.subagents && !acp.todo);
752 assert!(!acp.owned_processes && !acp.file_changes && !acp.history_summary);
753 assert_eq!(
754 claims(AdapterFamily::OneShot, "codex"),
755 (ObservationSourceFamilyV1::OneShot, ObservationCapabilitiesV1::default()),
756 );
757 for (family, expected) in [
759 (AdapterFamily::Hook, ObservationSourceFamilyV1::Hook),
760 (AdapterFamily::ManagedHook, ObservationSourceFamilyV1::ManagedHook),
761 ] {
762 assert_eq!(
763 claims(family, "claude-code"),
764 (expected, ObservationCapabilitiesV1::default()),
765 );
766 }
767 for family in [
768 AdapterFamily::History,
769 AdapterFamily::Resume,
770 AdapterFamily::SessionOptions,
771 AdapterFamily::CapabilityProbe,
772 ] {
773 assert!(observation_evidence(family).is_none());
774 assert!(observation_source_capabilities(&provider_source(family, "codex")).is_none());
775 }
776 }
777
778 #[test]
779 fn a_source_declares_its_capabilities_with_its_first_event_only() {
780 let first = control_event_observations(&provider_control_event_at(
781 AdapterFamily::Acp,
782 "claude-code",
783 1,
784 ProviderEvent::WorkingObserved,
785 ));
786 assert_eq!(first.len(), 2);
787 assert_eq!(first[0].source_sequence, 1);
788 assert!(matches!(
789 &first[0].kind,
790 ObservationKindV1::SourceCapabilities {
791 source_family: ObservationSourceFamilyV1::Acp,
792 source_adapter,
793 capabilities,
794 } if source_adapter == "claude-code" && capabilities.tools && capabilities.usage
795 ));
796 assert_eq!(first[1].kind, ObservationKindV1::Working);
797
798 let text = provider_control_event_at(
799 AdapterFamily::Acp,
800 "claude-code",
801 2,
802 ProviderEvent::Text { text: String::new(), is_delta: true },
803 );
804 assert!(control_event_observations(&text).is_empty());
805 let working = control_event_observations(&provider_control_event_at(
806 AdapterFamily::Acp,
807 "claude-code",
808 3,
809 ProviderEvent::WorkingObserved,
810 ));
811 assert_eq!(working.len(), 1);
812 assert_eq!(working[0].kind, ObservationKindV1::Working);
813 }
814
815 #[test]
816 fn a_first_event_that_is_only_an_identity_still_declares_capabilities() {
817 let identity = provider_control_event_at(
818 AdapterFamily::Acp,
819 "claude-code",
820 1,
821 ProviderEvent::SessionIdentityObserved {
822 identity: ProviderSessionIdentity {
823 key: ProviderSessionKey::SessionId,
824 id: String::new(),
825 transcript_path: None,
826 },
827 },
828 );
829 let projected = control_event_observations(&identity);
830 assert_eq!(projected.len(), 1);
831 assert!(matches!(
832 &projected[0].kind,
833 ObservationKindV1::SourceCapabilities {
834 source_family: ObservationSourceFamilyV1::Acp,
835 source_adapter,
836 ..
837 } if source_adapter == "claude-code"
838 ));
839 }
840
841 #[test]
842 fn provider_gap_receives_capabilities_and_preserves_source_sequence() {
843 let control = ControlEvent {
844 sequence: 99,
845 command_id: None,
846 instance_id: AgentInstanceId(7),
847 generation: SessionGeneration(3),
848 event: ControlEventKind::ProviderGap {
849 sequence: 44,
850 source: provider_source(AdapterFamily::Acp, "grok"),
851 source_sequence: 11,
852 missed: 2,
853 },
854 };
855 let projected = control_event_observations(&control);
856 assert_eq!(projected.len(), 2);
857 assert_eq!(projected[0].source_sequence, 11);
858 assert!(matches!(
859 &projected[0].kind,
860 ObservationKindV1::SourceCapabilities {
861 source_family: ObservationSourceFamilyV1::Acp,
862 source_adapter,
863 ..
864 } if source_adapter == "grok"
865 ));
866 assert_eq!(projected[1].source_sequence, 11);
867 assert_eq!(projected[1].kind, ObservationKindV1::Gap { missed: 2 });
868 }
869
870 #[test]
871 fn projection_is_deterministic_and_carries_no_wall_clock() {
872 let event = provider_control_event_at(
873 AdapterFamily::Acp,
874 "claude-code",
875 1,
876 ProviderEvent::WorkingObserved,
877 );
878 let first = control_event_observations(&event);
879 assert_eq!(first, control_event_observations(&event));
880 assert!(first.iter().all(|observation| observation.observed_at_unix_ms.is_none()));
881 let lifecycle = ControlEvent {
882 sequence: 5,
883 command_id: None,
884 instance_id: AgentInstanceId(7),
885 generation: SessionGeneration(3),
886 event: ControlEventKind::Running { process_id: None },
887 };
888 let projected = control_event_observations(&lifecycle);
889 assert_eq!(projected, control_event_observations(&lifecycle));
890 assert!(projected.iter().all(|observation| observation.observed_at_unix_ms.is_none()));
891 }
892
893 #[test]
894 fn node_lifecycle_events_project_to_owned_process_observations() {
895 let project = |event| {
896 control_event_observations(&ControlEvent {
897 sequence: 6,
898 command_id: None,
899 instance_id: AgentInstanceId(7),
900 generation: SessionGeneration(3),
901 event,
902 })
903 };
904 let running = project(ControlEventKind::Running { process_id: None });
905 assert!(matches!(
906 &running[0].kind,
907 ObservationKindV1::SourceCapabilities {
908 source_family: ObservationSourceFamilyV1::NodeLifecycle,
909 capabilities: ObservationCapabilitiesV1 { owned_processes: true, .. },
910 ..
911 }
912 ));
913 assert_eq!(running[1].kind, ObservationKindV1::SessionStarted);
914 let ObservationKindV1::OwnedProcessStarted { correlation_id, .. } = &running[2].kind
915 else {
916 panic!("expected the owned process to start");
917 };
918 assert_eq!(
919 correlation_id,
920 &process_correlation(AgentInstanceId(7), SessionGeneration(3)),
921 );
922 let exited = project(ControlEventKind::Exited { exit_code: Some(0), forced: false });
923 assert!(matches!(
924 exited[1].kind,
925 ObservationKindV1::OwnedProcessExited { success: Some(true), exit_code: Some(0), .. }
926 ));
927 assert_eq!(exited[2].kind, ObservationKindV1::Exited { success: Some(true) });
928 let failed = project(ControlEventKind::Failed { message: String::new() });
929 assert_eq!(
930 failed[2].kind,
931 ObservationKindV1::Error { detail: "session-failed".to_owned() },
932 );
933 assert_eq!(
934 project(ControlEventKind::Removed)[1].kind,
935 ObservationKindV1::Stopped,
936 );
937 assert!(project(ControlEventKind::Registered).is_empty());
938 }
939
940 #[test]
943 fn denied_host_request_projects_the_audit_observation_and_no_block() {
944 let projected = control_event_observations(&provider_control_event_at(
945 AdapterFamily::Acp,
946 "claude-code",
947 2,
948 ProviderEvent::HostRequestObserved {
949 method: "terminal/create".to_owned(),
950 params_json: String::new(),
951 decision: ProviderHostRequestDecision::Denied {
952 by: ProviderHostDecisionAuthority::Gate,
953 },
954 outcome: ProviderHostRequestOutcome::Executed,
955 reason: None,
956 },
957 ));
958 let timeline = timeline_observations(&projected);
959 assert_eq!(timeline.len(), 1);
960 assert!(matches!(
961 &timeline[0].kind,
962 ObservationKindV1::HostRequestObserved {
963 decision: HostRequestDecisionV1::Denied { by: HostDecisionAuthorityV1::Gate },
964 ..
965 }
966 ));
967 }
968
969 fn blocked_chunk(authority: NodeBlockAuthorityV1) -> AgentStreamChunkV1 {
970 AgentStreamChunkV1 {
971 source_sequence: 7,
972 kind: AgentStreamChunkKindV1::Blocked {
973 correlation_id: Some("tool-deadbeefcafebabe".to_owned()),
974 tool_class: "Shell".to_owned(),
975 authority,
976 reason_kind: Some("gate-rule".to_owned()),
977 reason: "blocked by dangerous-command gate".to_owned(),
978 help: Some("add a Bash permission rule".to_owned()),
979 },
980 }
981 }
982
983 #[test]
984 fn a_blocked_chunk_projects_to_exactly_its_action_blocked_fact() {
985 let observation = blocked_chunk_observation(&blocked_chunk(NodeBlockAuthorityV1::HostGate))
986 .expect("a blocked chunk projects");
987 assert_eq!(observation.source_sequence, 7);
988 assert_eq!(observation.evidence, ObservationEvidenceV1::StructuredProvider);
989 assert_eq!(observation.observed_at_unix_ms, None);
990 assert_eq!(
991 observation.kind,
992 ObservationKindV1::ActionBlocked {
993 correlation_id: Some("tool-deadbeefcafebabe".to_owned()),
994 tool_class: "Shell".to_owned(),
995 authority: BlockAuthorityV1::HarnessGate,
996 reason_kind: Some("gate-rule".to_owned()),
997 reason: "blocked by dangerous-command gate".to_owned(),
998 help: Some("add a Bash permission rule".to_owned()),
999 },
1000 );
1001 observation.validate().unwrap();
1002 }
1003
1004 #[test]
1005 fn every_node_block_authority_maps_to_its_observation_authority() {
1006 for (node, expected) in [
1007 (NodeBlockAuthorityV1::HostGate, BlockAuthorityV1::HarnessGate),
1008 (NodeBlockAuthorityV1::HostPolicy, BlockAuthorityV1::HarnessPolicy),
1009 (NodeBlockAuthorityV1::HostDeadline, BlockAuthorityV1::HarnessDeadline),
1010 (NodeBlockAuthorityV1::Operator, BlockAuthorityV1::Operator),
1011 (NodeBlockAuthorityV1::ProviderClassifier, BlockAuthorityV1::ProviderClassifier),
1012 (
1013 NodeBlockAuthorityV1::ProviderPermissionRule,
1014 BlockAuthorityV1::ProviderPermissionRule,
1015 ),
1016 (NodeBlockAuthorityV1::ProviderSandbox, BlockAuthorityV1::ProviderSandbox),
1017 (NodeBlockAuthorityV1::ProviderRefusal, BlockAuthorityV1::ProviderRefusal),
1018 (NodeBlockAuthorityV1::UserRejected, BlockAuthorityV1::UserRejected),
1019 (NodeBlockAuthorityV1::ProviderQuota, BlockAuthorityV1::ProviderQuota),
1020 (NodeBlockAuthorityV1::Unknown, BlockAuthorityV1::Unknown),
1021 ] {
1022 let observation = blocked_chunk_observation(&blocked_chunk(node)).unwrap();
1023 let ObservationKindV1::ActionBlocked { authority, .. } = observation.kind else {
1024 panic!("expected ActionBlocked");
1025 };
1026 assert_eq!(authority, expected);
1027 }
1028 }
1029
1030 #[test]
1031 fn a_history_summary_projects_a_declaration_and_an_aggregate_snapshot() {
1032 let summary = SessionHistorySummaryV1 {
1033 message_count: 12,
1034 message_count_exact: true,
1035 completed_turn_count: Some(5),
1036 total_tokens: Some(4_000),
1037 modified_at_unix_ms: Some(1_786_000_000_000),
1038 };
1039 let projected = history_summary_observations(&summary);
1040 assert!(matches!(
1041 projected[0].kind,
1042 ObservationKindV1::SourceCapabilities {
1043 source_family: ObservationSourceFamilyV1::History,
1044 capabilities: ObservationCapabilitiesV1 { history_summary: true, .. },
1045 ..
1046 }
1047 ));
1048 assert_eq!(
1049 projected[1].kind,
1050 ObservationKindV1::HistorySnapshot {
1051 message_count: 12,
1052 message_count_exact: true,
1053 completed_turn_count: Some(5),
1054 total_tokens: Some(4_000),
1055 },
1056 );
1057 for observation in &projected {
1058 assert_eq!(observation.source_sequence, 1_786_000_000_000);
1059 assert_eq!(observation.evidence, ObservationEvidenceV1::HistoryProjection);
1060 assert_eq!(observation.observed_at_unix_ms, None);
1061 observation.validate().unwrap();
1062 }
1063 let undated = history_summary_observations(&SessionHistorySummaryV1 {
1064 modified_at_unix_ms: None,
1065 ..summary
1066 });
1067 assert!(undated.iter().all(|observation| observation.source_sequence == 1));
1068 assert_eq!(history_summary_observations(&summary), projected);
1069 }
1070
1071 #[test]
1072 fn chunks_that_are_not_blocks_project_nothing() {
1073 let text = AgentStreamChunkV1 {
1074 source_sequence: 3,
1075 kind: AgentStreamChunkKindV1::Text { text: "hello".to_owned(), is_delta: false },
1076 };
1077 assert_eq!(blocked_chunk_observation(&text), None);
1078 }
1079
1080 #[test]
1081 fn exact_context_projects_only_from_structured_non_pty_provider_events() {
1082 let usage = ContextWindowUsage {
1083 uncached_input_tokens: 70,
1084 cache_read_tokens: 20,
1085 cache_write_tokens: 0,
1086 output_tokens: 10,
1087 unattributed_tokens: 5,
1088 used_tokens: 105,
1089 capacity_tokens: 100,
1090 };
1091 let structured = control_event_observations(&provider_control_event_at(
1092 AdapterFamily::Pipe,
1093 "codex",
1094 2,
1095 ProviderEvent::ContextWindowUsage { usage },
1096 ));
1097 let timeline = timeline_observations(&structured);
1098 assert_eq!(timeline.len(), 1);
1099 assert_eq!(timeline[0].evidence, ObservationEvidenceV1::StructuredProvider);
1100 assert_eq!(
1101 timeline[0].kind,
1102 ObservationKindV1::ContextWindowUsage {
1103 uncached_input_tokens: Some(70),
1104 cache_read_tokens: Some(20),
1105 cache_write_tokens: Some(0),
1106 output_tokens: Some(10),
1107 unattributed_tokens: Some(5),
1108 used_tokens: 105,
1109 capacity_tokens: 100,
1110 }
1111 );
1112
1113 for family in [AdapterFamily::PtySemantic, AdapterFamily::ManagedHook] {
1114 let projected = control_event_observations(&provider_control_event_at(
1115 family,
1116 "codex",
1117 2,
1118 ProviderEvent::ContextWindowUsage { usage },
1119 ));
1120 assert!(
1121 timeline_observations(&projected).is_empty(),
1122 "{family:?} must not project an authoritative context fact"
1123 );
1124 }
1125 }
1126
1127 #[test]
1131 fn pipe_events_project_private_categorical_tools_and_usage() {
1132 let ready = provider_control_event_at(AdapterFamily::Pipe, "codex", 1, ProviderEvent::Ready);
1133 let projected = control_event_observations(&ready);
1134 let ObservationKindV1::SourceCapabilities { capabilities, .. } = &projected[0].kind else {
1135 panic!("expected source capabilities");
1136 };
1137 assert!(capabilities.tools && capabilities.usage);
1138 assert!(!capabilities.attention);
1139 assert!(!capabilities.subagents && !capabilities.todo && !capabilities.file_changes);
1140
1141 let tool = control_event_observations(&provider_control_event_at(
1142 AdapterFamily::Pipe,
1143 "codex",
1144 2,
1145 ProviderEvent::ToolStarted {
1146 id: "private-provider-tool-id".to_owned(),
1147 name: "run_shell_command".to_owned(),
1148 input_json: String::new(),
1149 agent_id: None,
1150 },
1151 ));
1152 assert!(matches!(
1153 timeline_observations(&tool)[0].kind,
1154 ObservationKindV1::ToolStarted { ref class, .. } if class == "Shell"
1155 ));
1156 let attention = control_event_observations(&provider_control_event_at(
1157 AdapterFamily::Pipe,
1158 "codex",
1159 3,
1160 ProviderEvent::InteractionRequested {
1161 request_id: Some("private-request-id".to_owned()),
1162 interaction_kind: ProviderInteractionKind::Approval,
1163 tool_name: "Shell".to_owned(),
1164 title: None,
1165 prompt: String::new(),
1166 options: Vec::new(),
1167 agent_id: None,
1168 },
1169 ));
1170 let ObservationKindV1::ApprovalRequested {
1171 correlation_id: requested_correlation,
1172 tool_class,
1173 } = &timeline_observations(&attention)[0].kind else {
1174 panic!("expected approval observation");
1175 };
1176 assert_eq!(tool_class, "Shell");
1177 let requested_correlation = requested_correlation.clone();
1178 let raw_resolution = control_event_observations(&provider_control_event_at(
1179 AdapterFamily::Pipe,
1180 "codex",
1181 4,
1182 ProviderEvent::InteractionResolved {
1183 request_id: "private-request-id".to_owned(),
1184 outcome: gate4agent_types::ProviderInteractionOutcome::Approved,
1185 },
1186 ));
1187 assert!(raw_resolution.is_empty());
1188 let resolved = control_event_observations(&ControlEvent {
1189 sequence: 42,
1190 command_id: None,
1191 instance_id: AgentInstanceId(7),
1192 generation: SessionGeneration(3),
1193 event: ControlEventKind::InteractionResolved {
1194 interaction_id: gate4agent_types::ProviderInteractionId(8),
1195 outcome: gate4agent_types::ProviderInteractionOutcome::Approved,
1196 },
1197 });
1198 let ObservationKindV1::InteractionResolved {
1199 correlation_id,
1200 outcome,
1201 } = &resolved[0].kind else {
1202 panic!("expected interaction resolution observation");
1203 };
1204 assert_eq!(correlation_id, &requested_correlation);
1205 assert_eq!(*outcome, ObservationInteractionOutcomeV1::Approved);
1206 let usage = control_event_observations(&provider_control_event_at(
1207 AdapterFamily::Pipe,
1208 "codex",
1209 5,
1210 ProviderEvent::TurnCompleted {
1211 usage: TokenUsage {
1212 input_tokens: 5,
1213 output_tokens: 8,
1214 ..TokenUsage::default()
1215 },
1216 is_cumulative: false,
1217 },
1218 ));
1219 assert!(timeline_observations(&usage).iter().any(|observation| matches!(
1220 observation.kind,
1221 ObservationKindV1::Usage { input_tokens: 5, output_tokens: 8, .. }
1222 )));
1223 let encoded = serde_json::to_string(&(
1224 projected,
1225 tool,
1226 attention,
1227 raw_resolution,
1228 resolved,
1229 usage,
1230 ))
1231 .unwrap();
1232 assert!(!encoded.contains("private-provider-tool-id"));
1233 assert!(!encoded.contains("private-request-id"));
1234 }
1235
1236 #[test]
1237 fn pty_hint_never_projects_authoritative_completion() {
1238 let completed = provider_control_event(
1239 AdapterFamily::PtySemantic,
1240 ProviderEvent::TurnCompleted {
1241 usage: TokenUsage {
1242 input_tokens: 10,
1243 output_tokens: 20,
1244 cache_read_tokens: 30,
1245 cache_write_tokens: 40,
1246 reasoning_tokens: 50,
1247 context_window: Some(128_000),
1248 },
1249 is_cumulative: true,
1250 },
1251 );
1252 let projected = control_event_observations(&completed);
1253 assert!(projected.is_empty());
1254 assert!(timeline_observations(&projected).is_empty());
1255 let tool_completed = provider_control_event(
1256 AdapterFamily::PtySemantic,
1257 ProviderEvent::ToolCompleted {
1258 id: "private-tool-id".to_owned(),
1259 output: "private output".to_owned(),
1260 is_error: false,
1261 duration_ms: Some(5),
1262 agent_id: None,
1263 non_execution_kind: None,
1264 },
1265 );
1266 let projected = control_event_observations(&tool_completed);
1267 assert!(projected.is_empty());
1268 assert!(timeline_observations(&projected).is_empty());
1269 let subagent_completed = provider_control_event(
1270 AdapterFamily::PtySemantic,
1271 ProviderEvent::SubagentStopped {
1272 agent_id: "private-subagent-id".to_owned(),
1273 },
1274 );
1275 let projected = control_event_observations(&subagent_completed);
1276 assert!(projected.is_empty());
1277 assert!(timeline_observations(&projected).is_empty());
1278
1279 let working = provider_control_event(
1280 AdapterFamily::PtySemantic,
1281 ProviderEvent::WorkingObserved,
1282 );
1283 let projected = control_event_observations(&working);
1284 assert_eq!(projected.len(), 1);
1285 let timeline = timeline_observations(&projected);
1286 assert_eq!(timeline[0].evidence, ObservationEvidenceV1::PtyHint);
1287 assert_eq!(timeline[0].kind, ObservationKindV1::Working);
1288 }
1289
1290 #[test]
1291 fn unproven_codex_workflow_tool_payloads_do_not_project_detail() {
1292 for (name, input_json, private_value) in [
1293 (
1294 "plan_update",
1295 r#"{"summary":"private plan summary","status":"in_progress"}"#,
1296 "private plan summary",
1297 ),
1298 (
1299 "FileChange",
1300 r#"{"path":"C:\\private\\host.rs","status":"in_progress"}"#,
1301 r#"C:\\private\\host.rs"#,
1302 ),
1303 ] {
1304 let event = provider_control_event(
1305 AdapterFamily::Pipe,
1306 ProviderEvent::ToolStarted {
1307 id: "private-workflow-id".to_owned(),
1308 name: name.to_owned(),
1309 input_json: input_json.to_owned(),
1310 agent_id: None,
1311 },
1312 );
1313 let projected = control_event_observations(&event);
1314 assert_eq!(projected.len(), 1);
1315 let timeline = timeline_observations(&projected);
1316 assert_eq!(
1317 timeline[0].evidence,
1318 ObservationEvidenceV1::StructuredProvider,
1319 );
1320 assert!(matches!(
1321 &timeline[0].kind,
1322 ObservationKindV1::ToolStarted { .. }
1323 ));
1324 assert!(!matches!(
1325 &timeline[0].kind,
1326 ObservationKindV1::TodoSnapshot { .. }
1327 | ObservationKindV1::FileChanged { .. }
1328 ));
1329 let wire = serde_json::to_string(&projected).unwrap();
1330 assert!(!wire.contains("private-workflow-id"));
1331 assert!(!wire.contains(private_value));
1332 }
1333
1334 for name in ["plan_update", "FileChange"] {
1335 let pty = provider_control_event(
1336 AdapterFamily::PtySemantic,
1337 ProviderEvent::ToolStarted {
1338 id: "private-workflow-id".to_owned(),
1339 name: name.to_owned(),
1340 input_json: r#"{"path":"C:\\private\\host.rs"}"#.to_owned(),
1341 agent_id: None,
1342 },
1343 );
1344 let projected = control_event_observations(&pty);
1345 assert_eq!(projected.len(), 1);
1346 let timeline = timeline_observations(&projected);
1347 assert_eq!(timeline[0].evidence, ObservationEvidenceV1::PtyHint);
1348 assert!(matches!(
1349 &timeline[0].kind,
1350 ObservationKindV1::ToolStarted { .. }
1351 ));
1352 }
1353 }
1354
1355 #[test]
1356 fn acp_plan_produces_a_todo_snapshot_carrying_source_sequence_as_revision() {
1357 let event = provider_control_event(
1358 AdapterFamily::Acp,
1359 ProviderEvent::Plan {
1360 steps: vec![
1361 ProviderPlanStep {
1362 content: "read the file".to_owned(),
1363 priority: ProviderPlanPriority::High,
1364 status: ProviderPlanStatus::Completed,
1365 },
1366 ProviderPlanStep {
1367 content: "write the fix".to_owned(),
1368 priority: ProviderPlanPriority::Medium,
1369 status: ProviderPlanStatus::InProgress,
1370 },
1371 ],
1372 },
1373 );
1374 let projected = control_event_observations(&event);
1375 let timeline = timeline_observations(&projected);
1376 assert_eq!(timeline.len(), 1);
1377 assert_eq!(
1378 timeline[0].evidence,
1379 ObservationEvidenceV1::StructuredProvider
1380 );
1381 let ObservationKindV1::TodoSnapshot {
1382 revision,
1383 items,
1384 complete,
1385 } = &timeline[0].kind
1386 else {
1387 panic!("expected a todo snapshot observation");
1388 };
1389 assert_eq!(*revision, 9);
1393 assert_eq!(
1394 items,
1395 &vec![
1396 ObservationTodoItemV1 {
1397 id: None,
1398 text: "read the file".to_owned(),
1399 state: ObservationTodoStateV1::Completed,
1400 },
1401 ObservationTodoItemV1 {
1402 id: None,
1403 text: "write the fix".to_owned(),
1404 state: ObservationTodoStateV1::InProgress,
1405 },
1406 ]
1407 );
1408 assert!(!complete, "one step is still in progress");
1409
1410 let all_done = provider_control_event(
1411 AdapterFamily::Acp,
1412 ProviderEvent::Plan {
1413 steps: vec![ProviderPlanStep {
1414 content: "read the file".to_owned(),
1415 priority: ProviderPlanPriority::High,
1416 status: ProviderPlanStatus::Completed,
1417 }],
1418 },
1419 );
1420 let projected = control_event_observations(&all_done);
1421 let timeline = timeline_observations(&projected);
1422 let ObservationKindV1::TodoSnapshot { complete, .. } = &timeline[0].kind else {
1423 panic!("expected a todo snapshot observation");
1424 };
1425 assert!(*complete, "every step is completed");
1426
1427 let pty = provider_control_event(
1430 AdapterFamily::PtySemantic,
1431 ProviderEvent::Plan {
1432 steps: vec![ProviderPlanStep {
1433 content: "read the file".to_owned(),
1434 priority: ProviderPlanPriority::High,
1435 status: ProviderPlanStatus::Pending,
1436 }],
1437 },
1438 );
1439 let projected = control_event_observations(&pty);
1440 assert!(timeline_observations(&projected).is_empty());
1441 }
1442
1443 #[test]
1444 fn acp_usage_updated_produces_context_window_usage_with_unobserved_breakdown() {
1445 let event = provider_control_event(
1446 AdapterFamily::Acp,
1447 ProviderEvent::UsageUpdated {
1448 used_tokens: Some(4_200),
1449 context_window: Some(200_000),
1450 cost_amount: Some("0.42".to_owned()),
1451 cost_currency: Some("USD".to_owned()),
1452 },
1453 );
1454 let projected = control_event_observations(&event);
1455 let timeline = timeline_observations(&projected);
1456 assert_eq!(timeline.len(), 1);
1457 assert_eq!(
1458 timeline[0].evidence,
1459 ObservationEvidenceV1::StructuredProvider
1460 );
1461 assert_eq!(
1462 timeline[0].kind,
1463 ObservationKindV1::ContextWindowUsage {
1464 uncached_input_tokens: None,
1465 cache_read_tokens: None,
1466 cache_write_tokens: None,
1467 output_tokens: None,
1468 unattributed_tokens: None,
1469 used_tokens: 4_200,
1470 capacity_tokens: 200_000,
1471 }
1472 );
1473 let wire = serde_json::to_string(&projected).unwrap();
1474 assert!(!wire.contains("0.42"));
1475 assert!(!wire.contains("USD"));
1476
1477 for incomplete in [
1480 ProviderEvent::UsageUpdated {
1481 used_tokens: None,
1482 context_window: Some(200_000),
1483 cost_amount: None,
1484 cost_currency: None,
1485 },
1486 ProviderEvent::UsageUpdated {
1487 used_tokens: Some(4_200),
1488 context_window: None,
1489 cost_amount: None,
1490 cost_currency: None,
1491 },
1492 ] {
1493 let event = provider_control_event(AdapterFamily::Acp, incomplete);
1494 let projected = control_event_observations(&event);
1495 assert!(timeline_observations(&projected).is_empty());
1496 }
1497 }
1498
1499 #[test]
1500 fn token_bearing_pipe_turn_completed_projects_usage() {
1501 let completed = provider_control_event_at(
1502 AdapterFamily::Pipe,
1503 "codex",
1504 1,
1505 ProviderEvent::TurnCompleted {
1506 usage: TokenUsage {
1507 input_tokens: 10,
1508 output_tokens: 20,
1509 cache_read_tokens: 30,
1510 cache_write_tokens: 40,
1511 reasoning_tokens: 50,
1512 context_window: Some(128_000),
1513 },
1514 is_cumulative: true,
1515 },
1516 );
1517 let projected = control_event_observations(&completed);
1518 let ObservationKindV1::SourceCapabilities { capabilities, .. } = &projected[0].kind else {
1519 panic!("expected source capabilities");
1520 };
1521 assert!(capabilities.usage);
1522 assert_eq!(projected[1].kind, ObservationKindV1::TurnCompleted);
1523 assert!(matches!(
1524 projected[2].kind,
1525 ObservationKindV1::Usage {
1526 input_tokens: 10,
1527 output_tokens: 20,
1528 is_cumulative: true,
1529 ..
1530 }
1531 ));
1532 }
1533
1534 #[test]
1539 fn granted_host_request_execution_failure_carries_a_failed_outcome_never_a_block() {
1540 let spawn_error = "terminal/create spawn failed: os error 3";
1541 let event = provider_control_event_at(
1542 AdapterFamily::Acp,
1543 "claude-code",
1544 6,
1545 ProviderEvent::HostRequestObserved {
1546 method: "terminal/create".to_owned(),
1547 params_json: String::new(),
1548 decision: ProviderHostRequestDecision::Granted {
1549 by: ProviderHostDecisionAuthority::Policy,
1550 },
1551 outcome: ProviderHostRequestOutcome::Failed { error: spawn_error.to_owned() },
1552 reason: None,
1553 },
1554 );
1555 let projected = control_event_observations(&event);
1556 let timeline = timeline_observations(&projected);
1557 let (observed_decision, observed_outcome) = timeline
1558 .iter()
1559 .find_map(|observation| match &observation.kind {
1560 ObservationKindV1::HostRequestObserved { decision, outcome, .. } => {
1561 Some((*decision, outcome.clone()))
1562 }
1563 _ => None,
1564 })
1565 .expect("expected a HostRequestObserved observation");
1566 assert_eq!(
1567 observed_decision,
1568 HostRequestDecisionV1::Granted { by: HostDecisionAuthorityV1::Policy }
1569 );
1570 assert_eq!(
1571 observed_outcome,
1572 HostRequestOutcomeV1::Failed { error: spawn_error.to_owned() }
1573 );
1574 assert!(
1575 !timeline
1576 .iter()
1577 .any(|observation| matches!(observation.kind, ObservationKindV1::ActionBlocked { .. })),
1578 "a Granted decision must never mint ActionBlocked, regardless of outcome: {timeline:?}"
1579 );
1580 for observation in &projected {
1581 observation.validate().expect("minted observation must validate");
1582 }
1583 }
1584}