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}