Skip to main content

nomoreide_remote_protocol/
agent_event.rs

1//! What one agent turn emits, in order.
2//!
3//! Every event carries a `seq` that is monotonic within its run and starts at
4//! `0`. That number is the whole resumption story: a phone that reconnects
5//! sends the last `seq` it rendered and gets the rest, and a phone whose `seq`
6//! is older than the daemon's replay buffer is told to take a fresh snapshot
7//! rather than handed a gap it cannot detect. Ordering is never inferred from
8//! arrival — the relay is allowed to reorder, and one day will.
9//!
10//! Approvals are the security-critical member of this union. The remote policy
11//! is fail-closed and stated once, here, so no later reader has to reconstruct
12//! it: `autoApprove` does not exist remotely, an approval that is not answered
13//! within [`super::limits::APPROVAL_EXPIRY`] denies itself, a run that ends
14//! denies everything still pending, an unknown tool is treated as mutating, and
15//! there is no "always allow".
16//!
17//! The body is nested under `event` rather than flattened onto the envelope
18//! because serde cannot combine `flatten` with `deny_unknown_fields`, and of
19//! the two, strictness is the one worth keeping: a flat shape reads slightly
20//! better and silently tolerates every field nobody meant to send.
21
22use serde::{Deserialize, Serialize};
23
24/// One event in a run, with its place in the sequence.
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct AgentEvent {
28    pub run_id: String,
29    /// Monotonic within the run, from `0`, no gaps.
30    pub seq: u64,
31    pub event: AgentEventBody,
32}
33
34/// The event itself, adjacently tagged so every variant's body can be a named
35/// struct that refuses fields it does not define.
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(tag = "kind", content = "data", rename_all = "camelCase")]
38pub enum AgentEventBody {
39    /// A chunk of assistant text. Already stripped of ANSI and control bytes.
40    Text(TextEvent),
41    /// The agent is about to call a tool. Emitted whether or not the call needs
42    /// approval, so the phone can show what happened either way.
43    ToolUse(ToolUseEvent),
44    /// How that call went.
45    ToolResult(ToolResultEvent),
46    /// A mutating tool call is blocked on a human.
47    ApprovalRequest(ApprovalRequestEvent),
48    /// The approval was settled — by a human, or by the daemon denying it.
49    ApprovalSettled(ApprovalSettledEvent),
50    /// The turn finished normally.
51    Completed(NoData),
52    /// The turn was cancelled — by the phone, or because the daemon stopped.
53    Cancelled(NoData),
54    /// The turn failed. Prose for a human; never a stack trace, never a path.
55    Error(ErrorEvent),
56}
57
58/// An event body with nothing in it. Present as `{}` rather than absent, for
59/// the same reason [`super::device_bound::Empty`] is.
60#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct NoData {}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "camelCase", deny_unknown_fields)]
66pub struct TextEvent {
67    pub text: String,
68}
69
70#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
71#[serde(rename_all = "camelCase", deny_unknown_fields)]
72pub struct ToolUseEvent {
73    pub tool_use_id: String,
74    pub name: String,
75    pub input: serde_json::Value,
76}
77
78/// `summary` is bounded prose, never the raw result: a tool result can be a
79/// whole file, and the remote surface does not carry file contents.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82pub struct ToolResultEvent {
83    pub tool_use_id: String,
84    pub ok: bool,
85    pub summary: String,
86}
87
88/// Everything the approval card must show.
89///
90/// `input` is the **full** structured input on purpose. A summary is what lets
91/// a hostile prompt get a destructive call approved by making it look boring.
92#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
93#[serde(rename_all = "camelCase", deny_unknown_fields)]
94pub struct ApprovalRequestEvent {
95    pub approval_id: String,
96    pub provider: String,
97    pub tool_name: String,
98    pub input: serde_json::Value,
99    /// The workspace the call would run in, so the human knows *which* checkout
100    /// they are about to let it touch.
101    pub workspace: String,
102    /// RFC 3339, UTC. After this the daemon denies on its own.
103    pub expires_at: String,
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "camelCase", deny_unknown_fields)]
108pub struct ApprovalSettledEvent {
109    pub approval_id: String,
110    pub verdict: super::device_bound::ApprovalVerdict,
111    pub decided_by: ApprovalDecider,
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase", deny_unknown_fields)]
116pub struct ErrorEvent {
117    pub message: String,
118}
119
120/// Who settled an approval: a human, the expiry timer, or teardown.
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(rename_all = "camelCase")]
123pub enum ApprovalDecider {
124    User,
125    Expiry,
126    Shutdown,
127}
128
129impl AgentEventBody {
130    /// Whether this event ends the run. A phone stops expecting more after one
131    /// of these, and the daemon frees the run's replay buffer.
132    pub fn terminal(&self) -> bool {
133        matches!(
134            self,
135            Self::Completed(_) | Self::Cancelled(_) | Self::Error(_)
136        )
137    }
138
139    /// The wire spelling of `kind`.
140    pub fn kind(&self) -> &'static str {
141        match self {
142            Self::Text(_) => "text",
143            Self::ToolUse(_) => "toolUse",
144            Self::ToolResult(_) => "toolResult",
145            Self::ApprovalRequest(_) => "approvalRequest",
146            Self::ApprovalSettled(_) => "approvalSettled",
147            Self::Completed(_) => "completed",
148            Self::Cancelled(_) => "cancelled",
149            Self::Error(_) => "error",
150        }
151    }
152
153    /// Every event kind a v1 run may emit.
154    pub const KINDS: &'static [&'static str] = &[
155        "text",
156        "toolUse",
157        "toolResult",
158        "approvalRequest",
159        "approvalSettled",
160        "completed",
161        "cancelled",
162        "error",
163    ];
164}
165
166#[cfg(test)]
167mod tests {
168    use super::super::device_bound::ApprovalVerdict;
169    use super::*;
170
171    #[test]
172    fn an_event_carries_its_run_and_sequence_beside_the_body() {
173        let event = AgentEvent {
174            run_id: "run_1".into(),
175            seq: 7,
176            event: AgentEventBody::Text(TextEvent {
177                text: "hello".into(),
178            }),
179        };
180        let json = serde_json::to_value(&event).expect("serialise");
181        assert_eq!(json["runId"], "run_1");
182        assert_eq!(json["seq"], 7);
183        assert_eq!(json["event"]["kind"], "text");
184        assert_eq!(json["event"]["data"]["text"], "hello");
185    }
186
187    #[test]
188    fn an_approval_request_round_trips_with_its_full_input() {
189        let event = AgentEvent {
190            run_id: "run_1".into(),
191            seq: 0,
192            event: AgentEventBody::ApprovalRequest(ApprovalRequestEvent {
193                approval_id: "ap_1".into(),
194                provider: "claude".into(),
195                tool_name: "Bash".into(),
196                input: serde_json::json!({ "command": "rm -rf build" }),
197                workspace: "/w/project".into(),
198                expires_at: "2026-09-01T00:02:00Z".into(),
199            }),
200        };
201        let json = serde_json::to_string(&event).expect("serialise");
202        let back: AgentEvent = serde_json::from_str(&json).expect("parse");
203        assert_eq!(back, event);
204    }
205
206    #[test]
207    fn only_the_three_endings_are_terminal() {
208        assert!(AgentEventBody::Completed(NoData {}).terminal());
209        assert!(AgentEventBody::Cancelled(NoData {}).terminal());
210        assert!(AgentEventBody::Error(ErrorEvent {
211            message: "boom".into()
212        })
213        .terminal());
214        assert!(!AgentEventBody::Text(TextEvent { text: "x".into() }).terminal());
215        assert!(!AgentEventBody::ApprovalSettled(ApprovalSettledEvent {
216            approval_id: "ap_1".into(),
217            verdict: ApprovalVerdict::Deny,
218            decided_by: ApprovalDecider::Expiry,
219        })
220        .terminal());
221    }
222
223    /// A terminal event kind the remote surface deliberately does not have. If
224    /// this ever parses, raw terminal output has reached the wire.
225    #[test]
226    fn an_unknown_event_kind_is_refused() {
227        let refused = serde_json::from_str::<AgentEvent>(
228            r#"{"runId":"run_1","seq":0,"event":{"kind":"terminalOutput","data":{}}}"#,
229        );
230        assert!(refused.is_err());
231    }
232
233    #[test]
234    fn kinds_matches_the_union() {
235        let bodies = [
236            AgentEventBody::Text(TextEvent {
237                text: String::new(),
238            }),
239            AgentEventBody::ToolUse(ToolUseEvent {
240                tool_use_id: String::new(),
241                name: String::new(),
242                input: serde_json::Value::Null,
243            }),
244            AgentEventBody::ToolResult(ToolResultEvent {
245                tool_use_id: String::new(),
246                ok: true,
247                summary: String::new(),
248            }),
249            AgentEventBody::ApprovalRequest(ApprovalRequestEvent {
250                approval_id: String::new(),
251                provider: String::new(),
252                tool_name: String::new(),
253                input: serde_json::Value::Null,
254                workspace: String::new(),
255                expires_at: String::new(),
256            }),
257            AgentEventBody::ApprovalSettled(ApprovalSettledEvent {
258                approval_id: String::new(),
259                verdict: ApprovalVerdict::Allow,
260                decided_by: ApprovalDecider::User,
261            }),
262            AgentEventBody::Completed(NoData {}),
263            AgentEventBody::Cancelled(NoData {}),
264            AgentEventBody::Error(ErrorEvent {
265                message: String::new(),
266            }),
267        ];
268        let kinds: Vec<&str> = bodies.iter().map(AgentEventBody::kind).collect();
269        assert_eq!(kinds, AgentEventBody::KINDS);
270    }
271}