Skip to main content

basis_acp/
update.rs

1//! basis's [`Event`] stream, as ACP `session/update` notifications.
2//!
3//! This is the reason [`Event`] exists. mentra's `SessionEvent` is normalized
4//! once, in `event/mapping.rs`, and every surface downstream — JSONL, ACP,
5//! whatever comes next — maps from basis's own shape. Nothing here touches
6//! mentra.
7//!
8//! The match is exhaustive with no wildcard, for the same reason the mentra
9//! mapping is: a new [`Event`] variant should break this build rather than
10//! quietly stop reaching ACP clients.
11
12use agent_client_protocol::schema::v1::{
13    ContentBlock, ContentChunk, SessionUpdate, TextContent, ToolCall, ToolCallStatus,
14    ToolCallUpdate, ToolCallUpdateFields, ToolKind,
15};
16use serde_json::Value;
17
18use basis::{
19    event::{Event, Mutability},
20    tools::SPAWN,
21};
22
23/// Maps one basis event to an ACP session update.
24///
25/// `None` where ACP has no place for it — basis's own bookends, and the
26/// housekeeping events (usage, compaction, retries) that ACP models
27/// differently or not at all. Those still reach a JSONL consumer; they are
28/// simply not part of this protocol's vocabulary.
29pub fn session_update(event: &Event) -> Option<SessionUpdate> {
30    let update = match event {
31        // basis's bookends. `session/prompt` returning is what tells an ACP
32        // client the turn is over, so repeating it as an update would be
33        // noise.
34        Event::RunStarted { .. } | Event::RunFinished { .. } => return None,
35
36        // The client sent this; echoing it back would double it in the UI.
37        Event::UserMessage { .. } => return None,
38
39        Event::AssistantDelta { text } => SessionUpdate::AgentMessageChunk(chunk(text)),
40        Event::AssistantReasoningDelta { text } => SessionUpdate::AgentThoughtChunk(chunk(text)),
41
42        // The deltas already streamed this text. Sending the assembled message
43        // too would render it twice.
44        Event::AssistantMessage { .. } => return None,
45
46        Event::ToolQueued {
47            tool_call_id,
48            tool_name,
49            summary,
50            mutability,
51            input,
52        } => SessionUpdate::ToolCall(
53            ToolCall::new(tool_call_id.clone(), title(summary, tool_name))
54                .kind(tool_kind(tool_name, *mutability, input))
55                .status(ToolCallStatus::Pending)
56                .raw_input(input.clone()),
57        ),
58
59        Event::ToolStarted { tool_call_id, .. } => {
60            SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
61                tool_call_id.clone(),
62                ToolCallUpdateFields::new().status(ToolCallStatus::InProgress),
63            ))
64        }
65
66        // ACP has no progress field, so progress becomes the title: a client
67        // showing the call sees it change as the work proceeds.
68        Event::ToolProgress {
69            tool_call_id,
70            progress,
71            ..
72        } => SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
73            tool_call_id.clone(),
74            ToolCallUpdateFields::new().title(progress.clone()),
75        )),
76
77        Event::ToolCompleted {
78            tool_call_id,
79            summary,
80            is_error,
81            ..
82        } => SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
83            tool_call_id.clone(),
84            ToolCallUpdateFields::new()
85                .status(if *is_error {
86                    ToolCallStatus::Failed
87                } else {
88                    ToolCallStatus::Completed
89                })
90                .content(vec![text_block(summary).into()]),
91        )),
92
93        // The request itself is a `session/request_permission` round trip, not
94        // an update — see `acp/approver.rs`. The resolution is reflected by
95        // the tool call's own status.
96        Event::PermissionRequested { .. } | Event::PermissionResolved { .. } => return None,
97
98        // Concurrent work has no ACP vocabulary yet. Surfacing it as a thought
99        // keeps the client informed instead of dropping it silently.
100        Event::TaskUpdated {
101            title: task_title,
102            status,
103            ..
104        } => SessionUpdate::AgentThoughtChunk(chunk(&format!("[{status:?}] {task_title}"))),
105
106        // Housekeeping: real, and worth having on the JSONL stream, but not
107        // something an ACP client renders. `UsageUpdate` is about the context
108        // window rather than per-turn token counts, so basis does not pretend
109        // these are the same thing.
110        Event::CompactionStarted { .. }
111        | Event::CompactionCompleted { .. }
112        | Event::MemoryUpdated { .. }
113        | Event::Usage { .. }
114        | Event::Branched { .. } => return None,
115
116        // Anything the operator should see becomes a thought chunk: a retry or
117        // a recoverable error explains a pause the user is already watching.
118        Event::Notice { message, .. } => SessionUpdate::AgentThoughtChunk(chunk(message)),
119        Event::Retry {
120            error,
121            attempt,
122            max_attempts,
123            ..
124        } => SessionUpdate::AgentThoughtChunk(chunk(&format!(
125            "retrying after {error} (attempt {attempt}/{max_attempts})"
126        ))),
127        Event::Error { message, .. } => {
128            SessionUpdate::AgentThoughtChunk(chunk(&format!("error: {message}")))
129        }
130    };
131
132    Some(update)
133}
134
135fn chunk(text: &str) -> ContentChunk {
136    ContentChunk::new(text_block(text))
137}
138
139fn text_block(text: &str) -> ContentBlock {
140    ContentBlock::Text(TextContent::new(text.to_string()))
141}
142
143/// What to call the tool call in a client's UI.
144///
145/// mentra's summary is written for a person ("Run 'cargo test'"), so it is the
146/// better title when there is one; the tool's name is the fallback.
147fn title(summary: &str, tool_name: &str) -> String {
148    if summary.trim().is_empty() {
149        tool_name.to_string()
150    } else {
151        summary.to_string()
152    }
153}
154
155/// Classifies a mentra tool for ACP's icon vocabulary.
156///
157/// Name-based, because for every tool but one that is all the information
158/// there is — mentra reports a name and a mutability, not a category. Unknown
159/// names fall back to mutability, and then to `Other`: a wrong icon is worse
160/// than no icon, and a tool basis has never heard of is exactly the case where
161/// guessing is unwise.
162///
163/// [`SPAWN`] is the exception, and the reason this takes the call's input at
164/// all. Since ADR-0016 one name carries two acts — a command and a delegation —
165/// so a name-keyed answer is necessarily wrong for one of them.
166fn tool_kind(tool_name: &str, mutability: Mutability, input: &Value) -> ToolKind {
167    if tool_name == SPAWN {
168        return spawn_kind(input);
169    }
170
171    match tool_name {
172        "shell" | "bash" | "command" | "background_command" => ToolKind::Execute,
173        "files" | "read" | "read_file" => match mutability {
174            Mutability::ReadOnly => ToolKind::Read,
175            _ => ToolKind::Edit,
176        },
177        "write" | "write_file" | "edit" | "edit_file" | "apply_patch" => ToolKind::Edit,
178        "delete" | "remove" => ToolKind::Delete,
179        "move" | "rename" => ToolKind::Move,
180        "search" | "grep" | "glob" | "find" => ToolKind::Search,
181        "fetch" | "web_fetch" | "http" => ToolKind::Fetch,
182        "think" | "load_skill" => ToolKind::Think,
183        _ => match mutability {
184            Mutability::ReadOnly => ToolKind::Read,
185            Mutability::Mutating => ToolKind::Edit,
186            Mutability::Unknown => ToolKind::Other,
187        },
188    }
189}
190
191/// The field `spawn` takes its one string in.
192///
193/// A literal because `basis` keeps the name private; see [`spawn_kind`] for
194/// what that costs and what it does not.
195const SPAWN_INPUT: &str = "input";
196
197/// What ACP calls handing work to a subagent, which is nothing.
198///
199/// `ToolKind` in schema v1 offers `Read`, `Edit`, `Delete`, `Move`, `Search`,
200/// `Execute`, `Think`, `Fetch`, `SwitchMode` and `Other`, and none of them
201/// means delegation. `Think` is the nearest name and the wrong one: it promises
202/// internal reasoning with nothing outside the process changed, while a
203/// delegation is consequential enough that basis puts it to the approver, and the
204/// subagent on the other side of it holds `spawn` in its own turn. Rendering
205/// that as a thought would contradict the permission prompt the client has just
206/// been asked to answer.
207///
208/// So `Other` — the schema's own default, and an honest "no category" — until
209/// something can say `delegate`. ACP v2's `ToolKind::Unknown(String)` reserves
210/// leading-underscore values for exactly this kind of extension; v1, which this
211/// crate speaks, has no such escape.
212const DELEGATION: ToolKind = ToolKind::Other;
213
214/// `spawn`'s kind, which its *mode* decides rather than its name (ADR-0016).
215///
216/// The mode is re-derived from the raw string here, and that is a knowing
217/// second reading of the convention `basis::tools::spawn` parses exactly
218/// once. It is not free: if the two ever disagree — a new escape, a different
219/// trim — a client renders the wrong icon and nothing says so. What keeps it
220/// tolerable is that this path decides nothing. The typed
221/// `{mode, body, cwd, target}` is what reaches the approver, the rule store,
222/// the hooks and the audit trail;
223/// what reaches ACP is a `ToolQueued` event carrying the string the model
224/// wrote, and basis exports no reader for it. The fix is that reader,
225/// exported from the crate that owns the convention — not a second copy that
226/// grows.
227fn spawn_kind(input: &Value) -> ToolKind {
228    let Some(body) = input.get(SPAWN_INPUT).and_then(Value::as_str) else {
229        // Nothing to read, and spawn's own preview will refuse this call before
230        // it runs. Reported as the stronger of the two modes for the reason
231        // spawn's static descriptor is `Process`: a tool that can run commands
232        // should not describe itself as something milder when there is nothing
233        // per-call to go on.
234        return ToolKind::Execute;
235    };
236
237    match body.trim().strip_prefix('!') {
238        // `!!` is the escape a task whose own text starts with `!` is written
239        // with, so it is a delegation and not a command.
240        Some(rest) if rest.starts_with('!') => DELEGATION,
241        // Everything else after a single `!` is a command, `!@<target> …`
242        // included: ADR-0021 made *where* a dimension of a command rather than
243        // a third mode, so there is no third kind for a client to render.
244        Some(_) => ToolKind::Execute,
245        None => DELEGATION,
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use basis::event::{NoticeSeverity, RunOutcome};
253    use serde_json::json;
254
255    fn text_of(chunk: &ContentChunk) -> String {
256        match &chunk.content {
257            ContentBlock::Text(text) => text.text.clone(),
258            other => panic!("expected text content, got {other:?}"),
259        }
260    }
261
262    #[test]
263    fn assistant_deltas_become_message_chunks() {
264        let update = session_update(&Event::AssistantDelta {
265            text: "hello".to_string(),
266        })
267        .expect("mapped");
268
269        let SessionUpdate::AgentMessageChunk(chunk) = update else {
270            panic!("expected an agent message chunk");
271        };
272        assert_eq!(text_of(&chunk), "hello");
273    }
274
275    #[test]
276    fn reasoning_is_a_thought_not_a_message() {
277        // Rendering private reasoning as the answer would be a real leak of
278        // one into the other.
279        let update = session_update(&Event::AssistantReasoningDelta {
280            text: "considering".to_string(),
281        })
282        .expect("mapped");
283
284        assert!(matches!(update, SessionUpdate::AgentThoughtChunk(_)));
285    }
286
287    #[test]
288    fn the_assembled_message_is_not_sent_after_its_own_deltas() {
289        assert_eq!(
290            session_update(&Event::AssistantMessage {
291                text: "hello".to_string()
292            }),
293            None,
294            "the deltas already carried this text; sending it again renders it twice"
295        );
296    }
297
298    #[test]
299    fn lan_bookends_are_not_acp_updates() {
300        assert_eq!(
301            session_update(&Event::RunFinished {
302                outcome: RunOutcome::Ok,
303                stopped_by: None
304            }),
305            None
306        );
307        assert_eq!(
308            session_update(&Event::UserMessage {
309                text: "hi".to_string()
310            }),
311            None,
312            "the client sent this; echoing it doubles it"
313        );
314    }
315
316    #[test]
317    fn a_queued_tool_call_carries_its_title_kind_and_input() {
318        let update = session_update(&Event::ToolQueued {
319            tool_call_id: "c1".to_string(),
320            tool_name: "shell".to_string(),
321            summary: "Run 'cargo test'".to_string(),
322            mutability: Mutability::Mutating,
323            input: json!({"command": "cargo test"}),
324        })
325        .expect("mapped");
326
327        let SessionUpdate::ToolCall(call) = update else {
328            panic!("expected a tool call");
329        };
330        assert_eq!(&*call.tool_call_id.0, "c1");
331        assert_eq!(call.title, "Run 'cargo test'");
332        assert_eq!(call.kind, ToolKind::Execute);
333        assert_eq!(call.status, ToolCallStatus::Pending);
334        assert_eq!(
335            call.raw_input,
336            Some(json!({"command": "cargo test"})),
337            "a client showing what a call would do needs its real input"
338        );
339    }
340
341    #[test]
342    fn a_call_with_no_summary_falls_back_to_its_name() {
343        let update = session_update(&Event::ToolQueued {
344            tool_call_id: "c1".to_string(),
345            tool_name: "files".to_string(),
346            summary: "   ".to_string(),
347            mutability: Mutability::ReadOnly,
348            input: json!({}),
349        })
350        .expect("mapped");
351
352        let SessionUpdate::ToolCall(call) = update else {
353            panic!("expected a tool call");
354        };
355        assert_eq!(call.title, "files", "a blank title tells a client nothing");
356    }
357
358    #[test]
359    fn a_completed_call_reports_success_or_failure() {
360        for (is_error, expected) in [
361            (false, ToolCallStatus::Completed),
362            (true, ToolCallStatus::Failed),
363        ] {
364            let update = session_update(&Event::ToolCompleted {
365                tool_call_id: "c1".to_string(),
366                tool_name: "shell".to_string(),
367                summary: "output".to_string(),
368                is_error,
369            })
370            .expect("mapped");
371
372            let SessionUpdate::ToolCallUpdate(call) = update else {
373                panic!("expected a tool call update");
374            };
375            assert_eq!(call.fields.status, Some(expected));
376        }
377    }
378
379    #[test]
380    fn a_started_call_goes_in_progress() {
381        let update = session_update(&Event::ToolStarted {
382            tool_call_id: "c1".to_string(),
383            tool_name: "shell".to_string(),
384        })
385        .expect("mapped");
386
387        let SessionUpdate::ToolCallUpdate(call) = update else {
388            panic!("expected a tool call update");
389        };
390        assert_eq!(call.fields.status, Some(ToolCallStatus::InProgress));
391    }
392
393    #[test]
394    fn permission_events_are_a_round_trip_not_an_update() {
395        assert_eq!(
396            session_update(&Event::PermissionRequested {
397                request_id: "r1".to_string(),
398                tool_call_id: "c1".to_string(),
399                tool_name: "shell".to_string(),
400                description: "wants to run".to_string(),
401                preview: json!({}),
402            }),
403            None,
404            "a permission request is session/request_permission, not session/update"
405        );
406    }
407
408    #[test]
409    fn an_operator_facing_notice_reaches_the_client() {
410        let update = session_update(&Event::Notice {
411            severity: NoticeSeverity::Warning,
412            message: "context is nearly full".to_string(),
413        })
414        .expect("mapped");
415
416        let SessionUpdate::AgentThoughtChunk(chunk) = update else {
417            panic!("expected a thought chunk");
418        };
419        assert!(text_of(&chunk).contains("context is nearly full"));
420    }
421
422    #[test]
423    fn tool_kinds_follow_the_name_then_the_mutability() {
424        let no_input = json!({});
425
426        assert_eq!(
427            tool_kind("shell", Mutability::Mutating, &no_input),
428            ToolKind::Execute
429        );
430        assert_eq!(
431            tool_kind("files", Mutability::ReadOnly, &no_input),
432            ToolKind::Read
433        );
434        assert_eq!(
435            tool_kind("files", Mutability::Mutating, &no_input),
436            ToolKind::Edit
437        );
438        assert_eq!(
439            tool_kind("grep", Mutability::ReadOnly, &no_input),
440            ToolKind::Search
441        );
442
443        // An unknown tool falls back to what mentra says it does, and admits
444        // ignorance when mentra does not know either.
445        assert_eq!(
446            tool_kind("something_new", Mutability::ReadOnly, &no_input),
447            ToolKind::Read
448        );
449        assert_eq!(
450            tool_kind("something_new", Mutability::Unknown, &no_input),
451            ToolKind::Other
452        );
453    }
454
455    #[test]
456    fn spawn_is_classified_by_its_mode_rather_than_by_its_name() {
457        // The one tool whose name cannot answer for it. mentra reports
458        // `Unknown` mutability on every queued call, so before ADR-0016's map
459        // both of these rendered as `Other`.
460        assert_eq!(
461            tool_kind(
462                SPAWN,
463                Mutability::Unknown,
464                &json!({"input": "!cargo test -q"})
465            ),
466            ToolKind::Execute,
467            "a command is what `shell` always was"
468        );
469        assert_eq!(
470            tool_kind(
471                SPAWN,
472                Mutability::Unknown,
473                &json!({"input": "find every TODO under src/"})
474            ),
475            ToolKind::Other,
476            "ACP v1 has no kind meaning delegation, and `Think` would understate it"
477        );
478        assert_eq!(
479            tool_kind(
480                SPAWN,
481                Mutability::Unknown,
482                &json!({"input": "  !!urgent: rewrite the README"})
483            ),
484            ToolKind::Other,
485            "`!!` escapes a task whose own text starts with `!`; it is not a command"
486        );
487        assert_eq!(
488            tool_kind(
489                SPAWN,
490                Mutability::Unknown,
491                &json!({"input": "!@mac xcodebuild -list"})
492            ),
493            ToolKind::Execute,
494            "ADR-0021 made *where* a dimension of a command, not a third mode: \
495             a routed command still renders as an execution"
496        );
497    }
498
499    #[test]
500    fn an_unreadable_spawn_call_reports_the_stronger_mode() {
501        // Nothing per-call to go on, so this answers as spawn's static
502        // descriptor does: `Process`, never the milder of the two.
503        for input in [json!({}), json!({"input": 7}), json!("!cargo test")] {
504            assert_eq!(
505                tool_kind(SPAWN, Mutability::Unknown, &input),
506                ToolKind::Execute,
507                "{input}"
508            );
509        }
510    }
511
512    #[test]
513    fn a_queued_spawn_command_reaches_the_client_as_an_execution() {
514        // The mode lives in the input, so this pins the wiring as well as the
515        // classifier: a call site that forgot to pass the input would still
516        // satisfy the tests above.
517        let update = session_update(&Event::ToolQueued {
518            tool_call_id: "c1".to_string(),
519            tool_name: SPAWN.to_string(),
520            summary: "Run 'cargo test'".to_string(),
521            mutability: Mutability::Unknown,
522            input: json!({"input": "!cargo test"}),
523        })
524        .expect("mapped");
525
526        let SessionUpdate::ToolCall(call) = update else {
527            panic!("expected a tool call");
528        };
529        assert_eq!(call.kind, ToolKind::Execute);
530    }
531}