development
rs 683 lines 24.8 KB
Raw
1 //! End-to-end ACP protocol tests for splitfire-agent.
2 //!
3 //! Each test spawns the real `splitfire-agent` binary over stdio, drives it with the official
4 //! SDK's `Client`, and points the agent at a mock OpenAI-compatible `/chat/completions` server
5 //! that plays a scripted model. No real LLM or API key is needed: a dummy `ONDE_API_KEY` is
6 //! sent and the mock asserts it arrives as the bearer token.
7
8 mod common;
9
10 use std::path::{Path, PathBuf};
11 use std::sync::atomic::Ordering;
12 use std::sync::{Arc, Mutex};
13
14 use agent_client_protocol::schema::ProtocolVersion;
15 use agent_client_protocol::schema::v1::{
16 CancelNotification, ClientCapabilities, ContentBlock, FileSystemCapabilities,
17 InitializeRequest, NewSessionRequest, PermissionOptionId, PromptRequest, ReadTextFileRequest,
18 ReadTextFileResponse, RequestPermissionOutcome, RequestPermissionRequest,
19 RequestPermissionResponse, SelectedPermissionOutcome, SessionNotification, SessionUpdate,
20 StopReason, TextContent, ToolCallContent, ToolCallStatus, WriteTextFileRequest,
21 WriteTextFileResponse,
22 };
23 use agent_client_protocol::{AcpAgent, Agent, Client, ConnectionTo};
24 use common::{MockStats, Script, Step, agent_config, start_mock_llm, temp_dir};
25 use serde_json::json;
26
27 // ---------------------------------------------------------------------------
28 // ACP client harness
29 // ---------------------------------------------------------------------------
30
31 #[derive(Debug, Default)]
32 struct Captured {
33 text: String,
34 tool_calls: Vec<(String, ToolCallStatus)>,
35 tool_updates: Vec<(String, ToolCallStatus)>,
36 permission_requests: usize,
37 /// Paths of diffs attached to permission requests (ACP v1: must be absolute).
38 diff_paths: Vec<PathBuf>,
39 /// Tool calls seen as `session/update` ToolCall notifications: (title, locations).
40 tool_call_details: Vec<(String, Vec<PathBuf>)>,
41 }
42
43 /// What the fake user does when asked for permission.
44 #[derive(Clone, Copy)]
45 enum PermissionPolicy {
46 AllowAlways,
47 RejectOnce,
48 }
49
50 /// Whether the fake client advertises the ACP v1 fs methods, and what its
51 /// `fs/read_text_file` should serve.
52 #[derive(Clone, Default)]
53 enum FsPolicy {
54 /// No fs capabilities: the agent MUST fall back to the local filesystem and
55 /// MUST NOT send fs/* requests (spec: https://agentclientprotocol.com/protocol/v1/file-system).
56 #[default]
57 NotAdvertised,
58 /// Advertise readTextFile/writeTextFile and serve `content` for every read.
59 Advertise { content: String },
60 }
61
62 /// Records what fs methods the fake client received, if any.
63 /// A recorded `fs/read_text_file`: path, 1-based line, limit.
64 type ReadCall = (PathBuf, Option<u32>, Option<u32>);
65
66 #[derive(Debug, Default)]
67 struct FsCalls {
68 reads: Mutex<Vec<ReadCall>>,
69 writes: Mutex<Vec<(PathBuf, String)>>,
70 }
71
72 struct Harness {
73 captured: Arc<Mutex<Captured>>,
74 permission_policy: PermissionPolicy,
75 fs_policy: FsPolicy,
76 fs_calls: Arc<FsCalls>,
77 }
78
79 impl Harness {
80 async fn run_prompt(
81 &self,
82 base_url: &str,
83 script_workdir: &Path,
84 prompt: &str,
85 ) -> (StopReason, Arc<Mutex<Captured>>) {
86 self.run_prompt_maybe_cancel(base_url, script_workdir, prompt, false)
87 .await
88 }
89
90 async fn run_prompt_maybe_cancel(
91 &self,
92 base_url: &str,
93 workdir: &Path,
94 prompt: &str,
95 cancel_midway: bool,
96 ) -> (StopReason, Arc<Mutex<Captured>>) {
97 let data_dir = temp_dir("sf-data");
98 let agent = AcpAgent::new(agent_config(
99 env!("CARGO_BIN_EXE_splitfire-agent"),
100 base_url,
101 &data_dir,
102 ));
103 let captured = self.captured.clone();
104 let captured_notify = captured.clone();
105 let policy = self.permission_policy;
106 let fs_calls = self.fs_calls.clone();
107 // Owned per-connection values: what fs capabilities to advertise, and what
108 // the fake fs/read_text_file should serve.
109 let advertise_fs = matches!(self.fs_policy, FsPolicy::Advertise { .. });
110 let serve_content = match &self.fs_policy {
111 FsPolicy::Advertise { content } => content.clone(),
112 FsPolicy::NotAdvertised => String::new(),
113 };
114 let prompt = prompt.to_string();
115
116 let stop_reason = Client
117 .builder()
118 .on_receive_notification(
119 async move |n: SessionNotification, _cx| {
120 let mut c = captured_notify.lock().unwrap();
121 match n.update {
122 SessionUpdate::AgentMessageChunk(chunk) => {
123 if let ContentBlock::Text(t) = chunk.content {
124 c.text.push_str(&t.text);
125 }
126 }
127 SessionUpdate::ToolCall(tc) => {
128 c.tool_calls.push((tc.title.clone(), tc.status));
129 c.tool_call_details.push((
130 tc.title.clone(),
131 tc.locations
132 .iter()
133 .map(|l| l.path.clone())
134 .collect::<Vec<_>>(),
135 ));
136 }
137 SessionUpdate::ToolCallUpdate(upd) => {
138 if let Some(status) = upd.fields.status {
139 c.tool_updates
140 .push((upd.tool_call_id.0.to_string(), status));
141 }
142 }
143 _ => {}
144 }
145 Ok(())
146 },
147 agent_client_protocol::on_receive_notification!(),
148 )
149 .on_receive_request(
150 async move |req: RequestPermissionRequest, responder, _cx| {
151 {
152 let mut c = captured.lock().unwrap();
153 c.permission_requests += 1;
154 // Record the diff paths shown with the permission prompt so
155 // tests can assert the ACP v1 "absolute path" requirement.
156 for content in req.tool_call.fields.content.iter().flatten() {
157 if let ToolCallContent::Diff(d) = content {
158 c.diff_paths.push(d.path.clone());
159 }
160 }
161 }
162 let wanted = match policy {
163 PermissionPolicy::AllowAlways => "allow_always",
164 PermissionPolicy::RejectOnce => "reject_once",
165 };
166 // Sanity: the agent must offer the expected choice.
167 let option = req
168 .options
169 .iter()
170 .find(|o| o.option_id.0.as_ref() == wanted)
171 .unwrap_or_else(|| {
172 panic!("agent did not offer '{wanted}' option: {:?}", req.options)
173 });
174 responder.respond(RequestPermissionResponse::new(
175 RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(
176 PermissionOptionId::new(option.option_id.0.clone()),
177 )),
178 ))
179 },
180 agent_client_protocol::on_receive_request!(),
181 )
182 .on_receive_request(
183 {
184 let fs_calls = fs_calls.clone();
185 let serve_content = serve_content.clone();
186 async move |req: ReadTextFileRequest, responder, _cx| {
187 fs_calls.reads.lock().unwrap().push((
188 req.path.clone(),
189 req.line,
190 req.limit,
191 ));
192 responder.respond(ReadTextFileResponse::new(serve_content.clone()))
193 }
194 },
195 agent_client_protocol::on_receive_request!(),
196 )
197 .on_receive_request(
198 {
199 let fs_calls = fs_calls.clone();
200 async move |req: WriteTextFileRequest, responder, _cx| {
201 fs_calls
202 .writes
203 .lock()
204 .unwrap()
205 .push((req.path.clone(), req.content.clone()));
206 responder.respond(WriteTextFileResponse::new())
207 }
208 },
209 agent_client_protocol::on_receive_request!(),
210 )
211 .connect_with(agent, |connection: ConnectionTo<Agent>| async move {
212 let init = connection
213 .send_request(
214 InitializeRequest::new(ProtocolVersion::V1).client_capabilities(
215 ClientCapabilities::new().fs(if advertise_fs {
216 FileSystemCapabilities::new()
217 .read_text_file(true)
218 .write_text_file(true)
219 } else {
220 FileSystemCapabilities::new()
221 }),
222 ),
223 )
224 .block_task()
225 .await?;
226 let agent_info = init.agent_info.expect("agent must report its info");
227 assert_eq!(agent_info.name, "splitfire-agent");
228 assert!(init.agent_capabilities.prompt_capabilities.embedded_context);
229
230 let session = connection
231 .send_request(NewSessionRequest::new(workdir))
232 .block_task()
233 .await?;
234 let session_id = session.session_id;
235
236 if cancel_midway {
237 // Fire the prompt, cancel shortly after, and observe the stop reason.
238 let connection2 = connection.clone();
239 let sid = session_id.clone();
240 tokio::spawn(async move {
241 tokio::time::sleep(std::time::Duration::from_millis(150)).await;
242 let _ = connection2.send_notification(CancelNotification::new(sid));
243 });
244 }
245
246 let resp = connection
247 .send_request(PromptRequest::new(
248 session_id,
249 vec![ContentBlock::Text(TextContent::new(prompt))],
250 ))
251 .block_task()
252 .await?;
253 Ok(resp.stop_reason)
254 })
255 .await
256 .expect("ACP session failed");
257
258 (stop_reason, self.captured.clone())
259 }
260 }
261
262 fn temp_workdir() -> PathBuf {
263 temp_dir("splitfire-test")
264 }
265
266 // ---------------------------------------------------------------------------
267 // Tests
268 // ---------------------------------------------------------------------------
269
270 /// Full happy path: model calls list_directory → write_file → read_file →
271 /// run_command → final answer, with the user choosing "Always allow".
272 #[tokio::test]
273 async fn acp_full_agent_loop_with_tools() {
274 let script = Script(vec![
275 Step::ToolCall {
276 name: "list_directory",
277 arguments: json!({}),
278 },
279 Step::ToolCall {
280 name: "write_file",
281 arguments: json!({"path": "hello.txt", "content": "hello from acp test"}),
282 },
283 Step::ToolCall {
284 name: "read_file",
285 arguments: json!({"path": "hello.txt"}),
286 },
287 Step::ToolCall {
288 name: "run_command",
289 arguments: json!({"command": "cat hello.txt"}),
290 },
291 Step::Final("Done: created hello.txt and verified its contents."),
292 ]);
293 let stats = Arc::new(MockStats::default());
294 let base_url = start_mock_llm(script, stats.clone()).await;
295 let workdir = temp_workdir();
296
297 let harness = Harness {
298 captured: Arc::default(),
299 permission_policy: PermissionPolicy::AllowAlways,
300 fs_policy: FsPolicy::NotAdvertised,
301 fs_calls: Arc::default(),
302 };
303 let (stop, captured) = harness
304 .run_prompt(&base_url, &workdir, "Create hello.txt")
305 .await;
306
307 assert_eq!(stop, StopReason::EndTurn);
308 let c = captured.lock().unwrap();
309 assert!(
310 c.text.contains("Done: created hello.txt"),
311 "streamed text missing: {}",
312 c.text
313 );
314 // 4 tool calls started; the 2 writes asked for permission (reads/list don't).
315 assert_eq!(
316 c.tool_calls.len(),
317 4,
318 "tool call notifications: {:?}",
319 c.tool_calls
320 );
321 assert_eq!(
322 c.permission_requests, 2,
323 "write_file + run_command should each ask once"
324 );
325 assert!(
326 c.tool_updates
327 .iter()
328 .filter(|(_, s)| *s == ToolCallStatus::Completed)
329 .count()
330 == 4,
331 "all 4 tools should complete: {:?}",
332 c.tool_updates
333 );
334 drop(c);
335
336 // The agent really executed the tools against the local filesystem.
337 let written = std::fs::read_to_string(workdir.join("hello.txt")).unwrap();
338 assert_eq!(written, "hello from acp test");
339
340 // The mock endpoint saw the Onde key as the bearer token, and every request carried
341 // the full tool set (workspace + theory + audio).
342 assert!(stats.requests.load(Ordering::SeqCst) >= 5);
343 assert!(
344 *stats.saw_bearer.lock().unwrap(),
345 "Authorization: Bearer <ONDE_API_KEY> missing"
346 );
347 for payload in stats.payloads.lock().unwrap().iter() {
348 assert_eq!(payload["model"], common::MODEL);
349 assert_eq!(
350 payload["tools"].as_array().unwrap().len(),
351 common::BASE_TOOL_COUNT
352 );
353 }
354
355 std::fs::remove_dir_all(&workdir).ok();
356 }
357
358 /// When the user rejects a write, the agent must report the tool as failed,
359 /// tell the model, and the file must not be created.
360 #[tokio::test]
361 async fn acp_permission_rejection_blocks_write() {
362 let script = Script(vec![
363 Step::ToolCall {
364 name: "write_file",
365 arguments: json!({"path": "nope.txt", "content": "should not exist"}),
366 },
367 Step::Final("Understood, I won't create the file."),
368 ]);
369 let stats = Arc::new(MockStats::default());
370 let base_url = start_mock_llm(script, stats).await;
371 let workdir = temp_workdir();
372
373 let harness = Harness {
374 captured: Arc::default(),
375 permission_policy: PermissionPolicy::RejectOnce,
376 fs_policy: FsPolicy::NotAdvertised,
377 fs_calls: Arc::default(),
378 };
379 let (stop, captured) = harness
380 .run_prompt(&base_url, &workdir, "Create nope.txt")
381 .await;
382
383 assert_eq!(stop, StopReason::EndTurn);
384 let c = captured.lock().unwrap();
385 assert_eq!(c.permission_requests, 1);
386 assert!(
387 c.tool_updates
388 .iter()
389 .any(|(_, s)| *s == ToolCallStatus::Failed),
390 "rejected write must fail: {:?}",
391 c.tool_updates
392 );
393 drop(c);
394 assert!(
395 !workdir.join("nope.txt").exists(),
396 "rejected write must not touch disk"
397 );
398
399 std::fs::remove_dir_all(&workdir).ok();
400 }
401
402 /// A plain text answer with no tool calls still streams agent_message_chunk.
403 #[tokio::test]
404 async fn acp_plain_text_turn() {
405 let script = Script(vec![Step::Final("The answer is 42.")]);
406 let stats = Arc::new(MockStats::default());
407 let base_url = start_mock_llm(script, stats).await;
408 let workdir = temp_workdir();
409
410 let harness = Harness {
411 captured: Arc::default(),
412 permission_policy: PermissionPolicy::AllowAlways,
413 fs_policy: FsPolicy::NotAdvertised,
414 fs_calls: Arc::default(),
415 };
416 let (stop, captured) = harness
417 .run_prompt(&base_url, &workdir, "What is the answer?")
418 .await;
419
420 assert_eq!(stop, StopReason::EndTurn);
421 let c = captured.lock().unwrap();
422 assert_eq!(c.text, "The answer is 42.");
423 assert!(c.tool_calls.is_empty());
424 assert_eq!(c.permission_requests, 0);
425
426 std::fs::remove_dir_all(&workdir).ok();
427 }
428
429 /// session/cancel while a command runs must end the turn with Cancelled and
430 /// still close out every pending tool call.
431 #[tokio::test]
432 async fn acp_cancel_stops_turn() {
433 let script = Script(vec![
434 Step::ToolCall {
435 name: "run_command",
436 arguments: json!({"command": "sleep 30"}),
437 },
438 Step::Final("should never get here"),
439 ]);
440 let stats = Arc::new(MockStats::default());
441 let base_url = start_mock_llm(script, stats).await;
442 let workdir = temp_workdir();
443
444 let harness = Harness {
445 captured: Arc::default(),
446 permission_policy: PermissionPolicy::AllowAlways,
447 fs_policy: FsPolicy::NotAdvertised,
448 fs_calls: Arc::default(),
449 };
450 let (stop, _captured) = harness
451 .run_prompt_maybe_cancel(&base_url, &workdir, "Run a long command", true)
452 .await;
453
454 assert_eq!(stop, StopReason::Cancelled);
455
456 std::fs::remove_dir_all(&workdir).ok();
457 }
458
459 // ---------------------------------------------------------------------------
460 // ACP v1 fs routing (https://agentclientprotocol.com/protocol/v1/file-system)
461 // ---------------------------------------------------------------------------
462
463 /// When the client advertises fs.readTextFile/writeTextFile, the agent must
464 /// route reads and writes through the client instead of the local filesystem:
465 /// the read returns the client-served (e.g. unsaved-buffer) content, and the
466 /// write lands as an fs/write_text_file request with an absolute path.
467 #[tokio::test]
468 async fn acp_fs_advertised_routes_through_client() {
469 let script = Script(vec![
470 Step::ToolCall {
471 name: "read_file",
472 arguments: json!({"path": "buffer.txt"}),
473 },
474 Step::ToolCall {
475 name: "write_file",
476 arguments: json!({"path": "out.txt", "content": "written via client"}),
477 },
478 Step::Final("done"),
479 ]);
480 let stats = Arc::new(MockStats::default());
481 let base_url = start_mock_llm(script, stats).await;
482 let workdir = temp_workdir();
483 // Disk content differs from what the client serves, like an unsaved buffer.
484 std::fs::write(workdir.join("buffer.txt"), "stale on-disk content").unwrap();
485
486 let harness = Harness {
487 captured: Arc::default(),
488 permission_policy: PermissionPolicy::AllowAlways,
489 fs_policy: FsPolicy::Advertise {
490 content: "live buffer content".to_string(),
491 },
492 fs_calls: Arc::default(),
493 };
494 let (stop, _captured) = harness
495 .run_prompt(&base_url, &workdir, "Read and write files")
496 .await;
497 assert_eq!(stop, StopReason::EndTurn);
498
499 let f = harness.fs_calls;
500 let reads = f.reads.lock().unwrap();
501 assert_eq!(
502 reads.len(),
503 2,
504 "read_file on buffer.txt + write_file's diff pre-read of out.txt: {:?}",
505 reads
506 );
507 for (path, _line, _limit) in reads.iter() {
508 assert!(
509 path.is_absolute(),
510 "fs/read_text_file path must be absolute, got {}",
511 path.display()
512 );
513 }
514 // The model's read_file went to buffer.txt, canonicalized to its true path.
515 assert_eq!(
516 reads[0].0,
517 workdir.join("buffer.txt").canonicalize().unwrap()
518 );
519 // write_file's diff pre-read went to the not-yet-existing out.txt, absolutized.
520 assert!(reads[1].0.ends_with("out.txt"));
521
522 let writes = f.writes.lock().unwrap();
523 assert_eq!(writes.len(), 1);
524 let (path, content) = &writes[0];
525 assert!(
526 path.is_absolute(),
527 "fs/write_text_file path must be absolute, got {}",
528 path.display()
529 );
530 assert!(path.ends_with("out.txt"));
531 assert_eq!(content, "written via client");
532
533 // The agent must NOT have written to the local filesystem directly: with
534 // fs.writeTextFile advertised, the write went through the client.
535 assert!(
536 !workdir.join("out.txt").exists(),
537 "agent wrote to local fs despite advertising fs.writeTextFile"
538 );
539
540 std::fs::remove_dir_all(&workdir).ok();
541 }
542
543 /// The read_file result fed back to the model must be the client-served
544 /// content, proving the agent read through fs/read_text_file rather than disk.
545 #[tokio::test]
546 async fn acp_fs_read_uses_client_content_in_conversation() {
547 let script = Script(vec![
548 Step::ToolCall {
549 name: "read_file",
550 arguments: json!({"path": "buffer.txt", "line": 1, "limit": 5}),
551 },
552 Step::Final("done"),
553 ]);
554 let stats = Arc::new(MockStats::default());
555 let base_url = start_mock_llm(script, stats).await;
556 let workdir = temp_workdir();
557 std::fs::write(
558 workdir.join("buffer.txt"),
559 "one\ntwo\nthree\nfour\nfive\nsix\n",
560 )
561 .unwrap();
562
563 let harness = Harness {
564 captured: Arc::default(),
565 permission_policy: PermissionPolicy::AllowAlways,
566 fs_policy: FsPolicy::Advertise {
567 content: "client-served line".to_string(),
568 },
569 fs_calls: Arc::default(),
570 };
571 let (stop, _captured) = harness.run_prompt(&base_url, &workdir, "Read a file").await;
572 assert_eq!(stop, StopReason::EndTurn);
573
574 let reads = harness.fs_calls.reads.lock().unwrap();
575 assert_eq!(reads.len(), 1);
576 let (path, line, limit) = &reads[0];
577 assert!(path.is_absolute());
578 assert_eq!(*line, Some(1), "1-based `line` must be forwarded");
579 assert_eq!(*limit, Some(5), "`limit` must be forwarded");
580
581 std::fs::remove_dir_all(&workdir).ok();
582 }
583
584 /// With fs capabilities absent, the agent MUST NOT send any fs/* requests
585 /// (spec: "If readTextFile or writeTextFile is false or not present, the Agent
586 /// MUST NOT attempt to call the corresponding filesystem method") and must fall
587 /// back to the local filesystem.
588 #[tokio::test]
589 async fn acp_fs_not_advertised_falls_back_to_local() {
590 let script = Script(vec![
591 Step::ToolCall {
592 name: "read_file",
593 arguments: json!({"path": "hello.txt"}),
594 },
595 Step::Final("done"),
596 ]);
597 let stats = Arc::new(MockStats::default());
598 let base_url = start_mock_llm(script, stats).await;
599 let workdir = temp_workdir();
600 std::fs::write(workdir.join("hello.txt"), "local content").unwrap();
601
602 let harness = Harness {
603 captured: Arc::default(),
604 permission_policy: PermissionPolicy::AllowAlways,
605 fs_policy: FsPolicy::NotAdvertised,
606 fs_calls: Arc::default(),
607 };
608 let (stop, _captured) = harness.run_prompt(&base_url, &workdir, "Read a file").await;
609 assert_eq!(stop, StopReason::EndTurn);
610
611 assert!(
612 harness.fs_calls.reads.lock().unwrap().is_empty(),
613 "agent sent fs/read_text_file despite no advertised fs capability"
614 );
615 assert!(
616 harness.fs_calls.writes.lock().unwrap().is_empty(),
617 "agent sent fs/write_text_file despite no advertised fs capability"
618 );
619
620 std::fs::remove_dir_all(&workdir).ok();
621 }
622
623 /// ACP v1 requires absolute paths on every path the agent sends to the client:
624 /// `ToolCallLocation.path`, `Diff.path`, `fs/read_text_file` and
625 /// `fs/write_text_file` paths (spec: https://agentclientprotocol.com/protocol/v1).
626 /// The model passes relative paths; the agent must absolutize them.
627 #[tokio::test]
628 async fn acp_tool_metadata_paths_are_absolute() {
629 let script = Script(vec![
630 Step::ToolCall {
631 name: "write_file",
632 arguments: json!({"path": "new/dir/created.txt", "content": "hi"}),
633 },
634 Step::Final("done"),
635 ]);
636 let stats = Arc::new(MockStats::default());
637 let base_url = start_mock_llm(script, stats).await;
638 let workdir = temp_workdir();
639
640 let harness = Harness {
641 captured: Arc::default(),
642 permission_policy: PermissionPolicy::AllowAlways,
643 fs_policy: FsPolicy::NotAdvertised,
644 fs_calls: Arc::default(),
645 };
646 let (stop, captured) = harness
647 .run_prompt(&base_url, &workdir, "Write a file")
648 .await;
649 assert_eq!(stop, StopReason::EndTurn);
650
651 let c = captured.lock().unwrap();
652 // ToolCallLocation.path must be absolute, even for a file that doesn't exist yet.
653 let locations = c
654 .tool_call_details
655 .iter()
656 .flat_map(|(_, locs)| locs.iter())
657 .collect::<Vec<_>>();
658 assert_eq!(locations.len(), 1, "write_file should report one location");
659 assert!(
660 locations[0].is_absolute(),
661 "ToolCallLocation.path must be absolute, got {}",
662 locations[0].display()
663 );
664 assert!(locations[0].ends_with("new/dir/created.txt"));
665
666 // Diff.path on the permission prompt must be absolute too.
667 assert_eq!(c.diff_paths.len(), 1, "write_file asks for permission once");
668 let diff = &c.diff_paths[0];
669 assert!(
670 diff.is_absolute(),
671 "Diff.path must be absolute, got {}",
672 diff.display()
673 );
674 assert!(diff.ends_with("new/dir/created.txt"));
675 drop(c);
676
677 assert_eq!(
678 std::fs::read_to_string(workdir.join("new/dir/created.txt")).unwrap(),
679 "hi"
680 );
681
682 std::fs::remove_dir_all(&workdir).ok();
683 }