Skip to main content

zagens_core/
events.rs

1//! Events emitted by the engine to the UI (P2 PR4 → `zagens-core`).
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::chat::{Message, SystemPrompt};
9use crate::coherence::CoherenceState;
10use crate::cycle::CycleBriefing;
11use crate::error_taxonomy::ErrorEnvelope;
12use crate::models::Usage;
13use crate::subagent::{MailboxMessage, SubAgentResult};
14use crate::turn::TurnOutcomeStatus;
15use crate::user_input::UserInputRequest;
16use zagens_tools::{ToolError, ToolResult};
17
18/// Events emitted by the engine to update the UI.
19#[derive(Debug, Clone)]
20pub enum Event {
21    MessageStarted {
22        #[allow(dead_code)]
23        index: usize,
24    },
25    MessageDelta {
26        #[allow(dead_code)]
27        index: usize,
28        content: String,
29    },
30    MessageComplete {
31        #[allow(dead_code)]
32        index: usize,
33    },
34    ThinkingStarted {
35        #[allow(dead_code)]
36        index: usize,
37    },
38    ThinkingDelta {
39        #[allow(dead_code)]
40        index: usize,
41        content: String,
42    },
43    ThinkingComplete {
44        #[allow(dead_code)]
45        index: usize,
46    },
47    ToolCallStarted {
48        id: String,
49        name: String,
50        input: Value,
51    },
52    ToolCallProgress {
53        id: String,
54        output: String,
55    },
56    ToolCallComplete {
57        id: String,
58        name: String,
59        result: Result<ToolResult, ToolError>,
60    },
61    TurnStarted {
62        turn_id: String,
63    },
64    /// Prefix-cache fingerprint for one model API request (kernel-v2 M5).
65    ModelRequestPrepared {
66        static_prefix_sha256: String,
67        full_prefix_sha256: String,
68    },
69    TurnComplete {
70        usage: Usage,
71        last_request_input_tokens: Option<u32>,
72        status: TurnOutcomeStatus,
73        error: Option<String>,
74        step_count: u32,
75        tool_names: Vec<String>,
76        end_reason: Option<String>,
77    },
78    CompactionStarted {
79        id: String,
80        auto: bool,
81        message: String,
82    },
83    CompactionCompleted {
84        id: String,
85        auto: bool,
86        message: String,
87        #[allow(dead_code)]
88        messages_before: Option<usize>,
89        #[allow(dead_code)]
90        messages_after: Option<usize>,
91    },
92    CompactionFailed {
93        id: String,
94        auto: bool,
95        message: String,
96    },
97    CycleAdvanced {
98        from: u32,
99        to: u32,
100        briefing: CycleBriefing,
101    },
102    #[allow(dead_code)]
103    CapacityDecision {
104        session_id: String,
105        turn_id: String,
106        h_hat: f64,
107        c_hat: f64,
108        slack: f64,
109        min_slack: f64,
110        violation_ratio: f64,
111        p_fail: f64,
112        risk_band: String,
113        action: String,
114        cooldown_blocked: bool,
115        reason: String,
116    },
117    #[allow(dead_code)]
118    CapacityIntervention {
119        session_id: String,
120        turn_id: String,
121        action: String,
122        before_prompt_tokens: usize,
123        after_prompt_tokens: usize,
124        compaction_size_reduction: usize,
125        replay_outcome: Option<String>,
126        replan_performed: bool,
127    },
128    #[allow(dead_code)]
129    CapacityMemoryPersistFailed {
130        session_id: String,
131        turn_id: String,
132        action: String,
133        error: String,
134    },
135    CoherenceState {
136        state: CoherenceState,
137        label: String,
138        description: String,
139        reason: String,
140    },
141    AgentSpawned {
142        id: String,
143        prompt: String,
144    },
145    AgentProgress {
146        id: String,
147        status: String,
148    },
149    AgentComplete {
150        id: String,
151        result: String,
152    },
153    AgentList {
154        agents: Vec<SubAgentResult>,
155    },
156    SubAgentMailbox {
157        seq: u64,
158        message: MailboxMessage,
159    },
160    Error {
161        envelope: ErrorEnvelope,
162        #[allow(dead_code)]
163        recoverable: bool,
164    },
165    Status {
166        message: String,
167    },
168    PauseEvents,
169    ResumeEvents,
170    ApprovalRequired {
171        id: String,
172        tool_name: String,
173        description: String,
174        approval_key: String,
175    },
176    UserInputRequired {
177        id: String,
178        request: UserInputRequest,
179    },
180    SessionUpdated {
181        messages: Vec<Message>,
182        system_prompt: Option<SystemPrompt>,
183        model: String,
184        workspace: PathBuf,
185    },
186    /// CRAFT: structured verdict from a completing sub-agent (B-L1).
187    CraftVerdict {
188        agent_id: String,
189        agent_type: String,
190        task_id: Option<String>,
191        verdict: String,
192        summary: Option<String>,
193        items: Value,
194    },
195    /// CRAFT: blackboard partition updated under `.deepseek/blackboards/` (B-L1).
196    CraftBoardUpdated {
197        task_id: String,
198        partition: String,
199        agent_id: String,
200    },
201    #[allow(dead_code)]
202    ElevationRequired {
203        tool_id: String,
204        tool_name: String,
205        command: Option<String>,
206        denial_reason: String,
207        blocked_network: bool,
208        blocked_write: bool,
209    },
210}
211
212/// Structured turn outcome summary (A2.1 — step/tools/end reason for logs and runtime events).
213#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
214pub struct TurnSummary {
215    pub step_count: u32,
216    pub tool_names: Vec<String>,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub end_reason: Option<String>,
219}
220
221impl TurnSummary {
222    #[must_use]
223    pub fn new(step_count: u32, tool_names: Vec<String>, end_reason: Option<String>) -> Self {
224        Self {
225            step_count,
226            tool_names,
227            end_reason,
228        }
229    }
230
231    #[must_use]
232    pub fn to_value(&self) -> Value {
233        serde_json::to_value(self).unwrap_or(Value::Null)
234    }
235
236    /// Structured log line aligned with runtime `turn_summary` / `turn.completed`.
237    pub fn log_turn_complete(
238        &self,
239        turn_id: &str,
240        status: TurnOutcomeStatus,
241        thread_id: Option<&str>,
242    ) {
243        match thread_id {
244            Some(thread_id) => tracing::info!(
245                thread_id = %thread_id,
246                turn_id = %turn_id,
247                step_count = self.step_count,
248                tool_count = self.tool_names.len(),
249                tools = ?self.tool_names,
250                end_reason = self.end_reason.as_deref(),
251                ?status,
252                "turn complete"
253            ),
254            None => tracing::info!(
255                turn_id = %turn_id,
256                step_count = self.step_count,
257                tool_count = self.tool_names.len(),
258                tools = ?self.tool_names,
259                end_reason = self.end_reason.as_deref(),
260                ?status,
261                "turn complete"
262            ),
263        }
264    }
265}
266
267impl Event {
268    #[must_use]
269    pub fn error(envelope: ErrorEnvelope) -> Self {
270        let recoverable = envelope.recoverable;
271        Event::Error {
272            envelope,
273            recoverable,
274        }
275    }
276
277    #[must_use]
278    pub fn status(message: impl Into<String>) -> Self {
279        Event::Status {
280            message: message.into(),
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use serde_json::json;
289
290    #[test]
291    fn turn_summary_serializes_for_runtime_payload() {
292        let summary = TurnSummary::new(
293            2,
294            vec!["read_file".to_string()],
295            Some("completed".to_string()),
296        );
297        assert_eq!(
298            summary.to_value(),
299            json!({
300                "step_count": 2,
301                "tool_names": ["read_file"],
302                "end_reason": "completed",
303            })
304        );
305    }
306
307    #[test]
308    fn turn_summary_omits_null_end_reason() {
309        let summary = TurnSummary::new(0, vec![], None);
310        let value = summary.to_value();
311        assert_eq!(value.get("step_count").and_then(|v| v.as_u64()), Some(0));
312        assert!(value.get("end_reason").is_none());
313    }
314}