feat: implement toolcall-file-actions spec

This commit is contained in:
Nathan Flurry 2026-02-04 14:35:52 -08:00
parent 7378abee46
commit 618d1e0a31
7 changed files with 661 additions and 63 deletions

1
.turbo Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/.turbo

1
dist Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/dist

1
node_modules Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/node_modules

View file

@ -23,7 +23,10 @@ use tokio::sync::{broadcast, Mutex};
use tokio::time::interval;
use utoipa::{IntoParams, OpenApi, ToSchema};
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
use crate::router::{
AppState, CreateSessionRequest, PermissionReply, SessionFileAction, SessionFileActionKind,
ToolCallSnapshot, ToolCallStatus,
};
use sandbox_agent_error::SandboxError;
use sandbox_agent_agent_management::agents::AgentId;
use sandbox_agent_universal_agent_schema::{
@ -1275,6 +1278,103 @@ fn tool_input_from_arguments(arguments: Option<&str>) -> Value {
json!({ "arguments": arguments })
}
fn file_action_path(action: &SessionFileAction) -> &str {
action
.destination
.as_deref()
.unwrap_or_else(|| action.path.as_str())
}
fn file_action_mime(action: &SessionFileAction) -> &'static str {
if matches!(action.action, SessionFileActionKind::Patch) || action.diff.is_some() {
"text/x-diff"
} else {
"text/plain"
}
}
fn file_actions_to_parts(
session_id: &str,
message_id: &str,
actions: &[SessionFileAction],
) -> Vec<Value> {
actions
.iter()
.map(|action| {
let path = file_action_path(action);
build_file_part_from_path(
session_id,
message_id,
path,
file_action_mime(action),
action.diff.as_deref(),
)
})
.collect()
}
fn file_actions_from_refs(
file_refs: &[(String, FileAction, Option<String>)],
) -> Vec<SessionFileAction> {
file_refs
.iter()
.filter_map(|(path, action, diff)| match action {
FileAction::Write => Some(SessionFileAction {
path: path.clone(),
action: SessionFileActionKind::Write,
diff: diff.clone(),
destination: None,
}),
FileAction::Patch => Some(SessionFileAction {
path: path.clone(),
action: SessionFileActionKind::Patch,
diff: diff.clone(),
destination: None,
}),
_ => None,
})
.collect()
}
fn tool_state_from_snapshot(
snapshot: &ToolCallSnapshot,
now: i64,
attachments: Vec<Value>,
) -> Value {
let input_value = tool_input_from_arguments(Some(snapshot.arguments.as_str()));
let raw_args = snapshot.arguments.clone();
let started_at = snapshot.started_at_ms.unwrap_or(now);
let completed_at = snapshot.completed_at_ms.unwrap_or(now);
match snapshot.status {
ToolCallStatus::Pending => json!({
"status": "pending",
"input": input_value,
"raw": raw_args,
}),
ToolCallStatus::Running => json!({
"status": "running",
"input": input_value,
"time": {"start": started_at},
}),
ToolCallStatus::Completed => json!({
"status": "completed",
"input": input_value,
"output": snapshot.output.clone().unwrap_or_default(),
"title": "Tool result",
"metadata": {},
"time": {"start": started_at, "end": completed_at},
"attachments": attachments,
}),
ToolCallStatus::Error => json!({
"status": "error",
"input": input_value,
"error": snapshot.output.clone().unwrap_or_else(|| "Tool failed".to_string()),
"metadata": {},
"time": {"start": started_at, "end": completed_at},
}),
}
}
fn patterns_from_metadata(metadata: &Option<Value>) -> Vec<String> {
let mut patterns = Vec::new();
let Some(metadata) = metadata else {
@ -1747,10 +1847,27 @@ async fn apply_tool_item_event(
item: UniversalItem,
) {
let session_id = event.session_id.clone();
let tool_info = extract_tool_content(&item.content);
let call_id = match tool_info.call_id.clone() {
Some(call_id) => call_id,
None => return,
let tool_snapshot = state
.inner
.session_manager()
.tool_call_snapshot_for_item(
&session_id,
&item.item_id,
item.native_item_id.as_deref(),
)
.await;
let fallback_info = if tool_snapshot.is_none() {
extract_tool_content(&item.content)
} else {
ToolContentInfo::default()
};
let call_id = tool_snapshot
.as_ref()
.map(|snapshot| snapshot.call_id.clone())
.or_else(|| fallback_info.call_id.clone())
.or_else(|| item.native_item_id.clone());
let Some(call_id) = call_id else {
return;
};
let item_id_key = if item.item_id.is_empty() {
@ -1845,22 +1962,26 @@ async fn apply_tool_item_event(
.opencode
.emit_event(message_event("message.updated", &info));
let file_actions = tool_snapshot
.as_ref()
.map(|snapshot| snapshot.file_actions.clone())
.unwrap_or_else(|| file_actions_from_refs(&fallback_info.file_refs));
let mut attachments = Vec::new();
if item.kind == ItemKind::ToolResult && event.event_type == UniversalEventType::ItemCompleted {
for (path, action, diff) in tool_info.file_refs.iter() {
let mime = match action {
FileAction::Patch => "text/x-diff",
_ => "text/plain",
};
let part =
build_file_part_from_path(&session_id, &message_id, path, mime, diff.as_deref());
let parts = file_actions_to_parts(&session_id, &message_id, &file_actions);
for part in parts {
let path = part
.get("filename")
.and_then(|value| value.as_str())
.unwrap_or("")
.to_string();
upsert_message_part(&state.opencode, &session_id, &message_id, part.clone()).await;
state
.opencode
.emit_event(part_event("message.part.updated", &part));
attachments.push(part.clone());
if matches!(action, FileAction::Write | FileAction::Patch) {
emit_file_edited(&state.opencode, path);
if !path.is_empty() {
emit_file_edited(&state.opencode, &path);
}
}
}
@ -1870,67 +1991,78 @@ async fn apply_tool_item_event(
.get(&call_id)
.cloned()
.unwrap_or_else(|| next_id("part_", &PART_COUNTER));
let tool_name = tool_info
.tool_name
.clone()
let tool_name = tool_snapshot
.as_ref()
.and_then(|snapshot| snapshot.name.clone())
.or_else(|| fallback_info.tool_name.clone())
.unwrap_or_else(|| "tool".to_string());
let input_value = tool_input_from_arguments(tool_info.arguments.as_deref());
let raw_args = tool_info.arguments.clone().unwrap_or_default();
let output_value = tool_info
.output
.clone()
let arguments = tool_snapshot
.as_ref()
.map(|snapshot| snapshot.arguments.clone())
.or_else(|| fallback_info.arguments.clone())
.unwrap_or_default();
let input_value = tool_input_from_arguments(Some(arguments.as_str()));
let raw_args = arguments.clone();
let output_value = tool_snapshot
.as_ref()
.and_then(|snapshot| snapshot.output.clone())
.or_else(|| fallback_info.output.clone())
.or_else(|| extract_text_from_content(&item.content));
let state_value = match event.event_type {
UniversalEventType::ItemStarted => {
if item.kind == ItemKind::ToolResult {
json!({
"status": "running",
"input": input_value,
"time": {"start": now}
})
} else {
json!({
"status": "pending",
"input": input_value,
"raw": raw_args,
})
}
}
UniversalEventType::ItemCompleted => {
if item.kind == ItemKind::ToolResult {
if matches!(item.status, ItemStatus::Failed) {
let state_value = if let Some(snapshot) = tool_snapshot.as_ref() {
tool_state_from_snapshot(snapshot, now, attachments.clone())
} else {
match event.event_type {
UniversalEventType::ItemStarted => {
if item.kind == ItemKind::ToolResult {
json!({
"status": "error",
"status": "running",
"input": input_value,
"error": output_value.unwrap_or_else(|| "Tool failed".to_string()),
"metadata": {},
"time": {"start": now, "end": now},
"time": {"start": now}
})
} else {
json!({
"status": "completed",
"status": "pending",
"input": input_value,
"output": output_value.unwrap_or_default(),
"title": "Tool result",
"metadata": {},
"time": {"start": now, "end": now},
"attachments": attachments,
"raw": raw_args,
})
}
} else {
json!({
"status": "running",
"input": input_value,
"time": {"start": now},
})
}
UniversalEventType::ItemCompleted => {
if item.kind == ItemKind::ToolResult {
if matches!(item.status, ItemStatus::Failed) {
json!({
"status": "error",
"input": input_value,
"error": output_value.unwrap_or_else(|| "Tool failed".to_string()),
"metadata": {},
"time": {"start": now, "end": now},
})
} else {
json!({
"status": "completed",
"input": input_value,
"output": output_value.unwrap_or_default(),
"title": "Tool result",
"metadata": {},
"time": {"start": now, "end": now},
"attachments": attachments,
})
}
} else {
json!({
"status": "running",
"input": input_value,
"time": {"start": now},
})
}
}
_ => json!({
"status": "pending",
"input": input_value,
"raw": raw_args,
}),
}
_ => json!({
"status": "pending",
"input": input_value,
"raw": raw_args,
}),
};
let tool_part = build_tool_part(
@ -1980,6 +2112,73 @@ async fn apply_item_delta(
if is_user_delta {
return;
}
let tool_snapshot = state
.inner
.session_manager()
.tool_call_snapshot_for_item(
&session_id,
item_id_key.as_deref().unwrap_or(""),
native_id_key.as_deref(),
)
.await;
if let Some(snapshot) = tool_snapshot {
let runtime = state
.opencode
.update_runtime(&session_id, |_| {})
.await;
let message_id = runtime
.tool_message_by_call
.get(&snapshot.call_id)
.cloned()
.or_else(|| {
item_id_key
.as_ref()
.and_then(|key| runtime.message_id_for_item.get(key).cloned())
})
.or_else(|| {
native_id_key
.as_ref()
.and_then(|key| runtime.message_id_for_item.get(key).cloned())
});
let Some(message_id) = message_id else {
return;
};
let now = state.opencode.now_ms();
let part_id = runtime
.tool_part_by_call
.get(&snapshot.call_id)
.cloned()
.unwrap_or_else(|| next_id("part_", &PART_COUNTER));
let tool_name = snapshot
.name
.clone()
.unwrap_or_else(|| "tool".to_string());
let state_value = tool_state_from_snapshot(&snapshot, now, Vec::new());
let tool_part = build_tool_part(
&session_id,
&message_id,
&part_id,
&snapshot.call_id,
&tool_name,
state_value,
);
upsert_message_part(&state.opencode, &session_id, &message_id, tool_part.clone()).await;
state
.opencode
.emit_event(part_event("message.part.updated", &tool_part));
let _ = state
.opencode
.update_runtime(&session_id, |runtime| {
runtime
.tool_part_by_call
.insert(snapshot.call_id.clone(), part_id);
runtime
.tool_message_by_call
.insert(snapshot.call_id.clone(), message_id.clone());
})
.await;
return;
}
let mut message_id: Option<String> = None;
let mut parent_id: Option<String> = None;
let runtime = state

View file

@ -336,6 +336,9 @@ struct SessionState {
item_started: HashSet<String>,
item_delta_seen: HashSet<String>,
item_map: HashMap<String, String>,
tool_calls: HashMap<String, ToolCallRecord>,
tool_call_by_item: HashMap<String, String>,
tool_item_kind: HashMap<String, ItemKind>,
mock_sequence: u64,
broadcaster: broadcast::Sender<UniversalEvent>,
opencode_stream_started: bool,
@ -360,6 +363,141 @@ struct PendingQuestion {
options: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ToolCallStatus {
Pending,
Running,
Completed,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SessionFileActionKind {
Write,
Patch,
Rename,
Delete,
}
#[derive(Debug, Clone)]
pub(crate) struct SessionFileAction {
pub path: String,
pub action: SessionFileActionKind,
pub diff: Option<String>,
pub destination: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ToolCallSnapshot {
pub call_id: String,
pub name: Option<String>,
pub arguments: String,
pub output: Option<String>,
pub status: ToolCallStatus,
pub started_at_ms: Option<i64>,
pub completed_at_ms: Option<i64>,
pub file_actions: Vec<SessionFileAction>,
}
#[derive(Debug, Clone)]
struct ToolCallRecord {
call_id: String,
name: Option<String>,
arguments: Option<String>,
arguments_delta: String,
output: Option<String>,
output_delta: String,
status: ToolCallStatus,
started_at_ms: Option<i64>,
completed_at_ms: Option<i64>,
file_actions: Vec<SessionFileAction>,
}
impl ToolCallRecord {
fn new(call_id: String) -> Self {
Self {
call_id,
name: None,
arguments: None,
arguments_delta: String::new(),
output: None,
output_delta: String::new(),
status: ToolCallStatus::Pending,
started_at_ms: None,
completed_at_ms: None,
file_actions: Vec::new(),
}
}
fn arguments_value(&self) -> String {
self.arguments
.clone()
.unwrap_or_else(|| self.arguments_delta.clone())
}
fn output_value(&self) -> Option<String> {
if let Some(output) = self.output.clone() {
Some(output)
} else if self.output_delta.is_empty() {
None
} else {
Some(self.output_delta.clone())
}
}
fn snapshot(&self) -> ToolCallSnapshot {
ToolCallSnapshot {
call_id: self.call_id.clone(),
name: self.name.clone(),
arguments: self.arguments_value(),
output: self.output_value(),
status: self.status,
started_at_ms: self.started_at_ms,
completed_at_ms: self.completed_at_ms,
file_actions: self.file_actions.clone(),
}
}
fn mark_status(&mut self, status: ToolCallStatus, now_ms: i64) {
if matches!(status, ToolCallStatus::Pending | ToolCallStatus::Running) {
if self.started_at_ms.is_none() {
self.started_at_ms = Some(now_ms);
}
}
if matches!(status, ToolCallStatus::Completed | ToolCallStatus::Error) {
if self.completed_at_ms.is_none() {
self.completed_at_ms = Some(now_ms);
}
}
self.status = status;
}
fn apply_delta(&mut self, kind: Option<ItemKind>, delta: &str) {
match kind {
Some(ItemKind::ToolCall) => {
self.arguments_delta.push_str(delta);
}
Some(ItemKind::ToolResult) => {
self.output_delta.push_str(delta);
}
_ => {}
}
}
fn add_file_actions(&mut self, actions: Vec<SessionFileAction>) {
for action in actions {
if !self.file_actions.iter().any(|existing| {
existing.path == action.path
&& existing.action == action.action
&& existing.destination == action.destination
&& existing.diff == action.diff
}) {
self.file_actions.push(action);
}
}
}
}
impl SessionState {
fn new(
session_id: String,
@ -394,6 +532,9 @@ impl SessionState {
item_started: HashSet::new(),
item_delta_seen: HashSet::new(),
item_map: HashMap::new(),
tool_calls: HashMap::new(),
tool_call_by_item: HashMap::new(),
tool_item_kind: HashMap::new(),
mock_sequence: 0,
broadcaster,
opencode_stream_started: false,
@ -620,6 +761,7 @@ impl SessionState {
self.update_pending(&event);
self.update_item_tracking(&event);
self.update_tool_tracking(&event);
self.events.push(event.clone());
let _ = self.broadcaster.send(event.clone());
if self.native_session_id.is_none() {
@ -689,6 +831,132 @@ impl SessionState {
}
}
fn update_tool_tracking(&mut self, event: &UniversalEvent) {
match event.event_type {
UniversalEventType::ItemStarted | UniversalEventType::ItemCompleted => {
let UniversalEventData::Item(data) = &event.data else {
return;
};
let item = &data.item;
if !matches!(item.kind, ItemKind::ToolCall | ItemKind::ToolResult) {
return;
}
let tool_info = tool_tracking_info_from_parts(&item.content);
let call_id = tool_info
.call_id
.clone()
.or_else(|| item.native_item_id.clone())
.or_else(|| {
if item.item_id.is_empty() {
None
} else {
Some(item.item_id.clone())
}
});
let Some(call_id) = call_id else {
return;
};
let record = self
.tool_calls
.entry(call_id.clone())
.or_insert_with(|| ToolCallRecord::new(call_id.clone()));
if let Some(name) = tool_info.tool_name {
record.name = Some(name);
}
if let Some(arguments) = tool_info.arguments {
if !arguments.is_empty() {
record.arguments = Some(arguments);
}
}
if let Some(output) = tool_info.output {
if !output.is_empty() {
record.output = Some(output);
}
}
if event.event_type == UniversalEventType::ItemCompleted
&& item.kind == ItemKind::ToolResult
{
record.add_file_actions(tool_info.file_actions);
}
let now_ms = now_epoch_ms();
match item.kind {
ItemKind::ToolCall => {
if event.event_type == UniversalEventType::ItemStarted {
record.mark_status(ToolCallStatus::Pending, now_ms);
} else {
record.mark_status(ToolCallStatus::Running, now_ms);
}
}
ItemKind::ToolResult => {
if event.event_type == UniversalEventType::ItemStarted {
record.mark_status(ToolCallStatus::Running, now_ms);
} else if matches!(item.status, ItemStatus::Failed) {
record.mark_status(ToolCallStatus::Error, now_ms);
} else {
record.mark_status(ToolCallStatus::Completed, now_ms);
}
}
_ => {}
}
if !item.item_id.is_empty() {
self.tool_call_by_item
.insert(item.item_id.clone(), call_id.clone());
self.tool_item_kind
.insert(item.item_id.clone(), item.kind.clone());
}
if let Some(native) = &item.native_item_id {
self.tool_call_by_item
.insert(native.clone(), call_id.clone());
self.tool_item_kind
.insert(native.clone(), item.kind.clone());
}
}
UniversalEventType::ItemDelta => {
let UniversalEventData::ItemDelta(data) = &event.data else {
return;
};
let item_key = if !data.item_id.is_empty() {
Some(data.item_id.clone())
} else {
data.native_item_id.clone()
};
let Some(item_key) = item_key else {
return;
};
let Some(call_id) = self.tool_call_by_item.get(&item_key).cloned() else {
return;
};
let kind = self.tool_item_kind.get(&item_key).cloned();
let record = self
.tool_calls
.entry(call_id.clone())
.or_insert_with(|| ToolCallRecord::new(call_id.clone()));
record.apply_delta(kind, &data.delta);
}
_ => {}
}
}
fn tool_call_snapshot_for_item(
&self,
item_id: &str,
native_item_id: Option<&str>,
) -> Option<ToolCallSnapshot> {
let call_id = if !item_id.is_empty() {
self.tool_call_by_item.get(item_id).cloned()
} else {
None
}
.or_else(|| native_item_id.and_then(|id| self.tool_call_by_item.get(id).cloned()));
let Some(call_id) = call_id else {
return None;
};
self.tool_calls.get(&call_id).map(|record| record.snapshot())
}
fn take_question(&mut self, question_id: &str) -> Option<PendingQuestion> {
self.pending_questions.remove(question_id)
}
@ -2595,6 +2863,17 @@ impl SessionManager {
Ok(session.record_conversions(conversions))
}
pub(crate) async fn tool_call_snapshot_for_item(
&self,
session_id: &str,
item_id: &str,
native_item_id: Option<&str>,
) -> Option<ToolCallSnapshot> {
let sessions = self.sessions.lock().await;
let session = Self::session_ref(&sessions, session_id)?;
session.tool_call_snapshot_for_item(item_id, native_item_id)
}
async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec<EventConversion> {
let trimmed = line.trim();
if trimmed.is_empty() {
@ -6353,12 +6632,120 @@ fn agent_unparsed(location: &str, error: &str, raw: Value) -> EventConversion {
.with_raw(Some(raw))
}
#[derive(Default)]
struct ToolTrackingInfo {
call_id: Option<String>,
tool_name: Option<String>,
arguments: Option<String>,
output: Option<String>,
file_actions: Vec<SessionFileAction>,
}
fn tool_tracking_info_from_parts(parts: &[ContentPart]) -> ToolTrackingInfo {
let mut info = ToolTrackingInfo::default();
for part in parts {
match part {
ContentPart::ToolCall {
name,
arguments,
call_id,
} => {
info.call_id = Some(call_id.clone());
info.tool_name = Some(name.clone());
info.arguments = Some(arguments.clone());
}
ContentPart::ToolResult { call_id, output } => {
info.call_id = Some(call_id.clone());
info.output = Some(output.clone());
}
ContentPart::FileRef { path, action, diff } => {
let action = match action {
FileAction::Write => Some(SessionFileActionKind::Write),
FileAction::Patch => Some(SessionFileActionKind::Patch),
_ => None,
};
if let Some(action) = action {
info.file_actions.push(SessionFileAction {
path: path.clone(),
action,
diff: diff.clone(),
destination: None,
});
}
}
ContentPart::Json { json } => {
info.file_actions.extend(file_actions_from_json(json));
}
_ => {}
}
}
info
}
fn file_actions_from_json(value: &Value) -> Vec<SessionFileAction> {
let mut actions = Vec::new();
let Some(changes) = value.get("changes").and_then(Value::as_array) else {
return actions;
};
for change in changes {
let Some(path) = change.get("path").and_then(Value::as_str) else {
continue;
};
let diff = change
.get("diff")
.and_then(Value::as_str)
.map(|value| value.to_string());
let kind_value = change.get("kind");
let kind_type = kind_value
.and_then(|value| value.get("type"))
.and_then(Value::as_str)
.or_else(|| kind_value.and_then(Value::as_str));
let Some(kind_type) = kind_type else {
continue;
};
let destination = kind_value
.and_then(|value| value.get("move_path"))
.and_then(Value::as_str)
.map(|value| value.to_string());
let action = match kind_type {
"add" => Some(SessionFileActionKind::Write),
"delete" => Some(SessionFileActionKind::Delete),
"update" => {
if destination.is_some() {
Some(SessionFileActionKind::Rename)
} else {
Some(SessionFileActionKind::Patch)
}
}
_ => None,
};
if let Some(action) = action {
actions.push(SessionFileAction {
path: path.to_string(),
action,
diff: diff.clone(),
destination,
});
}
}
actions
}
fn now_rfc3339() -> String {
time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
fn now_epoch_ms() -> i64 {
let now = time::OffsetDateTime::now_utc();
let timestamp = now.unix_timestamp();
let nanos = now.nanosecond();
timestamp
.saturating_mul(1000)
.saturating_add(i64::from(nanos / 1_000_000))
}
struct TurnStreamState {
initial_events: VecDeque<UniversalEvent>,
receiver: broadcast::Receiver<UniversalEvent>,

View file

@ -37,6 +37,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
tool: false,
file: false,
edited: false,
toolStatuses: [] as string[],
};
const waiter = new Promise<void>((resolve, reject) => {
@ -48,6 +49,10 @@ describe("OpenCode-compatible Tool + File Actions", () => {
const part = event.properties?.part;
if (part?.type === "tool") {
tracker.tool = true;
const status = part?.state?.status;
if (status && tracker.toolStatuses[tracker.toolStatuses.length - 1] !== status) {
tracker.toolStatuses.push(status);
}
}
if (part?.type === "file") {
tracker.file = true;
@ -56,7 +61,7 @@ describe("OpenCode-compatible Tool + File Actions", () => {
if (event.type === "file.edited") {
tracker.edited = true;
}
if (tracker.tool && tracker.file && tracker.edited) {
if (tracker.tool && tracker.file && tracker.edited && tracker.toolStatuses.includes("error")) {
clearTimeout(timeout);
resolve();
break;
@ -81,5 +86,8 @@ describe("OpenCode-compatible Tool + File Actions", () => {
expect(tracker.tool).toBe(true);
expect(tracker.file).toBe(true);
expect(tracker.edited).toBe(true);
expect(tracker.toolStatuses).toContain("pending");
expect(tracker.toolStatuses).toContain("running");
expect(tracker.toolStatuses).toContain("error");
});
});

1
target Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/target