1use serde_json::Value;
42use std::collections::HashMap;
43
44#[cfg(target_arch = "wasm32")]
50#[allow(dead_code)]
51extern "C" {
52 fn wf_log(level: i32, msg_ptr: i32, msg_len: i32);
53 fn wf_set_response(ptr: i32, len: i32);
54 fn wf_get_host_response_len() -> i32;
55 fn wf_get_host_response(buf_ptr: i32, buf_len: i32) -> i32;
56 fn wf_generate_uuid() -> i32;
57 fn wf_current_time() -> i32;
58 fn wf_vault_get(key_ptr: i32, key_len: i32) -> i32;
59 fn wf_vault_set(key_ptr: i32, key_len: i32, val_ptr: i32, val_len: i32) -> i32;
60 fn wf_config_get(key_ptr: i32, key_len: i32) -> i32;
61 fn wf_http_fetch(
62 method_ptr: i32,
63 method_len: i32,
64 url_ptr: i32,
65 url_len: i32,
66 headers_ptr: i32,
67 headers_len: i32,
68 body_ptr: i32,
69 body_len: i32,
70 ) -> i32;
71 fn wf_read_artifact(id_ptr: i32, id_len: i32) -> i32;
72 fn wf_stage_artifact(payload_ptr: i32, payload_len: i32) -> i32;
73 fn wf_tool_invoke(name_ptr: i32, name_len: i32, args_ptr: i32, args_len: i32) -> i32;
74 fn wf_tool_invoke_many(payload_ptr: i32, payload_len: i32) -> i32;
75}
76
77#[cfg(target_arch = "wasm32")]
80fn read_host_response_string() -> Option<String> {
81 let len = unsafe { wf_get_host_response_len() };
82 if len <= 0 {
83 return None;
84 }
85 let mut buf = vec![0u8; len as usize];
86 let read = unsafe { wf_get_host_response(buf.as_mut_ptr() as i32, len) };
87 if read <= 0 {
88 return None;
89 }
90 buf.truncate(read as usize);
91 String::from_utf8(buf).ok()
92}
93
94#[derive(Debug, Clone)]
100pub struct ToolInput {
101 pub data: Value,
103 pub tool_name: String,
105 pub agent_id: String,
107 pub user_id: Option<String>,
114}
115
116impl ToolInput {
117 pub fn from_json(json: &str) -> Option<Self> {
119 let v: Value = serde_json::from_str(json).ok()?;
120 Some(Self {
121 data: v["input"].clone(),
122 tool_name: v["tool_name"].as_str().unwrap_or("").to_string(),
123 agent_id: v["agent_id"].as_str().unwrap_or("").to_string(),
124 user_id: v["user_id"].as_str().map(|s| s.to_string()),
125 })
126 }
127
128 pub fn get_str(&self, key: &str) -> Option<&str> {
130 self.data.get(key).and_then(|v| v.as_str())
131 }
132
133 pub fn get_i64(&self, key: &str) -> Option<i64> {
135 self.data.get(key).and_then(|v| v.as_i64())
136 }
137
138 pub fn get_f64(&self, key: &str) -> Option<f64> {
140 self.data.get(key).and_then(|v| v.as_f64())
141 }
142
143 pub fn get_bool(&self, key: &str) -> Option<bool> {
145 self.data.get(key).and_then(|v| v.as_bool())
146 }
147
148 pub fn get(&self, key: &str) -> Option<&Value> {
150 self.data.get(key)
151 }
152
153 pub fn raw(&self) -> &Value {
155 &self.data
156 }
157}
158
159#[derive(Debug, Clone)]
166pub struct ToolOutput {
167 success: bool,
168 result: Value,
169 error: Option<String>,
170}
171
172impl ToolOutput {
173 pub fn success(result: Value) -> Self {
175 Self {
176 success: true,
177 result,
178 error: None,
179 }
180 }
181
182 pub fn error(message: &str) -> Self {
184 Self {
185 success: false,
186 result: Value::Null,
187 error: Some(message.to_string()),
188 }
189 }
190
191 pub fn into_json(self) -> String {
193 let obj = serde_json::json!({
194 "success": self.success,
195 "result": self.result,
196 "error": self.error,
197 });
198 serde_json::to_string(&obj)
199 .unwrap_or_else(|_| r#"{"success":false,"error":"serialisation failed"}"#.to_string())
200 }
201}
202
203pub mod vault {
212 #[allow(unused_imports)]
213 use super::*;
214
215 pub fn get(key: &str) -> Option<String> {
221 #[cfg(target_arch = "wasm32")]
222 {
223 let bytes = key.as_bytes();
224 let result = unsafe { wf_vault_get(bytes.as_ptr() as i32, bytes.len() as i32) };
225 if result < 0 {
226 return None;
227 }
228 read_host_response_string()
229 }
230 #[cfg(not(target_arch = "wasm32"))]
231 {
232 let _ = key;
233 None
234 }
235 }
236
237 pub fn set(key: &str, value: &str) -> bool {
243 #[cfg(target_arch = "wasm32")]
244 {
245 let key_bytes = key.as_bytes();
246 let val_bytes = value.as_bytes();
247 let result = unsafe {
248 wf_vault_set(
249 key_bytes.as_ptr() as i32,
250 key_bytes.len() as i32,
251 val_bytes.as_ptr() as i32,
252 val_bytes.len() as i32,
253 )
254 };
255 result == 0
256 }
257 #[cfg(not(target_arch = "wasm32"))]
258 {
259 let _ = (key, value);
260 true
261 }
262 }
263}
264
265pub mod config {
274 #[allow(unused_imports)]
275 use super::*;
276
277 pub fn get(key: &str) -> Option<String> {
283 #[cfg(target_arch = "wasm32")]
284 {
285 let bytes = key.as_bytes();
286 let result = unsafe { wf_config_get(bytes.as_ptr() as i32, bytes.len() as i32) };
287 if result < 0 {
288 return None;
289 }
290 read_host_response_string()
291 }
292 #[cfg(not(target_arch = "wasm32"))]
293 {
294 let _ = key;
295 None
296 }
297 }
298}
299
300pub mod log {
306 #[allow(unused_imports)]
307 use super::*;
308
309 pub fn error(msg: &str) {
311 write(0, msg);
312 }
313
314 pub fn warn(msg: &str) {
316 write(1, msg);
317 }
318
319 pub fn info(msg: &str) {
321 write(2, msg);
322 }
323
324 pub fn debug(msg: &str) {
326 write(3, msg);
327 }
328
329 fn write(level: i32, msg: &str) {
330 #[cfg(target_arch = "wasm32")]
331 {
332 let bytes = msg.as_bytes();
333 unsafe {
334 super::wf_log(level, bytes.as_ptr() as i32, bytes.len() as i32);
335 }
336 }
337 #[cfg(not(target_arch = "wasm32"))]
338 {
339 let _ = (level, msg);
340 }
341 }
342}
343
344pub mod tools {
350 #[allow(unused_imports)]
351 use super::*;
352
353 pub fn invoke(name: &str, args: &Value) -> Result<Value, String> {
373 #[cfg(target_arch = "wasm32")]
374 {
375 let name_bytes = name.as_bytes();
376 let args_json = args.to_string();
377 let args_bytes = args_json.as_bytes();
378 let code = unsafe {
379 wf_tool_invoke(
380 name_bytes.as_ptr() as i32,
381 name_bytes.len() as i32,
382 args_bytes.as_ptr() as i32,
383 args_bytes.len() as i32,
384 )
385 };
386 match code {
387 0 => read_host_response_string()
388 .and_then(|s| serde_json::from_str(&s).ok())
389 .ok_or_else(|| "tool result unavailable".to_string()),
390 -2 => Err("the `tools` capability is not declared in Skill.toml".to_string()),
391 -4 => Err(
392 "denied by platform policy (declaration, grant, capability, or rule) — \
393 surface this failure; the runtime attaches the remedy for the agent"
394 .to_string(),
395 ),
396 -5 => Err("tool-call budget exhausted for this invocation".to_string()),
397 _ => Err("tool invocation failed".to_string()),
398 }
399 }
400 #[cfg(not(target_arch = "wasm32"))]
401 {
402 let _ = (name, args);
403 Err("not running in the WASM runtime".to_string())
404 }
405 }
406
407 pub fn invoke_many(calls: &[(&str, Value)]) -> Result<Vec<Value>, String> {
425 #[cfg(target_arch = "wasm32")]
426 {
427 let payload = Value::Array(
428 calls
429 .iter()
430 .map(|(name, args)| serde_json::json!({"tool": name, "args": args}))
431 .collect(),
432 )
433 .to_string();
434 let bytes = payload.as_bytes();
435 let code = unsafe { wf_tool_invoke_many(bytes.as_ptr() as i32, bytes.len() as i32) };
436 match code {
437 0 => read_host_response_string()
438 .and_then(|s| serde_json::from_str(&s).ok())
439 .ok_or_else(|| "tool results unavailable".to_string()),
440 -2 => Err("the `tools` capability is not declared in Skill.toml".to_string()),
441 -4 => Err(
442 "denied by platform policy (grant, marker ban, or rule) — surface \
443 this failure; the runtime attaches the remedy for the agent"
444 .to_string(),
445 ),
446 -5 => Err("tool-call budget exhausted for this invocation".to_string()),
447 _ => Err("tool invocation failed".to_string()),
448 }
449 }
450 #[cfg(not(target_arch = "wasm32"))]
451 {
452 let _ = calls;
453 Err("not running in the WASM runtime".to_string())
454 }
455 }
456}
457
458pub mod http {
459 #[allow(unused_imports)]
460 use super::*;
461
462 #[derive(Debug, Clone)]
464 pub struct FetchResponse {
465 pub status: i32,
467 pub body: String,
469 pub body_encoding: String,
471 pub headers: HashMap<String, String>,
473 }
474
475 impl FetchResponse {
476 pub fn json(&self) -> Option<Value> {
478 serde_json::from_str(&self.body).ok()
479 }
480
481 pub fn is_success(&self) -> bool {
483 (200..300).contains(&self.status)
484 }
485
486 pub fn is_base64(&self) -> bool {
488 self.body_encoding == "base64"
489 }
490 }
491
492 pub fn fetch(
494 method: &str,
495 url: &str,
496 headers: &[(&str, &str)],
497 body: Option<&str>,
498 ) -> Option<FetchResponse> {
499 #[cfg(target_arch = "wasm32")]
500 {
501 let method_bytes = method.as_bytes();
502 let url_bytes = url.as_bytes();
503 let headers_map: HashMap<&str, &str> = headers.iter().copied().collect();
504 let headers_json = serde_json::to_string(&headers_map).unwrap_or_default();
505 let headers_bytes = headers_json.as_bytes();
506 let (body_bytes, body_len) = match body {
507 Some(b) => (b.as_bytes(), b.len()),
508 None => (&[] as &[u8], 0),
509 };
510
511 let result = unsafe {
512 wf_http_fetch(
513 method_bytes.as_ptr() as i32,
514 method_bytes.len() as i32,
515 url_bytes.as_ptr() as i32,
516 url_bytes.len() as i32,
517 headers_bytes.as_ptr() as i32,
518 headers_bytes.len() as i32,
519 body_bytes.as_ptr() as i32,
520 body_len as i32,
521 )
522 };
523
524 if result < 0 {
525 return None;
526 }
527
528 read_fetch_response()
529 }
530 #[cfg(not(target_arch = "wasm32"))]
531 {
532 let _ = (method, url, headers, body);
533 None
534 }
535 }
536
537 pub fn get(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
539 fetch("GET", url, headers, None)
540 }
541
542 pub fn post(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
544 fetch("POST", url, headers, Some(body))
545 }
546
547 pub fn put(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
549 fetch("PUT", url, headers, Some(body))
550 }
551
552 pub fn delete(url: &str, headers: &[(&str, &str)]) -> Option<FetchResponse> {
554 fetch("DELETE", url, headers, None)
555 }
556
557 pub fn patch(url: &str, headers: &[(&str, &str)], body: &str) -> Option<FetchResponse> {
559 fetch("PATCH", url, headers, Some(body))
560 }
561
562 #[cfg(target_arch = "wasm32")]
563 fn read_fetch_response() -> Option<FetchResponse> {
564 let json_str = read_host_response_string()?;
565 let v: Value = serde_json::from_str(&json_str).ok()?;
566 Some(FetchResponse {
567 status: v["status"].as_i64().unwrap_or(0) as i32,
568 body: v["body"].as_str().unwrap_or("").to_string(),
569 body_encoding: v["body_encoding"].as_str().unwrap_or("utf8").to_string(),
570 headers: v["headers"]
571 .as_object()
572 .map(|m| {
573 m.iter()
574 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
575 .collect()
576 })
577 .unwrap_or_default(),
578 })
579 }
580}
581
582pub mod artifact {
592 #[allow(unused_imports)]
593 use super::*;
594 #[cfg(target_arch = "wasm32")]
595 use base64::Engine;
596
597 #[derive(Debug, Clone)]
599 pub struct Artifact {
600 pub bytes: Vec<u8>,
601 pub mime: String,
602 pub size: usize,
603 }
604
605 #[derive(Debug, Clone)]
608 pub struct StagedArtifact {
609 pub artifact_id: String,
610 pub media_type: String,
611 pub filename: String,
612 pub bytes: u64,
613 }
614
615 impl StagedArtifact {
616 pub fn entry(&self) -> Value {
618 serde_json::json!({
619 "artifact_id": self.artifact_id,
620 "media_type": self.media_type,
621 "filename": self.filename,
622 })
623 }
624 }
625
626 pub fn read(id: &str) -> Option<Artifact> {
629 #[cfg(target_arch = "wasm32")]
630 {
631 let bytes = id.as_bytes();
632 let result = unsafe { wf_read_artifact(bytes.as_ptr() as i32, bytes.len() as i32) };
633 if result < 0 {
634 return None;
635 }
636 let v: Value = serde_json::from_str(&read_host_response_string()?).ok()?;
637 let decoded = base64::engine::general_purpose::STANDARD
638 .decode(v["bytes_base64"].as_str()?)
639 .ok()?;
640 Some(Artifact {
641 bytes: decoded,
642 mime: v["mime"]
643 .as_str()
644 .unwrap_or("application/octet-stream")
645 .to_string(),
646 size: v["size"].as_u64().unwrap_or(0) as usize,
647 })
648 }
649 #[cfg(not(target_arch = "wasm32"))]
650 {
651 let _ = id;
652 None
653 }
654 }
655
656 pub fn stage(bytes: &[u8], media_type: &str, filename: &str) -> Option<StagedArtifact> {
659 #[cfg(target_arch = "wasm32")]
660 {
661 let payload = serde_json::json!({
662 "bytes_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
663 "media_type": media_type,
664 "filename": filename,
665 })
666 .to_string();
667 let pb = payload.as_bytes();
668 let result = unsafe { wf_stage_artifact(pb.as_ptr() as i32, pb.len() as i32) };
669 if result < 0 {
670 return None;
671 }
672 let v: Value = serde_json::from_str(&read_host_response_string()?).ok()?;
673 Some(StagedArtifact {
674 artifact_id: v["artifact_id"].as_str()?.to_string(),
675 media_type: v["media_type"].as_str().unwrap_or(media_type).to_string(),
676 filename: v["filename"].as_str().unwrap_or(filename).to_string(),
677 bytes: v["bytes"].as_u64().unwrap_or(bytes.len() as u64),
678 })
679 }
680 #[cfg(not(target_arch = "wasm32"))]
681 {
682 let _ = (bytes, media_type, filename);
683 None
684 }
685 }
686
687 const DELIVERY_KEY: &str = "generated_images";
689
690 pub fn attach(mut result: Value, files: &[StagedArtifact]) -> Value {
694 if let (Value::Object(map), false) = (&mut result, files.is_empty()) {
695 map.insert(
696 DELIVERY_KEY.to_string(),
697 Value::Array(files.iter().map(StagedArtifact::entry).collect()),
698 );
699 }
700 result
701 }
702}
703
704pub mod util {
708 #[allow(unused_imports)]
709 use super::*;
710
711 pub fn generate_uuid() -> String {
713 #[cfg(target_arch = "wasm32")]
714 {
715 let result = unsafe { super::wf_generate_uuid() };
716 if result < 0 {
717 return String::new();
718 }
719 read_host_response_string().unwrap_or_default()
720 }
721
722 #[cfg(not(target_arch = "wasm32"))]
723 {
724 format!("{:016x}", {
726 use std::time::SystemTime;
727 SystemTime::now()
728 .duration_since(SystemTime::UNIX_EPOCH)
729 .map(|d| d.as_nanos() as u64)
730 .unwrap_or(0)
731 })
732 }
733 }
734
735 pub fn current_time() -> String {
737 #[cfg(target_arch = "wasm32")]
738 {
739 let result = unsafe { super::wf_current_time() };
740 if result < 0 {
741 return String::new();
742 }
743 read_host_response_string().unwrap_or_default()
744 }
745
746 #[cfg(not(target_arch = "wasm32"))]
747 {
748 use std::time::SystemTime;
749 let secs = SystemTime::now()
750 .duration_since(SystemTime::UNIX_EPOCH)
751 .map(|d| d.as_secs())
752 .unwrap_or(0);
753 format!("1970-01-01T00:00:{:02}Z", secs % 60)
754 }
755 }
756}
757
758#[doc(hidden)]
762pub fn __run_tool_handler<F>(ptr: i32, len: i32, f: F) -> i32
763where
764 F: FnOnce(ToolInput) -> ToolOutput,
765{
766 let request_json = unsafe {
768 let slice = std::slice::from_raw_parts(ptr as *const u8, len as usize);
769 String::from_utf8_lossy(slice).into_owned()
770 };
771
772 let input = ToolInput::from_json(&request_json).unwrap_or_else(|| ToolInput {
774 data: Value::Null,
775 tool_name: String::new(),
776 agent_id: String::new(),
777 user_id: None,
778 });
779
780 let output = f(input);
782 let response_bytes = output.into_json().into_bytes();
783
784 let total = 4 + response_bytes.len();
786 let layout = std::alloc::Layout::from_size_align(total, 1).expect("invalid layout");
787 let out_ptr = unsafe { std::alloc::alloc(layout) };
788
789 unsafe {
790 let len_bytes = (response_bytes.len() as u32).to_le_bytes();
791 std::ptr::copy_nonoverlapping(len_bytes.as_ptr(), out_ptr, 4);
792 std::ptr::copy_nonoverlapping(
793 response_bytes.as_ptr(),
794 out_ptr.add(4),
795 response_bytes.len(),
796 );
797 }
798
799 out_ptr as i32
800}
801
802#[macro_export]
813macro_rules! init {
814 () => {
815 #[no_mangle]
816 pub extern "C" fn alloc(size: i32) -> i32 {
817 let layout = std::alloc::Layout::from_size_align(size as usize, 1).unwrap();
818 unsafe { std::alloc::alloc(layout) as i32 }
819 }
820 };
821}
822
823#[macro_export]
840macro_rules! tool {
841 ($name:ident, |$input:ident : ToolInput| $body:expr) => {
842 #[no_mangle]
843 pub extern "C" fn $name(ptr: i32, len: i32) -> i32 {
844 $crate::__run_tool_handler(ptr, len, |$input: $crate::ToolInput| $body)
845 }
846 };
847}
848
849pub mod prelude {
857 pub use crate::artifact;
858 pub use crate::config;
859 pub use crate::http;
860 pub use crate::log;
861 pub use crate::tools;
862 pub use crate::util;
863 pub use crate::vault;
864 pub use crate::ToolInput;
865 pub use crate::ToolOutput;
866 pub use serde_json::{json, Value};
867}
868
869#[cfg(test)]
872mod tests {
873 use super::*;
874 use serde_json::json;
875
876 #[test]
877 fn tool_input_parsing() {
878 let json = serde_json::to_string(&json!({
879 "tool_name": "weather.get_forecast",
880 "handler": "get_forecast",
881 "input": {"city": "London", "units": "metric"},
882 "agent_id": "agent-1",
883 }))
884 .unwrap();
885
886 let input = ToolInput::from_json(&json).unwrap();
887 assert_eq!(input.tool_name, "weather.get_forecast");
888 assert_eq!(input.agent_id, "agent-1");
889 assert_eq!(input.get_str("city"), Some("London"));
890 assert_eq!(input.get_str("units"), Some("metric"));
891 assert!(input.get_str("nonexistent").is_none());
892 }
893
894 #[test]
895 fn tool_input_accessors() {
896 let json = serde_json::to_string(&json!({
897 "input": {"count": 42, "ratio": 2.78, "active": true},
898 }))
899 .unwrap();
900
901 let input = ToolInput::from_json(&json).unwrap();
902 assert_eq!(input.get_i64("count"), Some(42));
903 assert_eq!(input.get_f64("ratio"), Some(2.78));
904 assert_eq!(input.get_bool("active"), Some(true));
905 }
906
907 #[test]
908 fn tool_input_missing_fields() {
909 let json = r#"{"input": {}}"#;
910 let input = ToolInput::from_json(json).unwrap();
911 assert_eq!(input.tool_name, "");
912 assert_eq!(input.agent_id, "");
913 assert_eq!(input.user_id, None);
914 }
915
916 #[test]
917 fn tool_input_parses_user_id() {
918 let json = serde_json::to_string(&json!({
919 "tool_name": "x",
920 "agent_id": "a",
921 "user_id": "alice",
922 "input": {},
923 }))
924 .unwrap();
925 let input = ToolInput::from_json(&json).unwrap();
926 assert_eq!(input.user_id.as_deref(), Some("alice"));
927 }
928
929 #[test]
930 fn tool_input_user_id_absent_when_unset() {
931 let json = r#"{"tool_name": "x", "agent_id": "a", "input": {}}"#;
932 let input = ToolInput::from_json(json).unwrap();
933 assert_eq!(input.user_id, None);
934 }
935
936 #[test]
937 fn tool_output_success() {
938 let output = ToolOutput::success(json!({"data": "test"}));
939 let json_str = output.into_json();
940 let parsed: Value = serde_json::from_str(&json_str).unwrap();
941 assert_eq!(parsed["success"], true);
942 assert_eq!(parsed["result"]["data"], "test");
943 assert!(parsed["error"].is_null());
944 }
945
946 #[test]
947 fn tool_output_error() {
948 let output = ToolOutput::error("something failed");
949 let json_str = output.into_json();
950 let parsed: Value = serde_json::from_str(&json_str).unwrap();
951 assert_eq!(parsed["success"], false);
952 assert!(parsed["result"].is_null());
953 assert_eq!(parsed["error"], "something failed");
954 }
955
956 #[test]
957 fn vault_get_noop_on_native() {
958 assert!(vault::get("any-key").is_none());
959 }
960
961 #[test]
962 fn vault_set_noop_on_native() {
963 assert!(vault::set("key", "value"));
964 }
965
966 #[test]
967 fn config_get_noop_on_native() {
968 assert!(config::get("any-key").is_none());
969 }
970
971 #[test]
972 fn http_get_noop_on_native() {
973 assert!(http::get("https://example.com", &[]).is_none());
974 }
975
976 #[test]
977 fn http_post_noop_on_native() {
978 assert!(http::post("https://example.com", &[], "{}").is_none());
979 }
980
981 #[test]
982 fn util_generate_uuid() {
983 let id = util::generate_uuid();
984 assert!(!id.is_empty());
985 }
986
987 #[test]
988 fn util_current_time() {
989 let time = util::current_time();
990 assert!(!time.is_empty());
991 }
992
993 #[test]
994 fn http_fetch_response_helpers() {
995 let resp = http::FetchResponse {
996 status: 200,
997 body: r#"{"key": "value"}"#.to_string(),
998 body_encoding: "utf8".to_string(),
999 headers: HashMap::new(),
1000 };
1001 assert!(resp.is_success());
1002 assert!(!resp.is_base64());
1003 let json = resp.json().unwrap();
1004 assert_eq!(json["key"], "value");
1005
1006 let err_resp = http::FetchResponse {
1007 status: 404,
1008 body: "not found".to_string(),
1009 body_encoding: "utf8".to_string(),
1010 headers: HashMap::new(),
1011 };
1012 assert!(!err_resp.is_success());
1013
1014 let binary_resp = http::FetchResponse {
1015 status: 200,
1016 body: "aW1hZ2VkYXRh".to_string(),
1017 body_encoding: "base64".to_string(),
1018 headers: HashMap::new(),
1019 };
1020 assert!(binary_resp.is_base64());
1021 }
1022
1023 #[test]
1024 fn artifact_read_stage_noop_on_native() {
1025 assert!(artifact::read("art_0").is_none());
1026 assert!(artifact::stage(b"x", "text/plain", "x.txt").is_none());
1027 }
1028
1029 #[test]
1030 fn tools_invoke_noop_on_native() {
1031 assert!(tools::invoke("echo.say", &json!({})).is_err());
1032 assert!(tools::invoke_many(&[("echo.say", json!({}))]).is_err());
1033 }
1034
1035 #[test]
1036 fn artifact_attach_sets_delivery_key() {
1037 let staged = artifact::StagedArtifact {
1038 artifact_id: "art_00000000000000000000000000000000".to_string(),
1039 media_type: "application/pdf".to_string(),
1040 filename: "out.pdf".to_string(),
1041 bytes: 42,
1042 };
1043 let out = artifact::attach(json!({ "rows": 3 }), std::slice::from_ref(&staged));
1044 assert_eq!(out["rows"], 3);
1045 let entries = out["generated_images"].as_array().unwrap();
1046 assert_eq!(entries.len(), 1);
1047 assert_eq!(entries[0]["artifact_id"], staged.artifact_id);
1048 assert_eq!(entries[0]["media_type"], "application/pdf");
1049 assert_eq!(entries[0]["filename"], "out.pdf");
1050 }
1051
1052 #[test]
1053 fn artifact_attach_empty_is_unchanged() {
1054 let out = artifact::attach(json!({ "rows": 3 }), &[]);
1055 assert!(out.get("generated_images").is_none());
1056 }
1057}