1use serde::{Deserialize, Serialize};
12
13use crate::engine::request_fingerprint::RequestFingerprint;
14use crate::models::Usage;
15use crate::turn::{TurnLoopMode, TurnOutcomeStatus};
16
17pub type TurnId = String;
21pub type CallId = String;
23pub type ArtifactId = String;
25
26#[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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68#[non_exhaustive]
69pub enum ToolOutcome {
70 Success,
72 Blocked { reason: String },
74 GuardHalt { reason: String },
76 Timeout,
78 ToolError { message: String },
80}
81
82#[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 Retried,
91}
92
93#[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#[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#[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#[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169pub struct MessageRange {
170 pub from: u32,
171 pub to: u32,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(tag = "event_type", rename_all = "snake_case")]
189#[non_exhaustive]
190pub enum KernelEvent {
191 SchemaVersion {
195 version: u32,
196 },
197
198 TurnStarted {
200 turn_id: TurnId,
201 mode: TurnLoopMode,
202 input_text: String,
205 max_steps: u32,
206 },
207 TurnEnded {
208 turn_id: TurnId,
209 outcome: TurnOutcome,
210 total_steps: u32,
211 },
212
213 ModelRequestIssued {
215 turn_id: TurnId,
216 step_idx: u32,
217 request_fp: RequestFingerprint,
218 token_budget: u32,
220 },
221 ModelDelta {
222 turn_id: TurnId,
223 step_idx: u32,
224 kind: DeltaKind,
225 call_id: Option<CallId>,
227 text: String,
228 },
229 ModelMessage {
230 turn_id: TurnId,
231 step_idx: u32,
232 usage: Usage,
233 block_count: u32,
236 #[serde(default, skip_serializing_if = "String::is_empty")]
238 text_preview: String,
239 #[serde(default, skip_serializing_if = "String::is_empty")]
241 assistant_text: String,
242 },
243
244 ToolCallPlanned {
246 turn_id: TurnId,
247 step_idx: u32,
248 call_id: CallId,
249 tool_name: String,
250 input_json: String,
254 decision: PolicyDecision,
255 },
256 ToolCallStarted {
257 turn_id: TurnId,
258 call_id: CallId,
259 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 wrote_state: bool,
272 #[serde(default, skip_serializing_if = "String::is_empty")]
274 result_preview: String,
275 #[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 CompactionArtifactCreated {
287 turn_id: TurnId,
288 artifact_id: ArtifactId,
289 replaced_range: MessageRange,
291 summary_token_count: u32,
292 },
293 ContextOverflowRecovered {
294 turn_id: TurnId,
295 step_idx: u32,
296 strategy: OverflowStrategy,
297 source_budget_cap: Option<u32>,
299 },
300
301 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 at_step: u32,
317 },
318 CycleBriefingInjected {
319 turn_id: TurnId,
320 cycle: u32,
321 step_idx: u32,
322 },
323 TopicMemoryInjected {
325 turn_id: TurnId,
326 step_idx: u32,
327 #[serde(default)]
329 block_token_est: u32,
330 },
331 MemoryPlaneQueried {
333 turn_id: TurnId,
334 step_idx: u32,
335 layer: String,
337 query_key: String,
338 #[serde(default)]
340 compiler_source: String,
341 },
342 LayeredContextSeamInjected {
344 turn_id: TurnId,
345 step_idx: u32,
346 level: u32,
347 messages_covered: u32,
348 text_preview: String,
350 },
351
352 LoopGuardTriggered {
354 turn_id: TurnId,
355 call_id: CallId,
356 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 #[serde(default)]
368 cooldown_blocked: bool,
369 },
370
371 CycleAdvanced {
373 turn_id: TurnId,
374 from_cycle: u32,
375 to_cycle: u32,
376 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 DeferredToolActivated {
397 turn_id: TurnId,
398 step_idx: u32,
399 tool_name: String,
400 },
401}
402
403impl KernelEvent {
404 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
477pub struct KernelEventEnvelope {
478 pub seq: u64,
480 pub ts_ms: u64,
482 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 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 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 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 assert!(!active.contains("write_file"));
660 }
661
662 #[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 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 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 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 assert_eq!(state.readonly_successes, 0);
759 assert_eq!(state.scratchpad_writes, 1);
760 }
761
762 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 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 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 #[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 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 #[test]
985 fn all_variants_have_kind_str() {
986 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}