Skip to main content

zai_rs/services/applications/
request.rs

1//! Typed requests for the seven LLM-application endpoints.
2//!
3//! Credentials and transport live on [`ZaiClient`]; each request models only
4//! the fields declared by the frozen OpenAPI operation.
5
6use serde::Serialize;
7
8use crate::services::applications::response::{
9    ApplicationConversationCreateResponse, ApplicationFileStatsResponse,
10    ApplicationFileUploadResponse, ApplicationHistoryResponse, ApplicationInvokeResponse,
11    ApplicationSliceInfoResponse, ApplicationVariablesResponse,
12};
13use crate::{ZaiResult, client::ZaiClient};
14
15/// Request body for application file parsing statuses.
16#[derive(Clone, PartialEq, Eq, Serialize)]
17pub struct ApplicationFileStatsRequest {
18    /// Application identifier.
19    pub app_id: String,
20    /// File identifiers whose parsing status should be returned.
21    pub file_ids: Vec<String>,
22}
23
24impl std::fmt::Debug for ApplicationFileStatsRequest {
25    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        formatter
27            .debug_struct("ApplicationFileStatsRequest")
28            .field("app_id", &"[REDACTED]")
29            .field("file_count", &self.file_ids.len())
30            .finish()
31    }
32}
33
34impl ApplicationFileStatsRequest {
35    /// Create a file-statistics request with all OpenAPI-required fields.
36    pub fn new(app_id: impl Into<String>, file_ids: Vec<String>) -> Self {
37        Self {
38            app_id: app_id.into(),
39            file_ids,
40        }
41    }
42
43    /// Validate the required application id and non-empty file-id list.
44    pub fn validate(&self) -> ZaiResult<()> {
45        crate::client::validation::require_non_blank(&self.app_id, "app_id")?;
46        if self.file_ids.is_empty() {
47            return Err(crate::client::validation::invalid(
48                "at least one file_id is required",
49            ));
50        }
51        for file_id in &self.file_ids {
52            crate::client::validation::require_non_blank(file_id, "file_id")?;
53        }
54        Ok(())
55    }
56
57    /// Send the request through `client`.
58    pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<ApplicationFileStatsResponse> {
59        self.validate()?;
60        let route = crate::client::routes::APPLICATIONS_FILE_STATS;
61        let url = client.endpoints().resolve_route(route, &[])?;
62        client
63            .send_json::<_, ApplicationFileStatsResponse>(route.method(), url, self)
64            .await
65    }
66}
67
68/// Multipart request for uploading one or more application files.
69pub struct ApplicationFileUploadRequest {
70    /// Application identifier (required multipart field `app_id`).
71    pub app_id: String,
72    /// Files represented as `(filename, bytes)` pairs. Each file is encoded as
73    /// a separate multipart field named `files`.
74    pub files: Vec<(String, Vec<u8>)>,
75    /// Upload component identifier for text applications.
76    pub upload_unit_id: Option<String>,
77    /// Conversation identifier for temporary conversation files.
78    pub conversation_id: Option<String>,
79    /// Upstream numeric file type (`1` through `5` are currently documented).
80    pub file_type: Option<i64>,
81}
82
83impl std::fmt::Debug for ApplicationFileUploadRequest {
84    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        formatter
86            .debug_struct("ApplicationFileUploadRequest")
87            .field("app_id", &"[REDACTED]")
88            .field("file_count", &self.files.len())
89            .field("upload_unit_id_configured", &self.upload_unit_id.is_some())
90            .field(
91                "conversation_id_configured",
92                &self.conversation_id.is_some(),
93            )
94            .field("file_type", &self.file_type)
95            .finish()
96    }
97}
98
99impl ApplicationFileUploadRequest {
100    /// Create an upload request with the required application and files.
101    pub fn new(app_id: impl Into<String>, files: Vec<(String, Vec<u8>)>) -> Self {
102        Self {
103            app_id: app_id.into(),
104            files,
105            upload_unit_id: None,
106            conversation_id: None,
107            file_type: None,
108        }
109    }
110
111    /// Set the upload component identifier.
112    pub fn with_upload_unit_id(mut self, upload_unit_id: impl Into<String>) -> Self {
113        self.upload_unit_id = Some(upload_unit_id.into());
114        self
115    }
116
117    /// Set the conversation identifier for temporary files.
118    pub fn with_conversation_id(mut self, conversation_id: impl Into<String>) -> Self {
119        self.conversation_id = Some(conversation_id.into());
120        self
121    }
122
123    /// Set the upstream numeric file type.
124    pub fn with_file_type(mut self, file_type: i64) -> Self {
125        self.file_type = Some(file_type);
126        self
127    }
128
129    /// Validate the required repeated binary field before allocating a body.
130    pub fn validate(&self) -> ZaiResult<()> {
131        crate::client::validation::require_non_blank(&self.app_id, "app_id")?;
132        if self.files.is_empty() {
133            return Err(crate::client::validation::invalid(
134                "at least one file is required",
135            ));
136        }
137        if let Some((index, _)) = self
138            .files
139            .iter()
140            .enumerate()
141            .find(|(_, (filename, _))| filename.trim().is_empty())
142        {
143            return Err(crate::client::validation::invalid(format!(
144                "file at index {index} must have a non-blank filename"
145            )));
146        }
147        if let Some(upload_unit_id) = self.upload_unit_id.as_deref() {
148            crate::client::validation::require_non_blank(upload_unit_id, "upload_unit_id")?;
149        }
150        if let Some(conversation_id) = self.conversation_id.as_deref() {
151            crate::client::validation::require_non_blank(conversation_id, "conversation_id")?;
152        }
153        if self
154            .file_type
155            .is_some_and(|file_type| !(1..=5).contains(&file_type))
156        {
157            return Err(crate::client::validation::invalid(
158                "file_type must be between 1 and 5",
159            ));
160        }
161        Ok(())
162    }
163
164    /// Send the multipart request through `client`.
165    pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<ApplicationFileUploadResponse> {
166        self.validate()?;
167        let route = crate::client::routes::APPLICATIONS_UPLOAD_FILE;
168        let url = client.endpoints().resolve_route(route, &[])?;
169        let mut factory = crate::client::transport::multipart::MultipartBodyFactory::new()
170            .field("app_id", self.app_id.clone())?;
171
172        if let Some(upload_unit_id) = &self.upload_unit_id {
173            factory = factory.field("upload_unit_id", upload_unit_id.clone())?;
174        }
175        if let Some(conversation_id) = &self.conversation_id {
176            factory = factory.field("conversation_id", conversation_id.clone())?;
177        }
178        if let Some(file_type) = self.file_type {
179            factory = factory.field("file_type", file_type.to_string())?;
180        }
181        for (filename, bytes) in &self.files {
182            factory = factory.bytes_named(
183                "files",
184                filename.clone(),
185                "application/octet-stream",
186                bytes.clone(),
187            )?;
188        }
189
190        client
191            .send_multipart::<ApplicationFileUploadResponse>(route.method(), url, &factory)
192            .await
193    }
194}
195
196/// Request body for application document slice information.
197#[derive(Clone, PartialEq, Eq, Serialize)]
198pub struct ApplicationSliceInfoRequest {
199    /// Identifier returned by conversation creation or text invocation.
200    pub request_id: String,
201    /// Application node identifier.
202    pub node_id: String,
203}
204
205impl std::fmt::Debug for ApplicationSliceInfoRequest {
206    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207        formatter
208            .debug_struct("ApplicationSliceInfoRequest")
209            .field("request_id", &"[REDACTED]")
210            .field("node_id", &"[REDACTED]")
211            .finish()
212    }
213}
214
215impl ApplicationSliceInfoRequest {
216    /// Create a slice-information request with both required identifiers.
217    pub fn new(request_id: impl Into<String>, node_id: impl Into<String>) -> Self {
218        Self {
219            request_id: request_id.into(),
220            node_id: node_id.into(),
221        }
222    }
223
224    /// Validate both required identifiers.
225    pub fn validate(&self) -> ZaiResult<()> {
226        crate::client::validation::require_non_blank(&self.request_id, "request_id")?;
227        crate::client::validation::require_non_blank(&self.node_id, "node_id")
228    }
229
230    /// Send the request through `client`.
231    pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<ApplicationSliceInfoResponse> {
232        self.validate()?;
233        let route = crate::client::routes::APPLICATIONS_SLICE_INFO;
234        let url = client.endpoints().resolve_route(route, &[])?;
235        client
236            .send_json::<_, ApplicationSliceInfoResponse>(route.method(), url, self)
237            .await
238    }
239}
240
241/// Create a conversation under an application.
242///
243/// The frozen operation has no request body; `app_id` is its sole input.
244#[derive(Clone, PartialEq, Eq)]
245pub struct ApplicationConversationCreateRequest {
246    /// Application identifier inserted into the request path.
247    pub app_id: String,
248}
249
250impl std::fmt::Debug for ApplicationConversationCreateRequest {
251    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
252        formatter
253            .debug_struct("ApplicationConversationCreateRequest")
254            .field("app_id", &"[REDACTED]")
255            .finish()
256    }
257}
258
259impl ApplicationConversationCreateRequest {
260    /// Create a conversation request for an application.
261    pub fn new(app_id: impl Into<String>) -> Self {
262        Self {
263            app_id: app_id.into(),
264        }
265    }
266
267    /// Validate the dynamic path identifier.
268    pub fn validate(&self) -> ZaiResult<()> {
269        crate::client::validation::require_non_blank(&self.app_id, "app_id")
270    }
271
272    /// Send the bodyless request through `client`.
273    pub async fn send_via(
274        &self,
275        client: &ZaiClient,
276    ) -> ZaiResult<ApplicationConversationCreateResponse> {
277        self.validate()?;
278        let route = crate::client::routes::APPLICATIONS_CREATE_CONVERSATION;
279        let url = client.endpoints().resolve_route(route, &[&self.app_id])?;
280        client
281            .send_empty::<ApplicationConversationCreateResponse>(route.method(), url)
282            .await
283    }
284}
285
286/// Retrieve variables for an application.
287#[derive(Clone, PartialEq, Eq)]
288pub struct ApplicationVariablesRequest {
289    /// Application identifier inserted into the request path.
290    pub app_id: String,
291}
292
293impl std::fmt::Debug for ApplicationVariablesRequest {
294    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
295        formatter
296            .debug_struct("ApplicationVariablesRequest")
297            .field("app_id", &"[REDACTED]")
298            .finish()
299    }
300}
301
302impl ApplicationVariablesRequest {
303    /// Create a request for one application's variables.
304    pub fn new(app_id: impl Into<String>) -> Self {
305        Self {
306            app_id: app_id.into(),
307        }
308    }
309
310    /// Validate the dynamic path identifier.
311    pub fn validate(&self) -> ZaiResult<()> {
312        crate::client::validation::require_non_blank(&self.app_id, "app_id")
313    }
314
315    /// Send the request through `client`.
316    pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<ApplicationVariablesResponse> {
317        self.validate()?;
318        let route = crate::client::routes::APPLICATIONS_VARIABLES;
319        let url = client.endpoints().resolve_route(route, &[&self.app_id])?;
320        client
321            .send_empty::<ApplicationVariablesResponse>(route.method(), url)
322            .await
323    }
324}
325
326/// Retrieve recommended questions for an application conversation.
327#[derive(Clone, PartialEq, Eq)]
328pub struct ApplicationHistoryRequest {
329    /// Application identifier inserted into the request path.
330    pub app_id: String,
331    /// Conversation identifier inserted into the request path.
332    pub conversation_id: String,
333}
334
335impl std::fmt::Debug for ApplicationHistoryRequest {
336    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
337        formatter
338            .debug_struct("ApplicationHistoryRequest")
339            .field("app_id", &"[REDACTED]")
340            .field("conversation_id", &"[REDACTED]")
341            .finish()
342    }
343}
344
345impl ApplicationHistoryRequest {
346    /// Create a conversation-history request.
347    pub fn new(app_id: impl Into<String>, conversation_id: impl Into<String>) -> Self {
348        Self {
349            app_id: app_id.into(),
350            conversation_id: conversation_id.into(),
351        }
352    }
353
354    /// Validate both dynamic path identifiers.
355    pub fn validate(&self) -> ZaiResult<()> {
356        crate::client::validation::require_non_blank(&self.app_id, "app_id")?;
357        crate::client::validation::require_non_blank(&self.conversation_id, "conversation_id")
358    }
359
360    /// Send the request through `client`.
361    pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<ApplicationHistoryResponse> {
362        self.validate()?;
363        let route = crate::client::routes::APPLICATIONS_HISTORY;
364        let url = client
365            .endpoints()
366            .resolve_route(route, &[&self.app_id, &self.conversation_id])?;
367        client
368            .send_empty::<ApplicationHistoryResponse>(route.method(), url)
369            .await
370    }
371}
372
373/// One typed content value in an application invocation message.
374#[derive(Clone, PartialEq, Eq, Serialize)]
375pub struct ApplicationInvokeContent {
376    /// Upstream content type, for example `input` or `upload_file`.
377    #[serde(rename = "type")]
378    pub type_: String,
379    /// Text, selection, file identifier, or media URL.
380    pub value: String,
381    /// Field name, required by the service for text applications.
382    #[serde(skip_serializing_if = "Option::is_none")]
383    pub key: Option<String>,
384}
385
386impl std::fmt::Debug for ApplicationInvokeContent {
387    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        formatter
389            .debug_struct("ApplicationInvokeContent")
390            .field("type", &"[REDACTED]")
391            .field("value", &"[REDACTED]")
392            .field("key_configured", &self.key.is_some())
393            .finish()
394    }
395}
396
397impl ApplicationInvokeContent {
398    /// Create a content value with both OpenAPI-required fields.
399    pub fn new(type_: impl Into<String>, value: impl Into<String>) -> Self {
400        Self {
401            type_: type_.into(),
402            value: value.into(),
403            key: None,
404        }
405    }
406
407    /// Set the text-application field name.
408    pub fn with_key(mut self, key: impl Into<String>) -> Self {
409        self.key = Some(key.into());
410        self
411    }
412
413    fn validate(&self) -> ZaiResult<()> {
414        crate::client::validation::require_non_blank(&self.type_, "content.type")?;
415        crate::client::validation::require_non_blank(&self.value, "content.value")?;
416        if let Some(key) = self.key.as_deref() {
417            crate::client::validation::require_non_blank(key, "content.key")?;
418        }
419        Ok(())
420    }
421}
422
423/// One message sent to an application invocation.
424#[derive(Clone, PartialEq, Eq, Serialize)]
425pub struct ApplicationInvokeMessage {
426    /// Optional role supplied for conversational applications.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub role: Option<String>,
429    /// Typed content values. The field itself is required by OpenAPI.
430    pub content: Vec<ApplicationInvokeContent>,
431}
432
433impl std::fmt::Debug for ApplicationInvokeMessage {
434    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        formatter
436            .debug_struct("ApplicationInvokeMessage")
437            .field("role_configured", &self.role.is_some())
438            .field("content_count", &self.content.len())
439            .finish()
440    }
441}
442
443impl ApplicationInvokeMessage {
444    /// Create a message from its required content array.
445    pub fn new(content: Vec<ApplicationInvokeContent>) -> Self {
446        Self {
447            role: None,
448            content,
449        }
450    }
451
452    /// Set the optional message role.
453    pub fn with_role(mut self, role: impl Into<String>) -> Self {
454        self.role = Some(role.into());
455        self
456    }
457
458    fn validate(&self) -> ZaiResult<()> {
459        if self.content.is_empty() {
460            return Err(crate::client::validation::invalid(
461                "application message content must not be empty",
462            ));
463        }
464        if let Some(role) = self.role.as_deref() {
465            crate::client::validation::require_non_blank(role, "message.role")?;
466        }
467        self.content
468            .iter()
469            .try_for_each(ApplicationInvokeContent::validate)
470    }
471}
472
473/// Request body for an application-v3 invocation.
474#[derive(Clone, PartialEq, Eq, Serialize)]
475pub struct ApplicationInvokeRequest {
476    /// Application identifier.
477    pub app_id: String,
478    /// Conversation identifier; omitted to create a new conversation.
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub conversation_id: Option<String>,
481    /// Caller-provided tracing identifier for plugin invocations.
482    #[serde(skip_serializing_if = "Option::is_none")]
483    pub third_request_id: Option<String>,
484    /// Streaming flag. The JSON-only `send_via` path requires this to remain
485    /// `false` and serializes it explicitly because the server default is true.
486    #[serde(default)]
487    pub stream: bool,
488    /// Invocation messages.
489    pub messages: Vec<ApplicationInvokeMessage>,
490    /// Top-level role required by some conversational applications.
491    #[serde(skip_serializing_if = "Option::is_none")]
492    pub role: Option<String>,
493    /// Whether process-log events should be emitted.
494    #[serde(skip_serializing_if = "Option::is_none")]
495    pub send_log_event: Option<bool>,
496}
497
498impl std::fmt::Debug for ApplicationInvokeRequest {
499    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        formatter
501            .debug_struct("ApplicationInvokeRequest")
502            .field("app_id", &"[REDACTED]")
503            .field(
504                "conversation_id_configured",
505                &self.conversation_id.is_some(),
506            )
507            .field(
508                "third_request_id_configured",
509                &self.third_request_id.is_some(),
510            )
511            .field("stream", &self.stream)
512            .field("message_count", &self.messages.len())
513            .field("role_configured", &self.role.is_some())
514            .field("send_log_event", &self.send_log_event)
515            .finish()
516    }
517}
518
519impl ApplicationInvokeRequest {
520    /// Create an invocation with all OpenAPI-required fields.
521    pub fn new(app_id: impl Into<String>, messages: Vec<ApplicationInvokeMessage>) -> Self {
522        Self {
523            app_id: app_id.into(),
524            conversation_id: None,
525            third_request_id: None,
526            stream: false,
527            messages,
528            role: None,
529            send_log_event: None,
530        }
531    }
532
533    /// Continue an existing conversation.
534    pub fn with_conversation_id(mut self, conversation_id: impl Into<String>) -> Self {
535        self.conversation_id = Some(conversation_id.into());
536        self
537    }
538
539    /// Set a third-party tracing identifier.
540    pub fn with_third_request_id(mut self, third_request_id: impl Into<String>) -> Self {
541        self.third_request_id = Some(third_request_id.into());
542        self
543    }
544
545    /// Set the top-level conversational role.
546    pub fn with_role(mut self, role: impl Into<String>) -> Self {
547        self.role = Some(role.into());
548        self
549    }
550
551    /// Configure process-log event delivery.
552    pub fn with_log_events(mut self, enabled: bool) -> Self {
553        self.send_log_event = Some(enabled);
554        self
555    }
556
557    /// Validate the JSON-only operation invariant.
558    pub fn validate(&self) -> ZaiResult<()> {
559        crate::client::validation::require_non_blank(&self.app_id, "app_id")?;
560        if self.stream {
561            return Err(crate::client::validation::invalid(
562                "application send_via supports only stream=false",
563            ));
564        }
565        if self.messages.is_empty() {
566            return Err(crate::client::validation::invalid(
567                "application messages must not be empty",
568            ));
569        }
570        for (value, name) in [
571            (self.conversation_id.as_deref(), "conversation_id"),
572            (self.third_request_id.as_deref(), "third_request_id"),
573            (self.role.as_deref(), "role"),
574        ] {
575            if let Some(value) = value {
576                crate::client::validation::require_non_blank(value, name)?;
577            }
578        }
579        self.messages
580            .iter()
581            .try_for_each(ApplicationInvokeMessage::validate)?;
582        Ok(())
583    }
584
585    /// Send the request through `client`.
586    pub async fn send_via(&self, client: &ZaiClient) -> ZaiResult<ApplicationInvokeResponse> {
587        self.validate()?;
588        let route = crate::client::routes::APPLICATIONS_INVOKE;
589        let url = client.endpoints().resolve_route(route, &[])?;
590        let response = client
591            .send_json::<_, ApplicationInvokeResponse>(route.method(), url, self)
592            .await?;
593        response.validate()?;
594        Ok(response)
595    }
596}
597
598#[cfg(test)]
599mod tests {
600    use super::*;
601
602    #[test]
603    fn closed_json_requests_serialize_required_fields_exactly() {
604        assert_eq!(
605            serde_json::to_value(ApplicationFileStatsRequest::new(
606                "app-1",
607                vec!["file-1".to_owned()]
608            ))
609            .unwrap(),
610            serde_json::json!({"app_id": "app-1", "file_ids": ["file-1"]})
611        );
612        assert_eq!(
613            serde_json::to_value(ApplicationSliceInfoRequest::new("req-1", "node-1")).unwrap(),
614            serde_json::json!({"request_id": "req-1", "node_id": "node-1"})
615        );
616
617        let invoke = ApplicationInvokeRequest::new(
618            "app-1",
619            vec![
620                ApplicationInvokeMessage::new(vec![
621                    ApplicationInvokeContent::new("input", "hello").with_key("question"),
622                ])
623                .with_role("user"),
624            ],
625        );
626        assert_eq!(
627            serde_json::to_value(invoke).unwrap(),
628            serde_json::json!({
629                "app_id": "app-1",
630                "stream": false,
631                "messages": [{
632                    "role": "user",
633                    "content": [{"type": "input", "value": "hello", "key": "question"}]
634                }]
635            })
636        );
637    }
638
639    #[test]
640    fn upload_validation_rejects_a_missing_or_unnamed_file() {
641        assert!(
642            ApplicationFileUploadRequest::new("app-1", Vec::new())
643                .validate()
644                .is_err()
645        );
646        assert!(
647            ApplicationFileUploadRequest::new("app-1", vec![(String::new(), vec![1])])
648                .validate()
649                .is_err()
650        );
651        assert!(
652            ApplicationFileUploadRequest::new(" ", vec![("file.txt".into(), vec![1])])
653                .validate()
654                .is_err()
655        );
656        assert!(
657            ApplicationFileUploadRequest::new("app-1", vec![("file.txt".into(), vec![1])])
658                .with_file_type(6)
659                .validate()
660                .is_err()
661        );
662    }
663
664    #[test]
665    fn path_requests_reject_blank_identifiers() {
666        assert!(
667            ApplicationConversationCreateRequest::new(" ")
668                .validate()
669                .is_err()
670        );
671        assert!(ApplicationVariablesRequest::new("").validate().is_err());
672        assert!(
673            ApplicationHistoryRequest::new("app", " ")
674                .validate()
675                .is_err()
676        );
677    }
678
679    #[test]
680    fn json_invoke_rejects_the_unsupported_streaming_mode() {
681        let mut request = ApplicationInvokeRequest::new(
682            "app-1",
683            vec![ApplicationInvokeMessage::new(vec![
684                ApplicationInvokeContent::new("input", "hello"),
685            ])],
686        );
687        request.stream = true;
688        assert!(request.validate().is_err());
689    }
690
691    #[test]
692    fn required_json_fields_are_non_blank_and_non_empty() {
693        assert!(
694            ApplicationFileStatsRequest::new(" ", vec!["file".into()])
695                .validate()
696                .is_err()
697        );
698        assert!(
699            ApplicationFileStatsRequest::new("app", Vec::new())
700                .validate()
701                .is_err()
702        );
703        assert!(
704            ApplicationSliceInfoRequest::new("request", " ")
705                .validate()
706                .is_err()
707        );
708        assert!(
709            ApplicationInvokeRequest::new("app", Vec::new())
710                .validate()
711                .is_err()
712        );
713        assert!(
714            ApplicationInvokeRequest::new(
715                "app",
716                vec![ApplicationInvokeMessage::new(vec![
717                    ApplicationInvokeContent::new("input", " "),
718                ])],
719            )
720            .validate()
721            .is_err()
722        );
723    }
724
725    #[test]
726    fn debug_output_redacts_application_content_and_identifiers() {
727        let stats =
728            ApplicationFileStatsRequest::new("private-app", vec!["private-file".to_owned()]);
729        let invoke = ApplicationInvokeRequest::new(
730            "private-app",
731            vec![
732                ApplicationInvokeMessage::new(vec![
733                    ApplicationInvokeContent::new("private-type", "private-value")
734                        .with_key("private-key"),
735                ])
736                .with_role("private-role"),
737            ],
738        )
739        .with_conversation_id("private-conversation")
740        .with_third_request_id("private-request")
741        .with_role("private-top-role");
742        let debug = format!("{stats:?} {invoke:?}");
743        for secret in [
744            "private-app",
745            "private-file",
746            "private-type",
747            "private-value",
748            "private-key",
749            "private-role",
750            "private-conversation",
751            "private-request",
752            "private-top-role",
753        ] {
754            assert!(!debug.contains(secret), "Debug leaked {secret}");
755        }
756    }
757}