Skip to main content

vtcode_exec_events/
diagnostic.rs

1//! Operational diagnostics shared by runtime events, agents, and human surfaces.
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
5#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6#[serde(rename_all = "snake_case")]
7pub enum DiagnosticSeverity {
8    Info,
9    Warning,
10    Error,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
14#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
15pub struct DiagnosticAction {
16    /// Stable input action; displaying it does not execute it.
17    pub id: String,
18    pub label: String,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
23pub struct Diagnostic {
24    pub severity: DiagnosticSeverity,
25    /// Stable machine-readable incident classification.
26    pub code: String,
27    /// Concise human-facing summary, always displayed.
28    pub summary: String,
29    /// Operational evidence only. Never include private model reasoning.
30    #[serde(default, skip_serializing_if = "String::is_empty")]
31    pub agent_details: String,
32    /// Stable incident identity; updates replace the existing presentation.
33    pub correlation_id: String,
34    #[serde(default, skip_serializing_if = "Vec::is_empty")]
35    pub actions: Vec<DiagnosticAction>,
36}
37
38impl Diagnostic {
39    /// Plain-text representation for headless consumers and transcript exports.
40    pub fn text(&self) -> String {
41        let mut text = self.summary.clone();
42        if !self.actions.is_empty() {
43            text.push_str("\nAvailable actions: ");
44            text.push_str(
45                &self
46                    .actions
47                    .iter()
48                    .map(|action| action.label.as_str())
49                    .collect::<Vec<_>>()
50                    .join("; "),
51            );
52        }
53        if !self.agent_details.is_empty() {
54            text.push_str("\nDetails:\n");
55            text.push_str(&self.agent_details);
56        }
57        text
58    }
59}
60
61/// Latest durable diagnostic per incident, ordered by its most recent transition.
62/// Feed this once during session resume; live turns use the existing event sink.
63#[derive(Default)]
64pub struct DiagnosticProjection {
65    latest: std::collections::BTreeMap<String, (u64, Diagnostic)>,
66    sequence: u64,
67}
68
69impl DiagnosticProjection {
70    pub fn observe(&mut self, event: &crate::ThreadEvent) {
71        use crate::{ThreadEvent, ThreadItemDetails};
72        let item = match event {
73            ThreadEvent::ItemStarted(event) => &event.item,
74            ThreadEvent::ItemUpdated(event) => &event.item,
75            ThreadEvent::ItemCompleted(event) => &event.item,
76            _ => return,
77        };
78        let ThreadItemDetails::Reasoning(reasoning) = &item.details else {
79            return;
80        };
81        let Some(diagnostic) = &reasoning.diagnostic else {
82            return;
83        };
84        let _ = self
85            .latest
86            .insert(diagnostic.correlation_id.clone(), (self.sequence, diagnostic.as_ref().clone()));
87        self.sequence = self.sequence.saturating_add(1);
88    }
89
90    pub fn into_diagnostics(self) -> Vec<Diagnostic> {
91        let mut ordered: Vec<_> = self.latest.into_values().collect();
92        ordered.sort_by_key(|(sequence, _)| *sequence);
93        ordered.into_iter().map(|(_, diagnostic)| diagnostic).collect()
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::{ReasoningItem, ThreadEvent};
101
102    #[test]
103    fn diagnostic_projection_keeps_latest_incidents_in_transition_order() {
104        use crate::{ItemCompletedEvent, ThreadItem, ThreadItemDetails};
105        let make = |id: &str, summary: &str| {
106            ThreadEvent::ItemCompleted(ItemCompletedEvent {
107                item: ThreadItem {
108                    id: id.into(),
109                    context: None,
110                    details: ThreadItemDetails::Reasoning(Box::new(ReasoningItem {
111                        text: summary.into(),
112                        stage: Some("diagnosis".into()),
113                        diagnostic: Some(Box::new(Diagnostic {
114                            severity: DiagnosticSeverity::Warning,
115                            code: "provider.retrying".into(),
116                            summary: summary.into(),
117                            agent_details: format!("evidence for {summary}"),
118                            correlation_id: id.into(),
119                            actions: Vec::new(),
120                        })),
121                    })),
122                },
123            })
124        };
125        let mut projection = DiagnosticProjection::default();
126        projection.observe(&make("request-a", "first"));
127        projection.observe(&make("request-b", "second"));
128        projection.observe(&make("request-a", "third"));
129        let diagnostics = projection.into_diagnostics();
130        assert_eq!(diagnostics.len(), 2);
131        assert_eq!(diagnostics[0].correlation_id, "request-b");
132        assert_eq!(diagnostics[1].summary, "third");
133        assert_eq!(diagnostics[1].agent_details, "evidence for third");
134    }
135
136    #[test]
137    fn diagnostic_legacy_reasoning_remains_readable() {
138        let old: ReasoningItem = serde_json::from_str(r#"{"text":"saved diagnosis","stage":"diagnosis"}"#).unwrap();
139        assert!(old.diagnostic.is_none());
140        let diagnostic = Diagnostic {
141            severity: DiagnosticSeverity::Warning,
142            code: "planning.awaiting_input".into(),
143            summary: "Planning needs your input.".into(),
144            agent_details: "attempt=3; remaining=0".into(),
145            correlation_id: "plan-7".into(),
146            actions: vec![DiagnosticAction {
147                id: "retry".into(),
148                label: "Retry from retained evidence".into(),
149            }],
150        };
151        let item = ReasoningItem {
152            text: diagnostic.summary.clone(),
153            stage: Some("diagnosis".into()),
154            diagnostic: Some(Box::new(diagnostic.clone())),
155        };
156        assert_eq!(serde_json::from_value::<ReasoningItem>(serde_json::to_value(&item).unwrap()).unwrap(), item);
157        assert!(diagnostic.text().contains("Details:\nattempt=3; remaining=0"));
158        assert!(size_of::<ThreadEvent>() <= 80);
159    }
160}