1use 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
23pub fn session_update(event: &Event) -> Option<SessionUpdate> {
30 let update = match event {
31 Event::RunStarted { .. } | Event::RunFinished { .. } => return None,
35
36 Event::UserMessage { .. } => return None,
38
39 Event::AssistantDelta { text } => SessionUpdate::AgentMessageChunk(chunk(text)),
40 Event::AssistantReasoningDelta { text } => SessionUpdate::AgentThoughtChunk(chunk(text)),
41
42 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 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 Event::PermissionRequested { .. } | Event::PermissionResolved { .. } => return None,
97
98 Event::TaskUpdated {
101 title: task_title,
102 status,
103 ..
104 } => SessionUpdate::AgentThoughtChunk(chunk(&format!("[{status:?}] {task_title}"))),
105
106 Event::CompactionStarted { .. }
111 | Event::CompactionCompleted { .. }
112 | Event::MemoryUpdated { .. }
113 | Event::Usage { .. }
114 | Event::Branched { .. } => return None,
115
116 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
143fn 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
155fn 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
191const SPAWN_INPUT: &str = "input";
196
197const DELEGATION: ToolKind = ToolKind::Other;
213
214fn spawn_kind(input: &Value) -> ToolKind {
228 let Some(body) = input.get(SPAWN_INPUT).and_then(Value::as_str) else {
229 return ToolKind::Execute;
235 };
236
237 match body.trim().strip_prefix('!') {
238 Some(rest) if rest.starts_with('!') => DELEGATION,
241 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 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 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 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 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 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}