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::Event;
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///
53/// The values mirror ACP's tool-call `kind`
54/// (`read`/`edit`/`delete`/`move`/`search`/`execute`/`fetch`/`other`), so a
55/// [`RunEvent::ToolStart`] maps onto an ACP `tool_call` without a translation
56/// table. The one divergence: our `Write` (create/overwrite a whole file) has
57/// no ACP counterpart — ACP folds whole-file writes into `edit` — so an
58/// ACP bridge maps `Write` → `edit`.
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "camelCase")]
61#[non_exhaustive]
62pub enum ToolKind {
63 /// Read or inspect a file's contents. (ACP `read`.)
64 Read,
65 /// Create or overwrite a whole file. (No ACP kind — bridges to `edit`.)
66 Write,
67 /// Modify part of an existing file. (ACP `edit`.)
68 Edit,
69 /// Delete a file. (ACP `delete`.)
70 Delete,
71 /// Move or rename a file. (ACP `move`.)
72 Move,
73 /// Search or list files / the web. (ACP `search`.)
74 Search,
75 /// Run a shell command or external process. (ACP `execute`.)
76 Execute,
77 /// Fetch a URL / remote resource. (ACP `fetch`.)
78 Fetch,
79 /// Anything else (MCP calls, task spawns, completion signals, …). (ACP `other`.)
80 Other,
81}
82
83/// A file/path a tool call touches — the neutral mirror of ACP's
84/// `ToolCallLocation`. Lets the app show the call's subject (and distinguish,
85/// say, listing a directory from reading a file) and offer follow-along
86/// navigation. Populated by the ACP adapter (passthrough) and openai-compatible's
87/// own tools; empty for the CLI adapters that don't report paths structurally.
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "camelCase")]
90pub struct ToolLocation {
91 pub path: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 pub line: Option<u32>,
94}
95
96/// A tool call beginning — its id + name, so the UI can render a
97/// state-ful card (running → done/✗) keyed by `tool_call_id`. `input`
98/// carries the call's arguments when the harness delivers them inline
99/// at the start (bob's `parameters`, codex's `command`); it is `None`
100/// when the harness streams them incrementally (Claude's
101/// `input_json_delta`), so the card stays correct either way. `tool_kind`
102/// is the neutral behaviour class (see [`ToolKind`]).
103#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
104#[serde(rename_all = "camelCase")]
105pub struct ToolCallStart {
106 pub tool_call_id: String,
107 pub name: String,
108 pub input: Option<String>,
109 pub tool_kind: ToolKind,
110}
111
112/// A tool call finishing — matched to its start by `tool_call_id`.
113/// `output` carries the tool's result when the harness reports it
114/// inline at completion (bob's `tool_result.output`, codex's
115/// `aggregated_output`, Claude's `tool_result.content`).
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
117#[serde(rename_all = "camelCase")]
118pub struct ToolCallEnd {
119 pub tool_call_id: String,
120 pub ok: bool,
121 pub output: Option<String>,
122}
123
124/// The normalized event stream. `#[serde(tag = "kind")]` +
125/// camelCase mirrors the existing `Event` wire contract the TS
126/// store already reads (`event.kind`, `event.runId`, …), so the
127/// front-end consumes one shape regardless of which harness produced it.
128// Not `Eq`: `Usage.cost_usd` is an `f64` (only `PartialEq`). `==`/`assert_eq!`
129// still work; `RunEvent` just can't be a `HashSet`/`HashMap` key.
130#[derive(Debug, Clone, PartialEq, Serialize)]
131// `rename_all` camelCases the variant tags ("suggestedEdits"); serde
132// does NOT cascade that to struct-variant fields, so `rename_all_fields`
133// is required to get `runId` / `exitCode` on the wire rather than the
134// snake_case Rust idents.
135#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
136// New event kinds (a richer Usage, a new lifecycle signal, …) can be added
137// without breaking consumers — they must carry a `_` arm. Adding `Session` /
138// `Usage` earlier was a breaking change precisely because this was missing.
139#[non_exhaustive]
140pub enum RunEvent {
141 /// First event, before any output. UI shows "thinking…". Fired the
142 /// instant the process spawns — *before* the CLI reports its
143 /// session/model, which arrive separately as [`RunEvent::Session`].
144 Started { run_id: String },
145 /// The agent session is established — its id and the model in use.
146 /// Distinct from `Started` because it arrives a beat later, in the
147 /// CLI's first output line (bob's `init`, Claude's `system/init`,
148 /// codex's `thread.started`); keeping `Started` instant matters for
149 /// the "thinking…" feedback. Either field may be absent when the CLI
150 /// doesn't report it (e.g. codex gives a thread id but no model).
151 /// Constructible out-of-tree: any `Harness` (in-tree, or a third-party
152 /// crate like `openai-compatible`) mints this directly, so it is *not*
153 /// variant-`#[non_exhaustive]` — sealing it would break the open-producer
154 /// contract (see the note on [`RunEvent::Exited`]).
155 Session {
156 run_id: String,
157 #[serde(skip_serializing_if = "Option::is_none")]
158 session_id: Option<String>,
159 #[serde(skip_serializing_if = "Option::is_none")]
160 model: Option<String>,
161 },
162 /// A chunk of assistant text. Appended to the active message.
163 Text { run_id: String, delta: String },
164 /// A chunk of model reasoning ("thinking"), rendered distinctly from
165 /// `Text` so the UI can show reasoning without mixing it into the
166 /// answer (e.g. Claude's `thinking_delta`).
167 Thinking { run_id: String, delta: String },
168 /// A tool call started — render a state-ful card keyed by id. Mirrors ACP's
169 /// `ToolCall`: `title` + `kind` + `locations` (files it touches) + the raw
170 /// arguments (`raw_input`, omitted when streamed separately, e.g. Claude).
171 ToolStart {
172 run_id: String,
173 tool_call_id: String,
174 title: String,
175 // ACP calls this `kind`; our wire reserves `kind` for the event tag, so
176 // it stays `tool_kind` (→ `toolKind` on the wire).
177 tool_kind: ToolKind,
178 #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 locations: Vec<ToolLocation>,
180 #[serde(skip_serializing_if = "Option::is_none")]
181 raw_input: Option<String>,
182 },
183 /// A tool call finished (matched to its start by id). Mirrors ACP's tool
184 /// result: `content` (human-readable, flattened to text) + `raw_output`
185 /// (structured JSON) + the `locations` it touched. `ok` reduces ACP's
186 /// terminal `Completed`/`Failed` status to a flag.
187 ToolEnd {
188 run_id: String,
189 tool_call_id: String,
190 ok: bool,
191 #[serde(skip_serializing_if = "Option::is_none")]
192 content: Option<String>,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
194 raw_output: Option<String>,
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 locations: Vec<ToolLocation>,
197 },
198 /// One or more proposed edits. The app prepares + previews them.
199 SuggestedEdits {
200 run_id: String,
201 edits: Vec<SuggestedEdit>,
202 },
203 /// A human-readable status line (tool call, file touch, edit
204 /// count). Replaces the message's transient activity text.
205 Activity { run_id: String, message: String },
206 /// Token accounting for the run, emitted near its end (from the
207 /// CLI's `result` / `turn.completed`). Neutral tokens only —
208 /// harness-specific costs/credits (bob's coins) are NOT here; a
209 /// consumer that wants them reads the harness's own output. Any
210 /// field may be absent when the CLI doesn't break usage down.
211 ///
212 /// `cache_read_tokens` / `cache_write_tokens` are the prompt-cache
213 /// counters reported *separately* from `input_tokens` (Claude's
214 /// `cache_read_input_tokens` / `cache_creation_input_tokens`) — not folded
215 /// into `input_tokens`, and omitted from the wire when the CLI doesn't
216 /// report caching.
217 Usage {
218 run_id: String,
219 #[serde(skip_serializing_if = "Option::is_none")]
220 input_tokens: Option<u64>,
221 #[serde(skip_serializing_if = "Option::is_none")]
222 output_tokens: Option<u64>,
223 #[serde(skip_serializing_if = "Option::is_none")]
224 total_tokens: Option<u64>,
225 /// Prompt-cache tokens served from cache this run (~0.1x input cost).
226 #[serde(skip_serializing_if = "Option::is_none")]
227 cache_read_tokens: Option<u64>,
228 /// Prompt-cache tokens written to cache this run (~1.25x input cost).
229 #[serde(skip_serializing_if = "Option::is_none")]
230 cache_write_tokens: Option<u64>,
231 /// Estimated cost in USD for this run, when the adapter knows per-token
232 /// rates (`openai-compatible` via `with_model_cost`); `None` otherwise.
233 #[serde(skip_serializing_if = "Option::is_none")]
234 cost_usd: Option<f64>,
235 },
236 /// The agent is asking the user one or more multiple-choice questions
237 /// (Claude's `AskUserQuestion`, Codex's `tool/requestUserInput`). The host
238 /// renders the options as selectable chips; the user's pick is sent back as
239 /// their **next message** on the existing chat path (which resumes the
240 /// session), so the agent continues with the answer in hand. Carrying the
241 /// questions as a neutral event keeps the harness-specific tool shape in the
242 /// adapter — the host never name-checks `AskUserQuestion` (cf. `ToolKind`).
243 AskQuestion {
244 run_id: String,
245 /// Identifies this question instance (the harness's tool-call id), so
246 /// the host can tie the answer + clear the chips for the right one.
247 request_id: String,
248 questions: Vec<Question>,
249 },
250 /// The agent's current task plan / todo list (Claude's `TodoWrite`,
251 /// Codex's plan items), replacing any prior plan for this run. The host
252 /// renders a checklist without knowing the harness's native plan tool.
253 /// The neutral plan vocabulary adapters map onto — the `acp` adapter from
254 /// ACP `plan`, and `openai-compatible` from its `todowrite` tool.
255 Plan { run_id: String, entries: Vec<PlanEntry> },
256 /// A live update to the session's display metadata — its title and/or
257 /// last-updated time — emitted mid-run when the agent (re)names the
258 /// conversation. Maps field-for-field onto ACP `session_info_update`
259 /// (`title` + `updatedAt`); lets a sessions list show a meaningful title
260 /// before the run ends. Both fields optional (a partial update).
261 SessionInfoUpdate {
262 run_id: String,
263 #[serde(skip_serializing_if = "Option::is_none")]
264 title: Option<String>,
265 /// ISO-8601 timestamp of the update, when the harness reports one
266 /// (ACP `updatedAt`). Omitted from the wire when absent.
267 #[serde(skip_serializing_if = "Option::is_none")]
268 updated_at: Option<String>,
269 },
270 /// Command / IO / parse failure. Terminal — followed by `Exited`.
271 Error { run_id: String, message: String },
272 /// The run finished. Sent exactly once. Like every `RunEvent` variant it is
273 /// constructible out-of-tree: harnesses live in their own crates (the
274 /// `Registry` is open — `openai-compatible`, plus `examples/custom_harness.rs`),
275 /// so no *produced* variant is variant-`#[non_exhaustive]` — that would
276 /// close the producer door. The enum itself stays `#[non_exhaustive]`, which
277 /// protects *consumers* (a new variant just needs a `_` arm) without
278 /// blocking construction of the existing ones.
279 Exited {
280 run_id: String,
281 exit_code: Option<i32>,
282 cancelled: bool,
283 },
284}
285
286/// Session identity decoded from a harness's init line → `RunEvent::Session`.
287#[derive(Debug, Default, Clone, PartialEq, Eq)]
288pub struct SessionInfo {
289 pub session_id: Option<String>,
290 pub model: Option<String>,
291}
292
293/// Token accounting decoded from a harness's result line → `RunEvent::Usage`.
294#[derive(Debug, Default, Clone, PartialEq, Eq)]
295pub struct UsageInfo {
296 pub input_tokens: Option<u64>,
297 pub output_tokens: Option<u64>,
298 pub total_tokens: Option<u64>,
299 /// Prompt-cache tokens read this run (Claude's `cache_read_input_tokens`).
300 pub cache_read_tokens: Option<u64>,
301 /// Prompt-cache tokens written (Claude's `cache_creation_input_tokens`).
302 pub cache_write_tokens: Option<u64>,
303}
304
305/// One multiple-choice question carried by [`RunEvent::AskQuestion`]. The
306/// neutral shape every adapter maps its harness's question tool onto. Wire-out
307/// only (Serialize), like the rest of [`RunEvent`].
308#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
309#[serde(rename_all = "camelCase")]
310pub struct Question {
311 /// Short label for the question (Claude's `header`); optional.
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub header: Option<String>,
314 /// The question text shown to the user.
315 pub prompt: String,
316 pub options: Vec<QuestionOption>,
317 /// Whether more than one option may be selected.
318 pub multi_select: bool,
319 /// Whether a free-text ("Other") answer is allowed alongside the options.
320 pub allow_free_text: bool,
321}
322
323/// One selectable option of a [`Question`].
324#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
325#[serde(rename_all = "camelCase")]
326pub struct QuestionOption {
327 pub label: String,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 pub description: Option<String>,
330}
331
332/// One entry in a [`RunEvent::Plan`] — a single task the agent is tracking.
333/// Wire-out only (Serialize), like the rest of [`RunEvent`].
334#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
335#[serde(rename_all = "camelCase")]
336pub struct PlanEntry {
337 /// The step text (the todo content).
338 pub content: String,
339 pub status: PlanEntryStatus,
340 /// Relative importance when the harness ranks steps; omitted otherwise.
341 #[serde(skip_serializing_if = "Option::is_none")]
342 pub priority: Option<PlanEntryPriority>,
343}
344
345/// Lifecycle of a [`PlanEntry`]. `#[non_exhaustive]` — a harness may report
346/// states beyond these.
347#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
348#[serde(rename_all = "camelCase")]
349#[non_exhaustive]
350pub enum PlanEntryStatus {
351 Pending,
352 InProgress,
353 Completed,
354 /// The step was abandoned (OpenCode's `cancelled` todo status).
355 Cancelled,
356}
357
358/// Relative priority of a [`PlanEntry`]. `#[non_exhaustive]`.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
360#[serde(rename_all = "camelCase")]
361#[non_exhaustive]
362pub enum PlanEntryPriority {
363 Low,
364 Medium,
365 High,
366}
367
368/// What a single harness output line decoded to. A line can yield
369/// text *and* edits at once, so this is not one-event-per-line.
370#[derive(Debug, Default, Clone, PartialEq, Eq)]
371pub struct ParsedLine {
372 pub text: Option<String>,
373 /// Model reasoning chunk → `RunEvent::Thinking`. Kept separate from
374 /// `text` so the UI can render it distinctly.
375 pub thinking: Option<String>,
376 /// Session identity (id + model) → `RunEvent::Session`.
377 pub session: Option<SessionInfo>,
378 /// A tool call began → `RunEvent::ToolStart`.
379 pub tool_start: Option<ToolCallStart>,
380 /// A tool call finished → `RunEvent::ToolEnd`.
381 pub tool_end: Option<ToolCallEnd>,
382 pub edits: Vec<SuggestedEdit>,
383 /// Token accounting → `RunEvent::Usage`.
384 pub usage: Option<UsageInfo>,
385 pub activity: Option<String>,
386 /// An in-band failure the harness reported on its *stdout* (codex's
387 /// `turn.failed` / `error` lines) → `RunEvent::Error`. Terminal. Kept
388 /// distinct from `activity` so a real failure (quota mid-turn, context
389 /// overflow, model error) surfaces as an error instead of being downgraded
390 /// to transient narration — otherwise a failed turn yields no answer *and*
391 /// no error, looking like the harness silently did nothing.
392 pub error: Option<String>,
393 /// A harness asked the user a multiple-choice question (Claude's
394 /// `AskUserQuestion`) → `RunEvent::AskQuestion`. The tuple is `(request_id,
395 /// questions)`: the tool-call id the host echoes when tying the answer and
396 /// clearing the chips, plus the parsed questions. The host renders the
397 /// options as chips; the answer returns as the user's next message.
398 pub ask_question: Option<(String, Vec<Question>)>,
399 /// The agent's current plan / todo list → `RunEvent::Plan`. No built-in
400 /// parser fills this yet; it's wired for adapters that will.
401 pub plan: Option<Vec<PlanEntry>>,
402 /// A live session-title update → `RunEvent::SessionInfoUpdate`.
403 pub title: Option<String>,
404}
405
406impl ParsedLine {
407 /// True when a line decoded to no actionable content. A useful
408 /// predicate for adapters + their tests; the normalize skeleton
409 /// relies instead on the natural no-op of pushing zero events.
410 pub fn is_empty(&self) -> bool {
411 self.text.is_none()
412 && self.thinking.is_none()
413 && self.session.is_none()
414 && self.tool_start.is_none()
415 && self.tool_end.is_none()
416 && self.edits.is_empty()
417 && self.usage.is_none()
418 && self.activity.is_none()
419 && self.error.is_none()
420 && self.ask_question.is_none()
421 && self.plan.is_none()
422 && self.title.is_none()
423 }
424}
425
426/// Translate one raw process event into zero or more normalized
427/// [`RunEvent`]s, using `parse_line` to decode the harness's stdout
428/// wire format. Lifecycle events (Started / Exited / Error) and
429/// stderr are harness-neutral and handled here; only the stdout
430/// parsing differs per harness — so every process-backed adapter
431/// shares this skeleton and supplies just its own line parser.
432pub fn normalize_process_event(
433 event: Event,
434 mut parse_line: impl FnMut(&str) -> ParsedLine,
435) -> Vec<RunEvent> {
436 match event {
437 Event::Started { run_id } => vec![RunEvent::Started { run_id }],
438 Event::Exited {
439 run_id,
440 exit_code,
441 cancelled,
442 } => vec![RunEvent::Exited {
443 run_id,
444 exit_code,
445 cancelled,
446 }],
447 Event::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
448 Event::Stderr { run_id, line } => {
449 // stderr is warnings/progress; surface as activity,
450 // truncated like the TS store did (240 chars).
451 let message = truncate(&line, 240);
452 if message.is_empty() {
453 vec![]
454 } else if cli_stream::needs_terminal(&line) {
455 // Otherwise this arrives as one more line of noise, and the run
456 // looks like it failed for no reason. Agents are spawned with
457 // pipes, so a CLI wanting a terminal needs its headless mode.
458 vec![RunEvent::Activity {
459 run_id,
460 message: format!("this agent wants an interactive terminal, which a run cannot give it — use its non-interactive mode. It said: {message}"),
461 }]
462 } else {
463 vec![RunEvent::Activity { run_id, message }]
464 }
465 }
466 Event::Stdout { run_id, line } => run_events_from_parsed(&run_id, parse_line(&line)),
467 // `Event` is #[non_exhaustive]; a future variant yields no
468 // events until an adapter learns to handle it.
469 _ => Vec::new(),
470 }
471}
472
473/// Expand a decoded [`ParsedLine`] into its [`RunEvent`]s for `run_id`, in a
474/// stable order: session (the run's init) → session-info/title → text →
475/// thinking → tool start/end → edits → plan → usage (end of turn) → activity
476/// → error (a terminal in-band failure, emitted last so any text/usage on the
477/// same line lands before it) → ask-question.
478///
479/// Used by [`normalize_process_event`] and by adapters that wrap the line
480/// parser in their own per-run state (e.g. codex's preamble-vs-answer state
481/// machine, which decides *where* a message goes but still relies on this
482/// for everything else) — so the `ParsedLine` → `RunEvent` mapping lives in
483/// exactly one place.
484///
485/// Public so an **out-of-tree** harness can build a stateful parser the same
486/// way: decide your own routing per line, then call this to expand a
487/// `ParsedLine` into events with the canonical ordering — instead of
488/// hand-rolling (and drifting from) the mapping. See `examples/custom_harness.rs`.
489pub fn run_events_from_parsed(run_id: &str, parsed: ParsedLine) -> Vec<RunEvent> {
490 let mut out = Vec::new();
491 if let Some(session) = parsed.session {
492 out.push(RunEvent::Session {
493 run_id: run_id.to_owned(),
494 session_id: session.session_id,
495 model: session.model,
496 });
497 }
498 if let Some(title) = parsed.title {
499 out.push(RunEvent::SessionInfoUpdate {
500 run_id: run_id.to_owned(),
501 title: Some(title),
502 // No parser supplies a timestamp yet; an adapter that has one
503 // constructs SessionInfoUpdate directly.
504 updated_at: None,
505 });
506 }
507 if let Some(text) = parsed.text {
508 out.push(RunEvent::Text {
509 run_id: run_id.to_owned(),
510 delta: text,
511 });
512 }
513 if let Some(thinking) = parsed.thinking {
514 out.push(RunEvent::Thinking {
515 run_id: run_id.to_owned(),
516 delta: thinking,
517 });
518 }
519 if let Some(start) = parsed.tool_start {
520 out.push(RunEvent::ToolStart {
521 run_id: run_id.to_owned(),
522 tool_call_id: start.tool_call_id,
523 title: start.name,
524 tool_kind: start.tool_kind,
525 // CLI adapters don't report structured paths; ACP + openai-compatible do.
526 locations: Vec::new(),
527 raw_input: start.input,
528 });
529 }
530 if let Some(end) = parsed.tool_end {
531 out.push(RunEvent::ToolEnd {
532 run_id: run_id.to_owned(),
533 tool_call_id: end.tool_call_id,
534 ok: end.ok,
535 content: end.output,
536 raw_output: None,
537 locations: Vec::new(),
538 });
539 }
540 if !parsed.edits.is_empty() {
541 out.push(RunEvent::SuggestedEdits {
542 run_id: run_id.to_owned(),
543 edits: parsed.edits,
544 });
545 }
546 if let Some(entries) = parsed.plan {
547 out.push(RunEvent::Plan {
548 run_id: run_id.to_owned(),
549 entries,
550 });
551 }
552 if let Some(usage) = parsed.usage {
553 out.push(RunEvent::Usage {
554 run_id: run_id.to_owned(),
555 input_tokens: usage.input_tokens,
556 output_tokens: usage.output_tokens,
557 total_tokens: usage.total_tokens,
558 cache_read_tokens: usage.cache_read_tokens,
559 cache_write_tokens: usage.cache_write_tokens,
560 // CLI adapters don't compute cost; openai-compatible does (with rates).
561 cost_usd: None,
562 });
563 }
564 if let Some(activity) = parsed.activity {
565 out.push(RunEvent::Activity {
566 run_id: run_id.to_owned(),
567 message: activity,
568 });
569 }
570 // A harness reported an in-band failure on its stdout. This is the one
571 // place a parsed line can become `RunEvent::Error` (the only other source
572 // is a process-level `Event::Error`), so an in-band failure from any
573 // harness — not just a spawn/IO failure — reaches the consumer.
574 if let Some(message) = parsed.error {
575 out.push(RunEvent::Error {
576 run_id: run_id.to_owned(),
577 message,
578 });
579 }
580 if let Some((request_id, questions)) = parsed.ask_question {
581 out.push(RunEvent::AskQuestion {
582 run_id: run_id.to_owned(),
583 request_id,
584 questions,
585 });
586 }
587 out
588}
589
590/// Take the first `max_chars` characters (not bytes) of `s`. Bounds the
591/// stderr activity line without splitting a multi-byte char.
592fn truncate(s: &str, max_chars: usize) -> String {
593 s.chars().take(max_chars).collect()
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 /// A line parser that yields nothing — exercises the neutral
601 /// skeleton without any harness-specific decoding.
602 fn empty_parser(_: &str) -> ParsedLine {
603 ParsedLine::default()
604 }
605
606 #[test]
607 fn normalize_passes_through_lifecycle_events() {
608 assert!(matches!(
609 normalize_process_event(Event::Started { run_id: "r".into() }, empty_parser)
610 .as_slice(),
611 [RunEvent::Started { .. }]
612 ));
613 assert!(matches!(
614 normalize_process_event(
615 Event::Exited {
616 run_id: "r".into(),
617 exit_code: Some(0),
618 cancelled: false
619 },
620 empty_parser
621 )
622 .as_slice(),
623 [RunEvent::Exited { exit_code: Some(0), cancelled: false, .. }]
624 ));
625 }
626
627 #[test]
628 fn stderr_becomes_truncated_activity() {
629 let long = "x".repeat(500);
630 let events = normalize_process_event(
631 Event::Stderr {
632 run_id: "r1".into(),
633 line: long,
634 },
635 empty_parser,
636 );
637 match events.as_slice() {
638 [RunEvent::Activity { run_id, message }] => {
639 assert_eq!(run_id, "r1");
640 assert_eq!(message.chars().count(), 240);
641 }
642 other => panic!("expected one Activity, got {other:?}"),
643 }
644 // Empty stderr line → no event.
645 assert!(normalize_process_event(
646 Event::Stderr {
647 run_id: "r1".into(),
648 line: String::new(),
649 },
650 empty_parser,
651 )
652 .is_empty());
653 }
654
655 #[test]
656 fn thinking_normalizes_and_serializes() {
657 let events = normalize_process_event(
658 Event::Stdout {
659 run_id: "r1".to_owned(),
660 line: "ignored".to_owned(),
661 },
662 |_| ParsedLine {
663 thinking: Some("pondering".to_owned()),
664 ..ParsedLine::default()
665 },
666 );
667 assert!(matches!(
668 events.as_slice(),
669 [RunEvent::Thinking { run_id, delta }] if run_id == "r1" && delta == "pondering"
670 ));
671 let json = serde_json::to_value(RunEvent::Thinking {
672 run_id: "r1".to_owned(),
673 delta: "d".to_owned(),
674 })
675 .unwrap();
676 assert_eq!(json["kind"], "thinking");
677 assert_eq!(json["runId"], "r1");
678 assert_eq!(json["delta"], "d");
679 }
680
681 #[test]
682 fn run_event_serializes_with_kind_and_camelcase() {
683 let json = serde_json::to_value(RunEvent::Exited {
684 run_id: "r1".to_owned(),
685 exit_code: Some(2),
686 cancelled: true,
687 })
688 .unwrap();
689 assert_eq!(json["kind"], "exited");
690 assert_eq!(json["runId"], "r1");
691 assert_eq!(json["exitCode"], 2);
692 assert_eq!(json["cancelled"], true);
693 }
694
695 #[test]
696 fn ask_question_serializes_with_camelcase_and_skips_empty_description() {
697 let json = serde_json::to_value(RunEvent::AskQuestion {
698 run_id: "r1".to_owned(),
699 request_id: "q-7".to_owned(),
700 questions: vec![Question {
701 header: Some("Scope".to_owned()),
702 prompt: "Which files?".to_owned(),
703 options: vec![
704 QuestionOption { label: "All".to_owned(), description: None },
705 QuestionOption {
706 label: "Changed only".to_owned(),
707 description: Some("Just the diff".to_owned()),
708 },
709 ],
710 multi_select: true,
711 allow_free_text: false,
712 }],
713 })
714 .unwrap();
715 assert_eq!(json["kind"], "askQuestion");
716 assert_eq!(json["runId"], "r1");
717 assert_eq!(json["requestId"], "q-7");
718 let q = &json["questions"][0];
719 assert_eq!(q["header"], "Scope");
720 assert_eq!(q["prompt"], "Which files?");
721 assert_eq!(q["multiSelect"], true);
722 assert_eq!(q["allowFreeText"], false);
723 assert_eq!(q["options"][0]["label"], "All");
724 // A `None` description is omitted from the wire (skip_serializing_if).
725 assert!(q["options"][0].get("description").is_none());
726 assert_eq!(q["options"][1]["description"], "Just the diff");
727 }
728
729 #[test]
730 fn session_normalizes_and_serializes() {
731 let events = normalize_process_event(
732 Event::Stdout {
733 run_id: "r1".to_owned(),
734 line: "ignored".to_owned(),
735 },
736 |_| ParsedLine {
737 session: Some(SessionInfo {
738 session_id: Some("sess-1".to_owned()),
739 model: Some("opus".to_owned()),
740 }),
741 ..ParsedLine::default()
742 },
743 );
744 assert!(matches!(
745 events.as_slice(),
746 [RunEvent::Session { run_id, session_id, model }]
747 if run_id == "r1"
748 && session_id.as_deref() == Some("sess-1")
749 && model.as_deref() == Some("opus")
750 ));
751 let json = serde_json::to_value(RunEvent::Session {
752 run_id: "r1".to_owned(),
753 session_id: Some("sess-1".to_owned()),
754 model: None,
755 })
756 .unwrap();
757 assert_eq!(json["kind"], "session");
758 assert_eq!(json["sessionId"], "sess-1");
759 // model omitted from the wire when None (backward-compatible).
760 assert!(json.get("model").is_none());
761 }
762
763 #[test]
764 fn usage_normalizes_and_serializes() {
765 let events = normalize_process_event(
766 Event::Stdout {
767 run_id: "r1".to_owned(),
768 line: "ignored".to_owned(),
769 },
770 |_| ParsedLine {
771 usage: Some(UsageInfo {
772 input_tokens: Some(10),
773 output_tokens: Some(20),
774 total_tokens: Some(30),
775 cache_read_tokens: Some(8),
776 cache_write_tokens: Some(2),
777 }),
778 ..ParsedLine::default()
779 },
780 );
781 assert!(matches!(
782 events.as_slice(),
783 [RunEvent::Usage {
784 run_id,
785 input_tokens: Some(10),
786 output_tokens: Some(20),
787 total_tokens: Some(30),
788 cache_read_tokens: Some(8),
789 cache_write_tokens: Some(2),
790 cost_usd: None,
791 }] if run_id == "r1"
792 ));
793 // Cache counters ride on the wire as camelCase; omitted when None.
794 let json = serde_json::to_value(RunEvent::Usage {
795 run_id: "r1".to_owned(),
796 input_tokens: Some(10),
797 output_tokens: None,
798 total_tokens: Some(30),
799 cache_read_tokens: Some(8),
800 cache_write_tokens: None,
801 cost_usd: None,
802 })
803 .unwrap();
804 assert_eq!(json["kind"], "usage");
805 assert_eq!(json["inputTokens"], 10);
806 assert_eq!(json["totalTokens"], 30);
807 assert_eq!(json["cacheReadTokens"], 8);
808 assert!(json.get("outputTokens").is_none()); // omitted when None
809 assert!(json.get("cacheWriteTokens").is_none()); // omitted when None
810 }
811
812 #[test]
813 fn plan_normalizes_and_serializes() {
814 let events = normalize_process_event(
815 Event::Stdout {
816 run_id: "r1".to_owned(),
817 line: "ignored".to_owned(),
818 },
819 |_| ParsedLine {
820 plan: Some(vec![
821 PlanEntry {
822 content: "Write the parser".to_owned(),
823 status: PlanEntryStatus::InProgress,
824 priority: Some(PlanEntryPriority::High),
825 },
826 PlanEntry {
827 content: "Add tests".to_owned(),
828 status: PlanEntryStatus::Pending,
829 priority: None,
830 },
831 ]),
832 ..ParsedLine::default()
833 },
834 );
835 assert!(matches!(
836 events.as_slice(),
837 [RunEvent::Plan { run_id, entries }] if run_id == "r1" && entries.len() == 2
838 ));
839 let json = serde_json::to_value(RunEvent::Plan {
840 run_id: "r1".to_owned(),
841 entries: vec![PlanEntry {
842 content: "Add tests".to_owned(),
843 status: PlanEntryStatus::Pending,
844 priority: None,
845 }],
846 })
847 .unwrap();
848 assert_eq!(json["kind"], "plan");
849 assert_eq!(json["entries"][0]["content"], "Add tests");
850 assert_eq!(json["entries"][0]["status"], "pending");
851 // priority omitted when None.
852 assert!(json["entries"][0].get("priority").is_none());
853 }
854
855 #[test]
856 fn session_info_update_serializes() {
857 let json = serde_json::to_value(RunEvent::SessionInfoUpdate {
858 run_id: "r1".to_owned(),
859 title: Some("Refactor the parser".to_owned()),
860 updated_at: Some("2026-06-16T12:00:00Z".to_owned()),
861 })
862 .unwrap();
863 assert_eq!(json["kind"], "sessionInfoUpdate");
864 assert_eq!(json["runId"], "r1");
865 assert_eq!(json["title"], "Refactor the parser");
866 // Maps onto ACP `session_info_update.updatedAt`.
867 assert_eq!(json["updatedAt"], "2026-06-16T12:00:00Z");
868 }
869
870 #[test]
871 fn tool_io_is_carried_and_omitted_when_absent() {
872 // input on ToolStart, output on ToolEnd — distinct events, distinct moments.
873 let start = normalize_process_event(
874 Event::Stdout {
875 run_id: "r1".to_owned(),
876 line: "ignored".to_owned(),
877 },
878 |_| ParsedLine {
879 tool_start: Some(ToolCallStart {
880 tool_call_id: "t1".to_owned(),
881 name: "ls".to_owned(),
882 input: Some("{\"dir\":\"/x\"}".to_owned()),
883 tool_kind: ToolKind::Other,
884 }),
885 ..ParsedLine::default()
886 },
887 );
888 assert!(matches!(
889 start.as_slice(),
890 [RunEvent::ToolStart { raw_input: Some(i), .. }] if i == "{\"dir\":\"/x\"}"
891 ));
892 // A ToolStart with no input omits the field on the wire (byte-identical
893 // to the pre-enrichment shape).
894 let json = serde_json::to_value(RunEvent::ToolStart {
895 run_id: "r1".to_owned(),
896 tool_call_id: "t1".to_owned(),
897 title: "ls".to_owned(),
898 tool_kind: ToolKind::Execute,
899 locations: Vec::new(),
900 raw_input: None,
901 })
902 .unwrap();
903 assert_eq!(json["kind"], "toolStart");
904 // The neutral class rides alongside as `toolKind` — distinct wire key
905 // from the `kind` event discriminator (no collision).
906 assert_eq!(json["toolKind"], "execute");
907 assert_eq!(json["toolCallId"], "t1");
908 assert!(json.get("rawInput").is_none());
909
910 let json = serde_json::to_value(RunEvent::ToolEnd {
911 run_id: "r1".to_owned(),
912 tool_call_id: "t1".to_owned(),
913 ok: true,
914 content: Some("done".to_owned()),
915 raw_output: None,
916 locations: Vec::new(),
917 })
918 .unwrap();
919 assert_eq!(json["kind"], "toolEnd");
920 assert_eq!(json["content"], "done");
921 }
922
923 #[test]
924 fn parsed_error_normalizes_to_run_event_error() {
925 // An in-band failure decoded onto a ParsedLine surfaces as
926 // RunEvent::Error — the path codex's `turn.failed` / `error` lines now
927 // take, so a failed turn no longer yields neither answer nor error.
928 let events = normalize_process_event(
929 Event::Stdout {
930 run_id: "r1".to_owned(),
931 line: "ignored".to_owned(),
932 },
933 |_| ParsedLine {
934 error: Some("rate limited".to_owned()),
935 ..ParsedLine::default()
936 },
937 );
938 assert!(matches!(
939 events.as_slice(),
940 [RunEvent::Error { run_id, message }] if run_id == "r1" && message == "rate limited"
941 ));
942 // An error-only line is not empty (is_empty stays honest).
943 assert!(!ParsedLine {
944 error: Some("x".to_owned()),
945 ..ParsedLine::default()
946 }
947 .is_empty());
948 }
949
950 #[test]
951 fn suggested_edits_event_serializes_camelcase() {
952 let json = serde_json::to_value(RunEvent::SuggestedEdits {
953 run_id: "r1".to_owned(),
954 edits: vec![SuggestedEdit {
955 file_path: "a.md".to_owned(),
956 range: ByteRange { start: 1, end: 2 },
957 replacement: "x".to_owned(),
958 title: None,
959 }],
960 })
961 .unwrap();
962 assert_eq!(json["kind"], "suggestedEdits");
963 assert_eq!(json["edits"][0]["filePath"], "a.md");
964 assert_eq!(json["edits"][0]["range"]["start"], 1);
965 // title omitted when None
966 assert!(json["edits"][0].get("title").is_none());
967 }
968}