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}