Skip to main content

cosh_tools/subagent/
events.rs

1//! Typed event stream for ACP sub-agent sessions.
2//!
3//! The bare `String` chunk channel (Phase 1 and earlier) only carried agent
4//! message text. The ACP `session/update` stream offers more — thoughts, tool
5//! calls, plans, usage — and the TUI needs all of it (Phase 3 renders the
6//! activity inside the sub-agent box). This module defines a self-owned event
7//! enum mirroring the relevant [`SessionUpdate`] variants, deliberately
8//! decoupled from the `agent-client-protocol` types so the display layer and
9//! any future persistence/rehydration do not depend on a versioned protocol
10//! crate shape.
11//!
12//! Protocol enums are `#[non_exhaustive]`: unknown variants map to the
13//! mirror enums' `Unknown` fallback (serde `#[serde(other)]` keeps the same
14//! behavior for persisted data), so a harness advertising a newer spec never
15//! breaks deserialization or the TUI.
16
17use agent_client_protocol::schema::v1 as schema;
18use serde::{Deserialize, Serialize};
19
20/// Category of a sub-agent tool call (mirror of ACP `ToolKind`).
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum ToolKind {
24    /// Reading files or data.
25    Read,
26    /// Modifying files or content.
27    Edit,
28    /// Removing files or data.
29    Delete,
30    /// Moving or renaming files.
31    Move,
32    /// Searching for information.
33    Search,
34    /// Running commands or code.
35    Execute,
36    /// Internal reasoning or planning.
37    Think,
38    /// Retrieving external data.
39    Fetch,
40    /// Switching the current session mode.
41    SwitchMode,
42    /// Anything the mirror does not know yet.
43    #[serde(other)]
44    Unknown,
45}
46
47impl ToolKind {
48    fn from_acp(kind: schema::ToolKind) -> Self {
49        match kind {
50            schema::ToolKind::Read => Self::Read,
51            schema::ToolKind::Edit => Self::Edit,
52            schema::ToolKind::Delete => Self::Delete,
53            schema::ToolKind::Move => Self::Move,
54            schema::ToolKind::Search => Self::Search,
55            schema::ToolKind::Execute => Self::Execute,
56            schema::ToolKind::Think => Self::Think,
57            schema::ToolKind::Fetch => Self::Fetch,
58            schema::ToolKind::SwitchMode => Self::SwitchMode,
59            // `#[non_exhaustive]`: future spec kinds stay recognizable
60            // without breaking this crate.
61            _ => Self::Unknown,
62        }
63    }
64}
65
66/// Lifecycle status of a sub-agent tool call (mirror of ACP
67/// `ToolCallStatus`).
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum ToolCallStatus {
71    /// Not started yet (input streaming or awaiting approval).
72    Pending,
73    /// Currently running.
74    InProgress,
75    /// Completed successfully.
76    Completed,
77    /// Failed with an error.
78    Failed,
79    /// Anything the mirror does not know yet.
80    #[serde(other)]
81    Unknown,
82}
83
84impl ToolCallStatus {
85    fn from_acp(status: schema::ToolCallStatus) -> Self {
86        match status {
87            schema::ToolCallStatus::Pending => Self::Pending,
88            schema::ToolCallStatus::InProgress => Self::InProgress,
89            schema::ToolCallStatus::Completed => Self::Completed,
90            schema::ToolCallStatus::Failed => Self::Failed,
91            _ => Self::Unknown,
92        }
93    }
94}
95
96/// Priority of a sub-agent plan entry (mirror of ACP `PlanEntryPriority`).
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum PlanEntryPriority {
100    /// Critical to the overall goal.
101    High,
102    /// Important but not critical.
103    Medium,
104    /// Nice to have.
105    Low,
106    /// Anything the mirror does not know yet.
107    #[serde(other)]
108    Unknown,
109}
110
111impl PlanEntryPriority {
112    fn from_acp(priority: schema::PlanEntryPriority) -> Self {
113        match priority {
114            schema::PlanEntryPriority::High => Self::High,
115            schema::PlanEntryPriority::Medium => Self::Medium,
116            schema::PlanEntryPriority::Low => Self::Low,
117            _ => Self::Unknown,
118        }
119    }
120}
121
122/// Status of a sub-agent plan entry (mirror of ACP `PlanEntryStatus`).
123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum PlanEntryStatus {
126    /// Not started yet.
127    Pending,
128    /// Currently being worked on.
129    InProgress,
130    /// Successfully completed.
131    Completed,
132    /// Anything the mirror does not know yet.
133    #[serde(other)]
134    Unknown,
135}
136
137impl PlanEntryStatus {
138    fn from_acp(status: schema::PlanEntryStatus) -> Self {
139        match status {
140            schema::PlanEntryStatus::Pending => Self::Pending,
141            schema::PlanEntryStatus::InProgress => Self::InProgress,
142            schema::PlanEntryStatus::Completed => Self::Completed,
143            _ => Self::Unknown,
144        }
145    }
146}
147
148/// One entry of the sub-agent's execution plan.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150pub struct PlanEntry {
151    /// Human-readable description of what this task aims to accomplish.
152    pub content: String,
153    /// Relative importance of this task.
154    pub priority: PlanEntryPriority,
155    /// Current execution status of this task.
156    pub status: PlanEntryStatus,
157}
158
159/// One tool-call output block: the text the TUI renders plus a compact
160/// diff summary for file modifications.
161///
162/// Images and terminals are counted but not carried (nothing renderable).
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
164pub struct ToolOutputBlock {
165    /// Extracted text (`ToolCallContent::Content(ContentBlock::Text)`).
166    pub text: String,
167    /// How many blocks were skipped because they carried no text.
168    pub skipped: u32,
169    /// Compact summary of the LAST diff block (`ToolCallContent::Diff`):
170    /// the modified path plus added/removed line counts derived from
171    /// `new_text`/`old_text`. CLI support varies (gemini confirmed sending
172    /// diffs); `None` when the update carried no diff.
173    pub diff: Option<ToolDiffSummary>,
174}
175
176/// Compact summary of one tool-call diff block: what changed and by how
177/// much, rendered as a single line (`path +12 −3`) under the tool call.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct ToolDiffSummary {
180    /// The file path being modified.
181    pub path: String,
182    /// Lines present in `new_text` but not in `old_text` (all lines of a
183    /// new file count as added).
184    pub added: u32,
185    /// Lines present in `old_text` but not in `new_text` (0 for new files).
186    pub removed: u32,
187}
188
189/// A typed event produced by a running sub-agent session.
190///
191/// Everything the ACP `session/update` stream offers that the TUI can act
192/// on. Serialized form is stable snake_case so Phase 6 can persist and
193/// rehydrate it without a protocol-crate dependency.
194#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
195#[serde(tag = "type", rename_all = "snake_case")]
196#[non_exhaustive]
197pub enum SubagentEvent {
198    /// A chunk of the agent's response text (the stream the TUI already
199    /// renders as the sub-agent's message).
200    Message {
201        /// The streamed text chunk.
202        text: String,
203    },
204    /// A chunk of the agent's internal reasoning.
205    Thought {
206        /// The streamed reasoning chunk.
207        text: String,
208    },
209    /// A tool call was initiated by the sub-agent.
210    ToolCall {
211        /// Unique id of the call within the session.
212        id: String,
213        /// Human-readable title describing what the tool is doing.
214        title: String,
215        /// Category of the tool.
216        kind: ToolKind,
217        /// Execution status at emission time.
218        status: ToolCallStatus,
219        /// Raw input parameters, when the harness reports them.
220        raw_input: Option<serde_json::Value>,
221    },
222    /// An update to a previously announced tool call.
223    ToolCallUpdate {
224        /// Id of the updated call.
225        id: String,
226        /// New status, when the update carries one.
227        status: Option<ToolCallStatus>,
228        /// New title, when the update carries one.
229        title: Option<String>,
230        /// Raw output returned by the tool, when the update carries it.
231        raw_output: Option<serde_json::Value>,
232        /// Text extracted from the update's content blocks (replaces, not
233        /// extends — mirroring the ACP "collections are overwritten" rule).
234        content: ToolOutputBlock,
235    },
236    /// The agent's execution plan (complete replacement, per the ACP spec).
237    Plan {
238        /// The full list of plan entries in order.
239        entries: Vec<PlanEntry>,
240    },
241    /// Context window usage snapshot.
242    Usage {
243        /// Total context window size in tokens.
244        context_window: u64,
245        /// Tokens currently in context.
246        tokens_in_context: u64,
247    },
248    /// The session mode changed.
249    Mode {
250        /// The id of the new mode.
251        id: String,
252    },
253    /// Session metadata (title) was set or changed.
254    SessionInfo {
255        /// The session title, when one was set.
256        title: Option<String>,
257    },
258}
259
260impl SubagentEvent {
261    /// Map one ACP `session/update` into a display event.
262    ///
263    /// Returns `None` for updates with no TUI representation (user chunks,
264    /// available-commands refreshes, config-option updates, unstable
265    /// variants); those are logged by the caller instead of silently
266    /// dropped.
267    pub(crate) fn from_session_update(update: schema::SessionUpdate) -> Option<Self> {
268        match update {
269            schema::SessionUpdate::AgentMessageChunk(chunk) => {
270                chunk_text(chunk, "agent message").map(|text| Self::Message { text })
271            }
272            schema::SessionUpdate::AgentThoughtChunk(chunk) => {
273                chunk_text(chunk, "agent thought").map(|text| Self::Thought { text })
274            }
275            schema::SessionUpdate::ToolCall(call) => Some(Self::from_tool_call(call)),
276            schema::SessionUpdate::ToolCallUpdate(update) => {
277                Some(Self::from_tool_call_update(update))
278            }
279            schema::SessionUpdate::Plan(plan) => Some(Self::from_plan(plan)),
280            schema::SessionUpdate::UsageUpdate(update) => Some(Self::from_usage(update)),
281            schema::SessionUpdate::CurrentModeUpdate(schema::CurrentModeUpdate {
282                current_mode_id,
283                ..
284            }) => Some(Self::Mode {
285                id: current_mode_id.0.to_string(),
286            }),
287            schema::SessionUpdate::SessionInfoUpdate(update) => {
288                session_info_title(&update).map(|title| Self::SessionInfo { title: Some(title) })
289            }
290            // No display representation (yet).
291            _ => None,
292        }
293    }
294
295    fn from_tool_call(call: schema::ToolCall) -> Self {
296        Self::ToolCall {
297            id: call.tool_call_id.0.to_string(),
298            title: call.title,
299            kind: ToolKind::from_acp(call.kind),
300            status: ToolCallStatus::from_acp(call.status),
301            raw_input: call.raw_input,
302        }
303    }
304
305    fn from_tool_call_update(update: schema::ToolCallUpdate) -> Self {
306        let fields = update.fields;
307        Self::ToolCallUpdate {
308            id: update.tool_call_id.0.to_string(),
309            status: fields.status.map(ToolCallStatus::from_acp),
310            title: fields.title,
311            raw_output: fields.raw_output,
312            content: extract_content_text(fields.content.unwrap_or_default()),
313        }
314    }
315
316    fn from_plan(plan: schema::Plan) -> Self {
317        Self::Plan {
318            entries: plan
319                .entries
320                .into_iter()
321                .map(
322                    |schema::PlanEntry {
323                         content,
324                         priority,
325                         status,
326                         ..
327                     }| PlanEntry {
328                        content,
329                        priority: PlanEntryPriority::from_acp(priority),
330                        status: PlanEntryStatus::from_acp(status),
331                    },
332                )
333                .collect(),
334        }
335    }
336
337    fn from_usage(schema::UsageUpdate { used, size, .. }: schema::UsageUpdate) -> Self {
338        Self::Usage {
339            context_window: size,
340            tokens_in_context: used,
341        }
342    }
343}
344
345/// The text of a content chunk, when it is a text block.
346///
347/// Non-text blocks (images, audio, resources) yield `None` — they have no
348/// place in the event stream yet — and are logged here with the update kind
349/// (`what`) so triage can distinguish "unmapped update variant" from
350/// "unrenderable content block inside a mapped variant".
351fn chunk_text(
352    schema::ContentChunk { content, .. }: schema::ContentChunk,
353    what: &str,
354) -> Option<String> {
355    match content {
356        schema::ContentBlock::Text(text) => Some(text.text),
357        other => {
358            log::debug!("sub-agent sent a non-text {what} chunk: {other:?}");
359            None
360        }
361    }
362}
363
364/// The session title, when the update sets one (not undefined, not null —
365/// a `null` title is a *clear*, which yields no event).
366fn session_info_title(update: &schema::SessionInfoUpdate) -> Option<String> {
367    update.title.value().cloned()
368}
369
370/// Flatten tool-call content blocks into the text the TUI renders.
371pub(crate) fn extract_content_text(content: Vec<schema::ToolCallContent>) -> ToolOutputBlock {
372    let mut text = String::new();
373    let mut skipped = 0_u32;
374    let mut diff = None;
375    for block in content {
376        match block {
377            schema::ToolCallContent::Content(inner) => match inner.content {
378                schema::ContentBlock::Text(t) => {
379                    if !text.is_empty() {
380                        text.push('\n');
381                    }
382                    text.push_str(&t.text);
383                }
384                _ => skipped += 1,
385            },
386            schema::ToolCallContent::Diff(d) => {
387                // The LAST diff block wins — one file edit per tool call is
388                // the norm, and a follow-up diff supersedes the previous.
389                diff = Some(diff_summary(
390                    &d.path.to_string_lossy(),
391                    d.old_text.as_deref(),
392                    &d.new_text,
393                ));
394            }
395            // Terminals are not rendered in this phase.
396            _ => skipped += 1,
397        }
398    }
399    ToolOutputBlock {
400        text,
401        skipped,
402        diff,
403    }
404}
405
406/// Line-count budget of [`diff_summary`]: beyond this many lines a text side
407/// is treated as unbounded and the set difference stops being meaningful —
408/// the counts degrade to the raw line-count delta instead of an O(n) scan
409/// over megabytes of generated code.
410pub(crate) const DIFF_SUMMARY_LINE_CAP: usize = 5_000;
411
412/// Compact `(+added −removed)` summary of one ACP diff block.
413///
414/// Added = lines present in `new_text` but not in `old_text`; removed the
415/// reverse. Line-set difference (not a true LCS diff) on purpose: the box
416/// only needs a magnitude, and set semantics stay correct for the common
417/// shapes (new file = everything added; rewrite = large on both sides).
418/// Past the line cap the counts degrade to the simple length delta.
419pub(crate) fn diff_summary(path: &str, old_text: Option<&str>, new_text: &str) -> ToolDiffSummary {
420    let old_lines = old_text.unwrap_or("").lines().count();
421    let new_lines = new_text.lines().count();
422    let (added, removed) = match (old_text, old_lines, new_lines) {
423        // New file: everything is an addition.
424        (None, _, _) => (new_lines, 0),
425        // Either side beyond the cap: approximate with the length delta.
426        (_, o, n) if o > DIFF_SUMMARY_LINE_CAP || n > DIFF_SUMMARY_LINE_CAP => {
427            (n.saturating_sub(o), o.saturating_sub(n))
428        }
429        _ => {
430            use std::collections::HashSet;
431            let old_set: HashSet<&str> = old_text.unwrap_or("").lines().collect();
432            let new_set: HashSet<&str> = new_text.lines().collect();
433            (
434                new_set.difference(&old_set).count(),
435                old_set.difference(&new_set).count(),
436            )
437        }
438    };
439    ToolDiffSummary {
440        path: path.to_string(),
441        added: u32::try_from(added).unwrap_or(u32::MAX),
442        removed: u32::try_from(removed).unwrap_or(u32::MAX),
443    }
444}