harness/events.rs
1//! Normalized run events — the one shape the UI consumes regardless
2//! of which harness produced them.
3//!
4//! Every adapter (bob's stream-json, Claude Code's stream-json,
5//! Codex's format, a raw-API agent loop) parses its own wire format
6//! into these variants *on the Rust side*. The front-end then learns
7//! exactly one event vocabulary and never grows a per-harness
8//! parser. This is the keystone of the harness abstraction: the cost
9//! of adding a harness is "write a parser into `RunEvent`," not
10//! "teach the UI another format."
11//!
12//! Suggested edits carry only the *raw* edit (path + byte range +
13//! replacement). Turning those into previewable drafts needs the
14//! workspace file content and the coordinate mapper, which live in
15//! the consuming app layer, so that step stays there — this module's
16//! job is just to lift the edit out of the harness's bespoke wire
17//! format.
18
19use serde::Serialize;
20
21use cli_stream::ProcessEvent;
22
23/// A UTF-8 byte range into a document. Mirrors the persisted
24/// `ByteOffset` discipline (see `docs/editor-guide.md`): positions
25/// crossing the harness boundary are bytes, never code units.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct ByteRange {
29 pub start: u64,
30 pub end: u64,
31}
32
33/// A raw suggested edit emitted by a harness. The app layer prepares
34/// these into previewable drafts; this is the transport shape.
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub struct SuggestedEdit {
38 pub file_path: String,
39 pub range: ByteRange,
40 pub replacement: String,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub title: Option<String>,
43}
44
45/// A neutral, cross-harness classification of what a tool call *does* — so a
46/// consumer can route by behaviour (a read → a context pill, an edit → a
47/// file-op card) without re-encoding each harness's native tool vocabulary
48/// (bob's `read_file`, Claude's `Read`, codex's `file_change`). The raw
49/// `name` is kept alongside for display/phrasing; `tool_kind` is for
50/// behaviour. Named `tool_kind` (not `kind`) so it never collides with the
51/// `#[serde(tag = "kind")]` event discriminator on [`RunEvent`].
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
53#[serde(rename_all = "camelCase")]
54#[non_exhaustive]
55pub enum ToolKind {
56 /// Read or inspect a file's contents.
57 Read,
58 /// Create or overwrite a whole file.
59 Write,
60 /// Modify part of an existing file.
61 Edit,
62 /// Search or list files / the web.
63 Search,
64 /// Run a shell command or external process.
65 Execute,
66 /// Anything else (MCP calls, task spawns, completion signals, …).
67 Other,
68}
69
70/// A tool call beginning — its id + name, so the UI can render a
71/// state-ful card (running → done/✗) keyed by `tool_call_id`. `input`
72/// carries the call's arguments when the harness delivers them inline
73/// at the start (bob's `parameters`, codex's `command`); it is `None`
74/// when the harness streams them incrementally (Claude's
75/// `input_json_delta`), so the card stays correct either way. `tool_kind`
76/// is the neutral behaviour class (see [`ToolKind`]).
77#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
78#[serde(rename_all = "camelCase")]
79pub struct ToolCallStart {
80 pub tool_call_id: String,
81 pub name: String,
82 pub input: Option<String>,
83 pub tool_kind: ToolKind,
84}
85
86/// A tool call finishing — matched to its start by `tool_call_id`.
87/// `output` carries the tool's result when the harness reports it
88/// inline at completion (bob's `tool_result.output`, codex's
89/// `aggregated_output`, Claude's `tool_result.content`).
90#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct ToolCallEnd {
93 pub tool_call_id: String,
94 pub ok: bool,
95 pub output: Option<String>,
96}
97
98/// The normalized event stream. `#[serde(tag = "kind")]` +
99/// camelCase mirrors the existing `ProcessEvent` wire contract the TS
100/// store already reads (`event.kind`, `event.runId`, …), so the
101/// front-end consumes one shape regardless of which harness produced it.
102#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103// `rename_all` camelCases the variant tags ("suggestedEdits"); serde
104// does NOT cascade that to struct-variant fields, so `rename_all_fields`
105// is required to get `runId` / `exitCode` on the wire rather than the
106// snake_case Rust idents.
107#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
108// New event kinds (a richer Usage, a new lifecycle signal, …) can be added
109// without breaking consumers — they must carry a `_` arm. Adding `Session` /
110// `Usage` earlier was a breaking change precisely because this was missing.
111#[non_exhaustive]
112pub enum RunEvent {
113 /// First event, before any output. UI shows "thinking…". Fired the
114 /// instant the process spawns — *before* the CLI reports its
115 /// session/model, which arrive separately as [`RunEvent::Session`].
116 Started { run_id: String },
117 /// The agent session is established — its id and the model in use.
118 /// Distinct from `Started` because it arrives a beat later, in the
119 /// CLI's first output line (bob's `init`, Claude's `system/init`,
120 /// codex's `thread.started`); keeping `Started` instant matters for
121 /// the "thinking…" feedback. Either field may be absent when the CLI
122 /// doesn't report it (e.g. codex gives a thread id but no model).
123 Session {
124 run_id: String,
125 #[serde(skip_serializing_if = "Option::is_none")]
126 session_id: Option<String>,
127 #[serde(skip_serializing_if = "Option::is_none")]
128 model: Option<String>,
129 },
130 /// A chunk of assistant text. Appended to the active message.
131 Text { run_id: String, delta: String },
132 /// A chunk of model reasoning ("thinking"), rendered distinctly from
133 /// `Text` so the UI can show reasoning without mixing it into the
134 /// answer (e.g. Claude's `thinking_delta`).
135 Thinking { run_id: String, delta: String },
136 /// A tool call started — render a state-ful card keyed by id.
137 /// `input` is the call's arguments when delivered inline (omitted
138 /// from the wire when absent, e.g. Claude streams them separately).
139 ToolStart {
140 run_id: String,
141 tool_call_id: String,
142 name: String,
143 #[serde(skip_serializing_if = "Option::is_none")]
144 input: Option<String>,
145 tool_kind: ToolKind,
146 },
147 /// A tool call finished (matched to its start by id). `output` is the
148 /// tool's result when the harness reports it inline (omitted when absent).
149 ToolEnd {
150 run_id: String,
151 tool_call_id: String,
152 ok: bool,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 output: Option<String>,
155 },
156 /// One or more proposed edits. The app prepares + previews them.
157 SuggestedEdits {
158 run_id: String,
159 edits: Vec<SuggestedEdit>,
160 },
161 /// A human-readable status line (tool call, file touch, edit
162 /// count). Replaces the message's transient activity text.
163 Activity { run_id: String, message: String },
164 /// Token accounting for the run, emitted near its end (from the
165 /// CLI's `result` / `turn.completed`). Neutral tokens only —
166 /// harness-specific costs/credits (bob's coins) are NOT here; a
167 /// consumer that wants them reads the harness's own output. Any
168 /// field may be absent when the CLI doesn't break usage down.
169 Usage {
170 run_id: String,
171 #[serde(skip_serializing_if = "Option::is_none")]
172 input_tokens: Option<u64>,
173 #[serde(skip_serializing_if = "Option::is_none")]
174 output_tokens: Option<u64>,
175 #[serde(skip_serializing_if = "Option::is_none")]
176 total_tokens: Option<u64>,
177 },
178 /// Spawn / IO / parse failure. Terminal — followed by `Exited`.
179 Error { run_id: String, message: String },
180 /// The run finished. Sent exactly once.
181 Exited {
182 run_id: String,
183 exit_code: Option<i32>,
184 cancelled: bool,
185 },
186}
187
188/// Session identity decoded from a harness's init line → `RunEvent::Session`.
189#[derive(Debug, Default, Clone, PartialEq, Eq)]
190pub struct SessionInfo {
191 pub session_id: Option<String>,
192 pub model: Option<String>,
193}
194
195/// Token accounting decoded from a harness's result line → `RunEvent::Usage`.
196#[derive(Debug, Default, Clone, PartialEq, Eq)]
197pub struct UsageInfo {
198 pub input_tokens: Option<u64>,
199 pub output_tokens: Option<u64>,
200 pub total_tokens: Option<u64>,
201}
202
203/// What a single harness output line decoded to. A line can yield
204/// text *and* edits at once, so this is not one-event-per-line.
205#[derive(Debug, Default, Clone, PartialEq, Eq)]
206pub struct ParsedLine {
207 pub text: Option<String>,
208 /// Model reasoning chunk → `RunEvent::Thinking`. Kept separate from
209 /// `text` so the UI can render it distinctly.
210 pub thinking: Option<String>,
211 /// Session identity (id + model) → `RunEvent::Session`.
212 pub session: Option<SessionInfo>,
213 /// A tool call began → `RunEvent::ToolStart`.
214 pub tool_start: Option<ToolCallStart>,
215 /// A tool call finished → `RunEvent::ToolEnd`.
216 pub tool_end: Option<ToolCallEnd>,
217 pub edits: Vec<SuggestedEdit>,
218 /// Token accounting → `RunEvent::Usage`.
219 pub usage: Option<UsageInfo>,
220 pub activity: Option<String>,
221 /// An in-band failure the harness reported on its *stdout* (codex's
222 /// `turn.failed` / `error` lines) → `RunEvent::Error`. Terminal. Kept
223 /// distinct from `activity` so a real failure (quota mid-turn, context
224 /// overflow, model error) surfaces as an error instead of being downgraded
225 /// to transient narration — otherwise a failed turn yields no answer *and*
226 /// no error, looking like the harness silently did nothing.
227 pub error: Option<String>,
228}
229
230impl ParsedLine {
231 /// True when a line decoded to no actionable content. A useful
232 /// predicate for adapters + their tests; the normalize skeleton
233 /// relies instead on the natural no-op of pushing zero events.
234 pub fn is_empty(&self) -> bool {
235 self.text.is_none()
236 && self.thinking.is_none()
237 && self.session.is_none()
238 && self.tool_start.is_none()
239 && self.tool_end.is_none()
240 && self.edits.is_empty()
241 && self.usage.is_none()
242 && self.activity.is_none()
243 && self.error.is_none()
244 }
245}
246
247/// Translate one raw process event into zero or more normalized
248/// [`RunEvent`]s, using `parse_line` to decode the harness's stdout
249/// wire format. Lifecycle events (Started / Exited / Error) and
250/// stderr are harness-neutral and handled here; only the stdout
251/// parsing differs per harness — so every process-backed adapter
252/// shares this skeleton and supplies just its own line parser.
253pub fn normalize_process_event(
254 event: ProcessEvent,
255 mut parse_line: impl FnMut(&str) -> ParsedLine,
256) -> Vec<RunEvent> {
257 match event {
258 ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
259 ProcessEvent::Exited {
260 run_id,
261 exit_code,
262 cancelled,
263 } => vec![RunEvent::Exited {
264 run_id,
265 exit_code,
266 cancelled,
267 }],
268 ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
269 ProcessEvent::Stderr { run_id, line } => {
270 // stderr is warnings/progress; surface as activity,
271 // truncated like the TS store did (240 chars).
272 let message = truncate(&line, 240);
273 if message.is_empty() {
274 vec![]
275 } else {
276 vec![RunEvent::Activity { run_id, message }]
277 }
278 }
279 ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
280 // `ProcessEvent` is #[non_exhaustive]; a future variant yields no
281 // events until an adapter learns to handle it.
282 _ => Vec::new(),
283 }
284}
285
286/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
287/// stable order: session (the run's init) → text → thinking → tool
288/// start/end → edits → usage (end of turn) → activity → error (a terminal
289/// in-band failure, emitted last so any text/usage on the same line lands
290/// before it).
291///
292/// Used by [`normalize_process_event`] and by adapters that wrap the line
293/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
294/// machine, which decides *where* a message goes but still relies on this
295/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
296/// exactly one place.
297///
298/// Public so an **out-of-tree** harness can build a stateful parser the same
299/// way: decide your own routing per line, then call this to expand a
300/// `ParsedLine` into events with the canonical ordering — instead of
301/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
302pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
303 let mut out = Vec::new();
304 if let Some(session) = parsed.session {
305 out.push(RunEvent::Session {
306 run_id: run_id.to_owned(),
307 session_id: session.session_id,
308 model: session.model,
309 });
310 }
311 if let Some(text) = parsed.text {
312 out.push(RunEvent::Text {
313 run_id: run_id.to_owned(),
314 delta: text,
315 });
316 }
317 if let Some(thinking) = parsed.thinking {
318 out.push(RunEvent::Thinking {
319 run_id: run_id.to_owned(),
320 delta: thinking,
321 });
322 }
323 if let Some(start) = parsed.tool_start {
324 out.push(RunEvent::ToolStart {
325 run_id: run_id.to_owned(),
326 tool_call_id: start.tool_call_id,
327 name: start.name,
328 input: start.input,
329 tool_kind: start.tool_kind,
330 });
331 }
332 if let Some(end) = parsed.tool_end {
333 out.push(RunEvent::ToolEnd {
334 run_id: run_id.to_owned(),
335 tool_call_id: end.tool_call_id,
336 ok: end.ok,
337 output: end.output,
338 });
339 }
340 if !parsed.edits.is_empty() {
341 out.push(RunEvent::SuggestedEdits {
342 run_id: run_id.to_owned(),
343 edits: parsed.edits,
344 });
345 }
346 if let Some(usage) = parsed.usage {
347 out.push(RunEvent::Usage {
348 run_id: run_id.to_owned(),
349 input_tokens: usage.input_tokens,
350 output_tokens: usage.output_tokens,
351 total_tokens: usage.total_tokens,
352 });
353 }
354 if let Some(activity) = parsed.activity {
355 out.push(RunEvent::Activity {
356 run_id: run_id.to_owned(),
357 message: activity,
358 });
359 }
360 // A harness reported an in-band failure on its stdout. This is the one
361 // place a parsed line can become `RunEvent::Error` (the only other source
362 // is a process-level `ProcessEvent::Error`), so an in-band failure from any
363 // harness — not just a spawn/IO failure — reaches the consumer.
364 if let Some(message) = parsed.error {
365 out.push(RunEvent::Error {
366 run_id: run_id.to_owned(),
367 message,
368 });
369 }
370 out
371}
372
373/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
374/// stderr activity line without splitting a multi-byte char.
375fn truncate(s: &str, max_chars: usize) -> String {
376 s.chars().take(max_chars).collect()
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 /// A line parser that yields nothing — exercises the neutral
384 /// skeleton without any harness-specific decoding.
385 fn empty_parser(_: &str) -> ParsedLine {
386 ParsedLine::default()
387 }
388
389 #[test]
390 fn normalize_passes_through_lifecycle_events() {
391 assert!(matches!(
392 normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
393 .as_slice(),
394 [RunEvent::Started { .. }]
395 ));
396 assert!(matches!(
397 normalize_process_event(
398 ProcessEvent::Exited {
399 run_id: "r".into(),
400 exit_code: Some(0),
401 cancelled: false
402 },
403 empty_parser
404 )
405 .as_slice(),
406 [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
407 ));
408 }
409
410 #[test]
411 fn stderr_becomes_truncated_activity() {
412 let long = "x".repeat(500);
413 let events = normalize_process_event(
414 ProcessEvent::Stderr {
415 run_id: "r1".into(),
416 line: long,
417 },
418 empty_parser,
419 );
420 match events.as_slice() {
421 [RunEvent::Activity { run_id, message }] => {
422 assert_eq!(run_id, "r1");
423 assert_eq!(message.chars().count(), 240);
424 }
425 other => panic!("expected one Activity, got {other:?}"),
426 }
427 // Empty stderr line → no event.
428 assert!(normalize_process_event(
429 ProcessEvent::Stderr {
430 run_id: "r1".into(),
431 line: String::new(),
432 },
433 empty_parser,
434 )
435 .is_empty());
436 }
437
438 #[test]
439 fn thinking_normalizes_and_serializes() {
440 let events = normalize_process_event(
441 ProcessEvent::Stdout {
442 run_id: "r1".to_owned(),
443 line: "ignored".to_owned(),
444 },
445 |_| ParsedLine {
446 thinking: Some("pondering".to_owned()),
447 ..ParsedLine::default()
448 },
449 );
450 assert!(matches!(
451 events.as_slice(),
452 [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
453 ));
454 let json = serde_json::to_value(RunEvent::Thinking {
455 run_id: "r1".to_owned(),
456 delta: "d".to_owned(),
457 })
458 .unwrap();
459 assert_eq!(json["kind"], "thinking");
460 assert_eq!(json["runId"], "r1");
461 assert_eq!(json["delta"], "d");
462 }
463
464 #[test]
465 fn run_event_serializes_with_kind_and_camelcase() {
466 let json = serde_json::to_value(RunEvent::Exited {
467 run_id: "r1".to_owned(),
468 exit_code: Some(2),
469 cancelled: true,
470 })
471 .unwrap();
472 assert_eq!(json["kind"], "exited");
473 assert_eq!(json["runId"], "r1");
474 assert_eq!(json["exitCode"], 2);
475 assert_eq!(json["cancelled"], true);
476 }
477
478 #[test]
479 fn session_normalizes_and_serializes() {
480 let events = normalize_process_event(
481 ProcessEvent::Stdout {
482 run_id: "r1".to_owned(),
483 line: "ignored".to_owned(),
484 },
485 |_| ParsedLine {
486 session: Some(SessionInfo {
487 session_id: Some("sess-1".to_owned()),
488 model: Some("opus".to_owned()),
489 }),
490 ..ParsedLine::default()
491 },
492 );
493 assert!(matches!(
494 events.as_slice(),
495 [RunEvent::Session { run_id, session_id, model }]
496 if run_id == "r1"
497 && session_id.as_deref() == Some("sess-1")
498 && model.as_deref() == Some("opus")
499 ));
500 let json = serde_json::to_value(RunEvent::Session {
501 run_id: "r1".to_owned(),
502 session_id: Some("sess-1".to_owned()),
503 model: None,
504 })
505 .unwrap();
506 assert_eq!(json["kind"], "session");
507 assert_eq!(json["sessionId"], "sess-1");
508 // model omitted from the wire when None (backward-compatible).
509 assert!(json.get("model").is_none());
510 }
511
512 #[test]
513 fn usage_normalizes_and_serializes() {
514 let events = normalize_process_event(
515 ProcessEvent::Stdout {
516 run_id: "r1".to_owned(),
517 line: "ignored".to_owned(),
518 },
519 |_| ParsedLine {
520 usage: Some(UsageInfo {
521 input_tokens: Some(10),
522 output_tokens: Some(20),
523 total_tokens: Some(30),
524 }),
525 ..ParsedLine::default()
526 },
527 );
528 assert!(matches!(
529 events.as_slice(),
530 [RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
531 if run_id == "r1"
532 ));
533 let json = serde_json::to_value(RunEvent::Usage {
534 run_id: "r1".to_owned(),
535 input_tokens: Some(10),
536 output_tokens: None,
537 total_tokens: Some(30),
538 })
539 .unwrap();
540 assert_eq!(json["kind"], "usage");
541 assert_eq!(json["inputTokens"], 10);
542 assert_eq!(json["totalTokens"], 30);
543 assert!(json.get("outputTokens").is_none()); // omitted when None
544 }
545
546 #[test]
547 fn tool_io_is_carried_and_omitted_when_absent() {
548 // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
549 let start = normalize_process_event(
550 ProcessEvent::Stdout {
551 run_id: "r1".to_owned(),
552 line: "ignored".to_owned(),
553 },
554 |_| ParsedLine {
555 tool_start: Some(ToolCallStart {
556 tool_call_id: "t1".to_owned(),
557 name: "ls".to_owned(),
558 input: Some("{\"dir\":\"/x\"}".to_owned()),
559 tool_kind: ToolKind::Other,
560 }),
561 ..ParsedLine::default()
562 },
563 );
564 assert!(matches!(
565 start.as_slice(),
566 [RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
567 ));
568 // A ToolStart with no input omits the field on the wire (byte-identical
569 // to the pre-enrichment shape).
570 let json = serde_json::to_value(RunEvent::ToolStart {
571 run_id: "r1".to_owned(),
572 tool_call_id: "t1".to_owned(),
573 name: "ls".to_owned(),
574 input: None,
575 tool_kind: ToolKind::Execute,
576 })
577 .unwrap();
578 assert_eq!(json["kind"], "toolStart");
579 // The neutral class rides alongside as `toolKind` — distinct wire key
580 // from the `kind` event discriminator (no collision).
581 assert_eq!(json["toolKind"], "execute");
582 assert_eq!(json["toolCallId"], "t1");
583 assert!(json.get("input").is_none());
584
585 let json = serde_json::to_value(RunEvent::ToolEnd {
586 run_id: "r1".to_owned(),
587 tool_call_id: "t1".to_owned(),
588 ok: true,
589 output: Some("done".to_owned()),
590 })
591 .unwrap();
592 assert_eq!(json["kind"], "toolEnd");
593 assert_eq!(json["output"], "done");
594 }
595
596 #[test]
597 fn parsed_error_normalizes_to_run_event_error() {
598 // An in-band failure decoded onto a ParsedLine surfaces as
599 // RunEvent::Error — the path codex's `turn.failed` / `error` lines now
600 // take, so a failed turn no longer yields neither answer nor error.
601 let events = normalize_process_event(
602 ProcessEvent::Stdout {
603 run_id: "r1".to_owned(),
604 line: "ignored".to_owned(),
605 },
606 |_| ParsedLine {
607 error: Some("rate limited".to_owned()),
608 ..ParsedLine::default()
609 },
610 );
611 assert!(matches!(
612 events.as_slice(),
613 [RunEvent::Error { run_id, message }] if run_id == "r1" && message == "rate limited"
614 ));
615 // An error-only line is not empty (is_empty stays honest).
616 assert!(!ParsedLine {
617 error: Some("x".to_owned()),
618 ..ParsedLine::default()
619 }
620 .is_empty());
621 }
622
623 #[test]
624 fn suggested_edits_event_serializes_camelcase() {
625 let json = serde_json::to_value(RunEvent::SuggestedEdits {
626 run_id: "r1".to_owned(),
627 edits: vec![SuggestedEdit {
628 file_path: "a.md".to_owned(),
629 range: ByteRange { start: 1, end: 2 },
630 replacement: "x".to_owned(),
631 title: None,
632 }],
633 })
634 .unwrap();
635 assert_eq!(json["kind"], "suggestedEdits");
636 assert_eq!(json["edits"][0]["filePath"], "a.md");
637 assert_eq!(json["edits"][0]["range"]["start"], 1);
638 // title omitted when None
639 assert!(json["edits"][0].get("title").is_none());
640 }
641}