Skip to main content

wcode_harness/
event.rs

1use crate::message::{AgentMessage, StopReason, Usage};
2use serde::{Deserialize, Serialize};
3
4/// Internal seam between LLM adapter and agent loop. Not serialized.
5#[derive(Clone, Debug, PartialEq)]
6pub enum LlmStreamEvent {
7    TextDelta(String),
8    ThinkingDelta(String),
9    /// A complete thinking block (provider restates the whole reasoning after
10    /// streaming deltas). Replacement semantics: supersedes *all* thinking
11    /// accumulated so far in this message — the kernel drops prior thinking
12    /// blocks instead of appending, so the context never doubles up.
13    ThinkingReplace(String),
14    ToolCall {
15        id: String,
16        name: String,
17        arguments: serde_json::Value,
18    },
19    Done {
20        stop_reason: StopReason,
21        usage: Option<Usage>,
22    },
23    /// The adapter is about to retry a failed connect after a transient error.
24    /// `attempt` is 1-based; advisory — the loop only surfaces it.
25    Retrying {
26        attempt: u32,
27        max: u32,
28        reason: String,
29    },
30    Error {
31        message: String,
32        /// True when the failure is of a hard-fatal class (bad request, auth,
33        /// schema, build/parse) that retrying or re-feeding cannot fix. The
34        /// loop ends the run on a fatal error; a transient-class error that
35        /// exhausted its retries (or landed mid-stream after content) is fed
36        /// back to the model for a corrective turn instead.
37        fatal: bool,
38    },
39}
40
41/// The status of a [`TodoItem`]. `Cancelled` is deliberately absent in v1: the
42/// model drops an item by rewriting the whole list.
43#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
44#[serde(rename_all = "snake_case")]
45pub enum TodoStatus {
46    Pending,
47    InProgress,
48    Completed,
49}
50
51/// One entry in the `todo` tool's session-local checklist. Lives in the harness
52/// because it rides the serialized [`AgentEvent`] (the CLI tool reuses it).
53#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
54pub struct TodoItem {
55    /// What the item is about (non-empty after trim; the tool validates).
56    pub content: String,
57    pub status: TodoStatus,
58}
59#[derive(Clone, Debug, Serialize, Deserialize)]
60#[serde(tag = "type", rename_all = "snake_case")]
61pub enum AgentEvent {
62    AgentStart,
63    TurnStart,
64    MessageStart {
65        message: AgentMessage,
66    },
67    MessageUpdate {
68        message: AgentMessage,
69    },
70    MessageEnd {
71        message: AgentMessage,
72    },
73    ToolExecutionStart {
74        call_id: String,
75        name: String,
76    },
77    ToolExecutionUpdate {
78        call_id: String,
79        name: String,
80        partial: String,
81    },
82    ToolExecutionEnd {
83        call_id: String,
84        name: String,
85        output: String,
86        is_error: bool,
87        /// UI-only unified diff (see `ToolOutput::diff`); absent when there is
88        /// nothing to show, or on an older peer that predates the field.
89        #[serde(default, skip_serializing_if = "Option::is_none")]
90        diff: Option<String>,
91        /// UI-only path of the changed file (see `ToolOutput::path`); absent
92        /// when the tool touched no file, or on an older peer.
93        #[serde(default, skip_serializing_if = "Option::is_none")]
94        path: Option<String>,
95        /// Wall-clock duration of the tool call in milliseconds — `execute` plus
96        /// its `after_tool_call` hook. UI-only, like `diff`/`path`: it rides this
97        /// event but never enters `ToolOutput`/the model's context. `None` on an
98        /// older peer, or a call that never ran (blocked by a hook, or a dead
99        /// sink).
100        #[serde(default, skip_serializing_if = "Option::is_none")]
101        duration_ms: Option<u64>,
102    },
103    TurnEnd {
104        message: AgentMessage,
105    },
106    /// Auto-compaction summarized `summarized` older messages, keeping `kept`.
107    Compaction {
108        summarized: usize,
109        kept: usize,
110    },
111    /// Best-effort auto-compaction was attempted (the context was near the
112    /// ceiling) but did NOT complete — a summarizer/stream failure. The run
113    /// continues. Deliberately NOT [`AgentEvent::Error`], whose contract is a
114    /// *stream-level* failure: a TUI renders `Error` as a failed run, so routing
115    /// a best-effort compaction miss through it would falsely mark the run
116    /// failed. `reason` is the summarizer's error text, for display only — never
117    /// fed back to the model and never fatal. Purely additive on the wire
118    /// (`{"type":"compaction_skipped","reason":…}`), so
119    /// [`crate::protocol::PROTOCOL_VERSION`] need not bump.
120    CompactionSkipped {
121        reason: String,
122    },
123    /// The adapter retried a failed connect after a transient error.
124    Retrying {
125        attempt: u32,
126        max: u32,
127        reason: String,
128    },
129    /// A peer delivered a message to this session (`Request::Notify`/
130    /// `Interrupt`/`Wake`). `from` is the sender's address (§5.2 — the human is
131    /// [`crate::protocol::USER`]); streamed, so a UI can surface an inbound
132    /// message. Not a reply.
133    MessageReceived {
134        from: crate::protocol::SessionId,
135        content: String,
136    },
137    /// Stream-level failure. A fatal-class failure (bad request, auth, schema)
138    /// ends the run with `StopReason::Error`; a transient-class failure that
139    /// exhausted its retries (or landed mid-stream) is fed back to the model
140    /// and the loop continues, still bounded by a consecutive-error cap.
141    Error {
142        message: String,
143    },
144    /// Reply: a request with no richer answer succeeded (e.g. `SetModel`).
145    /// Never *streamed* — it answers one request, correlated by the transport
146    /// (an in-process reply channel today, the envelope's `reply_to` on the
147    /// wire).
148    Ack,
149    /// Reply: the run finished, with why it stopped. Answers a `Submit`. Never
150    /// streamed (see [`AgentEvent::Ack`]).
151    Stopped {
152        stop_reason: StopReason,
153    },
154    /// Reply: a tool-free side answer to a `Request::SideAsk` (`/btw`). Never
155    /// streamed (see [`AgentEvent::Ack`]). Carries the answer text and the
156    /// turn's usage (for a `/usage`-style readout); styling is the front-end's.
157    SideAnswer {
158        text: String,
159        usage: Option<Usage>,
160    },
161    /// Reply: the conversation so far, answering a `Request::GetHistory`. Never
162    /// streamed (see [`AgentEvent::Ack`]).
163    History {
164        messages: Vec<AgentMessage>,
165    },
166    /// Reply: the sessions a server serves, in serve order (root first), each
167    /// with the model the server knows for it ([`crate::protocol::SessionInfo`]).
168    /// Answers a [`crate::protocol::Request::ListSessions`]. Never streamed (see
169    /// [`AgentEvent::Ack`]).
170    Sessions {
171        sessions: Vec<crate::protocol::SessionInfo>,
172    },
173    /// Reply: a peer defined a worker and returns its address, answering a
174    /// [`crate::protocol::Request::Define`]. Never streamed (see
175    /// [`AgentEvent::Ack`]).
176    ///
177    /// The field is `worker`, not `id`: the envelope already carries a top-level
178    /// `id` (the request it replies to), and a flattened duplicate key would not
179    /// deserialize.
180    Spawned {
181        worker: crate::protocol::SessionId,
182    },
183    /// The `todo` checklist, streamed on every write (mirror `Compaction` /
184    /// `Retrying`: a plain state payload the UI reduces). The field is `todos`,
185    /// not `id`: the envelope already carries a top-level `id` and a flattened
186    /// duplicate key would not deserialize.
187    Todo {
188        todos: Vec<TodoItem>,
189    },
190    AgentEnd,
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use crate::message::ContentBlock;
197    use serde_json::json;
198
199    fn roundtrip(e: AgentEvent, expected_tag: &str) {
200        let v = serde_json::to_value(&e).unwrap();
201        assert_eq!(v["type"], expected_tag);
202        let back: AgentEvent = serde_json::from_value(v.clone()).unwrap();
203        let v2 = serde_json::to_value(&back).unwrap();
204        assert_eq!(v, v2);
205    }
206
207    fn assistant() -> AgentMessage {
208        AgentMessage::Assistant {
209            content: vec![ContentBlock::Text { text: "a".into() }],
210            stop_reason: StopReason::Stop,
211            usage: Some(Usage::default()),
212            model: None,
213        }
214    }
215
216    #[test]
217    fn agent_event_roundtrip_all_variants() {
218        roundtrip(AgentEvent::AgentStart, "agent_start");
219        roundtrip(AgentEvent::TurnStart, "turn_start");
220        roundtrip(
221            AgentEvent::MessageStart {
222                message: assistant(),
223            },
224            "message_start",
225        );
226        roundtrip(
227            AgentEvent::MessageUpdate {
228                message: assistant(),
229            },
230            "message_update",
231        );
232        roundtrip(
233            AgentEvent::MessageEnd {
234                message: assistant(),
235            },
236            "message_end",
237        );
238        roundtrip(
239            AgentEvent::ToolExecutionStart {
240                call_id: "t1".into(),
241                name: "run".into(),
242            },
243            "tool_execution_start",
244        );
245        roundtrip(
246            AgentEvent::ToolExecutionUpdate {
247                call_id: "t1".into(),
248                name: "run".into(),
249                partial: "out".into(),
250            },
251            "tool_execution_update",
252        );
253        roundtrip(
254            AgentEvent::ToolExecutionEnd {
255                call_id: "t1".into(),
256                name: "run".into(),
257                output: "out".into(),
258                is_error: false,
259                diff: Some("@@ -1 +1 @@".into()),
260                path: Some("src/f.rs".into()),
261                duration_ms: Some(12),
262            },
263            "tool_execution_end",
264        );
265        roundtrip(
266            AgentEvent::TurnEnd {
267                message: assistant(),
268            },
269            "turn_end",
270        );
271        roundtrip(
272            AgentEvent::Error {
273                message: "boom".into(),
274            },
275            "error",
276        );
277        roundtrip(
278            AgentEvent::Compaction {
279                summarized: 3,
280                kept: 2,
281            },
282            "compaction",
283        );
284        roundtrip(
285            AgentEvent::CompactionSkipped {
286                reason: "summarizer 500".into(),
287            },
288            "compaction_skipped",
289        );
290        roundtrip(
291            AgentEvent::Retrying {
292                attempt: 2,
293                max: 3,
294                reason: "503".into(),
295            },
296            "retrying",
297        );
298        roundtrip(AgentEvent::Ack, "ack");
299        roundtrip(
300            AgentEvent::Stopped {
301                stop_reason: StopReason::Aborted,
302            },
303            "stopped",
304        );
305        roundtrip(
306            AgentEvent::SideAnswer {
307                text: "hi".into(),
308                usage: None,
309            },
310            "side_answer",
311        );
312        roundtrip(
313            AgentEvent::History {
314                messages: vec![AgentMessage::user_text("hi")],
315            },
316            "history",
317        );
318        roundtrip(
319            AgentEvent::MessageReceived {
320                from: crate::protocol::SessionId::new("user"),
321                content: "ping".into(),
322            },
323            "message_received",
324        );
325        roundtrip(AgentEvent::AgentEnd, "agent_end");
326        roundtrip(
327            AgentEvent::Sessions {
328                sessions: vec![
329                    crate::protocol::SessionInfo {
330                        id: crate::protocol::SessionId::new("root"),
331                        model: Some("m1".into()),
332                        state: crate::protocol::MemberState::Idle,
333                    },
334                    crate::protocol::SessionInfo {
335                        id: crate::protocol::SessionId::agent("w1"),
336                        model: None,
337                        state: crate::protocol::MemberState::Idle,
338                    },
339                ],
340            },
341            "sessions",
342        );
343        roundtrip(
344            AgentEvent::Spawned {
345                worker: crate::protocol::SessionId::agent("w1"),
346            },
347            "spawned",
348        );
349        roundtrip(
350            AgentEvent::Todo {
351                todos: vec![
352                    TodoItem {
353                        content: "write tests".into(),
354                        status: TodoStatus::Pending,
355                    },
356                    TodoItem {
357                        content: "ship it".into(),
358                        status: TodoStatus::Completed,
359                    },
360                ],
361            },
362            "todo",
363        );
364    }
365
366    #[test]
367    fn agent_event_shape_matches_tagged_payload() {
368        let v = serde_json::to_value(&AgentEvent::ToolExecutionStart {
369            call_id: "t1".into(),
370            name: "run".into(),
371        })
372        .unwrap();
373        assert_eq!(
374            v,
375            json!({"type": "tool_execution_start", "call_id": "t1", "name": "run"})
376        );
377
378        let m = AgentMessage::user_text("hi");
379        let v2 = serde_json::to_value(&AgentEvent::TurnEnd { message: m }).unwrap();
380        assert_eq!(v2["type"], "turn_end");
381        assert_eq!(v2["message"]["role"], "user");
382    }
383}