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}