1use serde::{Deserialize, Serialize};
15
16use super::msg::Msg;
17use super::runtime::{ToolMetadata, ToolStatus};
18use crate::models::FinishReason;
19
20pub const RUN_EVENT_PROTOCOL_VERSION: u32 = 1;
23
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "type", rename_all = "snake_case")]
29pub enum RunEvent {
30 SessionStarted {
32 protocol_version: u32,
34 cli_version: String,
36 model: String,
38 #[serde(default)]
40 task_id: Option<String>,
41 #[serde(default)]
45 session_id: String,
46 },
47 Text {
49 delta: String,
51 },
52 Reasoning {
54 delta: String,
56 },
57 ToolStarted {
60 call_id: String,
62 },
63 ToolFinished {
66 call_id: String,
68 name: String,
70 status: String,
72 summary: String,
74 #[serde(default)]
76 error: Option<String>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
83 plan: Option<PlanApproved>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 web: Option<Box<WebEventDetails>>,
87 },
88 ApprovalRequired {
91 call_id: String,
93 tool: String,
95 risk: String,
97 prompt: String,
99 },
100 TasksUpdated {
104 tasks: Vec<TaskLine>,
106 completed: u32,
108 total: u32,
110 },
111 Error {
113 message: String,
115 },
116 TurnDone {
118 #[serde(default)]
120 total_tokens: Option<u64>,
121 #[serde(default)]
123 stop_reason: Option<String>,
124 },
125 Result {
127 response: String,
129 #[serde(default)]
131 reasoning: Option<String>,
132 total_tokens: u64,
134 errors: Vec<String>,
136 #[serde(default)]
140 session_id: String,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
145 structured_output: Option<serde_json::Value>,
146 },
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152pub struct PlanApproved {
153 pub path: String,
155 #[serde(default)]
157 pub start: bool,
158 #[serde(default)]
160 pub fresh: bool,
161 #[serde(default)]
163 pub fork: bool,
164}
165
166#[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
302pub struct TaskLine {
303 pub id: u32,
304 pub subject: String,
305 pub status: String,
307 pub active_form: String,
308 #[serde(default)]
310 pub elapsed_secs: Option<u64>,
311 #[serde(default)]
313 pub tokens_spent: Option<u64>,
314 #[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
339fn 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
451fn status_str(status: ToolStatus) -> &'static str {
453 match status {
454 ToolStatus::Success => "success",
455 ToolStatus::Error => "error",
456 ToolStatus::Cancelled => "cancelled",
457 }
458}
459
460fn 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 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 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 assert_eq!(samples().len(), 10);
600 }
601
602 #[test]
603 fn result_structured_output_is_additive() {
604 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 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 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}