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: the agent's own settings, answering a
174    /// [`crate::protocol::Request::Settings`]. Never streamed (see
175    /// [`AgentEvent::Ack`]) — one reply, correlated by the transport, exactly
176    /// like [`AgentEvent::Status`]; what ships is four scalars, not the
177    /// transcript, so a mid-session read needs no baseline and compaction
178    /// cannot invalidate it.
179    ///
180    /// `context_limit: None` means the window is UNKNOWN — never `0`, and
181    /// never a guess. Today `limits::model_limit` recognises one endpoint
182    /// family (`limits.rs:63`), so `None` is the common case and the panel
183    /// renders the honest absence (`docs/work/W032-tui-settings.md` §11.8).
184    ///
185    /// Purely additive on the wire (`{"type":"settings","model":…}`) — an
186    /// older peer drops the frame — so `PROTOCOL_VERSION` stays 1, the same
187    /// argument `Usage` records above.
188    Settings {
189        model: String,
190        effort: Option<String>,
191        plan: bool,
192        context_limit: Option<u64>,
193    },
194
195    /// Reply: this session's usage report — turns, summed input/output/cache
196    /// tokens, the cache hit rate, per-tool `calls`/`errors`/`result_bytes`, and
197    /// a `$` cost when the model is priced (all inside [`crate::stats::SessionStats`]).
198    /// Answers a [`crate::protocol::Request::Usage`]. Never streamed (see
199    /// [`AgentEvent::Ack`]) — the [`AgentEvent::Status`] rationale: a FOLDED
200    /// summary ships, not the transcript, so a mid-session `Usage` needs no
201    /// baseline and cannot be invalidated by compaction. Purely additive on the
202    /// wire (`{"type":"usage","stats":{…}}`), exactly like `CompactionSkipped`
203    /// (`event.rs:117-119`) — an older peer drops the frame — so
204    /// `crate::protocol::PROTOCOL_VERSION` stays 1.
205    ///
206    /// `stats.cost` is `None` when nothing could be priced (an unknown model, or
207    /// a session that reported no usage) — absence, never `$0`.
208    Usage {
209        stats: crate::stats::SessionStats,
210    },
211
212    /// Reply: the model catalog the provider advertises, answering a
213    /// [`crate::protocol::Request::ListModels`]. Never streamed (see
214    /// [`AgentEvent::Ack`]) — one reply, correlated by the transport, exactly
215    /// like [`AgentEvent::Sessions`]. Each entry is a bare model id; ordering
216    /// is the provider's (`streamfn::list_models`).
217    Models {
218        models: Vec<String>,
219    },
220    /// Reply: the sessions a server serves, in order (root first), each
221    /// with the model the server knows for it ([`crate::protocol::SessionInfo`]).
222    /// Answers a [`crate::protocol::Request::ListSessions`]. Never streamed (see
223    /// [`AgentEvent::Ack`]).
224    Sessions {
225        sessions: Vec<crate::protocol::SessionInfo>,
226    },
227    /// Reply: a peer defined a worker and returns its address, answering a
228    /// [`crate::protocol::Request::Define`]. Never streamed (see
229    /// [`AgentEvent::Ack`]).
230    ///
231    /// The field is `worker`, not `id`: the envelope already carries a top-level
232    /// `id` (the request it replies to), and a flattened duplicate key would not
233    /// deserialize.
234    Spawned {
235        worker: crate::protocol::SessionId,
236    },
237    /// The `todo` checklist, streamed on every write (mirror `Compaction` /
238    /// `Retrying`: a plain state payload the UI reduces). The field is `todos`,
239    /// not `id`: the envelope already carries a top-level `id` and a flattened
240    /// duplicate key would not deserialize.
241    Todo {
242        todos: Vec<TodoItem>,
243    },
244    /// The session's title is now `title` (W031) — STREAMED, like `Todo` /
245    /// `Compaction` (a plain state payload the UI reduces), and also the reply
246    /// to a [`crate::protocol::Request::SetTitle`]. Emitted by the loop's
247    /// auto-titler at a turn boundary and by the actor's `SetTitle` arm.
248    ///
249    /// The field is `title`, not `id`: the envelope already carries a top-level
250    /// `id` and a flattened duplicate key would not deserialize — the same
251    /// constraint `Todo`/`Spawned` document.
252    ///
253    /// Wire note: unlike `Request`, this enum has **no** `#[serde(other)]`
254    /// catch-all (it is exhaustive by design). A peer that cannot deserialize a
255    /// new variant does not skip it — `read_frame` returns `Err` and the socket
256    /// client breaks its read loop and RECONNECTS. So an old client talking to a
257    /// new auto-titling server drops the connection once per titled session.
258    /// That is pre-existing forward-compat behaviour and deliberately NOT
259    /// "fixed" by adding an `Unknown` arm here (which would make every
260    /// consumer's match silently permissive instead of exhaustive).
261    SessionTitle {
262        title: String,
263    },
264    AgentEnd,
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::message::ContentBlock;
271    use serde_json::json;
272
273    fn roundtrip(e: AgentEvent, expected_tag: &str) {
274        let v = serde_json::to_value(&e).unwrap();
275        assert_eq!(v["type"], expected_tag);
276        let back: AgentEvent = serde_json::from_value(v.clone()).unwrap();
277        let v2 = serde_json::to_value(&back).unwrap();
278        assert_eq!(v, v2);
279    }
280
281    fn assistant() -> AgentMessage {
282        AgentMessage::Assistant {
283            content: vec![ContentBlock::Text { text: "a".into() }],
284            stop_reason: StopReason::Stop,
285            usage: Some(Usage::default()),
286            model: None,
287        }
288    }
289
290    #[test]
291    fn agent_event_roundtrip_all_variants() {
292        roundtrip(AgentEvent::AgentStart, "agent_start");
293        roundtrip(AgentEvent::TurnStart, "turn_start");
294        roundtrip(
295            AgentEvent::MessageStart {
296                message: assistant(),
297            },
298            "message_start",
299        );
300        roundtrip(
301            AgentEvent::MessageUpdate {
302                message: assistant(),
303            },
304            "message_update",
305        );
306        roundtrip(
307            AgentEvent::MessageEnd {
308                message: assistant(),
309            },
310            "message_end",
311        );
312        roundtrip(
313            AgentEvent::ToolExecutionStart {
314                call_id: "t1".into(),
315                name: "run".into(),
316            },
317            "tool_execution_start",
318        );
319        roundtrip(
320            AgentEvent::ToolExecutionUpdate {
321                call_id: "t1".into(),
322                name: "run".into(),
323                partial: "out".into(),
324            },
325            "tool_execution_update",
326        );
327        roundtrip(
328            AgentEvent::ToolExecutionEnd {
329                call_id: "t1".into(),
330                name: "run".into(),
331                output: "out".into(),
332                is_error: false,
333                diff: Some("@@ -1 +1 @@".into()),
334                path: Some("src/f.rs".into()),
335                duration_ms: Some(12),
336            },
337            "tool_execution_end",
338        );
339        roundtrip(
340            AgentEvent::TurnEnd {
341                message: assistant(),
342            },
343            "turn_end",
344        );
345        roundtrip(
346            AgentEvent::Error {
347                message: "boom".into(),
348            },
349            "error",
350        );
351        roundtrip(
352            AgentEvent::Compaction {
353                summarized: 3,
354                kept: 2,
355            },
356            "compaction",
357        );
358        roundtrip(
359            AgentEvent::CompactionSkipped {
360                reason: "summarizer 500".into(),
361            },
362            "compaction_skipped",
363        );
364        roundtrip(
365            AgentEvent::Retrying {
366                attempt: 2,
367                max: 3,
368                reason: "503".into(),
369            },
370            "retrying",
371        );
372        roundtrip(AgentEvent::Ack, "ack");
373        roundtrip(
374            AgentEvent::Stopped {
375                stop_reason: StopReason::Aborted,
376            },
377            "stopped",
378        );
379        roundtrip(
380            AgentEvent::SideAnswer {
381                text: "hi".into(),
382                usage: None,
383            },
384            "side_answer",
385        );
386        roundtrip(
387            AgentEvent::History {
388                messages: vec![AgentMessage::user_text("hi")],
389            },
390            "history",
391        );
392        roundtrip(
393            AgentEvent::Status {
394                last_assistant_text: Some("last said".into()),
395            },
396            "status",
397        );
398        roundtrip(
399            AgentEvent::Settings {
400                model: "m1".into(),
401                effort: Some("high".into()),
402                plan: true,
403                context_limit: Some(200_000),
404            },
405            "settings",
406        );
407        // The unknown-window spelling survives the wire as `null`, not `0`.
408        roundtrip(
409            AgentEvent::Settings {
410                model: "m1".into(),
411                effort: None,
412                plan: false,
413                context_limit: None,
414            },
415            "settings",
416        );
417        roundtrip(
418            AgentEvent::Usage {
419                stats: crate::stats::SessionStats {
420                    turns: 1,
421                    input_tokens: 1_000,
422                    output_tokens: 500,
423                    cache_read_tokens: 400,
424                    cache_write_tokens: 5,
425                    cache_turns: 1,
426                    tools: vec![crate::stats::ToolStat {
427                        name: "read".into(),
428                        calls: 2,
429                        errors: 1,
430                        result_bytes: 42,
431                    }],
432                    tool_errors: 1,
433                    last_input_tokens: Some(1_000),
434                    cost: Some(crate::stats::Cost {
435                        micro_usd: 391,
436                        unpriced_turns: 0,
437                    }),
438                },
439            },
440            "usage",
441        );
442        roundtrip(
443            AgentEvent::Models { models: vec!["m1".into(), "m2".into()] },
444            "models",
445        );
446        roundtrip(
447            AgentEvent::MessageReceived {
448                from: crate::protocol::SessionId::new("user"),
449                content: "ping".into(),
450            },
451            "message_received",
452        );
453        roundtrip(AgentEvent::AgentEnd, "agent_end");
454        roundtrip(
455            AgentEvent::Sessions {
456                sessions: vec![
457                    crate::protocol::SessionInfo {
458                        id: crate::protocol::SessionId::new("root"),
459                        model: Some("m1".into()),
460                        effort: Some("high".into()),
461                        context_window: Some(200_000),
462                        state: crate::protocol::MemberState::Idle,
463                    },
464                    crate::protocol::SessionInfo {
465                        id: crate::protocol::SessionId::agent("w1"),
466                        model: None,
467                        effort: None,
468                        context_window: None,
469                        state: crate::protocol::MemberState::Idle,
470                    },
471                ],
472            },
473            "sessions",
474        );
475        roundtrip(
476            AgentEvent::Spawned {
477                worker: crate::protocol::SessionId::agent("w1"),
478            },
479            "spawned",
480        );
481        roundtrip(
482            AgentEvent::Todo {
483                todos: vec![
484                    TodoItem {
485                        content: "write tests".into(),
486                        status: TodoStatus::Pending,
487                    },
488                    TodoItem {
489                        content: "ship it".into(),
490                        status: TodoStatus::Completed,
491                    },
492                ],
493            },
494            "todo",
495        );
496        roundtrip(
497            AgentEvent::SessionTitle {
498                title: "retry backoff fix".into(),
499            },
500            "session_title",
501        );
502    }
503
504    #[test]
505    fn agent_event_shape_matches_tagged_payload() {
506        let v = serde_json::to_value(&AgentEvent::ToolExecutionStart {
507            call_id: "t1".into(),
508            name: "run".into(),
509        })
510        .unwrap();
511        assert_eq!(
512            v,
513            json!({"type": "tool_execution_start", "call_id": "t1", "name": "run"})
514        );
515
516        let m = AgentMessage::user_text("hi");
517        let v2 = serde_json::to_value(&AgentEvent::TurnEnd { message: m }).unwrap();
518        assert_eq!(v2["type"], "turn_end");
519        assert_eq!(v2["message"]["role"], "user");
520
521        // Pin the flat `usage` shape once so a field rename cannot slip past.
522        let v3 = serde_json::to_value(&AgentEvent::Usage {
523            stats: crate::stats::SessionStats {
524                turns: 1,
525                ..Default::default()
526            },
527        })
528        .unwrap();
529        assert_eq!(v3["type"], "usage");
530        assert_eq!(v3["stats"]["turns"], 1);
531    }
532}