Skip to main content

bamboo_tools/tools/
workspace.rs

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
8/// Unified workspace tool: get or set the session working directory.
9///
10/// - When called **without** `path`  → returns the current workspace directory.
11/// - When called **with** `path`     → sets the workspace and returns the new path.
12///
13/// This replaces the previous `GetCurrentDir` + `SetWorkspace` pair.
14pub 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            // ── SET mode ──────────────────────────────────────────────
77            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                // `set_workspace` returns the FINAL stored path, which may
114                // differ from `absolute_path` when workspace-root
115                // confinement (#217) relocated it — report the truth rather
116                // than the pre-relocation request.
117                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            // ── GET mode ──────────────────────────────────────────────
138            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                    executing_caller: None,
201                    executing_supervisor: None,
202                    session_id: Some(std::sync::Arc::from(session.as_str())),
203                    tool_call_id: std::sync::Arc::from("call_1"),
204                    event_tx: None,
205                    available_tool_schemas: std::sync::Arc::from(Vec::new()),
206                    bypass_permissions: false,
207                    auto_approve_permissions: false,
208                    plan_read_only: false,
209                    can_async_resume: false,
210                    async_completion_sink: None,
211                    bash_completion_sink: None,
212                },
213            )
214            .await
215            .unwrap();
216        let ToolOutcome::Completed(result) = out else {
217            panic!("expected Completed")
218        };
219        assert!(result.success);
220        assert_eq!(
221            result.result,
222            bamboo_config::paths::path_to_display_string(&workspace)
223        );
224    }
225
226    #[tokio::test]
227    async fn workspace_set_changes_session_workspace() {
228        let dir = tempfile::tempdir().unwrap();
229        let workspace = dir.path().join("ws");
230        tokio::fs::create_dir_all(&workspace).await.unwrap();
231        let session = format!("session_{}", uuid::Uuid::new_v4());
232
233        let tool = WorkspaceTool::new();
234        let out = tool
235            .invoke(
236                json!({"path": workspace.to_string_lossy()}),
237                ToolCtx {
238                    executing_caller: None,
239                    executing_supervisor: None,
240                    session_id: Some(std::sync::Arc::from(session.as_str())),
241                    tool_call_id: std::sync::Arc::from("call_1"),
242                    event_tx: None,
243                    available_tool_schemas: std::sync::Arc::from(Vec::new()),
244                    bypass_permissions: false,
245                    auto_approve_permissions: false,
246                    plan_read_only: false,
247                    can_async_resume: false,
248                    async_completion_sink: None,
249                    bash_completion_sink: None,
250                },
251            )
252            .await
253            .unwrap();
254        let ToolOutcome::Completed(result) = out else {
255            panic!("expected Completed")
256        };
257        assert!(result.success);
258
259        // Verify get mode now returns the new workspace
260        let get_out = tool
261            .invoke(
262                json!({}),
263                ToolCtx {
264                    executing_caller: None,
265                    executing_supervisor: None,
266                    session_id: Some(std::sync::Arc::from(session.as_str())),
267                    tool_call_id: std::sync::Arc::from("call_2"),
268                    event_tx: None,
269                    available_tool_schemas: std::sync::Arc::from(Vec::new()),
270                    bypass_permissions: false,
271                    auto_approve_permissions: false,
272                    plan_read_only: false,
273                    can_async_resume: false,
274                    async_completion_sink: None,
275                    bash_completion_sink: None,
276                },
277            )
278            .await
279            .unwrap();
280        let ToolOutcome::Completed(get_result) = get_out else {
281            panic!("expected Completed")
282        };
283        assert!(get_result.success);
284        let expected = workspace.canonicalize().unwrap();
285        assert_eq!(
286            get_result.result,
287            bamboo_config::paths::path_to_display_string(&expected)
288        );
289    }
290
291    #[tokio::test]
292    async fn workspace_set_rejects_missing_path() {
293        let tool = WorkspaceTool::new();
294        let out = tool
295            .invoke(
296                json!({"path": "/tmp/bamboo-no-such-workspace-xyz-99999"}),
297                ToolCtx {
298                    executing_caller: None,
299                    executing_supervisor: None,
300                    session_id: Some(std::sync::Arc::from("session_1")),
301                    tool_call_id: std::sync::Arc::from("call_1"),
302                    event_tx: None,
303                    available_tool_schemas: std::sync::Arc::from(Vec::new()),
304                    bypass_permissions: false,
305                    auto_approve_permissions: false,
306                    plan_read_only: false,
307                    can_async_resume: false,
308                    async_completion_sink: None,
309                    bash_completion_sink: None,
310                },
311            )
312            .await
313            .unwrap();
314        let ToolOutcome::Completed(result) = out else {
315            panic!("expected Completed")
316        };
317        assert!(!result.success);
318        assert!(result.result.contains("does not exist"));
319    }
320
321    #[tokio::test]
322    async fn workspace_set_requires_session_context() {
323        let tool = WorkspaceTool::new();
324        let err = tool
325            .invoke(json!({"path": "/"}), ToolCtx::none("Workspace"))
326            .await
327            .expect_err("missing session should fail");
328        assert!(matches!(err, ToolError::Execution(msg) if msg.contains("session_id")));
329    }
330
331    // NOTE: the end-to-end test exercising `set_workspace_root_provider`
332    // (issue #217) lives in `tests/workspace_root_provider.rs`, NOT here.
333    // That `OnceLock` is process-global and first-registration-wins across
334    // this ENTIRE lib's unit-test binary (bash/glob/grep/slash_command/
335    // workspace tests all share one process) — registering it in-line here
336    // would non-deterministically poison every other test in the binary that
337    // assumes the pre-#217 unconfined default (e.g.
338    // `workspace_set_changes_session_workspace` below, which sets an
339    // arbitrary outside-any-root tempdir and expects it stored verbatim). A
340    // separate `tests/*.rs` integration file compiles to its own process, so
341    // it can safely register the provider without affecting anything here.
342}