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