Skip to main content

zagens_core/engine/
kernel_event.rs

1//! KernelEvent schema — Phase 3a (立契约).
2//!
3//! The single source of truth for all observable state transitions in a turn.
4//! Every variant is `#[non_exhaustive]`; consumers must handle unknown variants
5//! gracefully. Adding fields is backward-compatible; removing or renaming
6//! requires a schema upcast function + `schema_version` bump.
7//!
8//! **Status**: v1 (2026-06-15). Double-write started in Phase 3a; only
9//! consumed in Phase 3b once completeness verification passes.
10
11use serde::{Deserialize, Serialize};
12
13use crate::engine::request_fingerprint::RequestFingerprint;
14use crate::models::Usage;
15use crate::turn::{TurnLoopMode, TurnOutcomeStatus};
16
17// ── Opaque ID aliases ────────────────────────────────────────────────────────
18
19/// Unique identifier for a turn (maps to `TurnContext::id`).
20pub type TurnId = String;
21/// Unique identifier for a single tool call attempt.
22pub type CallId = String;
23/// Identifier for a compaction/snapshot artifact stored in the artifact store.
24pub type ArtifactId = String;
25
26// ── Supporting enums ─────────────────────────────────────────────────────────
27
28/// Reason a turn ended. Supersedes bare `TurnOutcomeStatus` with richer
29/// detail so that the state-machine can branch without out-of-band flags.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "snake_case")]
32#[non_exhaustive]
33pub enum TurnOutcome {
34    Completed,
35    Failed { message: String },
36    Interrupted,
37    Budget,
38    LoopGuard { reason: String },
39    MaxSteps,
40    CycleHandoff { next_cycle: u32 },
41}
42
43impl TurnOutcome {
44    /// Map to the legacy `TurnOutcomeStatus` for callers not yet migrated.
45    #[must_use]
46    pub fn as_status(&self) -> TurnOutcomeStatus {
47        match self {
48            TurnOutcome::Completed => TurnOutcomeStatus::Completed,
49            TurnOutcome::Interrupted => TurnOutcomeStatus::Interrupted,
50            _ => TurnOutcomeStatus::Failed,
51        }
52    }
53}
54
55/// Stream delta type during model response.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "snake_case")]
58#[non_exhaustive]
59pub enum DeltaKind {
60    Text,
61    ThinkingText,
62    ToolCallArg,
63}
64
65/// Resolved outcome for a single tool call.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum ToolOutcome {
70    /// Tool ran and returned a non-error result (may be empty).
71    Success,
72    /// Blocked pre-execution (loop-guard duplicate or approval rejected).
73    Blocked { reason: String },
74    /// Loop guard halted the turn during or after execution.
75    GuardHalt { reason: String },
76    /// Tool process or network timed out.
77    Timeout,
78    /// Tool returned a tool-level error (not kernel error).
79    ToolError { message: String },
80}
81
82/// Whether the user approved or rejected a planned tool call.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85#[non_exhaustive]
86pub enum ApprovalVerdict {
87    Approved,
88    Rejected,
89    /// User chose retry with an elevated sandbox policy (not a plain approval).
90    Retried,
91}
92
93/// Policy metadata resolved for a planned call (subset relevant to replay).
94#[derive(Debug, Clone, Default, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct PolicyDecision {
97    pub approval_required: bool,
98    pub parallel_eligible: bool,
99    pub read_only: bool,
100}
101
102impl PolicyDecision {
103    #[must_use]
104    pub fn new(approval_required: bool, parallel_eligible: bool, read_only: bool) -> Self {
105        Self {
106            approval_required,
107            parallel_eligible,
108            read_only,
109        }
110    }
111}
112
113/// Strategy applied when recovering from a context overflow.
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116#[non_exhaustive]
117pub enum OverflowStrategy {
118    BudgetRecompile,
119    LlmCompaction,
120    CycleHandoff,
121}
122
123/// Which code path triggered a capacity checkpoint.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
125#[serde(rename_all = "snake_case")]
126#[non_exhaustive]
127pub enum CapacityCheckpointKind {
128    PreRequest,
129    PostTool,
130    ErrorEscalation,
131}
132
133/// What the capacity subsystem decided to do at a checkpoint.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135#[serde(rename_all = "snake_case")]
136#[non_exhaustive]
137pub enum CapacityAction {
138    Continue,
139    Trim,
140    Handoff,
141    Abort { reason: String },
142}
143
144impl CapacityAction {
145    /// Map capacity-controller output to kernel schema action.
146    #[must_use]
147    pub fn from_guardrail(action: crate::capacity::GuardrailAction, reason: &str) -> Self {
148        let mapped = match action {
149            crate::capacity::GuardrailAction::NoIntervention => Self::Continue,
150            crate::capacity::GuardrailAction::TargetedContextRefresh => Self::Trim,
151            crate::capacity::GuardrailAction::VerifyWithToolReplay => Self::Continue,
152            crate::capacity::GuardrailAction::VerifyAndReplan => Self::Handoff,
153        };
154        if !reason.is_empty() && !matches!(mapped, Self::Continue) {
155            tracing::debug!(
156                target: "capacity",
157                guardrail = ?action,
158                kernel_action = ?mapped,
159                reason,
160                "CapacityAction mapped from guardrail"
161            );
162        }
163        mapped
164    }
165}
166
167/// Inclusive message-index range `[from, to]` (0-based, across all messages).
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169pub struct MessageRange {
170    pub from: u32,
171    pub to: u32,
172}
173
174// ── KernelEvent ──────────────────────────────────────────────────────────────
175
176/// Append-only, strongly-typed, monotonically-sequenced log of all observable
177/// state transitions in a single turn.
178///
179/// Replaces: `core::Event` free-string table, `RuntimeEventRecord`, `EventFrame`,
180/// and SSE-compat surface — four representations become one.
181///
182/// # Schema evolution
183/// - **Adding a variant**: add `#[serde(default)]` fields; increment nothing.
184/// - **Adding a field** to existing variant: add `#[serde(default)]`; no bump.
185/// - **Removing or renaming**: provide `upcast_v{N}_to_v{N+1}()` and bump
186///   [`SchemaVersion`](KernelEvent::SchemaVersion).
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(tag = "event_type", rename_all = "snake_case")]
189#[non_exhaustive]
190pub enum KernelEvent {
191    // ── Schema sentinel ─────────────────────────────────────────────────────
192    /// First record in every `kernel_events` table partition.
193    /// Currently always `version: 1`.
194    SchemaVersion {
195        version: u32,
196    },
197
198    // ── Turn lifecycle ───────────────────────────────────────────────────────
199    TurnStarted {
200        turn_id: TurnId,
201        mode: TurnLoopMode,
202        /// Serialised user message text. Attachments / large blobs: `None` with
203        /// a separate `EmitArtifact` event carrying the blob reference.
204        input_text: String,
205        max_steps: u32,
206    },
207    TurnEnded {
208        turn_id: TurnId,
209        outcome: TurnOutcome,
210        total_steps: u32,
211    },
212
213    // ── Model request ────────────────────────────────────────────────────────
214    ModelRequestIssued {
215        turn_id: TurnId,
216        step_idx: u32,
217        request_fp: RequestFingerprint,
218        /// Resolved token budget for this request.
219        token_budget: u32,
220    },
221    ModelDelta {
222        turn_id: TurnId,
223        step_idx: u32,
224        kind: DeltaKind,
225        /// For `ToolCallArg` deltas: the call_id of the in-progress tool block.
226        call_id: Option<CallId>,
227        text: String,
228    },
229    ModelMessage {
230        turn_id: TurnId,
231        step_idx: u32,
232        usage: Usage,
233        /// Number of content blocks in the final message (avoids embedding full
234        /// message text which belongs in the session store).
235        block_count: u32,
236        /// Truncated assistant text for log-driven transcript rebuild (Phase 3b 5c).
237        #[serde(default, skip_serializing_if = "String::is_empty")]
238        text_preview: String,
239        /// Full assistant text written to session JSON (Phase 3b 5c closure).
240        #[serde(default, skip_serializing_if = "String::is_empty")]
241        assistant_text: String,
242    },
243
244    // ── Tool calls ───────────────────────────────────────────────────────────
245    ToolCallPlanned {
246        turn_id: TurnId,
247        step_idx: u32,
248        call_id: CallId,
249        tool_name: String,
250        /// JSON-serialised tool input. Large inputs (>16 KB) are stored as an
251        /// artifact reference; this field holds the truncated preview or `{}`
252        /// with a `large_input_artifact_id` field added.
253        input_json: String,
254        decision: PolicyDecision,
255    },
256    ToolCallStarted {
257        turn_id: TurnId,
258        call_id: CallId,
259        /// DAG wave index (0 in legacy sequential mode).
260        wave_idx: u32,
261    },
262    ToolCallFinished {
263        turn_id: TurnId,
264        call_id: CallId,
265        tool_name: String,
266        outcome: ToolOutcome,
267        duration_ms: u32,
268        /// Whether the tool performed any filesystem/state writes. Mirrors
269        /// `tool_writes_state()` at call time; used to rebuild deferred-tool
270        /// activation projection in Phase 3b.
271        wrote_state: bool,
272        /// Truncated tool result / error text for log-driven transcript rebuild (Phase 3b 5c).
273        #[serde(default, skip_serializing_if = "String::is_empty")]
274        result_preview: String,
275        /// Exact tool-result body written to session JSON (Phase 3b 5c closure).
276        #[serde(default, skip_serializing_if = "String::is_empty")]
277        session_content: String,
278    },
279    ApprovalResolved {
280        turn_id: TurnId,
281        call_id: CallId,
282        verdict: ApprovalVerdict,
283    },
284
285    // ── Context & compaction ─────────────────────────────────────────────────
286    CompactionArtifactCreated {
287        turn_id: TurnId,
288        artifact_id: ArtifactId,
289        /// Message indices `[from, to]` replaced by this compaction artifact.
290        replaced_range: MessageRange,
291        summary_token_count: u32,
292    },
293    ContextOverflowRecovered {
294        turn_id: TurnId,
295        step_idx: u32,
296        strategy: OverflowStrategy,
297        /// Token budget cap applied during budget-recompile (None for other strategies).
298        source_budget_cap: Option<u32>,
299    },
300
301    // ── Memory injections ────────────────────────────────────────────────────
302    SteerInjected {
303        turn_id: TurnId,
304        step_idx: u32,
305        text: String,
306    },
307    ScratchpadReminderInjected {
308        turn_id: TurnId,
309        step_idx: u32,
310        area_path: String,
311    },
312    ScratchpadSummaryInjected {
313        turn_id: TurnId,
314        /// Step at which the summary was first injected. Subsequent steps read
315        /// the flag from the projection without a new event.
316        at_step: u32,
317    },
318    CycleBriefingInjected {
319        turn_id: TurnId,
320        cycle: u32,
321        step_idx: u32,
322    },
323    /// Episodic topic-memory block injected into the system prompt (B2 double-write).
324    TopicMemoryInjected {
325        turn_id: TurnId,
326        step_idx: u32,
327        /// Estimated tokens in the injected `<topic_memory>` block.
328        #[serde(default)]
329        block_token_est: u32,
330    },
331    /// Read-side memory plane query executed (v3 `Effect::QueryMemory` double-write).
332    MemoryPlaneQueried {
333        turn_id: TurnId,
334        step_idx: u32,
335        /// `working` | `episodic` | `archival`
336        layer: String,
337        query_key: String,
338        /// ContextCompiler source id resolved for this query (empty when unknown).
339        #[serde(default)]
340        compiler_source: String,
341    },
342    /// Flash layered-context seam appended as assistant message (v3 `#159` double-write).
343    LayeredContextSeamInjected {
344        turn_id: TurnId,
345        step_idx: u32,
346        level: u32,
347        messages_covered: u32,
348        /// Truncated seam text for observability (full body remains in session store until 5c).
349        text_preview: String,
350    },
351
352    // ── Guard decisions ──────────────────────────────────────────────────────
353    LoopGuardTriggered {
354        turn_id: TurnId,
355        call_id: CallId,
356        /// "identical_call" | "deferred_set_area_batch" | "failure_halt" …
357        reason: String,
358    },
359    CapacityCheckpoint {
360        turn_id: TurnId,
361        step_idx: u32,
362        kind: CapacityCheckpointKind,
363        tokens_used: u32,
364        token_budget: u32,
365        action: CapacityAction,
366        /// When true, a proposed guardrail was suppressed by cooldown (replay → `Effect::Sleep`).
367        #[serde(default)]
368        cooldown_blocked: bool,
369    },
370
371    // ── LHT / Cycle continuation ─────────────────────────────────────────────
372    CycleAdvanced {
373        turn_id: TurnId,
374        from_cycle: u32,
375        to_cycle: u32,
376        /// Human-readable reason emitted by the LHT cycle-advance hook.
377        reason: String,
378    },
379    StepLimitContinuation {
380        turn_id: TurnId,
381        step_idx: u32,
382        lht_objective_injected: bool,
383    },
384    LoopGuardContinuation {
385        turn_id: TurnId,
386        step_idx: u32,
387    },
388
389    // ── Tool catalog mutations ────────────────────────────────────────────────
390    /// A previously-deferred tool was promoted into the active tool set.
391    ///
392    /// This event is emitted by `maybe_activate_deferred_tool` whenever the
393    /// model requests a tool that was not yet active. It is necessary for
394    /// Phase 3b: `active_tool_names` (host state) must be rebuildable from
395    /// the log so that `TurnMachine::step` can be a pure function.
396    DeferredToolActivated {
397        turn_id: TurnId,
398        step_idx: u32,
399        tool_name: String,
400    },
401}
402
403impl KernelEvent {
404    /// Extract the `turn_id` field present in every variant except
405    /// [`KernelEvent::SchemaVersion`].
406    #[must_use]
407    pub fn turn_id(&self) -> Option<&str> {
408        match self {
409            KernelEvent::SchemaVersion { .. } => None,
410            KernelEvent::TurnStarted { turn_id, .. }
411            | KernelEvent::TurnEnded { turn_id, .. }
412            | KernelEvent::ModelRequestIssued { turn_id, .. }
413            | KernelEvent::ModelDelta { turn_id, .. }
414            | KernelEvent::ModelMessage { turn_id, .. }
415            | KernelEvent::ToolCallPlanned { turn_id, .. }
416            | KernelEvent::ToolCallStarted { turn_id, .. }
417            | KernelEvent::ToolCallFinished { turn_id, .. }
418            | KernelEvent::ApprovalResolved { turn_id, .. }
419            | KernelEvent::CompactionArtifactCreated { turn_id, .. }
420            | KernelEvent::ContextOverflowRecovered { turn_id, .. }
421            | KernelEvent::SteerInjected { turn_id, .. }
422            | KernelEvent::ScratchpadReminderInjected { turn_id, .. }
423            | KernelEvent::ScratchpadSummaryInjected { turn_id, .. }
424            | KernelEvent::CycleBriefingInjected { turn_id, .. }
425            | KernelEvent::TopicMemoryInjected { turn_id, .. }
426            | KernelEvent::MemoryPlaneQueried { turn_id, .. }
427            | KernelEvent::LayeredContextSeamInjected { turn_id, .. }
428            | KernelEvent::LoopGuardTriggered { turn_id, .. }
429            | KernelEvent::CapacityCheckpoint { turn_id, .. }
430            | KernelEvent::CycleAdvanced { turn_id, .. }
431            | KernelEvent::StepLimitContinuation { turn_id, .. }
432            | KernelEvent::LoopGuardContinuation { turn_id, .. }
433            | KernelEvent::DeferredToolActivated { turn_id, .. } => Some(turn_id.as_str()),
434        }
435    }
436
437    /// Variant name string for logging and schema drift CI.
438    #[must_use]
439    pub fn kind_str(&self) -> &'static str {
440        match self {
441            KernelEvent::SchemaVersion { .. } => "schema_version",
442            KernelEvent::TurnStarted { .. } => "turn_started",
443            KernelEvent::TurnEnded { .. } => "turn_ended",
444            KernelEvent::ModelRequestIssued { .. } => "model_request_issued",
445            KernelEvent::ModelDelta { .. } => "model_delta",
446            KernelEvent::ModelMessage { .. } => "model_message",
447            KernelEvent::ToolCallPlanned { .. } => "tool_call_planned",
448            KernelEvent::ToolCallStarted { .. } => "tool_call_started",
449            KernelEvent::ToolCallFinished { .. } => "tool_call_finished",
450            KernelEvent::ApprovalResolved { .. } => "approval_resolved",
451            KernelEvent::CompactionArtifactCreated { .. } => "compaction_artifact_created",
452            KernelEvent::ContextOverflowRecovered { .. } => "context_overflow_recovered",
453            KernelEvent::SteerInjected { .. } => "steer_injected",
454            KernelEvent::ScratchpadReminderInjected { .. } => "scratchpad_reminder_injected",
455            KernelEvent::ScratchpadSummaryInjected { .. } => "scratchpad_summary_injected",
456            KernelEvent::CycleBriefingInjected { .. } => "cycle_briefing_injected",
457            KernelEvent::TopicMemoryInjected { .. } => "topic_memory_injected",
458            KernelEvent::MemoryPlaneQueried { .. } => "memory_plane_queried",
459            KernelEvent::LayeredContextSeamInjected { .. } => "layered_context_seam_injected",
460            KernelEvent::LoopGuardTriggered { .. } => "loop_guard_triggered",
461            KernelEvent::CapacityCheckpoint { .. } => "capacity_checkpoint",
462            KernelEvent::CycleAdvanced { .. } => "cycle_advanced",
463            KernelEvent::StepLimitContinuation { .. } => "step_limit_continuation",
464            KernelEvent::LoopGuardContinuation { .. } => "loop_guard_continuation",
465            KernelEvent::DeferredToolActivated { .. } => "deferred_tool_activated",
466        }
467    }
468}
469
470// ── Envelope for log persistence ──────────────────────────────────────────────
471
472/// Row written to the `kernel_events` SQLite table.
473///
474/// The `seq` field is a global monotone counter within a runtime session
475/// (not per-turn); the `(turn_id, seq)` pair is unique across the table.
476#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct KernelEventEnvelope {
478    /// Global monotone sequence number assigned by the writer.
479    pub seq: u64,
480    /// Unix timestamp (milliseconds).
481    pub ts_ms: u64,
482    /// Variant name (mirrors `KernelEvent::kind_str()`).
483    pub kind: String,
484    pub event: KernelEvent,
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    fn make_turn_id() -> TurnId {
492        "test-turn-001".to_string()
493    }
494
495    #[test]
496    fn turn_started_round_trips() {
497        let ev = KernelEvent::TurnStarted {
498            turn_id: make_turn_id(),
499            mode: TurnLoopMode::Agent,
500            input_text: "hello".to_string(),
501            max_steps: 20,
502        };
503        let json = serde_json::to_string(&ev).expect("serialize");
504        let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
505        assert_eq!(back.kind_str(), "turn_started");
506        assert_eq!(back.turn_id(), Some("test-turn-001"));
507    }
508
509    #[test]
510    fn turn_ended_round_trips() {
511        let ev = KernelEvent::TurnEnded {
512            turn_id: make_turn_id(),
513            outcome: TurnOutcome::LoopGuard {
514                reason: "identical call".to_string(),
515            },
516            total_steps: 7,
517        };
518        let json = serde_json::to_string(&ev).expect("serialize");
519        let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
520        assert_eq!(back.kind_str(), "turn_ended");
521    }
522
523    #[test]
524    fn tool_call_round_trips() {
525        let ev = KernelEvent::ToolCallFinished {
526            turn_id: make_turn_id(),
527            call_id: "call-abc".to_string(),
528            tool_name: "read_file".to_string(),
529            outcome: ToolOutcome::Success,
530            duration_ms: 120,
531            wrote_state: true,
532            result_preview: String::new(),
533            session_content: String::new(),
534        };
535        let json = serde_json::to_string(&ev).expect("serialize");
536        let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
537        assert_eq!(back.kind_str(), "tool_call_finished");
538    }
539
540    #[test]
541    fn capacity_checkpoint_round_trips() {
542        let ev = KernelEvent::CapacityCheckpoint {
543            turn_id: make_turn_id(),
544            step_idx: 3,
545            kind: CapacityCheckpointKind::PostTool,
546            tokens_used: 8000,
547            token_budget: 32000,
548            action: CapacityAction::Continue,
549            cooldown_blocked: false,
550        };
551        let json = serde_json::to_string(&ev).expect("serialize");
552        let _back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
553    }
554
555    #[test]
556    fn schema_version_has_no_turn_id() {
557        let ev = KernelEvent::SchemaVersion { version: 1 };
558        assert!(ev.turn_id().is_none());
559        assert_eq!(ev.kind_str(), "schema_version");
560    }
561
562    #[test]
563    fn turn_outcome_as_status_mapping() {
564        assert_eq!(
565            TurnOutcome::Completed.as_status(),
566            TurnOutcomeStatus::Completed
567        );
568        assert_eq!(
569            TurnOutcome::Interrupted.as_status(),
570            TurnOutcomeStatus::Interrupted
571        );
572        assert_eq!(TurnOutcome::Budget.as_status(), TurnOutcomeStatus::Failed);
573    }
574
575    // ── Schema completeness verification (Phase 3a) ──────────────────────────
576    //
577    // Each test below demonstrates that a specific "A-class" host state field
578    // can be rebuilt purely from a KernelEvent sequence.  These are the
579    // preconditions for TurnMachine::step to be a pure function in Phase 3b.
580
581    /// Projection helper: rebuild `scratchpad_summary_injected` flag.
582    /// Rule: true iff a `ScratchpadSummaryInjected` event appeared for this turn.
583    fn proj_scratchpad_summary_injected(events: &[KernelEvent], turn: &str) -> bool {
584        events.iter().any(|ev| {
585            matches!(ev, KernelEvent::ScratchpadSummaryInjected { turn_id, .. }
586                if turn_id == turn)
587        })
588    }
589
590    #[test]
591    fn completeness_scratchpad_summary_injected() {
592        let tid = "t1".to_string();
593        let events: Vec<KernelEvent> = vec![
594            KernelEvent::TurnStarted {
595                turn_id: tid.clone(),
596                mode: TurnLoopMode::Agent,
597                input_text: "do stuff".into(),
598                max_steps: 10,
599            },
600            KernelEvent::ScratchpadSummaryInjected {
601                turn_id: tid.clone(),
602                at_step: 2,
603            },
604        ];
605        assert!(proj_scratchpad_summary_injected(&events, &tid));
606
607        // Without the event, flag is false.
608        let empty: Vec<KernelEvent> = vec![KernelEvent::TurnStarted {
609            turn_id: tid.clone(),
610            mode: TurnLoopMode::Agent,
611            input_text: "do stuff".into(),
612            max_steps: 10,
613        }];
614        assert!(!proj_scratchpad_summary_injected(&empty, &tid));
615    }
616
617    /// Projection helper: rebuild `active_tool_names` set.
618    /// Rule: starts from initial set; a `DeferredToolActivated` event adds to it.
619    fn proj_active_tools<'a>(
620        events: &'a [KernelEvent],
621        turn: &str,
622        initial: &[&'a str],
623    ) -> std::collections::HashSet<String> {
624        let mut active: std::collections::HashSet<String> =
625            initial.iter().map(|s| s.to_string()).collect();
626        for ev in events {
627            if let KernelEvent::DeferredToolActivated {
628                turn_id, tool_name, ..
629            } = ev
630                && turn_id == turn
631            {
632                active.insert(tool_name.clone());
633            }
634        }
635        active
636    }
637
638    #[test]
639    fn completeness_deferred_tool_activation() {
640        let tid = "t2".to_string();
641        let initial = &["read_file", "shell_exec"];
642        let events = vec![
643            KernelEvent::TurnStarted {
644                turn_id: tid.clone(),
645                mode: TurnLoopMode::Agent,
646                input_text: "search for foo".into(),
647                max_steps: 10,
648            },
649            KernelEvent::DeferredToolActivated {
650                turn_id: tid.clone(),
651                step_idx: 1,
652                tool_name: "tool_search_tool_regex".to_string(),
653            },
654        ];
655        let active = proj_active_tools(&events, &tid, initial);
656        assert!(active.contains("tool_search_tool_regex"));
657        assert!(active.contains("read_file"));
658        // A tool NOT activated should not appear.
659        assert!(!active.contains("write_file"));
660    }
661
662    /// Projection helper: rebuild `ScratchpadStepState` counters for the
663    /// **current** step (since last `ModelRequestIssued`).
664    /// Rule: reset at each ModelRequestIssued; increment from ToolCallFinished.
665    #[derive(Default, PartialEq, Eq, Debug)]
666    struct ScratchpadCounters {
667        readonly_successes: usize,
668        scratchpad_writes: usize,
669    }
670
671    fn proj_scratchpad_step_state(events: &[KernelEvent], turn: &str) -> ScratchpadCounters {
672        let mut counters = ScratchpadCounters::default();
673        for ev in events {
674            match ev {
675                // Reset at each new model request for this turn.
676                KernelEvent::ModelRequestIssued { turn_id, .. } if turn_id == turn => {
677                    counters = ScratchpadCounters::default();
678                }
679                KernelEvent::ToolCallFinished {
680                    turn_id,
681                    outcome,
682                    wrote_state,
683                    tool_name,
684                    ..
685                } if turn_id == turn => {
686                    if matches!(outcome, ToolOutcome::Success) {
687                        if *wrote_state {
688                            // scratchpad_append / scratchpad_set_area
689                            if tool_name.starts_with("scratchpad_") {
690                                counters.scratchpad_writes += 1;
691                            }
692                        } else {
693                            counters.readonly_successes += 1;
694                        }
695                    }
696                }
697                _ => {}
698            }
699        }
700        counters
701    }
702
703    #[test]
704    fn completeness_scratchpad_step_state_projection() {
705        let tid = "t3".to_string();
706        let fp = crate::engine::request_fingerprint::RequestFingerprint {
707            static_prefix_sha256: "aaa".into(),
708            full_prefix_sha256: "bbb".into(),
709        };
710        let events = vec![
711            KernelEvent::ModelRequestIssued {
712                turn_id: tid.clone(),
713                step_idx: 1,
714                request_fp: fp.clone(),
715                token_budget: 32000,
716            },
717            KernelEvent::ToolCallFinished {
718                turn_id: tid.clone(),
719                call_id: "c1".into(),
720                outcome: ToolOutcome::Success,
721                duration_ms: 50,
722                wrote_state: false,
723                tool_name: "read_file".into(),
724                result_preview: String::new(),
725                session_content: String::new(),
726            },
727            KernelEvent::ToolCallFinished {
728                turn_id: tid.clone(),
729                call_id: "c2".into(),
730                outcome: ToolOutcome::Success,
731                duration_ms: 30,
732                wrote_state: false,
733                tool_name: "shell_exec".into(),
734                result_preview: String::new(),
735                session_content: String::new(),
736            },
737            // Second model request resets counters.
738            KernelEvent::ModelRequestIssued {
739                turn_id: tid.clone(),
740                step_idx: 2,
741                request_fp: fp,
742                token_budget: 32000,
743            },
744            KernelEvent::ToolCallFinished {
745                turn_id: tid.clone(),
746                call_id: "c3".into(),
747                outcome: ToolOutcome::Success,
748                duration_ms: 20,
749                wrote_state: true,
750                tool_name: "scratchpad_append".into(),
751                result_preview: String::new(),
752                session_content: String::new(),
753            },
754        ];
755
756        let state = proj_scratchpad_step_state(&events, &tid);
757        // After step 2: only the scratchpad_append write, no readonly hits.
758        assert_eq!(state.readonly_successes, 0);
759        assert_eq!(state.scratchpad_writes, 1);
760    }
761
762    /// Projection helper: rebuild LHT continuation step count.
763    /// Rule: count of `StepLimitContinuation` events for this turn.
764    fn proj_lht_continuation_count(events: &[KernelEvent], turn: &str) -> u32 {
765        events
766            .iter()
767            .filter(|ev| {
768                matches!(ev, KernelEvent::StepLimitContinuation { turn_id, .. }
769                    if turn_id == turn)
770            })
771            .count() as u32
772    }
773
774    #[test]
775    fn completeness_lht_continuation_count() {
776        let tid = "t4".to_string();
777        let events = vec![
778            KernelEvent::StepLimitContinuation {
779                turn_id: tid.clone(),
780                step_idx: 20,
781                lht_objective_injected: true,
782            },
783            KernelEvent::StepLimitContinuation {
784                turn_id: tid.clone(),
785                step_idx: 40,
786                lht_objective_injected: false,
787            },
788        ];
789        assert_eq!(proj_lht_continuation_count(&events, &tid), 2);
790        assert_eq!(proj_lht_continuation_count(&events, "other-turn"), 0);
791    }
792
793    #[test]
794    fn completeness_steer_injection_consumed() {
795        // Rule: steer is consumed if a SteerInjected event exists at or before
796        // the current step. This is a boolean projection.
797        let tid = "t5".to_string();
798        let events = [KernelEvent::SteerInjected {
799            turn_id: tid.clone(),
800            step_idx: 3,
801            text: "change approach".into(),
802        }];
803        let injected = events
804            .iter()
805            .any(|ev| matches!(ev, KernelEvent::SteerInjected { turn_id, .. } if turn_id == &tid));
806        assert!(injected);
807    }
808
809    #[test]
810    fn completeness_capacity_state_projection() {
811        // Rule: the most recent CapacityCheckpoint.action for the turn determines
812        // capacity state. If it's Abort, the turn should have ended.
813        let tid = "t6".to_string();
814        let events = [
815            KernelEvent::CapacityCheckpoint {
816                turn_id: tid.clone(),
817                step_idx: 1,
818                kind: CapacityCheckpointKind::PreRequest,
819                tokens_used: 5000,
820                token_budget: 32000,
821                action: CapacityAction::Continue,
822                cooldown_blocked: false,
823            },
824            KernelEvent::CapacityCheckpoint {
825                turn_id: tid.clone(),
826                step_idx: 2,
827                kind: CapacityCheckpointKind::PostTool,
828                tokens_used: 28000,
829                token_budget: 32000,
830                action: CapacityAction::Trim,
831                cooldown_blocked: false,
832            },
833        ];
834        let last_action = events
835            .iter()
836            .filter_map(|ev| {
837                if let KernelEvent::CapacityCheckpoint {
838                    turn_id, action, ..
839                } = ev
840                    && turn_id == &tid
841                {
842                    return Some(action.clone());
843                }
844                None
845            })
846            .next_back();
847        assert_eq!(last_action, Some(CapacityAction::Trim));
848    }
849
850    #[test]
851    fn deferred_tool_activated_round_trips() {
852        let ev = KernelEvent::DeferredToolActivated {
853            turn_id: "t7".into(),
854            step_idx: 2,
855            tool_name: "tool_search_bm25".into(),
856        };
857        let json = serde_json::to_string(&ev).expect("serialize");
858        let back: KernelEvent = serde_json::from_str(&json).expect("deserialize");
859        assert_eq!(back.kind_str(), "deferred_tool_activated");
860    }
861
862    // ── Schema drift CI (Phase 3a §2.3) ─────────────────────────────────────
863    //
864    // These tests pin the exact JSON shape of representative KernelEvent
865    // variants. If the serde layout changes (rename, tag rename, new field
866    // without #[serde(default)], etc.), the string comparison fails immediately
867    // rather than silently breaking deserialization of stored logs.
868    //
869    // Update the golden strings ONLY when a schema version bump is also done
870    // (add a `SchemaVersion` event with incremented version AND an upcast fn).
871
872    #[test]
873    fn schema_drift_turn_started_shape() {
874        let ev = KernelEvent::TurnStarted {
875            turn_id: "TURN-001".into(),
876            mode: TurnLoopMode::Agent,
877            input_text: "hello".into(),
878            max_steps: 20,
879        };
880        let json = serde_json::to_string(&ev).expect("serialize");
881        // Pin the field names and tag value.  New *optional* fields are ok
882        // (they'll appear in the JSON but won't break old readers with
883        // #[serde(default)]).  Renaming any field here is a schema break.
884        assert!(
885            json.contains(r#""event_type":"turn_started""#),
886            "tag must be event_type:turn_started, got: {json}"
887        );
888        assert!(
889            json.contains(r#""turn_id":"TURN-001""#),
890            "missing turn_id, got: {json}"
891        );
892        assert!(
893            json.contains(r#""mode":"agent""#),
894            "missing mode, got: {json}"
895        );
896        assert!(
897            json.contains(r#""input_text":"hello""#),
898            "missing input_text, got: {json}"
899        );
900        assert!(
901            json.contains(r#""max_steps":20"#),
902            "missing max_steps, got: {json}"
903        );
904    }
905
906    #[test]
907    fn schema_drift_tool_call_finished_shape() {
908        let ev = KernelEvent::ToolCallFinished {
909            turn_id: "TURN-001".into(),
910            call_id: "CALL-001".into(),
911            tool_name: "read_file".into(),
912            outcome: ToolOutcome::Success,
913            duration_ms: 42,
914            wrote_state: false,
915            result_preview: String::new(),
916            session_content: String::new(),
917        };
918        let json = serde_json::to_string(&ev).expect("serialize");
919        assert!(
920            json.contains(r#""event_type":"tool_call_finished""#),
921            "tag drift: {json}"
922        );
923        assert!(
924            json.contains(r#""call_id":"CALL-001""#),
925            "missing call_id: {json}"
926        );
927        assert!(
928            json.contains(r#""tool_name":"read_file""#),
929            "missing tool_name: {json}"
930        );
931        assert!(
932            json.contains(r#""wrote_state":false"#),
933            "missing wrote_state: {json}"
934        );
935    }
936
937    #[test]
938    fn schema_drift_model_request_issued_shape() {
939        let fp = crate::engine::request_fingerprint::RequestFingerprint {
940            static_prefix_sha256: "aaabbb".into(),
941            full_prefix_sha256: "cccddd".into(),
942        };
943        let ev = KernelEvent::ModelRequestIssued {
944            turn_id: "TURN-001".into(),
945            step_idx: 1,
946            request_fp: fp,
947            token_budget: 16000,
948        };
949        let json = serde_json::to_string(&ev).expect("serialize");
950        assert!(
951            json.contains(r#""event_type":"model_request_issued""#),
952            "tag drift: {json}"
953        );
954        assert!(
955            json.contains(r#""static_prefix_sha256":"aaabbb""#),
956            "missing static fp: {json}"
957        );
958        assert!(
959            json.contains(r#""token_budget":16000"#),
960            "missing token_budget: {json}"
961        );
962    }
963
964    #[test]
965    fn schema_drift_deferred_tool_activated_shape() {
966        let ev = KernelEvent::DeferredToolActivated {
967            turn_id: "TURN-001".into(),
968            step_idx: 3,
969            tool_name: "tool_search_bm25".into(),
970        };
971        let json = serde_json::to_string(&ev).expect("serialize");
972        assert!(
973            json.contains(r#""event_type":"deferred_tool_activated""#),
974            "tag drift: {json}"
975        );
976        assert!(
977            json.contains(r#""tool_name":"tool_search_bm25""#),
978            "missing tool_name: {json}"
979        );
980    }
981
982    /// Verify all 22 variant kind strings are accounted for (prevents silent
983    /// addition of variants without updating `kind_str()`).
984    #[test]
985    fn all_variants_have_kind_str() {
986        // We can't enumerate non_exhaustive enums at compile time, but we can
987        // verify the count we know about hasn't silently changed.
988        let known_kinds = [
989            "schema_version",
990            "turn_started",
991            "turn_ended",
992            "model_request_issued",
993            "model_delta",
994            "model_message",
995            "tool_call_planned",
996            "tool_call_started",
997            "tool_call_finished",
998            "approval_resolved",
999            "compaction_artifact_created",
1000            "context_overflow_recovered",
1001            "steer_injected",
1002            "scratchpad_reminder_injected",
1003            "scratchpad_summary_injected",
1004            "cycle_briefing_injected",
1005            "loop_guard_triggered",
1006            "capacity_checkpoint",
1007            "cycle_advanced",
1008            "step_limit_continuation",
1009            "loop_guard_continuation",
1010            "deferred_tool_activated",
1011        ];
1012        assert_eq!(
1013            known_kinds.len(),
1014            22,
1015            "Update this count when adding variants"
1016        );
1017    }
1018}