Skip to main content

microclaw_core/
runtime_event.rs

1//! Provider-neutral runtime events shared by Server, Work, and headless clients.
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum RuntimePlanStepStatus {
9    #[default]
10    Pending,
11    InProgress,
12    Completed,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct RuntimePlanStep {
17    pub title: String,
18    pub status: RuntimePlanStepStatus,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum RuntimeApprovalOptionKind {
24    Primary,
25    Secondary,
26    Danger,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum RuntimeApprovalDecision {
32    Approve,
33    Deny,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
37pub struct RuntimeApprovalOption {
38    pub value: String,
39    pub label: String,
40    pub kind: RuntimeApprovalOptionKind,
41    pub decision: RuntimeApprovalDecision,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case")]
46pub enum RuntimeProcessKind {
47    Command,
48    Verification,
49}
50
51/// An observable event emitted by the shared agent runtime.
52///
53/// Keep provider-specific payloads behind the runtime boundary. Consumers
54/// should be able to project this stream without knowing which LLM produced it.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56#[serde(tag = "type", rename_all = "snake_case")]
57pub enum RuntimeEvent {
58    Iteration {
59        iteration: usize,
60    },
61    ToolStart {
62        call_id: String,
63        name: String,
64        input: Value,
65    },
66    ToolResult {
67        call_id: String,
68        name: String,
69        is_error: bool,
70        preview: String,
71        duration_ms: u64,
72        status_code: Option<i32>,
73        bytes: usize,
74        error_type: Option<String>,
75    },
76    TextDelta {
77        delta: String,
78    },
79    ToolWaveStart {
80        wave: usize,
81        tool_count: usize,
82    },
83    ToolWaveComplete {
84        wave: usize,
85    },
86    Cancelled {
87        final_text: String,
88    },
89    FinalResponse {
90        text: String,
91    },
92    MidTurnInjection {
93        count: usize,
94    },
95    FileDiff {
96        path: String,
97        diff: String,
98        added: usize,
99        removed: usize,
100        truncated: bool,
101    },
102    SubagentStarted {
103        run_id: String,
104        label: String,
105    },
106    SubagentFinished {
107        run_id: String,
108        status: String,
109    },
110    ApprovalRequired {
111        approval_id: String,
112        tool: String,
113        preview: Option<String>,
114        options: Vec<RuntimeApprovalOption>,
115        advisory: Option<String>,
116    },
117    CheckpointCreated {
118        commit: String,
119        label: String,
120    },
121    PlanUpdated {
122        steps: Vec<RuntimePlanStep>,
123    },
124    ProcessOutput {
125        call_id: String,
126        command: String,
127        output: String,
128        exit_code: Option<i32>,
129        duration_ms: u64,
130        truncated: bool,
131        kind: RuntimeProcessKind,
132    },
133}
134
135/// Versioned transport envelope for replayable runtime event streams.
136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
137pub struct RuntimeEventEnvelope {
138    pub schema_version: u32,
139    pub run_id: String,
140    pub sequence: u64,
141    pub event: RuntimeEvent,
142}
143
144impl RuntimeEventEnvelope {
145    pub const SCHEMA_VERSION: u32 = 6;
146
147    pub fn new(run_id: impl Into<String>, sequence: u64, event: RuntimeEvent) -> Self {
148        Self {
149            schema_version: Self::SCHEMA_VERSION,
150            run_id: run_id.into(),
151            sequence,
152            event,
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    #[test]
162    fn envelope_round_trips_as_tagged_json() {
163        let expected = RuntimeEventEnvelope::new(
164            "run-1",
165            7,
166            RuntimeEvent::ToolStart {
167                call_id: "call-1".into(),
168                name: "read_file".into(),
169                input: serde_json::json!({"path": "README.md"}),
170            },
171        );
172
173        let json = serde_json::to_string(&expected).expect("serialize runtime event");
174        assert!(json.contains("\"type\":\"tool_start\""));
175        let actual: RuntimeEventEnvelope =
176            serde_json::from_str(&json).expect("deserialize runtime event");
177        assert_eq!(actual, expected);
178    }
179
180    #[test]
181    fn plan_update_round_trips_with_step_statuses() {
182        let expected = RuntimeEventEnvelope::new(
183            "run-plan",
184            2,
185            RuntimeEvent::PlanUpdated {
186                steps: vec![
187                    RuntimePlanStep {
188                        title: "Inspect the workspace".into(),
189                        status: RuntimePlanStepStatus::Completed,
190                    },
191                    RuntimePlanStep {
192                        title: "Implement the change".into(),
193                        status: RuntimePlanStepStatus::InProgress,
194                    },
195                ],
196            },
197        );
198        let json = serde_json::to_string(&expected).expect("serialize plan update");
199        let actual: RuntimeEventEnvelope =
200            serde_json::from_str(&json).expect("deserialize plan update");
201        assert_eq!(actual, expected);
202    }
203
204    #[test]
205    fn approval_options_round_trip_with_stable_values_and_intent() {
206        let expected = RuntimeEventEnvelope::new(
207            "run-approval",
208            3,
209            RuntimeEvent::ApprovalRequired {
210                approval_id: "approval-1".into(),
211                tool: "bash".into(),
212                preview: Some("cargo test".into()),
213                options: vec![RuntimeApprovalOption {
214                    value: "3".into(),
215                    label: "Deny".into(),
216                    kind: RuntimeApprovalOptionKind::Danger,
217                    decision: RuntimeApprovalDecision::Deny,
218                }],
219                advisory: Some("Review command scope".into()),
220            },
221        );
222        let json = serde_json::to_string(&expected).expect("serialize approval event");
223        let actual: RuntimeEventEnvelope =
224            serde_json::from_str(&json).expect("deserialize approval event");
225        assert_eq!(actual, expected);
226    }
227
228    #[test]
229    fn process_output_round_trips_as_bounded_execution_evidence() {
230        let expected = RuntimeEventEnvelope::new(
231            "run-process",
232            4,
233            RuntimeEvent::ProcessOutput {
234                call_id: "bash-1".into(),
235                command: "cargo test".into(),
236                output: "test result: ok".into(),
237                exit_code: Some(0),
238                duration_ms: 250,
239                truncated: false,
240                kind: RuntimeProcessKind::Verification,
241            },
242        );
243        let json = serde_json::to_string(&expected).expect("serialize process output");
244        let actual: RuntimeEventEnvelope =
245            serde_json::from_str(&json).expect("deserialize process output");
246        assert_eq!(actual, expected);
247    }
248
249    #[test]
250    fn tool_result_duration_is_json_transport_safe() {
251        let expected = RuntimeEventEnvelope::new(
252            "run-tool",
253            5,
254            RuntimeEvent::ToolResult {
255                call_id: "call-1".into(),
256                name: "bash".into(),
257                is_error: false,
258                preview: "ok".into(),
259                duration_ms: u64::MAX,
260                status_code: Some(0),
261                bytes: 2,
262                error_type: None,
263            },
264        );
265        let json = serde_json::to_string(&expected).expect("serialize tool result");
266        let actual: RuntimeEventEnvelope =
267            serde_json::from_str(&json).expect("deserialize tool result");
268        assert_eq!(actual, expected);
269    }
270}