Skip to main content

thndrs_lib/server/
events.rs

1//! Pure mapping from app events to ACP session-update intents.
2//!
3//! The intents are intentionally small and protocol-agnostic so handlers can
4//! lower them to SDK `SessionNotification` values without importing transport
5//! details.
6
7use std::collections::BTreeSet;
8
9use crate::app::{AgentEvent, ToolStatus};
10use crate::tools::{WriteResult, shell::ProcessResult, shell::redact_secrets};
11use agent_client_protocol::schema::v1::ToolCallLocation;
12
13const MAX_TOOL_FIELD_CHARS: usize = 4096;
14const MAX_TOOL_LOCATIONS: usize = 6;
15const MAX_TOOL_PATH_TOKENS: usize = 12;
16
17/// Intent payload emitted by the ACP server for one `session/update` event.
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub enum SessionUpdateIntent {
20    /// Assistant-facing text chunk.
21    AssistantDelta(String),
22    /// Reasoning/thinking text chunk.
23    ReasoningDelta(String),
24    /// Generic status update for UIs or logs.
25    Status(String),
26    /// Token usage snapshot.
27    Usage {
28        /// Input tokens observed since the session started.
29        input_tokens: u64,
30        /// Output tokens observed since the session started.
31        output_tokens: u64,
32    },
33    /// Tool call created/in-progress event.
34    ToolStarted {
35        /// Tool-call correlation id.
36        id: String,
37        /// Human-readable tool name/title.
38        name: String,
39        /// Human-readable argument payload.
40        arguments: String,
41        /// Classified tool kind for client and permission handling.
42        kind: ToolCallKind,
43        /// Helpful file locations associated with this tool.
44        locations: Vec<ToolCallLocation>,
45    },
46    /// Tool call completion event.
47    ToolFinished {
48        /// Tool-call correlation id.
49        id: String,
50        /// Tool result status.
51        status: ToolStatusIntent,
52        /// Tool output lines.
53        output: Vec<String>,
54        /// Classified tool kind for client and permission handling.
55        kind: ToolCallKind,
56        /// Helpful file locations associated with this tool.
57        locations: Vec<ToolCallLocation>,
58    },
59    /// Prompt turn failed with terminal error details.
60    Failed(String),
61    /// Prompt turn was cancelled.
62    Cancelled,
63    /// Prompt turn finished normally.
64    Finished,
65}
66
67/// ACP-facing tool status intent used by [`SessionUpdateIntent::ToolFinished`].
68#[derive(Clone, Copy, Debug, Eq, PartialEq)]
69pub enum ToolStatusIntent {
70    /// Tool execution is still running.
71    InProgress,
72    /// Tool execution completed successfully.
73    Completed,
74    /// Tool execution failed.
75    Failed,
76}
77
78impl From<ToolStatus> for ToolStatusIntent {
79    fn from(status: ToolStatus) -> Self {
80        Self::from_tool_status(status)
81    }
82}
83
84impl ToolStatusIntent {
85    /// Convert the app transcript status into the ACP-facing status.
86    pub const fn from_tool_status(status: ToolStatus) -> Self {
87        match status {
88            ToolStatus::Ok => Self::Completed,
89            ToolStatus::Failed => Self::Failed,
90            ToolStatus::Cancelled => Self::Failed,
91            ToolStatus::Running => Self::InProgress,
92        }
93    }
94}
95
96/// Coarse tool-kind taxonomy for server-side permission hooks and UI hints.
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98pub enum ToolCallKind {
99    /// Reading files or data.
100    Read,
101    /// Modifying files or content.
102    Edit,
103    /// Searching for information.
104    Search,
105    /// Running commands or code.
106    Execute,
107    /// Retrieving external data.
108    Fetch,
109    /// Internal reasoning or planning.
110    Think,
111    /// Catch-all for unsupported/unmapped tools.
112    Other,
113}
114
115impl ToolCallKind {
116    /// Whether this kind should usually require a permission gate.
117    pub const fn requires_permission(self) -> bool {
118        matches!(self, Self::Edit | Self::Execute)
119    }
120
121    /// Convert to the ACP schema's tool kind.
122    pub const fn to_acp_kind(self) -> agent_client_protocol::schema::v1::ToolKind {
123        use agent_client_protocol::schema::v1::ToolKind;
124        match self {
125            Self::Read => ToolKind::Read,
126            Self::Edit => ToolKind::Edit,
127            Self::Search => ToolKind::Search,
128            Self::Execute => ToolKind::Execute,
129            Self::Fetch => ToolKind::Fetch,
130            Self::Think => ToolKind::Think,
131            Self::Other => ToolKind::Other,
132        }
133    }
134}
135
136/// Return whether this tool kind should usually go through permission review.
137pub const fn requires_permission(kind: ToolCallKind) -> bool {
138    kind.requires_permission()
139}
140
141/// Classify a tool call name into a limited UX/permission taxonomy.
142pub fn classify_tool(name: &str) -> ToolCallKind {
143    match name {
144        "find_files" | "list_searchable_files" | "read_file_range" | "read" | "sawk" | "acp.fs.read_text_file" => {
145            ToolCallKind::Read
146        }
147        "search_text" | "web_search" => ToolCallKind::Search,
148        "create_file" | "replace_range" | "write_patch" | "acp.fs.write_text_file" => ToolCallKind::Edit,
149        "run_shell" | "acp.terminal" => ToolCallKind::Execute,
150        "read_url" | "fetch_url" | "http_request" => ToolCallKind::Fetch,
151        "plan" | "think" => ToolCallKind::Think,
152        _ => ToolCallKind::Other,
153    }
154}
155
156/// Convert one app event into zero or more ACP session-update intents.
157///
158/// Returns an empty list for internal events that are not visible protocol
159/// updates.
160pub fn map_agent_event(event: &AgentEvent) -> Vec<SessionUpdateIntent> {
161    match event {
162        AgentEvent::AssistantDelta(text) => vec![SessionUpdateIntent::AssistantDelta(text.clone())],
163        AgentEvent::ReasoningDelta(text) => vec![SessionUpdateIntent::ReasoningDelta(text.clone())],
164        AgentEvent::Status(text) => vec![SessionUpdateIntent::Status(text.clone())],
165        AgentEvent::Usage { input_tokens, output_tokens } => {
166            vec![SessionUpdateIntent::Usage { input_tokens: *input_tokens, output_tokens: *output_tokens }]
167        }
168        AgentEvent::RequestAccounting(accounting) => {
169            let Some(usage) = &accounting.provider_usage else {
170                return vec![];
171            };
172            vec![SessionUpdateIntent::Usage {
173                input_tokens: usage.components.input_tokens.unwrap_or(0),
174                output_tokens: usage.components.output_tokens.unwrap_or(0),
175            }]
176        }
177        AgentEvent::ToolStarted { id, name, arguments } => {
178            let kind = classify_tool(name);
179            vec![SessionUpdateIntent::ToolStarted {
180                id: id.clone(),
181                name: name.clone(),
182                arguments: sanitize_tool_payload(arguments),
183                kind,
184                locations: infer_tool_locations_from_arguments(name, arguments),
185            }]
186        }
187        AgentEvent::ToolFinished { id, status, output, write_result, shell_result, .. } => {
188            let kind = infer_tool_kind_from_result(write_result.as_ref(), shell_result.as_deref());
189            vec![SessionUpdateIntent::ToolFinished {
190                id: id.clone(),
191                status: (*status).into(),
192                output: sanitize_tool_outputs(output),
193                kind,
194                locations: infer_tool_locations(output, write_result.as_ref(), shell_result.as_deref()),
195            }]
196        }
197        AgentEvent::Failed(message) => vec![SessionUpdateIntent::Failed(message.clone())],
198        AgentEvent::Cancelled => vec![SessionUpdateIntent::Cancelled],
199        AgentEvent::Finished => vec![SessionUpdateIntent::Finished],
200        AgentEvent::Started
201        | AgentEvent::ModelMetadataLoaded(_)
202        | AgentEvent::Retrying { .. }
203        | AgentEvent::PermissionRequest(_)
204        | AgentEvent::PermissionResolved { .. }
205        | AgentEvent::AcpSession(_) => vec![],
206    }
207}
208
209fn infer_tool_kind_from_result(
210    write_result: Option<&WriteResult>, shell_result: Option<&ProcessResult>,
211) -> ToolCallKind {
212    match write_result {
213        Some(_) => ToolCallKind::Edit,
214        None => match shell_result {
215            Some(_) => ToolCallKind::Execute,
216            None => ToolCallKind::Other,
217        },
218    }
219}
220
221fn infer_tool_locations(
222    output: &[String], write_result: Option<&WriteResult>, shell_result: Option<&ProcessResult>,
223) -> Vec<ToolCallLocation> {
224    let mut locations = BTreeSet::new();
225    if let Some(result) = write_result {
226        locations.insert((result.path.display().to_string(), None));
227    }
228    if let Some(result) = shell_result {
229        let cwd = result.cwd.to_string_lossy().to_string();
230        if !cwd.is_empty() {
231            locations.insert((cwd, None));
232        }
233    }
234
235    for line in output {
236        if locations.len() >= MAX_TOOL_LOCATIONS {
237            break;
238        }
239        collect_shell_path_candidates(line, &mut locations);
240    }
241
242    locations
243        .into_iter()
244        .filter_map(tool_call_location)
245        .take(MAX_TOOL_LOCATIONS)
246        .collect()
247}
248
249fn infer_tool_locations_from_arguments(tool_name: &str, arguments: &str) -> Vec<ToolCallLocation> {
250    let mut locations = BTreeSet::new();
251
252    if let Ok(value) = serde_json::from_str::<serde_json::Value>(arguments) {
253        collect_location_values(&value, None, &mut locations);
254    }
255    if matches!(tool_name, "run_shell") && locations.is_empty() {
256        collect_shell_path_candidates(arguments, &mut locations);
257    }
258
259    locations
260        .into_iter()
261        .filter_map(tool_call_location)
262        .take(MAX_TOOL_LOCATIONS)
263        .collect()
264}
265
266fn collect_shell_path_candidates(line: &str, locations: &mut BTreeSet<(String, Option<u32>)>) {
267    let mut accepted = 0;
268    for token in line.split_whitespace() {
269        if accepted >= MAX_TOOL_PATH_TOKENS || locations.len() >= MAX_TOOL_LOCATIONS {
270            return;
271        }
272        let token = token.trim_matches(|c| c == '"' || c == '\'');
273        if is_file_like_path(token) {
274            let _ = locations.insert((token.to_string(), None));
275            accepted += 1;
276        }
277    }
278}
279
280fn collect_location_values(
281    value: &serde_json::Value, line_hint: Option<u32>, locations: &mut BTreeSet<(String, Option<u32>)>,
282) {
283    match value {
284        serde_json::Value::Object(entries) => {
285            let mut current_line = line_hint;
286            if let Some(line) = entries.get("line").and_then(as_u32) {
287                current_line = Some(line);
288            }
289            if let Some(line) = entries.get("start_line").and_then(as_u32) {
290                current_line = Some(line);
291            }
292
293            for (key, child) in entries {
294                if is_path_key(key) {
295                    collect_locations_for_key(key, child, current_line, locations);
296                } else {
297                    collect_location_values(child, current_line, locations);
298                }
299            }
300        }
301        serde_json::Value::Array(values) => {
302            for child in values {
303                collect_location_values(child, line_hint, locations);
304            }
305        }
306        _ => {}
307    }
308}
309
310fn collect_locations_for_key(
311    _key: &str, value: &serde_json::Value, line_hint: Option<u32>, locations: &mut BTreeSet<(String, Option<u32>)>,
312) {
313    match value {
314        serde_json::Value::String(path) if is_path_like_value(path) => {
315            if is_file_like_path(path) {
316                locations.insert((path.to_string(), line_hint));
317            }
318        }
319        serde_json::Value::Array(values) => {
320            for child in values {
321                if let serde_json::Value::String(path) = child
322                    && is_file_like_path(path)
323                {
324                    locations.insert((path.to_string(), line_hint));
325                }
326            }
327        }
328        serde_json::Value::Object(_) => {
329            collect_location_values(value, line_hint, locations);
330        }
331        _ => {}
332    }
333}
334
335fn is_path_key(key: &str) -> bool {
336    matches!(
337        key,
338        "path"
339            | "paths"
340            | "file"
341            | "filename"
342            | "source"
343            | "source_path"
344            | "target"
345            | "target_path"
346            | "destination"
347            | "destination_path"
348            | "old_path"
349            | "new_path"
350            | "cwd"
351            | "directory"
352            | "dir"
353    )
354}
355
356fn is_path_like_value(value: &str) -> bool {
357    if value.is_empty() {
358        return false;
359    }
360
361    let trimmed = value.trim();
362    !trimmed.starts_with('-') && !trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
363}
364
365fn is_file_like_path(value: &str) -> bool {
366    let trimmed = value.trim();
367    if trimmed.is_empty() {
368        return false;
369    }
370
371    trimmed.contains('/') || trimmed.contains('.') || trimmed == ".." || trimmed == "."
372}
373
374fn as_u32(value: &serde_json::Value) -> Option<u32> {
375    value.as_u64().and_then(|value| u32::try_from(value).ok())
376}
377
378fn tool_call_location((path, line): (String, Option<u32>)) -> Option<ToolCallLocation> {
379    let trimmed = path.trim();
380    if trimmed.is_empty() {
381        return None;
382    }
383
384    Some(ToolCallLocation::new(trimmed).line(line))
385}
386
387pub fn sanitize_tool_payload(payload: &str) -> String {
388    sanitize_tool_text(payload)
389}
390
391fn sanitize_tool_outputs(outputs: &[String]) -> Vec<String> {
392    outputs.iter().map(|line| sanitize_tool_text(line)).collect()
393}
394
395fn sanitize_tool_text(raw: &str) -> String {
396    let value = redact_sensitive_payload(raw);
397    cap_value(&value)
398}
399
400fn redact_sensitive_payload(value: &str) -> String {
401    match serde_json::from_str::<serde_json::Value>(value) {
402        Ok(json) => redact_json_value(json).to_string(),
403        Err(_) => redact_secrets(value),
404    }
405}
406
407fn redact_json_value(value: serde_json::Value) -> serde_json::Value {
408    match value {
409        serde_json::Value::String(text) => serde_json::Value::String(redact_secrets(&text)),
410        serde_json::Value::Array(items) => serde_json::Value::Array(items.into_iter().map(redact_json_value).collect()),
411        serde_json::Value::Object(entries) => serde_json::Value::Object(
412            entries
413                .into_iter()
414                .map(|(key, value)| {
415                    let redacted = if key_is_sensitive(&key) {
416                        serde_json::Value::String(String::from("[REDACTED]"))
417                    } else {
418                        redact_json_value(value)
419                    };
420                    (key, redacted)
421                })
422                .collect(),
423        ),
424        _ => value,
425    }
426}
427
428fn key_is_sensitive(key: &str) -> bool {
429    matches!(
430        key.to_ascii_lowercase().as_str(),
431        "password" | "passwd" | "api_key" | "apikey" | "access_token" | "secret" | "token"
432    )
433}
434
435fn cap_value(value: &str) -> String {
436    let char_len = value.chars().count();
437    if char_len <= MAX_TOOL_FIELD_CHARS {
438        return value.to_string();
439    }
440
441    let truncated: String = value.chars().take(MAX_TOOL_FIELD_CHARS).collect();
442    format!("{truncated}...[truncated]")
443}