1use 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#[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#[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}