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}