use agent_client_protocol::schema::v1::{
ContentBlock, ContentChunk, SessionUpdate, TextContent, ToolCall, ToolCallStatus,
ToolCallUpdate, ToolCallUpdateFields, ToolKind,
};
use serde_json::Value;
use basis::{
event::{Event, Mutability},
tools::SPAWN,
};
pub fn session_update(event: &Event) -> Option<SessionUpdate> {
let update = match event {
Event::RunStarted { .. } | Event::RunFinished { .. } => return None,
Event::UserMessage { .. } => return None,
Event::AssistantDelta { text } => SessionUpdate::AgentMessageChunk(chunk(text)),
Event::AssistantReasoningDelta { text } => SessionUpdate::AgentThoughtChunk(chunk(text)),
Event::AssistantMessage { .. } => return None,
Event::ToolQueued {
tool_call_id,
tool_name,
summary,
mutability,
input,
} => SessionUpdate::ToolCall(
ToolCall::new(tool_call_id.clone(), title(summary, tool_name))
.kind(tool_kind(tool_name, *mutability, input))
.status(ToolCallStatus::Pending)
.raw_input(input.clone()),
),
Event::ToolStarted { tool_call_id, .. } => {
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
tool_call_id.clone(),
ToolCallUpdateFields::new().status(ToolCallStatus::InProgress),
))
}
Event::ToolProgress {
tool_call_id,
progress,
..
} => SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
tool_call_id.clone(),
ToolCallUpdateFields::new().title(progress.clone()),
)),
Event::ToolCompleted {
tool_call_id,
summary,
is_error,
..
} => SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
tool_call_id.clone(),
ToolCallUpdateFields::new()
.status(if *is_error {
ToolCallStatus::Failed
} else {
ToolCallStatus::Completed
})
.content(vec![text_block(summary).into()]),
)),
Event::PermissionRequested { .. } | Event::PermissionResolved { .. } => return None,
Event::TaskUpdated {
title: task_title,
status,
..
} => SessionUpdate::AgentThoughtChunk(chunk(&format!("[{status:?}] {task_title}"))),
Event::CompactionStarted { .. }
| Event::CompactionCompleted { .. }
| Event::MemoryUpdated { .. }
| Event::Usage { .. }
| Event::Branched { .. } => return None,
Event::Notice { message, .. } => SessionUpdate::AgentThoughtChunk(chunk(message)),
Event::Retry {
error,
attempt,
max_attempts,
..
} => SessionUpdate::AgentThoughtChunk(chunk(&format!(
"retrying after {error} (attempt {attempt}/{max_attempts})"
))),
Event::Error { message, .. } => {
SessionUpdate::AgentThoughtChunk(chunk(&format!("error: {message}")))
}
};
Some(update)
}
fn chunk(text: &str) -> ContentChunk {
ContentChunk::new(text_block(text))
}
fn text_block(text: &str) -> ContentBlock {
ContentBlock::Text(TextContent::new(text.to_string()))
}
fn title(summary: &str, tool_name: &str) -> String {
if summary.trim().is_empty() {
tool_name.to_string()
} else {
summary.to_string()
}
}
fn tool_kind(tool_name: &str, mutability: Mutability, input: &Value) -> ToolKind {
if tool_name == SPAWN {
return spawn_kind(input);
}
match tool_name {
"shell" | "bash" | "command" | "background_command" => ToolKind::Execute,
"files" | "read" | "read_file" => match mutability {
Mutability::ReadOnly => ToolKind::Read,
_ => ToolKind::Edit,
},
"write" | "write_file" | "edit" | "edit_file" | "apply_patch" => ToolKind::Edit,
"delete" | "remove" => ToolKind::Delete,
"move" | "rename" => ToolKind::Move,
"search" | "grep" | "glob" | "find" => ToolKind::Search,
"fetch" | "web_fetch" | "http" => ToolKind::Fetch,
"think" | "load_skill" => ToolKind::Think,
_ => match mutability {
Mutability::ReadOnly => ToolKind::Read,
Mutability::Mutating => ToolKind::Edit,
Mutability::Unknown => ToolKind::Other,
},
}
}
const SPAWN_INPUT: &str = "input";
const DELEGATION: ToolKind = ToolKind::Other;
fn spawn_kind(input: &Value) -> ToolKind {
let Some(body) = input.get(SPAWN_INPUT).and_then(Value::as_str) else {
return ToolKind::Execute;
};
match body.trim().strip_prefix('!') {
Some(rest) if rest.starts_with('!') => DELEGATION,
Some(_) => ToolKind::Execute,
None => DELEGATION,
}
}
#[cfg(test)]
mod tests {
use super::*;
use basis::event::{NoticeSeverity, RunOutcome};
use serde_json::json;
fn text_of(chunk: &ContentChunk) -> String {
match &chunk.content {
ContentBlock::Text(text) => text.text.clone(),
other => panic!("expected text content, got {other:?}"),
}
}
#[test]
fn assistant_deltas_become_message_chunks() {
let update = session_update(&Event::AssistantDelta {
text: "hello".to_string(),
})
.expect("mapped");
let SessionUpdate::AgentMessageChunk(chunk) = update else {
panic!("expected an agent message chunk");
};
assert_eq!(text_of(&chunk), "hello");
}
#[test]
fn reasoning_is_a_thought_not_a_message() {
let update = session_update(&Event::AssistantReasoningDelta {
text: "considering".to_string(),
})
.expect("mapped");
assert!(matches!(update, SessionUpdate::AgentThoughtChunk(_)));
}
#[test]
fn the_assembled_message_is_not_sent_after_its_own_deltas() {
assert_eq!(
session_update(&Event::AssistantMessage {
text: "hello".to_string()
}),
None,
"the deltas already carried this text; sending it again renders it twice"
);
}
#[test]
fn lan_bookends_are_not_acp_updates() {
assert_eq!(
session_update(&Event::RunFinished {
outcome: RunOutcome::Ok,
stopped_by: None
}),
None
);
assert_eq!(
session_update(&Event::UserMessage {
text: "hi".to_string()
}),
None,
"the client sent this; echoing it doubles it"
);
}
#[test]
fn a_queued_tool_call_carries_its_title_kind_and_input() {
let update = session_update(&Event::ToolQueued {
tool_call_id: "c1".to_string(),
tool_name: "shell".to_string(),
summary: "Run 'cargo test'".to_string(),
mutability: Mutability::Mutating,
input: json!({"command": "cargo test"}),
})
.expect("mapped");
let SessionUpdate::ToolCall(call) = update else {
panic!("expected a tool call");
};
assert_eq!(&*call.tool_call_id.0, "c1");
assert_eq!(call.title, "Run 'cargo test'");
assert_eq!(call.kind, ToolKind::Execute);
assert_eq!(call.status, ToolCallStatus::Pending);
assert_eq!(
call.raw_input,
Some(json!({"command": "cargo test"})),
"a client showing what a call would do needs its real input"
);
}
#[test]
fn a_call_with_no_summary_falls_back_to_its_name() {
let update = session_update(&Event::ToolQueued {
tool_call_id: "c1".to_string(),
tool_name: "files".to_string(),
summary: " ".to_string(),
mutability: Mutability::ReadOnly,
input: json!({}),
})
.expect("mapped");
let SessionUpdate::ToolCall(call) = update else {
panic!("expected a tool call");
};
assert_eq!(call.title, "files", "a blank title tells a client nothing");
}
#[test]
fn a_completed_call_reports_success_or_failure() {
for (is_error, expected) in [
(false, ToolCallStatus::Completed),
(true, ToolCallStatus::Failed),
] {
let update = session_update(&Event::ToolCompleted {
tool_call_id: "c1".to_string(),
tool_name: "shell".to_string(),
summary: "output".to_string(),
is_error,
})
.expect("mapped");
let SessionUpdate::ToolCallUpdate(call) = update else {
panic!("expected a tool call update");
};
assert_eq!(call.fields.status, Some(expected));
}
}
#[test]
fn a_started_call_goes_in_progress() {
let update = session_update(&Event::ToolStarted {
tool_call_id: "c1".to_string(),
tool_name: "shell".to_string(),
})
.expect("mapped");
let SessionUpdate::ToolCallUpdate(call) = update else {
panic!("expected a tool call update");
};
assert_eq!(call.fields.status, Some(ToolCallStatus::InProgress));
}
#[test]
fn permission_events_are_a_round_trip_not_an_update() {
assert_eq!(
session_update(&Event::PermissionRequested {
request_id: "r1".to_string(),
tool_call_id: "c1".to_string(),
tool_name: "shell".to_string(),
description: "wants to run".to_string(),
preview: json!({}),
}),
None,
"a permission request is session/request_permission, not session/update"
);
}
#[test]
fn an_operator_facing_notice_reaches_the_client() {
let update = session_update(&Event::Notice {
severity: NoticeSeverity::Warning,
message: "context is nearly full".to_string(),
})
.expect("mapped");
let SessionUpdate::AgentThoughtChunk(chunk) = update else {
panic!("expected a thought chunk");
};
assert!(text_of(&chunk).contains("context is nearly full"));
}
#[test]
fn tool_kinds_follow_the_name_then_the_mutability() {
let no_input = json!({});
assert_eq!(
tool_kind("shell", Mutability::Mutating, &no_input),
ToolKind::Execute
);
assert_eq!(
tool_kind("files", Mutability::ReadOnly, &no_input),
ToolKind::Read
);
assert_eq!(
tool_kind("files", Mutability::Mutating, &no_input),
ToolKind::Edit
);
assert_eq!(
tool_kind("grep", Mutability::ReadOnly, &no_input),
ToolKind::Search
);
assert_eq!(
tool_kind("something_new", Mutability::ReadOnly, &no_input),
ToolKind::Read
);
assert_eq!(
tool_kind("something_new", Mutability::Unknown, &no_input),
ToolKind::Other
);
}
#[test]
fn spawn_is_classified_by_its_mode_rather_than_by_its_name() {
assert_eq!(
tool_kind(
SPAWN,
Mutability::Unknown,
&json!({"input": "!cargo test -q"})
),
ToolKind::Execute,
"a command is what `shell` always was"
);
assert_eq!(
tool_kind(
SPAWN,
Mutability::Unknown,
&json!({"input": "find every TODO under src/"})
),
ToolKind::Other,
"ACP v1 has no kind meaning delegation, and `Think` would understate it"
);
assert_eq!(
tool_kind(
SPAWN,
Mutability::Unknown,
&json!({"input": " !!urgent: rewrite the README"})
),
ToolKind::Other,
"`!!` escapes a task whose own text starts with `!`; it is not a command"
);
}
#[test]
fn an_unreadable_spawn_call_reports_the_stronger_mode() {
for input in [json!({}), json!({"input": 7}), json!("!cargo test")] {
assert_eq!(
tool_kind(SPAWN, Mutability::Unknown, &input),
ToolKind::Execute,
"{input}"
);
}
}
#[test]
fn a_queued_spawn_command_reaches_the_client_as_an_execution() {
let update = session_update(&Event::ToolQueued {
tool_call_id: "c1".to_string(),
tool_name: SPAWN.to_string(),
summary: "Run 'cargo test'".to_string(),
mutability: Mutability::Unknown,
input: json!({"input": "!cargo test"}),
})
.expect("mapped");
let SessionUpdate::ToolCall(call) = update else {
panic!("expected a tool call");
};
assert_eq!(call.kind, ToolKind::Execute);
}
}