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 /// The agent is asking the user one or more multiple-choice questions
179 /// (Claude's `AskUserQuestion`, Codex's `tool/requestUserInput`). The host
180 /// renders the options as selectable chips; the user's pick is sent back as
181 /// their **next message** on the existing chat path (which resumes the
182 /// session), so the agent continues with the answer in hand. Carrying the
183 /// questions as a neutral event keeps the harness-specific tool shape in the
184 /// adapter — the host never name-checks `AskUserQuestion` (cf. `ToolKind`).
185 AskQuestion {
186 run_id: String,
187 /// Identifies this question instance (the harness's tool-call id), so
188 /// the host can tie the answer + clear the chips for the right one.
189 request_id: String,
190 questions: Vec<Question>,
191 },
192 /// Spawn / IO / parse failure. Terminal — followed by `Exited`.
193 Error { run_id: String, message: String },
194 /// The run finished. Sent exactly once.
195 Exited {
196 run_id: String,
197 exit_code: Option<i32>,
198 cancelled: bool,
199 },
200}
201
202/// Session identity decoded from a harness's init line → `RunEvent::Session`.
203#[derive(Debug, Default, Clone, PartialEq, Eq)]
204pub struct SessionInfo {
205 pub session_id: Option<String>,
206 pub model: Option<String>,
207}
208
209/// Token accounting decoded from a harness's result line → `RunEvent::Usage`.
210#[derive(Debug, Default, Clone, PartialEq, Eq)]
211pub struct UsageInfo {
212 pub input_tokens: Option<u64>,
213 pub output_tokens: Option<u64>,
214 pub total_tokens: Option<u64>,
215}
216
217/// One multiple-choice question carried by [`RunEvent::AskQuestion`]. The
218/// neutral shape every adapter maps its harness's question tool onto. Wire-out
219/// only (Serialize), like the rest of [`RunEvent`].
220#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
221#[serde(rename_all = "camelCase")]
222pub struct Question {
223 /// Short label for the question (Claude's `header`); optional.
224 #[serde(skip_serializing_if = "Option::is_none")]
225 pub header: Option<String>,
226 /// The question text shown to the user.
227 pub prompt: String,
228 pub options: Vec<QuestionOption>,
229 /// Whether more than one option may be selected.
230 pub multi_select: bool,
231 /// Whether a free-text ("Other") answer is allowed alongside the options.
232 pub allow_free_text: bool,
233}
234
235/// One selectable option of a [`Question`].
236#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
237#[serde(rename_all = "camelCase")]
238pub struct QuestionOption {
239 pub label: String,
240 #[serde(skip_serializing_if = "Option::is_none")]
241 pub description: Option<String>,
242}
243
244/// What a single harness output line decoded to. A line can yield
245/// text *and* edits at once, so this is not one-event-per-line.
246#[derive(Debug, Default, Clone, PartialEq, Eq)]
247pub struct ParsedLine {
248 pub text: Option<String>,
249 /// Model reasoning chunk → `RunEvent::Thinking`. Kept separate from
250 /// `text` so the UI can render it distinctly.
251 pub thinking: Option<String>,
252 /// Session identity (id + model) → `RunEvent::Session`.
253 pub session: Option<SessionInfo>,
254 /// A tool call began → `RunEvent::ToolStart`.
255 pub tool_start: Option<ToolCallStart>,
256 /// A tool call finished → `RunEvent::ToolEnd`.
257 pub tool_end: Option<ToolCallEnd>,
258 pub edits: Vec<SuggestedEdit>,
259 /// Token accounting → `RunEvent::Usage`.
260 pub usage: Option<UsageInfo>,
261 pub activity: Option<String>,
262 /// An in-band failure the harness reported on its *stdout* (codex's
263 /// `turn.failed` / `error` lines) → `RunEvent::Error`. Terminal. Kept
264 /// distinct from `activity` so a real failure (quota mid-turn, context
265 /// overflow, model error) surfaces as an error instead of being downgraded
266 /// to transient narration — otherwise a failed turn yields no answer *and*
267 /// no error, looking like the harness silently did nothing.
268 pub error: Option<String>,
269 /// A harness asked the user a multiple-choice question (Claude's
270 /// `AskUserQuestion`) → `RunEvent::AskQuestion`. The tuple is `(request_id,
271 /// questions)`: the tool-call id the host echoes when tying the answer and
272 /// clearing the chips, plus the parsed questions. The host renders the
273 /// options as chips; the answer returns as the user's next message.
274 pub ask_question: Option<(String, Vec<Question>)>,
275}
276
277impl ParsedLine {
278 /// True when a line decoded to no actionable content. A useful
279 /// predicate for adapters + their tests; the normalize skeleton
280 /// relies instead on the natural no-op of pushing zero events.
281 pub fn is_empty(&self) -> bool {
282 self.text.is_none()
283 && self.thinking.is_none()
284 && self.session.is_none()
285 && self.tool_start.is_none()
286 && self.tool_end.is_none()
287 && self.edits.is_empty()
288 && self.usage.is_none()
289 && self.activity.is_none()
290 && self.error.is_none()
291 && self.ask_question.is_none()
292 }
293}
294
295/// Translate one raw process event into zero or more normalized
296/// [`RunEvent`]s, using `parse_line` to decode the harness's stdout
297/// wire format. Lifecycle events (Started / Exited / Error) and
298/// stderr are harness-neutral and handled here; only the stdout
299/// parsing differs per harness — so every process-backed adapter
300/// shares this skeleton and supplies just its own line parser.
301pub fn normalize_process_event(
302 event: ProcessEvent,
303 mut parse_line: impl FnMut(&str) -> ParsedLine,
304) -> Vec<RunEvent> {
305 match event {
306 ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
307 ProcessEvent::Exited {
308 run_id,
309 exit_code,
310 cancelled,
311 } => vec![RunEvent::Exited {
312 run_id,
313 exit_code,
314 cancelled,
315 }],
316 ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
317 ProcessEvent::Stderr { run_id, line } => {
318 // stderr is warnings/progress; surface as activity,
319 // truncated like the TS store did (240 chars).
320 let message = truncate(&line, 240);
321 if message.is_empty() {
322 vec![]
323 } else {
324 vec![RunEvent::Activity { run_id, message }]
325 }
326 }
327 ProcessEvent::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
328 // `ProcessEvent` is #[non_exhaustive]; a future variant yields no
329 // events until an adapter learns to handle it.
330 _ => Vec::new(),
331 }
332}
333
334/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
335/// stable order: session (the run's init) → text → thinking → tool
336/// start/end → edits → usage (end of turn) → activity → error (a terminal
337/// in-band failure, emitted last so any text/usage on the same line lands
338/// before it).
339///
340/// Used by [`normalize_process_event`] and by adapters that wrap the line
341/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
342/// machine, which decides *where* a message goes but still relies on this
343/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
344/// exactly one place.
345///
346/// Public so an **out-of-tree** harness can build a stateful parser the same
347/// way: decide your own routing per line, then call this to expand a
348/// `ParsedLine` into events with the canonical ordering — instead of
349/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
350pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
351 let mut out = Vec::new();
352 if let Some(session) = parsed.session {
353 out.push(RunEvent::Session {
354 run_id: run_id.to_owned(),
355 session_id: session.session_id,
356 model: session.model,
357 });
358 }
359 if let Some(text) = parsed.text {
360 out.push(RunEvent::Text {
361 run_id: run_id.to_owned(),
362 delta: text,
363 });
364 }
365 if let Some(thinking) = parsed.thinking {
366 out.push(RunEvent::Thinking {
367 run_id: run_id.to_owned(),
368 delta: thinking,
369 });
370 }
371 if let Some(start) = parsed.tool_start {
372 out.push(RunEvent::ToolStart {
373 run_id: run_id.to_owned(),
374 tool_call_id: start.tool_call_id,
375 name: start.name,
376 input: start.input,
377 tool_kind: start.tool_kind,
378 });
379 }
380 if let Some(end) = parsed.tool_end {
381 out.push(RunEvent::ToolEnd {
382 run_id: run_id.to_owned(),
383 tool_call_id: end.tool_call_id,
384 ok: end.ok,
385 output: end.output,
386 });
387 }
388 if !parsed.edits.is_empty() {
389 out.push(RunEvent::SuggestedEdits {
390 run_id: run_id.to_owned(),
391 edits: parsed.edits,
392 });
393 }
394 if let Some(usage) = parsed.usage {
395 out.push(RunEvent::Usage {
396 run_id: run_id.to_owned(),
397 input_tokens: usage.input_tokens,
398 output_tokens: usage.output_tokens,
399 total_tokens: usage.total_tokens,
400 });
401 }
402 if let Some(activity) = parsed.activity {
403 out.push(RunEvent::Activity {
404 run_id: run_id.to_owned(),
405 message: activity,
406 });
407 }
408 // A harness reported an in-band failure on its stdout. This is the one
409 // place a parsed line can become `RunEvent::Error` (the only other source
410 // is a process-level `ProcessEvent::Error`), so an in-band failure from any
411 // harness — not just a spawn/IO failure — reaches the consumer.
412 if let Some(message) = parsed.error {
413 out.push(RunEvent::Error {
414 run_id: run_id.to_owned(),
415 message,
416 });
417 }
418 if let Some((request_id, questions)) = parsed.ask_question {
419 out.push(RunEvent::AskQuestion {
420 run_id: run_id.to_owned(),
421 request_id,
422 questions,
423 });
424 }
425 out
426}
427
428/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
429/// stderr activity line without splitting a multi-byte char.
430fn truncate(s: &str, max_chars: usize) -> String {
431 s.chars().take(max_chars).collect()
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 /// A line parser that yields nothing — exercises the neutral
439 /// skeleton without any harness-specific decoding.
440 fn empty_parser(_: &str) -> ParsedLine {
441 ParsedLine::default()
442 }
443
444 #[test]
445 fn normalize_passes_through_lifecycle_events() {
446 assert!(matches!(
447 normalize_process_event(ProcessEvent::Started { run_id: "r".into() }, empty_parser)
448 .as_slice(),
449 [RunEvent::Started { .. }]
450 ));
451 assert!(matches!(
452 normalize_process_event(
453 ProcessEvent::Exited {
454 run_id: "r".into(),
455 exit_code: Some(0),
456 cancelled: false
457 },
458 empty_parser
459 )
460 .as_slice(),
461 [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
462 ));
463 }
464
465 #[test]
466 fn stderr_becomes_truncated_activity() {
467 let long = "x".repeat(500);
468 let events = normalize_process_event(
469 ProcessEvent::Stderr {
470 run_id: "r1".into(),
471 line: long,
472 },
473 empty_parser,
474 );
475 match events.as_slice() {
476 [RunEvent::Activity { run_id, message }] => {
477 assert_eq!(run_id, "r1");
478 assert_eq!(message.chars().count(), 240);
479 }
480 other => panic!("expected one Activity, got {other:?}"),
481 }
482 // Empty stderr line → no event.
483 assert!(normalize_process_event(
484 ProcessEvent::Stderr {
485 run_id: "r1".into(),
486 line: String::new(),
487 },
488 empty_parser,
489 )
490 .is_empty());
491 }
492
493 #[test]
494 fn thinking_normalizes_and_serializes() {
495 let events = normalize_process_event(
496 ProcessEvent::Stdout {
497 run_id: "r1".to_owned(),
498 line: "ignored".to_owned(),
499 },
500 |_| ParsedLine {
501 thinking: Some("pondering".to_owned()),
502 ..ParsedLine::default()
503 },
504 );
505 assert!(matches!(
506 events.as_slice(),
507 [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
508 ));
509 let json = serde_json::to_value(RunEvent::Thinking {
510 run_id: "r1".to_owned(),
511 delta: "d".to_owned(),
512 })
513 .unwrap();
514 assert_eq!(json["kind"], "thinking");
515 assert_eq!(json["runId"], "r1");
516 assert_eq!(json["delta"], "d");
517 }
518
519 #[test]
520 fn run_event_serializes_with_kind_and_camelcase() {
521 let json = serde_json::to_value(RunEvent::Exited {
522 run_id: "r1".to_owned(),
523 exit_code: Some(2),
524 cancelled: true,
525 })
526 .unwrap();
527 assert_eq!(json["kind"], "exited");
528 assert_eq!(json["runId"], "r1");
529 assert_eq!(json["exitCode"], 2);
530 assert_eq!(json["cancelled"], true);
531 }
532
533 #[test]
534 fn ask_question_serializes_with_camelcase_and_skips_empty_description() {
535 let json = serde_json::to_value(RunEvent::AskQuestion {
536 run_id: "r1".to_owned(),
537 request_id: "q-7".to_owned(),
538 questions: vec![Question {
539 header: Some("Scope".to_owned()),
540 prompt: "Which files?".to_owned(),
541 options: vec![
542 QuestionOption { label: "All".to_owned(), description: None },
543 QuestionOption {
544 label: "Changed only".to_owned(),
545 description: Some("Just the diff".to_owned()),
546 },
547 ],
548 multi_select: true,
549 allow_free_text: false,
550 }],
551 })
552 .unwrap();
553 assert_eq!(json["kind"], "askQuestion");
554 assert_eq!(json["runId"], "r1");
555 assert_eq!(json["requestId"], "q-7");
556 let q = &json["questions"][0];
557 assert_eq!(q["header"], "Scope");
558 assert_eq!(q["prompt"], "Which files?");
559 assert_eq!(q["multiSelect"], true);
560 assert_eq!(q["allowFreeText"], false);
561 assert_eq!(q["options"][0]["label"], "All");
562 // A `None` description is omitted from the wire (skip_serializing_if).
563 assert!(q["options"][0].get("description").is_none());
564 assert_eq!(q["options"][1]["description"], "Just the diff");
565 }
566
567 #[test]
568 fn session_normalizes_and_serializes() {
569 let events = normalize_process_event(
570 ProcessEvent::Stdout {
571 run_id: "r1".to_owned(),
572 line: "ignored".to_owned(),
573 },
574 |_| ParsedLine {
575 session: Some(SessionInfo {
576 session_id: Some("sess-1".to_owned()),
577 model: Some("opus".to_owned()),
578 }),
579 ..ParsedLine::default()
580 },
581 );
582 assert!(matches!(
583 events.as_slice(),
584 [RunEvent::Session { run_id, session_id, model }]
585 if run_id == "r1"
586 && session_id.as_deref() == Some("sess-1")
587 && model.as_deref() == Some("opus")
588 ));
589 let json = serde_json::to_value(RunEvent::Session {
590 run_id: "r1".to_owned(),
591 session_id: Some("sess-1".to_owned()),
592 model: None,
593 })
594 .unwrap();
595 assert_eq!(json["kind"], "session");
596 assert_eq!(json["sessionId"], "sess-1");
597 // model omitted from the wire when None (backward-compatible).
598 assert!(json.get("model").is_none());
599 }
600
601 #[test]
602 fn usage_normalizes_and_serializes() {
603 let events = normalize_process_event(
604 ProcessEvent::Stdout {
605 run_id: "r1".to_owned(),
606 line: "ignored".to_owned(),
607 },
608 |_| ParsedLine {
609 usage: Some(UsageInfo {
610 input_tokens: Some(10),
611 output_tokens: Some(20),
612 total_tokens: Some(30),
613 }),
614 ..ParsedLine::default()
615 },
616 );
617 assert!(matches!(
618 events.as_slice(),
619 [RunEvent::Usage { run_id, input_tokens: Some(10), output_tokens: Some(20), total_tokens: Some(30) }]
620 if run_id == "r1"
621 ));
622 let json = serde_json::to_value(RunEvent::Usage {
623 run_id: "r1".to_owned(),
624 input_tokens: Some(10),
625 output_tokens: None,
626 total_tokens: Some(30),
627 })
628 .unwrap();
629 assert_eq!(json["kind"], "usage");
630 assert_eq!(json["inputTokens"], 10);
631 assert_eq!(json["totalTokens"], 30);
632 assert!(json.get("outputTokens").is_none()); // omitted when None
633 }
634
635 #[test]
636 fn tool_io_is_carried_and_omitted_when_absent() {
637 // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
638 let start = normalize_process_event(
639 ProcessEvent::Stdout {
640 run_id: "r1".to_owned(),
641 line: "ignored".to_owned(),
642 },
643 |_| ParsedLine {
644 tool_start: Some(ToolCallStart {
645 tool_call_id: "t1".to_owned(),
646 name: "ls".to_owned(),
647 input: Some("{\"dir\":\"/x\"}".to_owned()),
648 tool_kind: ToolKind::Other,
649 }),
650 ..ParsedLine::default()
651 },
652 );
653 assert!(matches!(
654 start.as_slice(),
655 [RunEvent::ToolStart { input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
656 ));
657 // A ToolStart with no input omits the field on the wire (byte-identical
658 // to the pre-enrichment shape).
659 let json = serde_json::to_value(RunEvent::ToolStart {
660 run_id: "r1".to_owned(),
661 tool_call_id: "t1".to_owned(),
662 name: "ls".to_owned(),
663 input: None,
664 tool_kind: ToolKind::Execute,
665 })
666 .unwrap();
667 assert_eq!(json["kind"], "toolStart");
668 // The neutral class rides alongside as `toolKind` — distinct wire key
669 // from the `kind` event discriminator (no collision).
670 assert_eq!(json["toolKind"], "execute");
671 assert_eq!(json["toolCallId"], "t1");
672 assert!(json.get("input").is_none());
673
674 let json = serde_json::to_value(RunEvent::ToolEnd {
675 run_id: "r1".to_owned(),
676 tool_call_id: "t1".to_owned(),
677 ok: true,
678 output: Some("done".to_owned()),
679 })
680 .unwrap();
681 assert_eq!(json["kind"], "toolEnd");
682 assert_eq!(json["output"], "done");
683 }
684
685 #[test]
686 fn parsed_error_normalizes_to_run_event_error() {
687 // An in-band failure decoded onto a ParsedLine surfaces as
688 // RunEvent::Error — the path codex's `turn.failed` / `error` lines now
689 // take, so a failed turn no longer yields neither answer nor error.
690 let events = normalize_process_event(
691 ProcessEvent::Stdout {
692 run_id: "r1".to_owned(),
693 line: "ignored".to_owned(),
694 },
695 |_| ParsedLine {
696 error: Some("rate limited".to_owned()),
697 ..ParsedLine::default()
698 },
699 );
700 assert!(matches!(
701 events.as_slice(),
702 [RunEvent::Error { run_id, message }] if run_id == "r1" && message == "rate limited"
703 ));
704 // An error-only line is not empty (is_empty stays honest).
705 assert!(!ParsedLine {
706 error: Some("x".to_owned()),
707 ..ParsedLine::default()
708 }
709 .is_empty());
710 }
711
712 #[test]
713 fn suggested_edits_event_serializes_camelcase() {
714 let json = serde_json::to_value(RunEvent::SuggestedEdits {
715 run_id: "r1".to_owned(),
716 edits: vec![SuggestedEdit {
717 file_path: "a.md".to_owned(),
718 range: ByteRange { start: 1, end: 2 },
719 replacement: "x".to_owned(),
720 title: None,
721 }],
722 })
723 .unwrap();
724 assert_eq!(json["kind"], "suggestedEdits");
725 assert_eq!(json["edits"][0]["filePath"], "a.md");
726 assert_eq!(json["edits"][0]["range"]["start"], 1);
727 // title omitted when None
728 assert!(json["edits"][0].get("title").is_none());
729 }
730}