1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
3use serde_json::json;
4use std::path::{Path, PathBuf};
5
6use super::workspace_state;
7
8pub struct WorkspaceTool;
15
16impl WorkspaceTool {
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22impl Default for WorkspaceTool {
23 fn default() -> Self {
24 Self::new()
25 }
26}
27
28#[async_trait]
29impl Tool for WorkspaceTool {
30 fn name(&self) -> &str {
31 "Workspace"
32 }
33
34 fn description(&self) -> &str {
35 "Get or set the current session workspace directory. Call without 'path' to get the current workspace; call with 'path' to change it."
36 }
37
38 fn classify(&self, args: &serde_json::Value) -> ToolClass {
39 let has_path = args
40 .get("path")
41 .and_then(|v| v.as_str())
42 .map(str::trim)
43 .is_some_and(|v| !v.is_empty());
44 if has_path {
45 ToolClass::MUTATING_SERIAL
46 } else {
47 ToolClass::READONLY_PARALLEL
48 }
49 }
50
51 fn parameters_schema(&self) -> serde_json::Value {
52 json!({
53 "type": "object",
54 "properties": {
55 "path": {
56 "type": "string",
57 "description": "Path of the workspace directory to set. Omit to just read the current workspace."
58 }
59 },
60 "additionalProperties": false
61 })
62 }
63
64 async fn invoke(
65 &self,
66 args: serde_json::Value,
67 ctx: ToolCtx,
68 ) -> Result<ToolOutcome, ToolError> {
69 let path_arg = args
70 .get("path")
71 .and_then(|v| v.as_str())
72 .map(|s| s.trim())
73 .filter(|s| !s.is_empty());
74
75 match path_arg {
76 Some(path) => {
78 let session_id = ctx.session_id().ok_or_else(|| {
79 ToolError::Execution(
80 "Workspace(set) requires a session_id in tool context".to_string(),
81 )
82 })?;
83
84 let base = workspace_state::workspace_or_process_cwd(Some(session_id));
85 let raw_path = Path::new(path);
86 let path_obj: PathBuf = if raw_path.is_absolute() {
87 raw_path.to_path_buf()
88 } else {
89 base.join(raw_path)
90 };
91
92 if !path_obj.exists() {
93 return Ok(ToolOutcome::Completed(ToolResult {
94 success: false,
95 result: format!("Path does not exist: {}", path_obj.display()),
96 display_preference: Some("error".to_string()),
97 images: Vec::new(),
98 }));
99 }
100 if !path_obj.is_dir() {
101 return Ok(ToolOutcome::Completed(ToolResult {
102 success: false,
103 result: format!("Path is not a directory: {}", path_obj.display()),
104 display_preference: Some("error".to_string()),
105 images: Vec::new(),
106 }));
107 }
108
109 let absolute_path = path_obj.canonicalize().map_err(|e| {
110 ToolError::Execution(format!("Failed to canonicalize path: {e}"))
111 })?;
112
113 let stored = workspace_state::set_workspace(session_id, absolute_path.clone());
118 let relocated = stored != absolute_path;
119
120 let mut payload = json!({
121 "session_id": session_id,
122 "workspace": bamboo_config::paths::path_to_display_string(&stored)
123 });
124 if relocated {
125 payload["relocated_from"] =
126 json!(bamboo_config::paths::path_to_display_string(&absolute_path));
127 }
128
129 Ok(ToolOutcome::Completed(ToolResult {
130 success: true,
131 result: payload.to_string(),
132 display_preference: Some("json".to_string()),
133 images: Vec::new(),
134 }))
135 }
136
137 None => {
139 if let Some(session_id) = ctx.session_id() {
140 if let Some(workspace) = workspace_state::get_workspace(session_id) {
141 return Ok(ToolOutcome::Completed(ToolResult {
142 success: true,
143 result: bamboo_config::paths::path_to_display_string(&workspace),
144 display_preference: None,
145 images: Vec::new(),
146 }));
147 }
148 }
149
150 match std::env::current_dir() {
151 Ok(dir) => Ok(ToolOutcome::Completed(ToolResult {
152 success: true,
153 result: bamboo_config::paths::path_to_display_string(&dir),
154 display_preference: None,
155 images: Vec::new(),
156 })),
157 Err(error) => Ok(ToolOutcome::Completed(ToolResult {
158 success: false,
159 result: format!("Failed to get current directory: {error}"),
160 display_preference: Some("error".to_string()),
161 images: Vec::new(),
162 })),
163 }
164 }
165 }
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[tokio::test]
174 async fn workspace_get_returns_non_empty_path() {
175 let tool = WorkspaceTool::new();
176 let out = tool
177 .invoke(json!({}), ToolCtx::none("Workspace"))
178 .await
179 .unwrap();
180 let ToolOutcome::Completed(result) = out else {
181 panic!("expected Completed")
182 };
183 assert!(result.success);
184 assert!(!result.result.trim().is_empty());
185 }
186
187 #[tokio::test]
188 async fn workspace_get_prefers_session_workspace() {
189 let dir = tempfile::tempdir().unwrap();
190 let workspace = dir.path().join("workspace");
191 tokio::fs::create_dir_all(&workspace).await.unwrap();
192 let session = format!("session_{}", uuid::Uuid::new_v4());
193 workspace_state::set_workspace(&session, workspace.clone());
194
195 let tool = WorkspaceTool::new();
196 let out = tool
197 .invoke(
198 json!({}),
199 ToolCtx {
200 session_id: Some(std::sync::Arc::from(session.as_str())),
201 tool_call_id: std::sync::Arc::from("call_1"),
202 event_tx: None,
203 available_tool_schemas: std::sync::Arc::from(Vec::new()),
204 bypass_permissions: false,
205 auto_approve_permissions: false,
206 plan_read_only: false,
207 can_async_resume: false,
208 async_completion_sink: None,
209 bash_completion_sink: None,
210 },
211 )
212 .await
213 .unwrap();
214 let ToolOutcome::Completed(result) = out else {
215 panic!("expected Completed")
216 };
217 assert!(result.success);
218 assert_eq!(
219 result.result,
220 bamboo_config::paths::path_to_display_string(&workspace)
221 );
222 }
223
224 #[tokio::test]
225 async fn workspace_set_changes_session_workspace() {
226 let dir = tempfile::tempdir().unwrap();
227 let workspace = dir.path().join("ws");
228 tokio::fs::create_dir_all(&workspace).await.unwrap();
229 let session = format!("session_{}", uuid::Uuid::new_v4());
230
231 let tool = WorkspaceTool::new();
232 let out = tool
233 .invoke(
234 json!({"path": workspace.to_string_lossy()}),
235 ToolCtx {
236 session_id: Some(std::sync::Arc::from(session.as_str())),
237 tool_call_id: std::sync::Arc::from("call_1"),
238 event_tx: None,
239 available_tool_schemas: std::sync::Arc::from(Vec::new()),
240 bypass_permissions: false,
241 auto_approve_permissions: false,
242 plan_read_only: false,
243 can_async_resume: false,
244 async_completion_sink: None,
245 bash_completion_sink: None,
246 },
247 )
248 .await
249 .unwrap();
250 let ToolOutcome::Completed(result) = out else {
251 panic!("expected Completed")
252 };
253 assert!(result.success);
254
255 let get_out = tool
257 .invoke(
258 json!({}),
259 ToolCtx {
260 session_id: Some(std::sync::Arc::from(session.as_str())),
261 tool_call_id: std::sync::Arc::from("call_2"),
262 event_tx: None,
263 available_tool_schemas: std::sync::Arc::from(Vec::new()),
264 bypass_permissions: false,
265 auto_approve_permissions: false,
266 plan_read_only: false,
267 can_async_resume: false,
268 async_completion_sink: None,
269 bash_completion_sink: None,
270 },
271 )
272 .await
273 .unwrap();
274 let ToolOutcome::Completed(get_result) = get_out else {
275 panic!("expected Completed")
276 };
277 assert!(get_result.success);
278 let expected = workspace.canonicalize().unwrap();
279 assert_eq!(
280 get_result.result,
281 bamboo_config::paths::path_to_display_string(&expected)
282 );
283 }
284
285 #[tokio::test]
286 async fn workspace_set_rejects_missing_path() {
287 let tool = WorkspaceTool::new();
288 let out = tool
289 .invoke(
290 json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
291 ToolCtx {
292 session_id: Some(std::sync::Arc::from("session_1")),
293 tool_call_id: std::sync::Arc::from("call_1"),
294 event_tx: None,
295 available_tool_schemas: std::sync::Arc::from(Vec::new()),
296 bypass_permissions: false,
297 auto_approve_permissions: false,
298 plan_read_only: false,
299 can_async_resume: false,
300 async_completion_sink: None,
301 bash_completion_sink: None,
302 },
303 )
304 .await
305 .unwrap();
306 let ToolOutcome::Completed(result) = out else {
307 panic!("expected Completed")
308 };
309 assert!(!result.success);
310 assert!(result.result.contains("does not exist"));
311 }
312
313 #[tokio::test]
314 async fn workspace_set_requires_session_context() {
315 let tool = WorkspaceTool::new();
316 let err = tool
317 .invoke(json!({"path": "/"}), ToolCtx::none("Workspace"))
318 .await
319 .expect_err("missing session should fail");
320 assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
321 }
322
323 }