1use serde::{Deserialize, Serialize};
17
18use super::command::CancellationReason;
19use super::effect::{KernelEffect, ProviderMessage};
20use super::scalar::{NodeId, WireU64, WorkflowId};
21
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum KernelTerminal {
30 Agent(AgentTerminal),
32 Workflow(WorkflowTerminal),
36 Cancelled(CancelledTerminal),
39 Failed(FailedTerminal),
41}
42
43impl KernelTerminal {
44 pub fn usage(&self) -> &UsageReport {
46 match self {
47 Self::Agent(terminal) => &terminal.usage,
48 Self::Workflow(terminal) => &terminal.usage,
49 Self::Cancelled(terminal) => &terminal.usage,
50 Self::Failed(terminal) => &terminal.usage,
51 }
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(deny_unknown_fields)]
57pub struct AgentTerminal {
58 pub result: LoopResult,
59 pub usage: UsageReport,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(deny_unknown_fields)]
64pub struct WorkflowTerminal {
65 pub outcome: WorkflowOutcome,
66 pub usage: UsageReport,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
70#[serde(deny_unknown_fields)]
71pub struct CancelledTerminal {
72 pub reason: CancellationReason,
73 pub usage: UsageReport,
74}
75
76#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
77#[serde(deny_unknown_fields)]
78pub struct FailedTerminal {
79 pub failure: KernelFailure,
80 pub usage: UsageReport,
81}
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(deny_unknown_fields)]
86pub struct LoopResult {
87 pub termination: TerminationReason,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
89 pub final_message: Option<ProviderMessage>,
90 pub turns_used: u32,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub pace_decision: Option<PaceDecision>,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
96#[serde(deny_unknown_fields)]
97pub struct PaceDecision {
98 pub action: PaceAction,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub delay_ms: Option<WireU64>,
101 pub reason: String,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub coerced_from: Option<String>,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum PaceAction {
109 Continue,
110 Sleep,
111 Stop,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum TerminationReason {
122 Completed,
123 MaxTurns,
124 TokenBudget,
125 Deadline,
126 ContextOverflow,
128 NoProgress,
131 MilestoneExceeded,
132}
133
134#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
137#[serde(deny_unknown_fields)]
138pub struct UsageReport {
139 pub input_tokens: WireU64,
140 pub output_tokens: WireU64,
141 pub turns: u32,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub cached_input_tokens: Option<WireU64>,
144}
145
146#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
147#[serde(deny_unknown_fields)]
148pub struct WorkflowOutcome {
149 pub workflow_id: WorkflowId,
150 pub status: WorkflowStatus,
151 #[serde(default, skip_serializing_if = "Vec::is_empty")]
152 pub completed_nodes: Vec<NodeId>,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub failed_nodes: Vec<NodeId>,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
158#[serde(rename_all = "snake_case")]
159pub enum WorkflowStatus {
160 Completed,
161 Failed,
162 Cancelled,
163}
164
165#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(deny_unknown_fields)]
170pub struct KernelFailure {
171 pub code: KernelFailureCode,
172 #[serde(default, skip_serializing_if = "String::is_empty")]
173 pub message: String,
174}
175
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum KernelFailureCode {
179 ProviderRecoveryExhausted,
180 OutputRecoveryExhausted,
181 HostEffectFailed,
183 ResourceExhausted,
184 InvariantViolated,
185}
186
187#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
197#[serde(tag = "kind", rename_all = "snake_case")]
198pub enum StepDisposition {
199 Effects(EffectsDisposition),
200 Terminal(TerminalDisposition),
201}
202
203impl StepDisposition {
204 pub fn effects(&self) -> &[KernelEffect] {
205 match self {
206 Self::Effects(disposition) => &disposition.effects,
207 Self::Terminal(_) => &[],
208 }
209 }
210
211 pub fn terminal(&self) -> Option<&KernelTerminal> {
212 match self {
213 Self::Effects(_) => None,
214 Self::Terminal(disposition) => Some(&disposition.terminal),
215 }
216 }
217
218 pub fn is_terminal(&self) -> bool {
219 matches!(self, Self::Terminal(_))
220 }
221}
222
223#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
224#[serde(deny_unknown_fields)]
225pub struct EffectsDisposition {
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub effects: Vec<KernelEffect>,
228}
229
230#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct TerminalDisposition {
233 pub terminal: KernelTerminal,
234}
235
236#[derive(Debug, Clone, Default, PartialEq)]
243pub struct TerminalSlot {
244 terminal: Option<KernelTerminal>,
245}
246
247impl TerminalSlot {
248 pub fn empty() -> Self {
249 Self { terminal: None }
250 }
251
252 pub fn commit(
253 &mut self,
254 terminal: KernelTerminal,
255 ) -> Result<&KernelTerminal, Box<TerminalAlreadyCommitted>> {
256 if let Some(committed) = &self.terminal {
257 return Err(Box::new(TerminalAlreadyCommitted {
258 committed: committed.clone(),
259 rejected: terminal,
260 }));
261 }
262 Ok(self.terminal.insert(terminal))
263 }
264
265 pub fn get(&self) -> Option<&KernelTerminal> {
266 self.terminal.as_ref()
267 }
268
269 pub fn is_committed(&self) -> bool {
270 self.terminal.is_some()
271 }
272}
273
274#[derive(Debug, Clone, PartialEq)]
276pub struct TerminalAlreadyCommitted {
277 pub committed: KernelTerminal,
278 pub rejected: KernelTerminal,
279}
280
281impl std::fmt::Display for TerminalAlreadyCommitted {
282 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283 f.write_str("this operation has already committed a terminal")
284 }
285}
286
287impl std::error::Error for TerminalAlreadyCommitted {}
288
289#[cfg(test)]
290mod tests {
291 use std::collections::BTreeSet;
292 use std::fs;
293 use std::path::PathBuf;
294
295 use serde_json::{Value, json};
296
297 use super::super::*;
298
299 fn fixture(name: &str) -> Value {
300 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
301 .join("../../tests/fixtures/kernel-wire")
302 .join(name);
303 let raw = fs::read_to_string(&path)
304 .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
305 serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{name} is not JSON: {e}"))
306 }
307
308 fn keys(value: &Value, out: &mut BTreeSet<String>) {
309 match value {
310 Value::Object(map) => {
311 for (key, child) in map {
312 out.insert(key.clone());
313 keys(child, out);
314 }
315 }
316 Value::Array(items) => items.iter().for_each(|item| keys(item, out)),
317 _ => {}
318 }
319 }
320
321 fn usage() -> UsageReport {
322 UsageReport {
323 input_tokens: WireU64::new(18_402),
324 output_tokens: WireU64::new(2_117),
325 turns: 7,
326 cached_input_tokens: None,
327 }
328 }
329
330 fn samples() -> Vec<KernelTerminal> {
331 vec![
332 KernelTerminal::Agent(AgentTerminal {
333 result: LoopResult {
334 termination: TerminationReason::Completed,
335 final_message: None,
336 turns_used: 7,
337 pace_decision: None,
338 },
339 usage: usage(),
340 }),
341 KernelTerminal::Workflow(WorkflowTerminal {
342 outcome: WorkflowOutcome {
343 workflow_id: WorkflowId::new("wf-1").unwrap(),
344 status: WorkflowStatus::Completed,
345 completed_nodes: vec![NodeId::new("node-a").unwrap()],
346 failed_nodes: Vec::new(),
347 },
348 usage: usage(),
349 }),
350 KernelTerminal::Cancelled(CancelledTerminal {
351 reason: CancellationReason::User,
352 usage: usage(),
353 }),
354 KernelTerminal::Failed(FailedTerminal {
355 failure: KernelFailure {
356 code: KernelFailureCode::ProviderRecoveryExhausted,
357 message: "context overflow ladder exhausted".to_string(),
358 },
359 usage: usage(),
360 }),
361 ]
362 }
363
364 #[test]
369 fn a_terminal_has_four_shapes_and_every_one_commits_usage_exactly_once() {
370 let mut tags = BTreeSet::new();
371 for terminal in samples() {
372 let value = serde_json::to_value(&terminal).unwrap();
373 tags.insert(value["kind"].as_str().unwrap().to_string());
374 assert!(
375 value.get("usage").is_some(),
376 "every terminal commits the usage report: {value}"
377 );
378 let mut all = BTreeSet::new();
380 keys(&value, &mut all);
381 assert!(
382 !all.contains("usage_report") && !all.contains("budget_usage"),
383 "usage travels in exactly one field: {value}"
384 );
385 let back: KernelTerminal = serde_json::from_value(value).unwrap();
386 assert_eq!(back, terminal);
387 }
388 assert_eq!(
389 tags,
390 BTreeSet::from([
391 "agent".to_string(),
392 "cancelled".to_string(),
393 "failed".to_string(),
394 "workflow".to_string(),
395 ])
396 );
397 }
398
399 #[test]
400 fn a_terminal_requires_no_resolution_and_carries_no_effect_id() {
401 for terminal in samples() {
402 let mut all = BTreeSet::new();
403 keys(&serde_json::to_value(&terminal).unwrap(), &mut all);
404 for banned in ["effect_id", "causation_input_id", "resolution"] {
405 assert!(
406 !all.contains(banned),
407 "a terminal is not an effect; it must not carry {banned:?}"
408 );
409 }
410 }
411 }
412
413 #[test]
414 fn a_terminal_carries_no_host_wall_clock() {
415 for terminal in samples() {
416 let mut all = BTreeSet::new();
417 keys(&serde_json::to_value(&terminal).unwrap(), &mut all);
418 for banned in [
419 "now_ms",
420 "observed_at_ms",
421 "timestamp",
422 "timestamp_ms",
423 "started_at_ms",
424 "completed_at_ms",
425 "wall_clock_ms",
426 "duration_ms",
427 ] {
428 assert!(!all.contains(banned), "terminal must not carry {banned:?}");
429 }
430 }
431 }
432
433 #[test]
438 fn a_terminal_slot_accepts_exactly_one_terminal() {
439 let mut slot = TerminalSlot::empty();
440 assert!(!slot.is_committed());
441 assert!(slot.get().is_none());
442
443 let first = samples().into_iter().next().unwrap();
444 slot.commit(first.clone()).expect("first terminal commits");
445 assert!(slot.is_committed());
446 assert_eq!(slot.get(), Some(&first));
447
448 for second in samples() {
450 let rejected = slot
451 .commit(second)
452 .expect_err("an operation has at most one terminal");
453 assert_eq!(rejected.committed, first);
454 }
455 assert_eq!(slot.get(), Some(&first));
456 }
457
458 #[test]
459 fn a_committed_step_publishes_effects_or_a_terminal_but_never_both() {
460 let effects = StepDisposition::Effects(EffectsDisposition {
461 effects: vec![KernelEffect {
462 effect_id: EffectId::new("op-1:step:1:effect:0").unwrap(),
463 causation_input_id: InputId::new("in-1").unwrap(),
464 effect: EffectKind::EvaluateMilestone(EvaluateMilestoneEffect {
465 request: MilestoneRequest {
466 contract_id: "brief-quality-primary".to_string(),
467 phase_id: "phase-1".to_string(),
468 },
469 }),
470 }],
471 });
472 assert!(effects.terminal().is_none());
473 assert_eq!(effects.effects().len(), 1);
474
475 let terminal = StepDisposition::Terminal(TerminalDisposition {
476 terminal: samples().into_iter().next().unwrap(),
477 });
478 assert!(terminal.terminal().is_some());
479 assert!(
480 terminal.effects().is_empty(),
481 "the terminal step publishes no effect"
482 );
483
484 let mixed = json!({
486 "kind": "terminal",
487 "terminal": serde_json::to_value(samples().into_iter().next().unwrap()).unwrap(),
488 "effects": [],
489 });
490 assert!(
491 serde_json::from_value::<StepDisposition>(mixed).is_err(),
492 "a step must not carry both a terminal and an effect list"
493 );
494 }
495
496 #[test]
501 fn unknown_terminal_kinds_and_fields_are_rejected() {
502 let unknown_kind = json!({ "kind": "done", "usage": { "input_tokens": "1", "output_tokens": "1", "turns": 1 } });
503 assert!(serde_json::from_value::<KernelTerminal>(unknown_kind).is_err());
504
505 let unknown_field = json!({
506 "kind": "cancelled",
507 "reason": "user",
508 "usage": { "input_tokens": "1", "output_tokens": "1", "turns": 1 },
509 "now_ms": 1753747203000u64,
510 });
511 assert!(serde_json::from_value::<KernelTerminal>(unknown_field).is_err());
512
513 let numeric_tokens = json!({
514 "kind": "cancelled",
515 "reason": "user",
516 "usage": { "input_tokens": 1, "output_tokens": "1", "turns": 1 },
517 });
518 assert!(serde_json::from_value::<KernelTerminal>(numeric_tokens).is_err());
519 }
520
521 #[test]
526 fn terminal_goldens_round_trip_unchanged() {
527 let mut covered = BTreeSet::new();
528 for name in [
529 "golden_terminal_agent.json",
530 "golden_terminal_workflow.json",
531 "golden_terminal_cancelled.json",
532 "golden_terminal_failed.json",
533 ] {
534 let golden = fixture(name);
535 let terminal: KernelTerminal = serde_json::from_value(golden.clone())
536 .unwrap_or_else(|e| panic!("{name} does not decode: {e}"));
537 assert_eq!(
538 serde_json::to_value(&terminal).unwrap(),
539 golden,
540 "{name}: round-trip changed the document"
541 );
542 covered.insert(golden["kind"].as_str().unwrap().to_string());
543 }
544 assert_eq!(covered.len(), 4, "one golden per terminal shape");
545 }
546}