Skip to main content

mermaid_cli/domain/
run_event.rs

1//! Public, versioned event stream for `mermaid run --format ndjson`.
2//!
3//! [`RunEvent`] is the stable SDK surface: a lossy but frozen projection of the
4//! internal turn/tool/approval lifecycle ([`Msg`]) into one JSON object per
5//! line. Unlike `Msg` — which is deliberately loose so `--record`/`--replay`
6//! can grow variants freely — this wire format is a contract. The golden test
7//! in this module pins every variant's serialization so it cannot drift
8//! silently.
9//!
10//! Purity: this module is serde-only (no I/O, no wall clock), so it lives in
11//! `domain` and stays inside the purity guard. The impure emission (writing the
12//! lines to stdout) lives in the headless driver, `app::run_non_interactive`.
13
14use serde::{Deserialize, Serialize};
15
16use super::msg::Msg;
17use super::runtime::{ToolMetadata, ToolStatus};
18use crate::models::FinishReason;
19
20/// Wire-format version of the `RunEvent` stream. Bump only on a breaking change
21/// to an existing variant's shape; additive variants keep version 1.
22pub const RUN_EVENT_PROTOCOL_VERSION: u32 = 1;
23
24/// One line of the `mermaid run --format ndjson` stream. Internally tagged on
25/// `type` (snake_case), matching the house style for stable wire unions
26/// (`ToolMetadata`).
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum RunEvent {
30    /// First line of every stream: protocol version + run identity.
31    SessionStarted {
32        /// [`RUN_EVENT_PROTOCOL_VERSION`] at the time of emission.
33        protocol_version: u32,
34        /// Mermaid version that produced the stream.
35        cli_version: String,
36        /// Resolved model id driving the run.
37        model: String,
38        /// Durable runtime task id, when the run is task-backed.
39        #[serde(default)]
40        task_id: Option<String>,
41        /// Conversation/session id owning this run — pass it to
42        /// `mermaid run --resume <id>` to continue the session. Additive
43        /// (defaulted) so pre-existing recordings still deserialize.
44        #[serde(default)]
45        session_id: String,
46    },
47    /// A chunk of assistant answer text.
48    Text {
49        /// The appended text.
50        delta: String,
51    },
52    /// A chunk of model reasoning / thinking.
53    Reasoning {
54        /// The appended reasoning text.
55        delta: String,
56    },
57    /// A tool began executing (bracketed by a later [`RunEvent::ToolFinished`]
58    /// with the same `call_id`).
59    ToolStarted {
60        /// Stable per-run tool-call id.
61        call_id: String,
62    },
63    /// A tool finished. `name` is derived from the run metadata; `status` is
64    /// `success` / `error` / `cancelled`.
65    ToolFinished {
66        /// Stable per-run tool-call id (matches the `tool_started` line).
67        call_id: String,
68        /// Tool name, e.g. `execute_command`, `read_file`, `<server>/<tool>`.
69        name: String,
70        /// `success`, `error`, or `cancelled`.
71        status: String,
72        /// One-line human summary of the outcome.
73        summary: String,
74        /// Error detail, when the tool failed.
75        #[serde(default)]
76        error: Option<String>,
77        /// Present when this call is `exit_plan_mode` resolving with an
78        /// APPROVED plan — first-class plan visibility for SDK/daemon
79        /// subscribers without breaking the started/finished pairing.
80        /// Additive: absent for every other tool, and omitted from the wire
81        /// when `None`.
82        #[serde(default, skip_serializing_if = "Option::is_none")]
83        plan: Option<PlanApproved>,
84        /// Structured, secret-safe web transport details for web tools.
85        #[serde(default, skip_serializing_if = "Option::is_none")]
86        web: Option<Box<WebEventDetails>>,
87    },
88    /// A gated tool is waiting for approval. Headless runs surface this so a
89    /// supervising process can decide.
90    ApprovalRequired {
91        /// Stable per-run tool-call id.
92        call_id: String,
93        /// Tool name being gated.
94        tool: String,
95        /// Risk classification (e.g. `network`, `mutation`).
96        risk: String,
97        /// Human-readable approval prompt.
98        prompt: String,
99    },
100    /// The session task checklist changed (`task_create` / `task_update` /
101    /// a user `/tasks` edit). Full snapshot of the visible list, so consumers
102    /// never need to correlate diffs. Additive — protocol stays v1.
103    TasksUpdated {
104        /// Every non-deleted task, in creation order.
105        tasks: Vec<TaskLine>,
106        /// Count of completed tasks (numerator of "Tasks m/n").
107        completed: u32,
108        /// Count of visible tasks (denominator of "Tasks m/n").
109        total: u32,
110    },
111    /// The turn hit a recoverable or terminal upstream error.
112    Error {
113        /// Human-readable error message.
114        message: String,
115    },
116    /// A model turn completed (token usage + why it stopped, when known).
117    TurnDone {
118        /// Total tokens for the turn, when the provider reported them.
119        #[serde(default)]
120        total_tokens: Option<u64>,
121        /// Why the turn stopped (`stop`, `length`, `tool_use`, …), when known.
122        #[serde(default)]
123        stop_reason: Option<String>,
124    },
125    /// Terminal line of the stream: the aggregated run result.
126    Result {
127        /// Final assistant response text.
128        response: String,
129        /// Final reasoning text, when the model exposed any.
130        #[serde(default)]
131        reasoning: Option<String>,
132        /// Cumulative token usage for the whole run.
133        total_tokens: u64,
134        /// Errors encountered during the run (empty on success).
135        errors: Vec<String>,
136        /// Conversation/session id owning this run (same as the
137        /// `session_started` line; repeated here so a consumer that only
138        /// reads the terminal line still gets it). Additive (defaulted).
139        #[serde(default)]
140        session_id: String,
141        /// `--output-schema` runs: the response parsed as JSON, present only
142        /// when it parsed AND validated against the schema. Additive
143        /// (defaulted) — protocol_version stays 1.
144        #[serde(default, skip_serializing_if = "Option::is_none")]
145        structured_output: Option<serde_json::Value>,
146    },
147}
148
149/// Plan payload on a [`RunEvent::ToolFinished`] for `exit_plan_mode`: the
150/// approved plan's location and the execution disposition the user chose.
151#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct PlanApproved {
153    /// Plan-file path, project-relative.
154    pub path: String,
155    /// Implementation starts immediately.
156    #[serde(default)]
157    pub start: bool,
158    /// Execution continues in a fresh conversation.
159    #[serde(default)]
160    pub fresh: bool,
161    /// Execution continues in a forked conversation.
162    #[serde(default)]
163    pub fork: bool,
164}
165
166/// Stable, bounded web facts exposed to NDJSON consumers. Query text and page
167/// content deliberately stay out of this surface; URLs are sanitized before
168/// entering `ToolMetadata`.
169#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
170pub struct WebEventDetails {
171    #[serde(default, skip_serializing_if = "String::is_empty")]
172    pub backend: String,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub requested_url: Option<String>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub final_url: Option<String>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub status: Option<u16>,
179    #[serde(default, skip_serializing_if = "Option::is_none")]
180    pub error_kind: Option<String>,
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    pub media_type: Option<String>,
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub charset: Option<String>,
185    #[serde(default, skip_serializing_if = "Option::is_none")]
186    pub extraction: Option<String>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub snapshot_id: Option<String>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    pub source_byte_count: Option<usize>,
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub output_byte_count: Option<usize>,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub rendered_byte_count: Option<usize>,
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub line_count: Option<usize>,
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub pattern: Option<String>,
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub context_lines: Option<usize>,
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub match_count: Option<usize>,
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub query_count: Option<usize>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub succeeded_count: Option<usize>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub requested_count: Option<usize>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub result_count: Option<usize>,
211    #[serde(default)]
212    pub failed_count: usize,
213    #[serde(default, skip_serializing_if = "Vec::is_empty")]
214    pub failures: Vec<super::WebSearchFailure>,
215    #[serde(default)]
216    pub partial: bool,
217    #[serde(default)]
218    pub truncated: bool,
219}
220
221impl RunEvent {
222    /// Project a lifecycle [`Msg`] into a public `RunEvent`, or `None` for the
223    /// many messages that have no place in the SDK stream.
224    ///
225    /// Pure and stateless: tool identity is read from the finished outcome's
226    /// metadata rather than correlated across messages, so no projector state
227    /// is required. The wildcard arm is intentional here (this is a lossy
228    /// projection, not the reducer — most `Msg`s are deliberately dropped).
229    pub fn from_msg(msg: &Msg) -> Option<RunEvent> {
230        Some(match msg {
231            Msg::StreamText { chunk, .. } => RunEvent::Text {
232                delta: chunk.clone(),
233            },
234            Msg::StreamReasoning { chunk, .. } => RunEvent::Reasoning {
235                delta: chunk.text.clone(),
236            },
237            Msg::ToolStarted { call_id, .. } => RunEvent::ToolStarted {
238                call_id: call_id.to_string(),
239            },
240            Msg::ToolFinished {
241                call_id, outcome, ..
242            } => RunEvent::ToolFinished {
243                call_id: call_id.to_string(),
244                name: tool_name(&outcome.metadata.detail),
245                status: status_str(outcome.status).to_string(),
246                summary: outcome.summary.clone(),
247                error: outcome.error.clone(),
248                plan: match &outcome.metadata.detail {
249                    ToolMetadata::Plan {
250                        path,
251                        start,
252                        fresh,
253                        fork,
254                        ..
255                    } => Some(PlanApproved {
256                        path: path.clone(),
257                        start: *start,
258                        fresh: *fresh,
259                        fork: *fork,
260                    }),
261                    _ => None,
262                },
263                web: web_event_details(&outcome.metadata.detail).map(Box::new),
264            },
265            Msg::ApprovalRequested {
266                call_id,
267                tool,
268                risk,
269                prompt,
270                ..
271            } => RunEvent::ApprovalRequired {
272                call_id: call_id.to_string(),
273                tool: tool.clone(),
274                risk: risk.clone(),
275                prompt: prompt.clone(),
276            },
277            Msg::TasksUpdated { store } => {
278                let (completed, total) = store.counts();
279                RunEvent::TasksUpdated {
280                    tasks: store.visible().map(TaskLine::from).collect(),
281                    completed: completed as u32,
282                    total: total as u32,
283                }
284            },
285            Msg::UpstreamError { error, .. } => RunEvent::Error {
286                message: error.message.clone(),
287            },
288            Msg::StreamDone {
289                usage, stop_reason, ..
290            } => RunEvent::TurnDone {
291                total_tokens: usage.as_ref().map(|u| u.total_tokens() as u64),
292                stop_reason: stop_reason.as_ref().map(finish_reason_str),
293            },
294            _ => return None,
295        })
296    }
297}
298
299/// One checklist row on the `tasks_updated` line. Flat and stringly-statused
300/// (wire contract — the internal enum can grow without breaking consumers).
301#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct TaskLine {
303    pub id: u32,
304    pub subject: String,
305    /// `pending` / `in_progress` / `completed`.
306    pub status: String,
307    pub active_form: String,
308    /// Seconds spent in_progress→completed, when both stamps exist.
309    #[serde(default)]
310    pub elapsed_secs: Option<u64>,
311    /// Completion tokens attributed while in progress, when known.
312    #[serde(default)]
313    pub tokens_spent: Option<u64>,
314    /// `model` or `user` (a `/tasks add` entry).
315    #[serde(default)]
316    pub origin: Option<String>,
317}
318
319impl From<&crate::domain::TaskItem> for TaskLine {
320    fn from(task: &crate::domain::TaskItem) -> Self {
321        Self {
322            id: task.id,
323            subject: task.subject.clone(),
324            status: task.status.as_str().to_string(),
325            active_form: task.active_form.clone(),
326            elapsed_secs: task.elapsed_secs(),
327            tokens_spent: task.tokens_spent,
328            origin: Some(
329                match task.origin {
330                    crate::domain::TaskOrigin::Model => "model",
331                    crate::domain::TaskOrigin::User => "user",
332                }
333                .to_string(),
334            ),
335        }
336    }
337}
338
339/// Snake_case name of a finished tool, from its run-metadata tag. The exhaustive
340/// match doubles as a drift guard: a new `ToolMetadata` variant forces a name
341/// mapping here.
342fn tool_name(detail: &ToolMetadata) -> String {
343    match detail {
344        ToolMetadata::None => "tool".to_string(),
345        ToolMetadata::ReadFile { .. } => "read_file".to_string(),
346        ToolMetadata::WriteFile { .. } => "write_file".to_string(),
347        ToolMetadata::ApplyPatch { .. } => "apply_patch".to_string(),
348        ToolMetadata::DeleteFile { .. } => "delete_file".to_string(),
349        ToolMetadata::CreateDirectory { .. } => "create_directory".to_string(),
350        ToolMetadata::WebSearch { .. } => "web_search".to_string(),
351        ToolMetadata::WebFetch { .. } => "web_fetch".to_string(),
352        ToolMetadata::ExecuteCommand { .. } => "execute_command".to_string(),
353        ToolMetadata::ComputerUse { .. } => "computer_use".to_string(),
354        ToolMetadata::Mcp { server, tool } => format!("{server}/{tool}"),
355        ToolMetadata::Subagent { .. } => "agent".to_string(),
356        ToolMetadata::Tasks { action, .. } => format!("task_{action}"),
357        ToolMetadata::Questions { .. } => "ask_user_question".to_string(),
358        ToolMetadata::Plan { .. } => "exit_plan_mode".to_string(),
359        ToolMetadata::Custom { name, .. } => name.clone(),
360    }
361}
362
363fn web_event_details(detail: &ToolMetadata) -> Option<WebEventDetails> {
364    match detail {
365        ToolMetadata::WebSearch {
366            queries,
367            requested_count,
368            result_count,
369            backend,
370            succeeded_queries,
371            failed_queries,
372            partial,
373            truncated,
374            failures,
375            ..
376        } => Some(WebEventDetails {
377            backend: backend.clone(),
378            requested_url: None,
379            final_url: None,
380            status: None,
381            error_kind: None,
382            media_type: None,
383            charset: None,
384            extraction: None,
385            snapshot_id: None,
386            source_byte_count: None,
387            output_byte_count: None,
388            rendered_byte_count: None,
389            line_count: None,
390            pattern: None,
391            context_lines: None,
392            match_count: None,
393            query_count: Some(queries.len()),
394            succeeded_count: Some(*succeeded_queries),
395            requested_count: Some(*requested_count),
396            result_count: Some(*result_count),
397            failed_count: *failed_queries,
398            failures: failures.clone(),
399            partial: *partial,
400            truncated: *truncated,
401        }),
402        ToolMetadata::WebFetch {
403            url,
404            final_url,
405            status,
406            error_kind,
407            media_type,
408            charset,
409            backend,
410            extraction,
411            snapshot_id,
412            source_byte_count,
413            output_byte_count,
414            byte_count,
415            line_count,
416            pattern,
417            context_lines,
418            match_count,
419            truncated,
420            ..
421        } => Some(WebEventDetails {
422            backend: backend.clone(),
423            requested_url: Some(url.clone()),
424            final_url: final_url.clone(),
425            status: *status,
426            error_kind: error_kind.clone(),
427            media_type: media_type.clone(),
428            charset: charset.clone(),
429            extraction: (!extraction.is_empty()).then(|| extraction.clone()),
430            snapshot_id: snapshot_id.clone(),
431            source_byte_count: Some(*source_byte_count),
432            output_byte_count: Some(*output_byte_count),
433            rendered_byte_count: Some(*byte_count),
434            line_count: Some(*line_count),
435            pattern: pattern.as_deref().map(crate::utils::redact_secrets),
436            context_lines: *context_lines,
437            match_count: *match_count,
438            query_count: None,
439            succeeded_count: None,
440            requested_count: None,
441            result_count: None,
442            failed_count: 0,
443            failures: Vec::new(),
444            partial: false,
445            truncated: *truncated,
446        }),
447        _ => None,
448    }
449}
450
451/// Stable string form of a tool status.
452fn status_str(status: ToolStatus) -> &'static str {
453    match status {
454        ToolStatus::Success => "success",
455        ToolStatus::Error => "error",
456        ToolStatus::Cancelled => "cancelled",
457    }
458}
459
460/// Stable string form of a finish reason (mirrors the `FinishReason` serde tags).
461fn finish_reason_str(reason: &FinishReason) -> String {
462    match reason {
463        FinishReason::Stop => "stop".to_string(),
464        FinishReason::ToolUse => "tool_use".to_string(),
465        FinishReason::Length => "length".to_string(),
466        FinishReason::ContentFilter => "content_filter".to_string(),
467        FinishReason::Other(other) => other.clone(),
468    }
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::domain::ids::{ToolCallId, TurnId};
475    use crate::domain::runtime::ToolRunMetadata;
476    use crate::domain::state::ToolOutcome;
477    use crate::models::TokenUsage;
478
479    /// One canonical value per variant, in declaration order.
480    fn samples() -> Vec<RunEvent> {
481        vec![
482            RunEvent::SessionStarted {
483                protocol_version: RUN_EVENT_PROTOCOL_VERSION,
484                cli_version: "9.9.9".to_string(),
485                model: "anthropic/claude-x".to_string(),
486                task_id: None,
487                session_id: "20260709_120000_000".to_string(),
488            },
489            RunEvent::Text {
490                delta: "hello".to_string(),
491            },
492            RunEvent::Reasoning {
493                delta: "thinking".to_string(),
494            },
495            RunEvent::ToolStarted {
496                call_id: "tool#3".to_string(),
497            },
498            RunEvent::ToolFinished {
499                call_id: "tool#3".to_string(),
500                name: "execute_command".to_string(),
501                status: "success".to_string(),
502                summary: "command completed".to_string(),
503                error: None,
504                plan: None,
505                web: None,
506            },
507            RunEvent::ApprovalRequired {
508                call_id: "tool#4".to_string(),
509                tool: "execute_command".to_string(),
510                risk: "network".to_string(),
511                prompt: "Run curl?".to_string(),
512            },
513            RunEvent::TasksUpdated {
514                tasks: vec![TaskLine {
515                    id: 1,
516                    subject: "wire the broker".to_string(),
517                    status: "completed".to_string(),
518                    active_form: "wiring the broker".to_string(),
519                    elapsed_secs: Some(130),
520                    tokens_spent: Some(8400),
521                    origin: Some("model".to_string()),
522                }],
523                completed: 1,
524                total: 1,
525            },
526            RunEvent::Error {
527                message: "connection failed".to_string(),
528            },
529            RunEvent::TurnDone {
530                total_tokens: Some(1234),
531                stop_reason: Some("stop".to_string()),
532            },
533            RunEvent::Result {
534                response: "Hi there".to_string(),
535                reasoning: None,
536                total_tokens: 1234,
537                errors: vec![],
538                session_id: "20260709_120000_000".to_string(),
539                structured_output: None,
540            },
541        ]
542    }
543
544    /// The frozen wire form of each variant. The exhaustive match (no `_ =>`) is
545    /// the compile-time drift guard: a new variant forces a pinned string here,
546    /// exactly mirroring `app::recorder`'s per-`MsgKind` sample guard.
547    fn golden(ev: &RunEvent) -> &'static str {
548        match ev {
549            RunEvent::SessionStarted { .. } => {
550                r#"{"type":"session_started","protocol_version":1,"cli_version":"9.9.9","model":"anthropic/claude-x","task_id":null,"session_id":"20260709_120000_000"}"#
551            },
552            RunEvent::Text { .. } => r#"{"type":"text","delta":"hello"}"#,
553            RunEvent::Reasoning { .. } => r#"{"type":"reasoning","delta":"thinking"}"#,
554            RunEvent::ToolStarted { .. } => r#"{"type":"tool_started","call_id":"tool#3"}"#,
555            RunEvent::ToolFinished { .. } => {
556                r#"{"type":"tool_finished","call_id":"tool#3","name":"execute_command","status":"success","summary":"command completed","error":null}"#
557            },
558            RunEvent::ApprovalRequired { .. } => {
559                r#"{"type":"approval_required","call_id":"tool#4","tool":"execute_command","risk":"network","prompt":"Run curl?"}"#
560            },
561            RunEvent::TasksUpdated { .. } => {
562                r#"{"type":"tasks_updated","tasks":[{"id":1,"subject":"wire the broker","status":"completed","active_form":"wiring the broker","elapsed_secs":130,"tokens_spent":8400,"origin":"model"}],"completed":1,"total":1}"#
563            },
564            RunEvent::Error { .. } => r#"{"type":"error","message":"connection failed"}"#,
565            RunEvent::TurnDone { .. } => {
566                r#"{"type":"turn_done","total_tokens":1234,"stop_reason":"stop"}"#
567            },
568            RunEvent::Result { .. } => {
569                r#"{"type":"result","response":"Hi there","reasoning":null,"total_tokens":1234,"errors":[],"session_id":"20260709_120000_000"}"#
570            },
571        }
572    }
573
574    #[test]
575    fn wire_format_is_frozen() {
576        for ev in samples() {
577            assert_eq!(
578                serde_json::to_string(&ev).unwrap(),
579                golden(&ev),
580                "RunEvent wire format drifted for {ev:?}"
581            );
582        }
583    }
584
585    #[test]
586    fn every_variant_round_trips() {
587        for ev in samples() {
588            let wire = serde_json::to_string(&ev).unwrap();
589            let back: RunEvent = serde_json::from_str(&wire).unwrap();
590            assert_eq!(back, ev);
591        }
592    }
593
594    #[test]
595    fn every_variant_has_a_sample() {
596        // Backstop for `golden`'s compile-time guard: keep one sample per
597        // variant. Bump the count when a variant lands (and add its golden
598        // line above, which won't compile otherwise).
599        assert_eq!(samples().len(), 10);
600    }
601
602    #[test]
603    fn result_structured_output_is_additive() {
604        // Present -> serialized; absent -> omitted entirely (golden above
605        // stays frozen); old wire lines without the field still parse.
606        let ev = RunEvent::Result {
607            response: "{\"answer\":42}".to_string(),
608            reasoning: None,
609            total_tokens: 10,
610            errors: vec![],
611            session_id: "s".to_string(),
612            structured_output: Some(serde_json::json!({"answer": 42})),
613        };
614        let wire = serde_json::to_string(&ev).unwrap();
615        assert!(
616            wire.contains("\"structured_output\":{\"answer\":42}"),
617            "{wire}"
618        );
619        let back: RunEvent = serde_json::from_str(&wire).unwrap();
620        assert_eq!(back, ev);
621        let old = r#"{"type":"result","response":"x","reasoning":null,"total_tokens":1,"errors":[],"session_id":"s"}"#;
622        let parsed: RunEvent = serde_json::from_str(old).unwrap();
623        assert!(matches!(
624            parsed,
625            RunEvent::Result {
626                structured_output: None,
627                ..
628            }
629        ));
630    }
631
632    #[test]
633    fn protocol_version_is_pinned() {
634        assert_eq!(RUN_EVENT_PROTOCOL_VERSION, 1);
635    }
636
637    #[test]
638    fn tool_name_maps_each_metadata_kind() {
639        assert_eq!(tool_name(&ToolMetadata::None), "tool");
640        assert_eq!(
641            tool_name(&ToolMetadata::Mcp {
642                server: "srv".to_string(),
643                tool: "do".to_string(),
644            }),
645            "srv/do"
646        );
647        assert_eq!(
648            tool_name(&ToolMetadata::Custom {
649                name: "weird".to_string(),
650                data: serde_json::Value::Null,
651            }),
652            "weird"
653        );
654    }
655
656    #[test]
657    fn from_msg_projects_streamed_and_drops_the_rest() {
658        let text = Msg::StreamText {
659            turn: TurnId(1),
660            chunk: "hi".to_string(),
661        };
662        assert_eq!(
663            RunEvent::from_msg(&text),
664            Some(RunEvent::Text {
665                delta: "hi".to_string()
666            })
667        );
668
669        // A message with no SDK projection returns None.
670        assert_eq!(RunEvent::from_msg(&Msg::Tick), None);
671
672        let done = Msg::StreamDone {
673            turn: TurnId(1),
674            usage: Some(TokenUsage::provider(10, 20)),
675            provider_continuation: None,
676            stop_reason: Some(FinishReason::Stop),
677        };
678        assert_eq!(
679            RunEvent::from_msg(&done),
680            Some(RunEvent::TurnDone {
681                total_tokens: Some(30),
682                stop_reason: Some("stop".to_string()),
683            })
684        );
685    }
686
687    #[test]
688    fn from_msg_projects_tool_finished_with_name_from_metadata() {
689        let outcome = ToolOutcome {
690            status: ToolStatus::Success,
691            summary: "command completed".to_string(),
692            model_content: "out".to_string(),
693            error: None,
694            metadata: Box::new(ToolRunMetadata {
695                detail: ToolMetadata::ExecuteCommand {
696                    command: "ls".to_string(),
697                    working_dir: None,
698                    exit_code: Some(0),
699                    timed_out: false,
700                    background: false,
701                    stdout_lines: 1,
702                    stderr_lines: 0,
703                    detected_urls: vec![],
704                    pid: None,
705                    log_path: None,
706                    denied_by_sandbox: false,
707                },
708                ..ToolRunMetadata::default()
709            }),
710            artifacts: vec![],
711            duration_secs: Some(0.0),
712        };
713        let finished = Msg::ToolFinished {
714            turn: TurnId(1),
715            call_id: ToolCallId(3),
716            outcome,
717        };
718        assert_eq!(
719            RunEvent::from_msg(&finished),
720            Some(RunEvent::ToolFinished {
721                call_id: "tool#3".to_string(),
722                name: "execute_command".to_string(),
723                status: "success".to_string(),
724                summary: "command completed".to_string(),
725                error: None,
726                plan: None,
727                web: None,
728            })
729        );
730    }
731
732    #[test]
733    fn web_fetch_event_exposes_bounded_structured_provenance() {
734        let outcome = ToolOutcome::success("page", "fetched", 0.1).with_metadata(ToolRunMetadata {
735            detail: ToolMetadata::WebFetch {
736                url: "https://example.test/start".to_string(),
737                final_url: Some("https://example.test/final".to_string()),
738                status: Some(200),
739                error_kind: None,
740                media_type: Some("text/html".to_string()),
741                charset: Some("utf-8".to_string()),
742                backend: "native".to_string(),
743                extraction: "readability".to_string(),
744                title: Some("Example".to_string()),
745                line_count: 3,
746                byte_count: 100,
747                source_byte_count: 400,
748                output_byte_count: 240,
749                truncated: true,
750                pattern: Some("needle".to_string()),
751                context_lines: Some(2),
752                match_count: Some(3),
753                snapshot_id: Some("web-1".to_string()),
754            },
755            ..ToolRunMetadata::default()
756        });
757        let event = RunEvent::from_msg(&Msg::ToolFinished {
758            turn: TurnId(1),
759            call_id: ToolCallId(9),
760            outcome,
761        })
762        .expect("mapped");
763        let RunEvent::ToolFinished {
764            name,
765            web: Some(web),
766            ..
767        } = event
768        else {
769            panic!("expected structured web event");
770        };
771        assert_eq!(name, "web_fetch");
772        assert_eq!(web.backend, "native");
773        assert_eq!(web.status, Some(200));
774        assert!(web.error_kind.is_none());
775        assert_eq!(web.final_url.as_deref(), Some("https://example.test/final"));
776        assert_eq!(web.source_byte_count, Some(400));
777        assert_eq!(web.output_byte_count, Some(240));
778        assert_eq!(web.rendered_byte_count, Some(100));
779        assert_eq!(web.pattern.as_deref(), Some("needle"));
780        assert_eq!(web.match_count, Some(3));
781        assert!(web.truncated);
782    }
783
784    #[test]
785    fn web_fetch_error_event_keeps_typed_failure_context() {
786        let outcome =
787            ToolOutcome::error("backend unavailable", 0.1).with_metadata(ToolRunMetadata {
788                detail: ToolMetadata::WebFetch {
789                    url: "https://example.test/start".to_string(),
790                    final_url: None,
791                    status: Some(503),
792                    error_kind: Some("http_status".to_string()),
793                    media_type: None,
794                    charset: None,
795                    backend: "native".to_string(),
796                    extraction: String::new(),
797                    title: None,
798                    line_count: 0,
799                    byte_count: 0,
800                    source_byte_count: 0,
801                    output_byte_count: 0,
802                    truncated: false,
803                    pattern: None,
804                    context_lines: None,
805                    match_count: None,
806                    snapshot_id: None,
807                },
808                ..ToolRunMetadata::default()
809            });
810        let event = RunEvent::from_msg(&Msg::ToolFinished {
811            turn: TurnId(1),
812            call_id: ToolCallId(10),
813            outcome,
814        })
815        .expect("mapped");
816        let RunEvent::ToolFinished { web: Some(web), .. } = event else {
817            panic!("expected structured web event");
818        };
819        assert_eq!(web.status, Some(503));
820        assert_eq!(web.error_kind.as_deref(), Some("http_status"));
821        assert_eq!(web.backend, "native");
822        assert!(web.final_url.is_none());
823    }
824
825    #[test]
826    fn web_search_event_keeps_partial_failure_and_backend_context() {
827        let outcome = ToolOutcome::success("results", "3 results returned", 0.1).with_metadata(
828            ToolRunMetadata {
829                detail: ToolMetadata::WebSearch {
830                    queries: vec!["first".to_string(), "second".to_string()],
831                    requested_count: 10,
832                    result_count: 3,
833                    sources: vec!["https://example.test/result".to_string()],
834                    backend: "managed_searxng".to_string(),
835                    succeeded_queries: 1,
836                    failed_queries: 1,
837                    partial: true,
838                    truncated: true,
839                    failures: vec![crate::domain::WebSearchFailure {
840                        query_index: 1,
841                        error: "upstream timed out".to_string(),
842                    }],
843                },
844                ..ToolRunMetadata::default()
845            },
846        );
847        let event = RunEvent::from_msg(&Msg::ToolFinished {
848            turn: TurnId(1),
849            call_id: ToolCallId(11),
850            outcome,
851        })
852        .expect("mapped");
853        let RunEvent::ToolFinished {
854            name,
855            web: Some(web),
856            ..
857        } = event
858        else {
859            panic!("expected structured web event");
860        };
861        assert_eq!(name, "web_search");
862        assert_eq!(web.backend, "managed_searxng");
863        assert_eq!(web.query_count, Some(2));
864        assert_eq!(web.succeeded_count, Some(1));
865        assert_eq!(web.failed_count, 1);
866        assert_eq!(web.result_count, Some(3));
867        assert!(web.partial);
868        assert!(web.truncated);
869        assert_eq!(web.failures.len(), 1);
870        assert_eq!(web.failures[0].query_index, 1);
871    }
872
873    #[test]
874    fn approved_plan_rides_tool_finished_as_an_additive_payload() {
875        let outcome =
876            ToolOutcome::success("approved", "plan approved", 0.1).with_metadata(ToolRunMetadata {
877                detail: ToolMetadata::Plan {
878                    path: ".mermaid/plans/x.md".to_string(),
879                    body: "## Summary".to_string(),
880                    start: true,
881                    fresh: true,
882                    fork: false,
883                    model: None,
884                },
885                ..ToolRunMetadata::default()
886            });
887        let event = RunEvent::from_msg(&Msg::ToolFinished {
888            turn: TurnId(1),
889            call_id: ToolCallId(7),
890            outcome,
891        })
892        .expect("mapped");
893        let RunEvent::ToolFinished { name, plan, .. } = &event else {
894            panic!("expected ToolFinished, got {event:?}");
895        };
896        assert_eq!(name, "exit_plan_mode");
897        let plan = plan.as_ref().expect("plan payload");
898        assert_eq!(plan.path, ".mermaid/plans/x.md");
899        assert!(plan.start && plan.fresh && !plan.fork);
900        // The wire stays clean for every other tool: `plan` is omitted, not
901        // null, so existing consumers see byte-identical lines.
902        let json = serde_json::to_string(&RunEvent::ToolFinished {
903            call_id: "tool#1".to_string(),
904            name: "read_file".to_string(),
905            status: "success".to_string(),
906            summary: "read".to_string(),
907            error: None,
908            plan: None,
909            web: None,
910        })
911        .unwrap();
912        assert!(!json.contains("\"plan\""));
913    }
914}