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, PartialEq, 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
173    /// Reply: this session's usage report — turns, summed input/output/cache
174    /// tokens, the cache hit rate, per-tool `calls`/`errors`/`result_bytes`, and
175    /// a `$` cost when the model is priced (all inside [`crate::stats::SessionStats`]).
176    /// Answers a [`crate::protocol::Request::Usage`]. Never streamed (see
177    /// [`AgentEvent::Ack`]) — the [`AgentEvent::Status`] rationale: a FOLDED
178    /// summary ships, not the transcript, so a mid-session `Usage` needs no
179    /// baseline and cannot be invalidated by compaction. Purely additive on the
180    /// wire (`{"type":"usage","stats":{…}}`), exactly like `CompactionSkipped`
181    /// (`event.rs:117-119`) — an older peer drops the frame — so
182    /// `crate::protocol::PROTOCOL_VERSION` stays 1.
183    ///
184    /// `stats.cost` is `None` when nothing could be priced (an unknown model, or
185    /// a session that reported no usage) — absence, never `$0`.
186    Usage {
187        stats: crate::stats::SessionStats,
188    },
189
190    /// Reply: the model catalog the provider advertises, answering a
191    /// [`crate::protocol::Request::ListModels`]. Never streamed (see
192    /// [`AgentEvent::Ack`]) — one reply, correlated by the transport, exactly
193    /// like [`AgentEvent::Sessions`]. Each entry is a bare model id; ordering
194    /// is the provider's (`streamfn::list_models`).
195    Models {
196        models: Vec<String>,
197    },
198    /// Reply: the sessions a server serves, in order (root first), each
199    /// with the model the server knows for it ([`crate::protocol::SessionInfo`]).
200    /// Answers a [`crate::protocol::Request::ListSessions`]. Never streamed (see
201    /// [`AgentEvent::Ack`]).
202    Sessions {
203        sessions: Vec<crate::protocol::SessionInfo>,
204    },
205    /// Reply: a peer defined a worker and returns its address, answering a
206    /// [`crate::protocol::Request::Define`]. Never streamed (see
207    /// [`AgentEvent::Ack`]).
208    ///
209    /// The field is `worker`, not `id`: the envelope already carries a top-level
210    /// `id` (the request it replies to), and a flattened duplicate key would not
211    /// deserialize.
212    Spawned {
213        worker: crate::protocol::SessionId,
214    },
215    /// The `todo` checklist, streamed on every write (mirror `Compaction` /
216    /// `Retrying`: a plain state payload the UI reduces). The field is `todos`,
217    /// not `id`: the envelope already carries a top-level `id` and a flattened
218    /// duplicate key would not deserialize.
219    Todo {
220        todos: Vec<TodoItem>,
221    },
222    AgentEnd,
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use crate::message::ContentBlock;
229    use serde_json::json;
230
231    fn roundtrip(e: AgentEvent, expected_tag: &str) {
232        let v = serde_json::to_value(&e).unwrap();
233        assert_eq!(v["type"], expected_tag);
234        let back: AgentEvent = serde_json::from_value(v.clone()).unwrap();
235        let v2 = serde_json::to_value(&back).unwrap();
236        assert_eq!(v, v2);
237    }
238
239    fn assistant() -> AgentMessage {
240        AgentMessage::Assistant {
241            content: vec![ContentBlock::Text { text: "a".into() }],
242            stop_reason: StopReason::Stop,
243            usage: Some(Usage::default()),
244            model: None,
245        }
246    }
247
248    #[test]
249    fn agent_event_roundtrip_all_variants() {
250        roundtrip(AgentEvent::AgentStart, "agent_start");
251        roundtrip(AgentEvent::TurnStart, "turn_start");
252        roundtrip(
253            AgentEvent::MessageStart {
254                message: assistant(),
255            },
256            "message_start",
257        );
258        roundtrip(
259            AgentEvent::MessageUpdate {
260                message: assistant(),
261            },
262            "message_update",
263        );
264        roundtrip(
265            AgentEvent::MessageEnd {
266                message: assistant(),
267            },
268            "message_end",
269        );
270        roundtrip(
271            AgentEvent::ToolExecutionStart {
272                call_id: "t1".into(),
273                name: "run".into(),
274            },
275            "tool_execution_start",
276        );
277        roundtrip(
278            AgentEvent::ToolExecutionUpdate {
279                call_id: "t1".into(),
280                name: "run".into(),
281                partial: "out".into(),
282            },
283            "tool_execution_update",
284        );
285        roundtrip(
286            AgentEvent::ToolExecutionEnd {
287                call_id: "t1".into(),
288                name: "run".into(),
289                output: "out".into(),
290                is_error: false,
291                diff: Some("@@ -1 +1 @@".into()),
292                path: Some("src/f.rs".into()),
293                duration_ms: Some(12),
294            },
295            "tool_execution_end",
296        );
297        roundtrip(
298            AgentEvent::TurnEnd {
299                message: assistant(),
300            },
301            "turn_end",
302        );
303        roundtrip(
304            AgentEvent::Error {
305                message: "boom".into(),
306            },
307            "error",
308        );
309        roundtrip(
310            AgentEvent::Compaction {
311                summarized: 3,
312                kept: 2,
313            },
314            "compaction",
315        );
316        roundtrip(
317            AgentEvent::CompactionSkipped {
318                reason: "summarizer 500".into(),
319            },
320            "compaction_skipped",
321        );
322        roundtrip(
323            AgentEvent::Retrying {
324                attempt: 2,
325                max: 3,
326                reason: "503".into(),
327            },
328            "retrying",
329        );
330        roundtrip(AgentEvent::Ack, "ack");
331        roundtrip(
332            AgentEvent::Stopped {
333                stop_reason: StopReason::Aborted,
334            },
335            "stopped",
336        );
337        roundtrip(
338            AgentEvent::SideAnswer {
339                text: "hi".into(),
340                usage: None,
341            },
342            "side_answer",
343        );
344        roundtrip(
345            AgentEvent::History {
346                messages: vec![AgentMessage::user_text("hi")],
347            },
348            "history",
349        );
350        roundtrip(
351            AgentEvent::Status {
352                last_assistant_text: Some("last said".into()),
353            },
354            "status",
355        );
356        roundtrip(
357            AgentEvent::Usage {
358                stats: crate::stats::SessionStats {
359                    turns: 1,
360                    input_tokens: 1_000,
361                    output_tokens: 500,
362                    cache_read_tokens: 400,
363                    cache_write_tokens: 5,
364                    cache_turns: 1,
365                    tools: vec![crate::stats::ToolStat {
366                        name: "read".into(),
367                        calls: 2,
368                        errors: 1,
369                        result_bytes: 42,
370                    }],
371                    tool_errors: 1,
372                    last_input_tokens: Some(1_000),
373                    cost: Some(crate::stats::Cost {
374                        micro_usd: 391,
375                        unpriced_turns: 0,
376                    }),
377                },
378            },
379            "usage",
380        );
381        roundtrip(
382            AgentEvent::Models { models: vec!["m1".into(), "m2".into()] },
383            "models",
384        );
385        roundtrip(
386            AgentEvent::MessageReceived {
387                from: crate::protocol::SessionId::new("user"),
388                content: "ping".into(),
389            },
390            "message_received",
391        );
392        roundtrip(AgentEvent::AgentEnd, "agent_end");
393        roundtrip(
394            AgentEvent::Sessions {
395                sessions: vec![
396                    crate::protocol::SessionInfo {
397                        id: crate::protocol::SessionId::new("root"),
398                        model: Some("m1".into()),
399                        effort: Some("high".into()),
400                        context_window: Some(200_000),
401                        state: crate::protocol::MemberState::Idle,
402                    },
403                    crate::protocol::SessionInfo {
404                        id: crate::protocol::SessionId::agent("w1"),
405                        model: None,
406                        effort: None,
407                        context_window: None,
408                        state: crate::protocol::MemberState::Idle,
409                    },
410                ],
411            },
412            "sessions",
413        );
414        roundtrip(
415            AgentEvent::Spawned {
416                worker: crate::protocol::SessionId::agent("w1"),
417            },
418            "spawned",
419        );
420        roundtrip(
421            AgentEvent::Todo {
422                todos: vec![
423                    TodoItem {
424                        content: "write tests".into(),
425                        status: TodoStatus::Pending,
426                    },
427                    TodoItem {
428                        content: "ship it".into(),
429                        status: TodoStatus::Completed,
430                    },
431                ],
432            },
433            "todo",
434        );
435    }
436
437    #[test]
438    fn agent_event_shape_matches_tagged_payload() {
439        let v = serde_json::to_value(&AgentEvent::ToolExecutionStart {
440            call_id: "t1".into(),
441            name: "run".into(),
442        })
443        .unwrap();
444        assert_eq!(
445            v,
446            json!({"type": "tool_execution_start", "call_id": "t1", "name": "run"})
447        );
448
449        let m = AgentMessage::user_text("hi");
450        let v2 = serde_json::to_value(&AgentEvent::TurnEnd { message: m }).unwrap();
451        assert_eq!(v2["type"], "turn_end");
452        assert_eq!(v2["message"]["role"], "user");
453
454        // Pin the flat `usage` shape once so a field rename cannot slip past.
455        let v3 = serde_json::to_value(&AgentEvent::Usage {
456            stats: crate::stats::SessionStats {
457                turns: 1,
458                ..Default::default()
459            },
460        })
461        .unwrap();
462        assert_eq!(v3["type"], "usage");
463        assert_eq!(v3["stats"]["turns"], 1);
464    }
465}