diff --git a/.turbo b/.turbo new file mode 120000 index 0000000..0b7d9ca --- /dev/null +++ b/.turbo @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/.turbo \ No newline at end of file diff --git a/dist b/dist new file mode 120000 index 0000000..f02d77f --- /dev/null +++ b/dist @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/dist \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..501480b --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/node_modules \ No newline at end of file diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55b7050..b466187 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -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 { + 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)], +) -> Vec { + 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 { + 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) -> Vec { 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 = None; let mut parent_id: Option = None; let runtime = state diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 3ca437a..6eb1763 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -336,6 +336,9 @@ struct SessionState { item_started: HashSet, item_delta_seen: HashSet, item_map: HashMap, + tool_calls: HashMap, + tool_call_by_item: HashMap, + tool_item_kind: HashMap, mock_sequence: u64, broadcaster: broadcast::Sender, opencode_stream_started: bool, @@ -360,6 +363,141 @@ struct PendingQuestion { options: Vec, } +#[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, + pub destination: Option, +} + +#[derive(Debug, Clone)] +pub(crate) struct ToolCallSnapshot { + pub call_id: String, + pub name: Option, + pub arguments: String, + pub output: Option, + pub status: ToolCallStatus, + pub started_at_ms: Option, + pub completed_at_ms: Option, + pub file_actions: Vec, +} + +#[derive(Debug, Clone)] +struct ToolCallRecord { + call_id: String, + name: Option, + arguments: Option, + arguments_delta: String, + output: Option, + output_delta: String, + status: ToolCallStatus, + started_at_ms: Option, + completed_at_ms: Option, + file_actions: Vec, +} + +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 { + 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, 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) { + 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 { + 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 { 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 { + 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 { 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, + tool_name: Option, + arguments: Option, + output: Option, + file_actions: Vec, +} + +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 { + 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, receiver: broadcast::Receiver, diff --git a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts index 4cdda8f..335e93d 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/tools.test.ts @@ -37,6 +37,7 @@ describe("OpenCode-compatible Tool + File Actions", () => { tool: false, file: false, edited: false, + toolStatuses: [] as string[], }; const waiter = new Promise((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"); }); }); diff --git a/target b/target new file mode 120000 index 0000000..3d6ad8c --- /dev/null +++ b/target @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/target \ No newline at end of file