Skip to main content

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 tool call beginning — its id + name, so the UI can render a
46/// state-ful card (running → done/✗) keyed by `tool_call_id`. `input`
47/// carries the call's arguments when the harness delivers them inline
48/// at the start (bob's `parameters`, codex's `command`); it is `None`
49/// when the harness streams them incrementally (Claude's
50/// `input_json_delta`), so the card stays correct either way.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
52#[serde(rename_all = "camelCase")]
53pub struct ToolCallStart {
54    pub tool_call_id: String,
55    pub name: String,
56    pub input: Option<String>,
57}
58
59/// A tool call finishing — matched to its start by `tool_call_id`.
60/// `output` carries the tool's result when the harness reports it
61/// inline at completion (bob's `tool_result.output`, codex's
62/// `aggregated_output`, Claude's `tool_result.content`).
63#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct ToolCallEnd {
66    pub tool_call_id: String,
67    pub ok: bool,
68    pub output: Option<String>,
69}
70
71/// The normalized event stream. `#[serde(tag = "kind")]` +
72/// camelCase mirrors the existing `ProcessEvent` wire contract the TS
73/// store already reads (`event.kind`, `event.runId`, …), so the
74/// front-end consumes one shape regardless of which harness produced it.
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76// `rename_all` camelCases the variant tags ("suggestedEdits"); serde
77// does NOT cascade that to struct-variant fields, so `rename_all_fields`
78// is required to get `runId` / `exitCode` on the wire rather than the
79// snake_case Rust idents.
80#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
81// New event kinds (a richer Usage, a new lifecycle signal, …) can be added
82// without breaking consumers — they must carry a `_` arm. Adding `Session` /
83// `Usage` earlier was a breaking change precisely because this was missing.
84#[non_exhaustive]
85pub enum RunEvent {
86    /// First event, before any output. UI shows "thinking…". Fired the
87    /// instant the process spawns — *before* the CLI reports its
88    /// session/model, which arrive separately as [`RunEvent::Session`].
89    Started { run_id: String },
90    /// The agent session is established — its id and the model in use.
91    /// Distinct from `Started` because it arrives a beat later, in the
92    /// CLI's first output line (bob's `init`, Claude's `system/init`,
93    /// codex's `thread.started`); keeping `Started` instant matters for
94    /// the "thinking…" feedback. Either field may be absent when the CLI
95    /// doesn't report it (e.g. codex gives a thread id but no model).
96    Session {
97        run_id: String,
98        #[serde(skip_serializing_if = "Option::is_none")]
99        session_id: Option<String>,
100        #[serde(skip_serializing_if = "Option::is_none")]
101        model: Option<String>,
102    },
103    /// A chunk of assistant text. Appended to the active message.
104    Text { run_id: String, delta: String },
105    /// A chunk of model reasoning ("thinking"), rendered distinctly from
106    /// `Text` so the UI can show reasoning without mixing it into the
107    /// answer (e.g. Claude's `thinking_delta`).
108    Thinking { run_id: String, delta: String },
109    /// A tool call started — render a state-ful card keyed by id.
110    /// `input` is the call's arguments when delivered inline (omitted
111    /// from the wire when absent, e.g. Claude streams them separately).
112    ToolStart {
113        run_id: String,
114        tool_call_id: String,
115        name: String,
116        #[serde(skip_serializing_if = "Option::is_none")]
117        input: Option<String>,
118    },
119    /// A tool call finished (matched to its start by id). `output` is the
120    /// tool's result when the harness reports it inline (omitted when absent).
121    ToolEnd {
122        run_id: String,
123        tool_call_id: String,
124        ok: bool,
125        #[serde(skip_serializing_if = "Option::is_none")]
126        output: Option<String>,
127    },
128    /// One or more proposed edits. The app prepares + previews them.
129    SuggestedEdits {
130        run_id: String,
131        edits: Vec<SuggestedEdit>,
132    },
133    /// A human-readable status line (tool call, file touch, edit
134    /// count). Replaces the message's transient activity text.
135    Activity { run_id: String, message: String },
136    /// Token accounting for the run, emitted near its end (from the
137    /// CLI's `result` / `turn.completed`). Neutral tokens only —
138    /// harness-specific costs/credits (bob's coins) are NOT here; a
139    /// consumer that wants them reads the harness's own output. Any
140    /// field may be absent when the CLI doesn't break usage down.
141    Usage {
142        run_id: String,
143        #[serde(skip_serializing_if = "Option::is_none")]
144        input_tokens: Option<u64>,
145        #[serde(skip_serializing_if = "Option::is_none")]
146        output_tokens: Option<u64>,
147        #[serde(skip_serializing_if = "Option::is_none")]
148        total_tokens: Option<u64>,
149    },
150    /// Spawn / IO / parse failure. Terminal — followed by `Exited`.
151    Error { run_id: String, message: String },
152    /// The run finished. Sent exactly once.
153    Exited {
154        run_id: String,
155        exit_code: Option<i32>,
156        cancelled: bool,
157    },
158}
159
160/// Session identity decoded from a harness's init line → `RunEvent::Session`.
161#[derive(Debug, Default, Clone, PartialEq, Eq)]
162pub struct SessionInfo {
163    pub session_id: Option<String>,
164    pub model: Option<String>,
165}
166
167/// Token accounting decoded from a harness's result line → `RunEvent::Usage`.
168#[derive(Debug, Default, Clone, PartialEq, Eq)]
169pub struct UsageInfo {
170    pub input_tokens: Option<u64>,
171    pub output_tokens: Option<u64>,
172    pub total_tokens: Option<u64>,
173}
174
175/// What a single harness output line decoded to. A line can yield
176/// text *and* edits at once, so this is not one-event-per-line.
177#[derive(Debug, Default, Clone, PartialEq, Eq)]
178pub struct ParsedLine {
179    pub text: Option<String>,
180    /// Model reasoning chunk → `RunEvent::Thinking`. Kept separate from
181    /// `text` so the UI can render it distinctly.
182    pub thinking: Option<String>,
183    /// Session identity (id + model) → `RunEvent::Session`.
184    pub session: Option<SessionInfo>,
185    /// A tool call began → `RunEvent::ToolStart`.
186    pub tool_start: Option<ToolCallStart>,
187    /// A tool call finished → `RunEvent::ToolEnd`.
188    pub tool_end: Option<ToolCallEnd>,
189    pub edits: Vec<SuggestedEdit>,
190    /// Token accounting → `RunEvent::Usage`.
191    pub usage: Option<UsageInfo>,
192    pub activity: Option<String>,
193}
194
195impl ParsedLine {
196    /// True when a line decoded to no actionable content. A useful
197    /// predicate for adapters + their tests; the normalize skeleton
198    /// relies instead on the natural no-op of pushing zero events.
199    pub fn is_empty(&self) -> bool {
200        self.text.is_none()
201            && self.thinking.is_none()
202            && self.session.is_none()
203            && self.tool_start.is_none()
204            && self.tool_end.is_none()
205            && self.edits.is_empty()
206            && self.usage.is_none()
207            && self.activity.is_none()
208    }
209}
210
211/// Translate one raw process event into zero or more normalized
212/// [`RunEvent`]s, using `parse_line` to decode the harness's stdout
213/// wire format. Lifecycle events (Started / Exited / Error) and
214/// stderr are harness-neutral and handled here; only the stdout
215/// parsing differs per harness — so every process-backed adapter
216/// shares this skeleton and supplies just its own line parser.
217pub fn normalize_process_event(
218    event: ProcessEvent,
219    mut parse_line: impl FnMut(&str) -> ParsedLine,
220) -> Vec<RunEvent> {
221    match event {
222        ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
223        ProcessEvent::Exited {
224            run_id,
225            exit_code,
226            cancelled,
227        } => vec![RunEvent::Exited {
228            run_id,
229            exit_code,
230            cancelled,
231        }],
232        ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
233        ProcessEvent::Stderr { run_id, line } => {
234            // stderr is warnings/progress; surface as activity,
235            // truncated like the TS store did (240 chars).
236            let message = truncate(&line, 240);
237            if message.is_empty() {
238                vec![]
239            } else {
240                vec![RunEvent::Activity { run_id, message }]
241            }
242        }
243        ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
244        // `ProcessEvent` is #[non_exhaustive]; a future variant yields no
245        // events until an adapter learns to handle it.
246        _ => Vec::new(),
247    }
248}
249
250/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
251/// stable order: session (the run's init) → text → thinking → tool
252/// start/end → edits → usage (end of turn) → activity.
253///
254/// Used by [`normalize_process_event`] and by adapters that wrap the line
255/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
256/// machine, which decides *where* a message goes but still relies on this
257/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
258/// exactly one place.
259///
260/// Public so an **out-of-tree** harness can build a stateful parser the same
261/// way: decide your own routing per line, then call this to expand a
262/// `ParsedLine` into events with the canonical ordering — instead of
263/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
264pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
265    let mut out = Vec::new();
266    if let Some(session) = parsed.session {
267        out.push(RunEvent::Session {
268            run_id: run_id.to_owned(),
269            session_id: session.session_id,
270            model: session.model,
271        });
272    }
273    if let Some(text) = parsed.text {
274        out.push(RunEvent::Text {
275            run_id: run_id.to_owned(),
276            delta: text,
277        });
278    }
279    if let Some(thinking) = parsed.thinking {
280        out.push(RunEvent::Thinking {
281            run_id: run_id.to_owned(),
282            delta: thinking,
283        });
284    }
285    if let Some(start) = parsed.tool_start {
286        out.push(RunEvent::ToolStart {
287            run_id: run_id.to_owned(),
288            tool_call_id: start.tool_call_id,
289            name: start.name,
290            input: start.input,
291        });
292    }
293    if let Some(end) = parsed.tool_end {
294        out.push(RunEvent::ToolEnd {
295            run_id: run_id.to_owned(),
296            tool_call_id: end.tool_call_id,
297            ok: end.ok,
298            output: end.output,
299        });
300    }
301    if !parsed.edits.is_empty() {
302        out.push(RunEvent::SuggestedEdits {
303            run_id: run_id.to_owned(),
304            edits: parsed.edits,
305        });
306    }
307    if let Some(usage) = parsed.usage {
308        out.push(RunEvent::Usage {
309            run_id: run_id.to_owned(),
310            input_tokens: usage.input_tokens,
311            output_tokens: usage.output_tokens,
312            total_tokens: usage.total_tokens,
313        });
314    }
315    if let Some(activity) = parsed.activity {
316        out.push(RunEvent::Activity {
317            run_id: run_id.to_owned(),
318            message: activity,
319        });
320    }
321    out
322}
323
324/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
325/// stderr activity line without splitting a multi-byte char.
326fn truncate(s: &str, max_chars: usize) -> String {
327    s.chars().take(max_chars).collect()
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    /// A line parser that yields nothing — exercises the neutral
335    /// skeleton without any harness-specific decoding.
336    fn empty_parser(_: &str) -> ParsedLine {
337        ParsedLine::default()
338    }
339
340    #[test]
341    fn normalize_passes_through_lifecycle_events() {
342        assert!(matches!(
343            normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
344                .as_slice(),
345            [RunEvent::Started { .. }]
346        ));
347        assert!(matches!(
348            normalize_process_event(
349                ProcessEvent::Exited {
350                    run_id: "r".into(),
351                    exit_code: Some(0),
352                    cancelled: false
353                },
354                empty_parser
355            )
356            .as_slice(),
357            [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
358        ));
359    }
360
361    #[test]
362    fn stderr_becomes_truncated_activity() {
363        let long = "x".repeat(500);
364        let events = normalize_process_event(
365            ProcessEvent::Stderr {
366                run_id: "r1".into(),
367                line: long,
368            },
369            empty_parser,
370        );
371        match events.as_slice() {
372            [RunEvent::Activity { run_id, message }] => {
373                assert_eq!(run_id, "r1");
374                assert_eq!(message.chars().count(), 240);
375            }
376            other => panic!("expected one Activity, got {other:?}"),
377        }
378        // Empty stderr line → no event.
379        assert!(normalize_process_event(
380            ProcessEvent::Stderr {
381                run_id: "r1".into(),
382                line: String::new(),
383            },
384            empty_parser,
385        )
386        .is_empty());
387    }
388
389    #[test]
390    fn thinking_normalizes_and_serializes() {
391        let events = normalize_process_event(
392            ProcessEvent::Stdout {
393                run_id: "r1".to_owned(),
394                line: "ignored".to_owned(),
395            },
396            |_| ParsedLine {
397                thinking: Some("pondering".to_owned()),
398                ..ParsedLine::default()
399            },
400        );
401        assert!(matches!(
402            events.as_slice(),
403            [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
404        ));
405        let json = serde_json::to_value(RunEvent::Thinking {
406            run_id: "r1".to_owned(),
407            delta: "d".to_owned(),
408        })
409        .unwrap();
410        assert_eq!(json["kind"], "thinking");
411        assert_eq!(json["runId"], "r1");
412        assert_eq!(json["delta"], "d");
413    }
414
415    #[test]
416    fn run_event_serializes_with_kind_and_camelcase() {
417        let json = serde_json::to_value(RunEvent::Exited {
418            run_id: "r1".to_owned(),
419            exit_code: Some(2),
420            cancelled: true,
421        })
422        .unwrap();
423        assert_eq!(json["kind"], "exited");
424        assert_eq!(json["runId"], "r1");
425        assert_eq!(json["exitCode"], 2);
426        assert_eq!(json["cancelled"], true);
427    }
428
429    #[test]
430    fn session_normalizes_and_serializes() {
431        let events = normalize_process_event(
432            ProcessEvent::Stdout {
433                run_id: "r1".to_owned(),
434                line: "ignored".to_owned(),
435            },
436            |_| ParsedLine {
437                session: Some(SessionInfo {
438                    session_id: Some("sess-1".to_owned()),
439                    model: Some("opus".to_owned()),
440                }),
441                ..ParsedLine::default()
442            },
443        );
444        assert!(matches!(
445            events.as_slice(),
446            [RunEvent::Session { run_id, session_id, model }]
447                if run_id == "r1"
448                    && session_id.as_deref() == Some("sess-1")
449                    && model.as_deref() == Some("opus")
450        ));
451        let json = serde_json::to_value(RunEvent::Session {
452            run_id: "r1".to_owned(),
453            session_id: Some("sess-1".to_owned()),
454            model: None,
455        })
456        .unwrap();
457        assert_eq!(json["kind"], "session");
458        assert_eq!(json["sessionId"], "sess-1");
459        // model omitted from the wire when None (backward-compatible).
460        assert!(json.get("model").is_none());
461    }
462
463    #[test]
464    fn usage_normalizes_and_serializes() {
465        let events = normalize_process_event(
466            ProcessEvent::Stdout {
467                run_id: "r1".to_owned(),
468                line: "ignored".to_owned(),
469            },
470            |_| ParsedLine {
471                usage: Some(UsageInfo {
472                    input_tokens: Some(10),
473                    output_tokens: Some(20),
474                    total_tokens: Some(30),
475                }),
476                ..ParsedLine::default()
477            },
478        );
479        assert!(matches!(
480            events.as_slice(),
481            [RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
482                if run_id == "r1"
483        ));
484        let json = serde_json::to_value(RunEvent::Usage {
485            run_id: "r1".to_owned(),
486            input_tokens: Some(10),
487            output_tokens: None,
488            total_tokens: Some(30),
489        })
490        .unwrap();
491        assert_eq!(json["kind"], "usage");
492        assert_eq!(json["inputTokens"], 10);
493        assert_eq!(json["totalTokens"], 30);
494        assert!(json.get("outputTokens").is_none()); // omitted when None
495    }
496
497    #[test]
498    fn tool_io_is_carried_and_omitted_when_absent() {
499        // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
500        let start = normalize_process_event(
501            ProcessEvent::Stdout {
502                run_id: "r1".to_owned(),
503                line: "ignored".to_owned(),
504            },
505            |_| ParsedLine {
506                tool_start: Some(ToolCallStart {
507                    tool_call_id: "t1".to_owned(),
508                    name: "ls".to_owned(),
509                    input: Some("{\"dir\":\"/x\"}".to_owned()),
510                }),
511                ..ParsedLine::default()
512            },
513        );
514        assert!(matches!(
515            start.as_slice(),
516            [RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
517        ));
518        // A ToolStart with no input omits the field on the wire (byte-identical
519        // to the pre-enrichment shape).
520        let json = serde_json::to_value(RunEvent::ToolStart {
521            run_id: "r1".to_owned(),
522            tool_call_id: "t1".to_owned(),
523            name: "ls".to_owned(),
524            input: None,
525        })
526        .unwrap();
527        assert_eq!(json["kind"], "toolStart");
528        assert_eq!(json["toolCallId"], "t1");
529        assert!(json.get("input").is_none());
530
531        let json = serde_json::to_value(RunEvent::ToolEnd {
532            run_id: "r1".to_owned(),
533            tool_call_id: "t1".to_owned(),
534            ok: true,
535            output: Some("done".to_owned()),
536        })
537        .unwrap();
538        assert_eq!(json["kind"], "toolEnd");
539        assert_eq!(json["output"], "done");
540    }
541
542    #[test]
543    fn suggested_edits_event_serializes_camelcase() {
544        let json = serde_json::to_value(RunEvent::SuggestedEdits {
545            run_id: "r1".to_owned(),
546            edits: vec![SuggestedEdit {
547                file_path: "a.md".to_owned(),
548                range: ByteRange { start: 1, end: 2 },
549                replacement: "x".to_owned(),
550                title: None,
551            }],
552        })
553        .unwrap();
554        assert_eq!(json["kind"], "suggestedEdits");
555        assert_eq!(json["edits"][0]["filePath"], "a.md");
556        assert_eq!(json["edits"][0]["range"]["start"], 1);
557        // title omitted when None
558        assert!(json["edits"][0].get("title").is_none());
559    }
560}