Skip to main content

hatchery_observation_engine/
node_projection.rs

1//! Projection of a gate4agent node's own event stream onto the observation
2//! vocabulary.
3//!
4//! The node publishes `NodeEvent::Control` (session lifecycle and provider
5//! events) and the agent stream; it names no observation type. This module is
6//! the harness side of that boundary: it derives `ObservationV1` telemetry from
7//! those events, deterministically, so the same node cursor observed twice --
8//! directly and through C2, or replayed on resync -- projects to equal
9//! observations.
10//!
11//! Three inputs feed it:
12//! - a control event ([`control_event_observations`]) -- lifecycle, turns,
13//!   tools, usage, plans, subagents, interactions, host-request audit;
14//! - an agent-stream `Blocked` chunk ([`blocked_chunk_observation`]) -- the
15//!   blocked-action fact, computed once by the node and only mapped here;
16//! - a record history summary ([`history_summary_observations`]) -- the counts
17//!   the node publishes when a client previews a session record.
18
19use 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
42/// The category `ObservationKindV1::Error` carries for a provider error.
43///
44/// `detail` is a CATEGORY, not a message: `validate_error_category`
45/// (`hatchery-observation-api`) accepts only hyphen-separated segments of
46/// lowercase letters and digits, and rejects anything else -- and that
47/// rejection propagates out of the harness runtime loop, taking the operator
48/// wire down with it. Passing a provider's sentence through here did exactly
49/// that, live. A refusal still names itself, in this field's own vocabulary: a
50/// distinct slug rather than a distinct sentence.
51fn 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        // Lifecycle hooks are retired (owner ruling 2026-09-25): nothing
62        // produces an `AdapterFamily::Hook`/`ManagedHook`-sourced event
63        // anymore. Both fall through to the same evidence a PTY-transport
64        // session otherwise reports; `ObservationEvidenceV1::ManagedHook`
65        // stays defined on the observation wire (schema/wire-compat, not a
66        // code-removal question) but nothing here produces it.
67        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        // Hooks are retired, so no normalizer backs a capability claim for
98        // these families: they claim none.
99        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
154/// Map `gate4agent-types`' `ProviderHostRequestDecision`/
155/// `ProviderHostDecisionAuthority` onto this crate's observation-wire
156/// `HostRequestDecisionV1`/`HostDecisionAuthorityV1` -- `gate4agent-
157/// observation-protocol` cannot depend on `gate4agent-types` (dependency-
158/// light wire crate), so the conversion lives here, the shell that already
159/// depends on both. A plain match, not `format!("{:?}", ..)`: the wire
160/// carries the typed value itself.
161fn 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
173/// Map `gate4agent-types`' `ProviderHostRequestOutcome` onto this crate's
174/// observation-wire `HostRequestOutcomeV1` -- same reason
175/// `observation_host_request_decision` exists rather than a `From` impl on
176/// either side (`gate4agent-observation-protocol` cannot depend on
177/// `gate4agent-types`). A `Failed` error is cut to the wire bound BEFORE the
178/// observation is constructed, at a safe UTF-8 boundary, since
179/// `ObservationKindV1::validate` rejects an over-long value outright rather
180/// than truncating.
181fn 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
226/// Maps ACP's per-step plan status onto the observation wire's own todo
227/// state -- a plain match, not `format!("{:?}", ..)`, for the same reason
228/// `observation_host_decision_authority` above is: the source carries the
229/// typed value itself, not a string to reparse.
230fn 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
238/// The observations one node control event projects to, in the order they
239/// were minted. Deterministic: the same event always yields the same
240/// observations (`observed_at_unix_ms` is left unset -- the receive time lives
241/// on the ingress envelope), so the same node cursor observed twice compares
242/// canonically equal.
243///
244/// A blocked action (`ObservationKindV1::ActionBlocked`) is NOT minted here: the
245/// node computes the block once, on its agent stream, and
246/// [`blocked_chunk_observation`] projects that chunk.
247pub 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        // ACP's `usage_update` only ever reports `used_tokens`/
350        // `context_window`, never the cache/segment breakdown the
351        // `ProviderEvent::ContextWindowUsage` arm above maps -- the five
352        // breakdown fields go in as `None` rather than a fabricated zero.
353        // `cost_amount`/`cost_currency` have no `ObservationKindV1` of
354        // their own; carrying provider turn cost onto this wire is a
355        // separate decision this arm deliberately does not make, so both
356        // are dropped here.
357        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        // ACP always sends the FULL plan on `session/update`, never a
375        // delta, so each snapshot wholesale-replaces the previous one --
376        // `source_sequence` is already strictly increasing per (instance,
377        // generation, provider source) (`Gate4AgentEngine::ingest_provider`
378        // rejects any `source_sequence` that is not, `gate4agent-engine`),
379        // so it doubles as the per-session `revision` a `TodoSnapshot`
380        // needs without this function tracking any counter of its own.
381        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                // An empty plan is a CLEARED plan, not a finished one. `all`
393                // is vacuously true on an empty list, and an agent that sent
394                // `plan` with no entries would therefore have its timeline
395                // entry rendered as "done" -- a completion nobody reported.
396                // Emptiness has to be excluded explicitly.
397                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        // The message travels verbatim, bounded, rather than being replaced
408        // by a constant. It was `"provider-error"` for every error alike,
409        // which is a category where a reason belongs: the engine mints a
410        // refusal here naming which capability it withheld and how many
411        // events it dropped, and flattening that to a fixed word turns a
412        // named refusal back into the silence it was written to end. An
413        // empty message keeps the old constant, because a blank detail is
414        // rejected by `ObservationKindV1::validate`.
415        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        // ACP session/update coverage beyond text/tool/turn/plan/usage
489        // streaming has no dedicated `ObservationKindV1` yet -- see the
490        // matching comment on `agent_progress_event_kind` above for why
491        // minting one is left to whoever owns this versioned wire contract.
492        | 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    // The observation engine coalesces this declaration by source and excludes it
510    // from timeline rows. Emit it with the first source receipt (or an explicit
511    // session restart), while gaps carry their own declaration for repair.
512    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
580/// The observations one session record history summary projects to: the
581/// declaration that this record reports a history summary, and the aggregate
582/// snapshot itself. Both are `HistoryProjection` evidence and deterministic;
583/// `source_sequence` is the record modification time (1 when the history has
584/// none), so a newer history orders after an older one.
585pub 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            // The observation intentionally carries aggregate metrics only.
613            // Omitted preview messages therefore do not make this aggregate
614            // partial.
615            truncated: false,
616        },
617    ]
618}
619
620/// The `ActionBlocked` observation for one agent-stream `Blocked` chunk, or
621/// `None` for every other chunk.
622///
623/// The node decides what is a block (a denied host request, ACP's
624/// `nonExecutionKind`, a refusal or quota stop) once, when it builds the chunk;
625/// the observation carries exactly that fact and never re-derives it. The
626/// evidence is `StructuredProvider`: a block is only ever minted from a typed
627/// provider or host field, never from PTY text.
628pub 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
657/// Maps the node's block authority onto the observation wire's own: the node
658/// names the host side `Host*`, this vocabulary names it `Harness*`.
659fn 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        // Lifecycle hooks are retired: their families claim nothing.
758        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    /// A denied host request projects the audit-only `HostRequestObserved`; the
941    /// blocked action itself arrives as the node's `Blocked` chunk.
942    #[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    /// A fleet Pipe adapter's structured stream projects private categorical
1128    /// observations end to end: source capabilities, a redacted tool start,
1129    /// an approval request/resolution round-trip, and usage.
1130    #[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        // `provider_control_event` fixes `source_sequence` at 9 (see its own
1390        // definition); that value doubles as the snapshot's revision -- see
1391        // the doc comment on the `ProviderEvent::Plan` match arm.
1392        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        // A pty-hint source cannot claim the authoritative plan snapshot --
1428        // same rule as `ToolCompleted`/`SubagentCompleted` above.
1429        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        // ACP requires both `used`/`size` -- a notification missing either
1478        // is malformed, not a measurement, and mints nothing.
1479        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    /// K1c's split: an authorized (`Granted`) host request that failed
1535    /// WHILE EXECUTING (a `terminal/create` spawn error) carries
1536    /// `HostRequestOutcomeV1::Failed` on the SAME audit-only
1537    /// `HostRequestObserved` observation `decision: Granted` already mints.
1538    #[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}