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: a compact status — the text of the session's most recent assistant
167    /// message — answering a [`crate::protocol::Request::Status`]. Never streamed
168    /// (see [`AgentEvent::Ack`]); one small string, not the transcript.
169    Status {
170        last_assistant_text: Option<String>,
171    },
172    /// Reply: the model catalog the provider advertises, answering a
173    /// [`crate::protocol::Request::ListModels`]. Never streamed (see
174    /// [`AgentEvent::Ack`]) — one reply, correlated by the transport, exactly
175    /// like [`AgentEvent::Sessions`]. Each entry is a bare model id; ordering
176    /// is the provider's (`streamfn::list_models`).
177    Models {
178        models: Vec<String>,
179    },
180    /// Reply: the sessions a server serves, in order (root first), each
181    /// with the model the server knows for it ([`crate::protocol::SessionInfo`]).
182    /// Answers a [`crate::protocol::Request::ListSessions`]. Never streamed (see
183    /// [`AgentEvent::Ack`]).
184    Sessions {
185        sessions: Vec<crate::protocol::SessionInfo>,
186    },
187    /// Reply: a peer defined a worker and returns its address, answering a
188    /// [`crate::protocol::Request::Define`]. Never streamed (see
189    /// [`AgentEvent::Ack`]).
190    ///
191    /// The field is `worker`, not `id`: the envelope already carries a top-level
192    /// `id` (the request it replies to), and a flattened duplicate key would not
193    /// deserialize.
194    Spawned {
195        worker: crate::protocol::SessionId,
196    },
197    /// The `todo` checklist, streamed on every write (mirror `Compaction` /
198    /// `Retrying`: a plain state payload the UI reduces). The field is `todos`,
199    /// not `id`: the envelope already carries a top-level `id` and a flattened
200    /// duplicate key would not deserialize.
201    Todo {
202        todos: Vec<TodoItem>,
203    },
204    AgentEnd,
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::message::ContentBlock;
211    use serde_json::json;
212
213    fn roundtrip(e: AgentEvent, expected_tag: &str) {
214        let v = serde_json::to_value(&e).unwrap();
215        assert_eq!(v["type"], expected_tag);
216        let back: AgentEvent = serde_json::from_value(v.clone()).unwrap();
217        let v2 = serde_json::to_value(&back).unwrap();
218        assert_eq!(v, v2);
219    }
220
221    fn assistant() -> AgentMessage {
222        AgentMessage::Assistant {
223            content: vec![ContentBlock::Text { text: "a".into() }],
224            stop_reason: StopReason::Stop,
225            usage: Some(Usage::default()),
226            model: None,
227        }
228    }
229
230    #[test]
231    fn agent_event_roundtrip_all_variants() {
232        roundtrip(AgentEvent::AgentStart, "agent_start");
233        roundtrip(AgentEvent::TurnStart, "turn_start");
234        roundtrip(
235            AgentEvent::MessageStart {
236                message: assistant(),
237            },
238            "message_start",
239        );
240        roundtrip(
241            AgentEvent::MessageUpdate {
242                message: assistant(),
243            },
244            "message_update",
245        );
246        roundtrip(
247            AgentEvent::MessageEnd {
248                message: assistant(),
249            },
250            "message_end",
251        );
252        roundtrip(
253            AgentEvent::ToolExecutionStart {
254                call_id: "t1".into(),
255                name: "run".into(),
256            },
257            "tool_execution_start",
258        );
259        roundtrip(
260            AgentEvent::ToolExecutionUpdate {
261                call_id: "t1".into(),
262                name: "run".into(),
263                partial: "out".into(),
264            },
265            "tool_execution_update",
266        );
267        roundtrip(
268            AgentEvent::ToolExecutionEnd {
269                call_id: "t1".into(),
270                name: "run".into(),
271                output: "out".into(),
272                is_error: false,
273                diff: Some("@@ -1 +1 @@".into()),
274                path: Some("src/f.rs".into()),
275                duration_ms: Some(12),
276            },
277            "tool_execution_end",
278        );
279        roundtrip(
280            AgentEvent::TurnEnd {
281                message: assistant(),
282            },
283            "turn_end",
284        );
285        roundtrip(
286            AgentEvent::Error {
287                message: "boom".into(),
288            },
289            "error",
290        );
291        roundtrip(
292            AgentEvent::Compaction {
293                summarized: 3,
294                kept: 2,
295            },
296            "compaction",
297        );
298        roundtrip(
299            AgentEvent::CompactionSkipped {
300                reason: "summarizer 500".into(),
301            },
302            "compaction_skipped",
303        );
304        roundtrip(
305            AgentEvent::Retrying {
306                attempt: 2,
307                max: 3,
308                reason: "503".into(),
309            },
310            "retrying",
311        );
312        roundtrip(AgentEvent::Ack, "ack");
313        roundtrip(
314            AgentEvent::Stopped {
315                stop_reason: StopReason::Aborted,
316            },
317            "stopped",
318        );
319        roundtrip(
320            AgentEvent::SideAnswer {
321                text: "hi".into(),
322                usage: None,
323            },
324            "side_answer",
325        );
326        roundtrip(
327            AgentEvent::History {
328                messages: vec![AgentMessage::user_text("hi")],
329            },
330            "history",
331        );
332        roundtrip(
333            AgentEvent::Status {
334                last_assistant_text: Some("last said".into()),
335            },
336            "status",
337        );
338        roundtrip(
339            AgentEvent::Models { models: vec!["m1".into(), "m2".into()] },
340            "models",
341        );
342        roundtrip(
343            AgentEvent::MessageReceived {
344                from: crate::protocol::SessionId::new("user"),
345                content: "ping".into(),
346            },
347            "message_received",
348        );
349        roundtrip(AgentEvent::AgentEnd, "agent_end");
350        roundtrip(
351            AgentEvent::Sessions {
352                sessions: vec![
353                    crate::protocol::SessionInfo {
354                        id: crate::protocol::SessionId::new("root"),
355                        model: Some("m1".into()),
356                        effort: Some("high".into()),
357                        context_window: Some(200_000),
358                        state: crate::protocol::MemberState::Idle,
359                    },
360                    crate::protocol::SessionInfo {
361                        id: crate::protocol::SessionId::agent("w1"),
362                        model: None,
363                        effort: None,
364                        context_window: None,
365                        state: crate::protocol::MemberState::Idle,
366                    },
367                ],
368            },
369            "sessions",
370        );
371        roundtrip(
372            AgentEvent::Spawned {
373                worker: crate::protocol::SessionId::agent("w1"),
374            },
375            "spawned",
376        );
377        roundtrip(
378            AgentEvent::Todo {
379                todos: vec![
380                    TodoItem {
381                        content: "write tests".into(),
382                        status: TodoStatus::Pending,
383                    },
384                    TodoItem {
385                        content: "ship it".into(),
386                        status: TodoStatus::Completed,
387                    },
388                ],
389            },
390            "todo",
391        );
392    }
393
394    #[test]
395    fn agent_event_shape_matches_tagged_payload() {
396        let v = serde_json::to_value(&AgentEvent::ToolExecutionStart {
397            call_id: "t1".into(),
398            name: "run".into(),
399        })
400        .unwrap();
401        assert_eq!(
402            v,
403            json!({"type": "tool_execution_start", "call_id": "t1", "name": "run"})
404        );
405
406        let m = AgentMessage::user_text("hi");
407        let v2 = serde_json::to_value(&AgentEvent::TurnEnd { message: m }).unwrap();
408        assert_eq!(v2["type"], "turn_end");
409        assert_eq!(v2["message"]["role"], "user");
410    }
411}