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: 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 /// Reply: the sessions a server serves, in order (root first), each
173 /// with the model the server knows for it ([`crate::protocol::SessionInfo`]).
174 /// Answers a [`crate::protocol::Request::ListSessions`]. Never streamed (see
175 /// [`AgentEvent::Ack`]).
176 Sessions {
177 sessions: Vec<crate::protocol::SessionInfo>,
178 },
179 /// Reply: a peer defined a worker and returns its address, answering a
180 /// [`crate::protocol::Request::Define`]. Never streamed (see
181 /// [`AgentEvent::Ack`]).
182 ///
183 /// The field is `worker`, not `id`: the envelope already carries a top-level
184 /// `id` (the request it replies to), and a flattened duplicate key would not
185 /// deserialize.
186 Spawned {
187 worker: crate::protocol::SessionId,
188 },
189 /// The `todo` checklist, streamed on every write (mirror `Compaction` /
190 /// `Retrying`: a plain state payload the UI reduces). The field is `todos`,
191 /// not `id`: the envelope already carries a top-level `id` and a flattened
192 /// duplicate key would not deserialize.
193 Todo {
194 todos: Vec<TodoItem>,
195 },
196 AgentEnd,
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use crate::message::ContentBlock;
203 use serde_json::json;
204
205 fn roundtrip(e: AgentEvent, expected_tag: &str) {
206 let v = serde_json::to_value(&e).unwrap();
207 assert_eq!(v["type"], expected_tag);
208 let back: AgentEvent = serde_json::from_value(v.clone()).unwrap();
209 let v2 = serde_json::to_value(&back).unwrap();
210 assert_eq!(v, v2);
211 }
212
213 fn assistant() -> AgentMessage {
214 AgentMessage::Assistant {
215 content: vec![ContentBlock::Text { text: "a".into() }],
216 stop_reason: StopReason::Stop,
217 usage: Some(Usage::default()),
218 model: None,
219 }
220 }
221
222 #[test]
223 fn agent_event_roundtrip_all_variants() {
224 roundtrip(AgentEvent::AgentStart, "agent_start");
225 roundtrip(AgentEvent::TurnStart, "turn_start");
226 roundtrip(
227 AgentEvent::MessageStart {
228 message: assistant(),
229 },
230 "message_start",
231 );
232 roundtrip(
233 AgentEvent::MessageUpdate {
234 message: assistant(),
235 },
236 "message_update",
237 );
238 roundtrip(
239 AgentEvent::MessageEnd {
240 message: assistant(),
241 },
242 "message_end",
243 );
244 roundtrip(
245 AgentEvent::ToolExecutionStart {
246 call_id: "t1".into(),
247 name: "run".into(),
248 },
249 "tool_execution_start",
250 );
251 roundtrip(
252 AgentEvent::ToolExecutionUpdate {
253 call_id: "t1".into(),
254 name: "run".into(),
255 partial: "out".into(),
256 },
257 "tool_execution_update",
258 );
259 roundtrip(
260 AgentEvent::ToolExecutionEnd {
261 call_id: "t1".into(),
262 name: "run".into(),
263 output: "out".into(),
264 is_error: false,
265 diff: Some("@@ -1 +1 @@".into()),
266 path: Some("src/f.rs".into()),
267 duration_ms: Some(12),
268 },
269 "tool_execution_end",
270 );
271 roundtrip(
272 AgentEvent::TurnEnd {
273 message: assistant(),
274 },
275 "turn_end",
276 );
277 roundtrip(
278 AgentEvent::Error {
279 message: "boom".into(),
280 },
281 "error",
282 );
283 roundtrip(
284 AgentEvent::Compaction {
285 summarized: 3,
286 kept: 2,
287 },
288 "compaction",
289 );
290 roundtrip(
291 AgentEvent::CompactionSkipped {
292 reason: "summarizer 500".into(),
293 },
294 "compaction_skipped",
295 );
296 roundtrip(
297 AgentEvent::Retrying {
298 attempt: 2,
299 max: 3,
300 reason: "503".into(),
301 },
302 "retrying",
303 );
304 roundtrip(AgentEvent::Ack, "ack");
305 roundtrip(
306 AgentEvent::Stopped {
307 stop_reason: StopReason::Aborted,
308 },
309 "stopped",
310 );
311 roundtrip(
312 AgentEvent::SideAnswer {
313 text: "hi".into(),
314 usage: None,
315 },
316 "side_answer",
317 );
318 roundtrip(
319 AgentEvent::History {
320 messages: vec![AgentMessage::user_text("hi")],
321 },
322 "history",
323 );
324 roundtrip(
325 AgentEvent::Status {
326 last_assistant_text: Some("last said".into()),
327 },
328 "status",
329 );
330 roundtrip(
331 AgentEvent::MessageReceived {
332 from: crate::protocol::SessionId::new("user"),
333 content: "ping".into(),
334 },
335 "message_received",
336 );
337 roundtrip(AgentEvent::AgentEnd, "agent_end");
338 roundtrip(
339 AgentEvent::Sessions {
340 sessions: vec![
341 crate::protocol::SessionInfo {
342 id: crate::protocol::SessionId::new("root"),
343 model: Some("m1".into()),
344 state: crate::protocol::MemberState::Idle,
345 },
346 crate::protocol::SessionInfo {
347 id: crate::protocol::SessionId::agent("w1"),
348 model: None,
349 state: crate::protocol::MemberState::Idle,
350 },
351 ],
352 },
353 "sessions",
354 );
355 roundtrip(
356 AgentEvent::Spawned {
357 worker: crate::protocol::SessionId::agent("w1"),
358 },
359 "spawned",
360 );
361 roundtrip(
362 AgentEvent::Todo {
363 todos: vec![
364 TodoItem {
365 content: "write tests".into(),
366 status: TodoStatus::Pending,
367 },
368 TodoItem {
369 content: "ship it".into(),
370 status: TodoStatus::Completed,
371 },
372 ],
373 },
374 "todo",
375 );
376 }
377
378 #[test]
379 fn agent_event_shape_matches_tagged_payload() {
380 let v = serde_json::to_value(&AgentEvent::ToolExecutionStart {
381 call_id: "t1".into(),
382 name: "run".into(),
383 })
384 .unwrap();
385 assert_eq!(
386 v,
387 json!({"type": "tool_execution_start", "call_id": "t1", "name": "run"})
388 );
389
390 let m = AgentMessage::user_text("hi");
391 let v2 = serde_json::to_value(&AgentEvent::TurnEnd { message: m }).unwrap();
392 assert_eq!(v2["type"], "turn_end");
393 assert_eq!(v2["message"]["role"], "user");
394 }
395}