Skip to main content

opencode_sdk/http/
messages.rs

1//! Messages API for OpenCode.
2//!
3//! This module provides methods for message endpoints (6 total).
4
5use crate::error::Result;
6use crate::http::HttpClient;
7use crate::types::api::{CommandResponse, ShellResponse};
8use crate::types::message::{CommandRequest, Message, PromptRequest, ShellRequest};
9use crate::types::project::ModelRef;
10use reqwest::Method;
11
12/// Messages API client.
13#[derive(Clone)]
14pub struct MessagesApi {
15    http: HttpClient,
16}
17
18impl MessagesApi {
19    /// Create a new Messages API client.
20    pub fn new(http: HttpClient) -> Self {
21        Self { http }
22    }
23
24    /// Send a prompt to a session.
25    ///
26    /// # Errors
27    ///
28    /// Returns an error if the request fails.
29    pub async fn prompt(&self, session_id: &str, req: &PromptRequest) -> Result<()> {
30        let body = serde_json::to_value(req)?;
31        self.http
32            .request_empty(
33                Method::POST,
34                &format!("/session/{}/message", session_id),
35                Some(body),
36            )
37            .await
38    }
39
40    /// List messages in a session.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the request fails.
45    pub async fn list(&self, session_id: &str) -> Result<Vec<Message>> {
46        self.http
47            .request_json(
48                Method::GET,
49                &format!("/session/{}/message", session_id),
50                None,
51            )
52            .await
53    }
54
55    /// Get a specific message.
56    ///
57    /// # Errors
58    ///
59    /// Returns an error if the request fails.
60    pub async fn get(&self, session_id: &str, message_id: &str) -> Result<Message> {
61        self.http
62            .request_json(
63                Method::GET,
64                &format!("/session/{}/message/{}", session_id, message_id),
65                None,
66            )
67            .await
68    }
69
70    /// Send a prompt asynchronously (returns immediately).
71    ///
72    /// Unlike `prompt`, this endpoint returns immediately and the response
73    /// is streamed via SSE events. The server returns an empty body on success.
74    ///
75    /// # Errors
76    ///
77    /// Returns an error if the request fails.
78    pub async fn prompt_async(&self, session_id: &str, req: &PromptRequest) -> Result<()> {
79        let body = serde_json::to_value(req)?;
80        self.http
81            .request_empty(
82                Method::POST,
83                &format!("/session/{}/prompt_async", session_id),
84                Some(body),
85            )
86            .await
87    }
88
89    /// Send a plain text prompt asynchronously.
90    ///
91    /// This convenience helper wraps [`Self::prompt_async`].
92    /// The server returns an empty body on success.
93    ///
94    /// # Errors
95    ///
96    /// Returns an error if the request fails.
97    pub async fn send_text_async(
98        &self,
99        session_id: &str,
100        text: impl Into<String>,
101        model: Option<ModelRef>,
102    ) -> Result<()> {
103        let mut req = PromptRequest::text(text);
104        req.model = model;
105        self.prompt_async(session_id, &req).await
106    }
107
108    /// Execute a command in a session.
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if the request fails.
113    pub async fn command(&self, session_id: &str, req: &CommandRequest) -> Result<CommandResponse> {
114        let body = serde_json::to_value(req)?;
115        self.http
116            .request_json(
117                Method::POST,
118                &format!("/session/{}/command", session_id),
119                Some(body),
120            )
121            .await
122    }
123
124    /// Execute a shell command in a session.
125    ///
126    /// # Errors
127    ///
128    /// Returns an error if the request fails.
129    pub async fn shell(&self, session_id: &str, req: &ShellRequest) -> Result<ShellResponse> {
130        let body = serde_json::to_value(req)?;
131        self.http
132            .request_json(
133                Method::POST,
134                &format!("/session/{}/shell", session_id),
135                Some(body),
136            )
137            .await
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::http::HttpConfig;
145    use crate::types::message::{CommandRequest, PromptPart, ShellRequest};
146    use std::time::Duration;
147    use wiremock::matchers::{method, path};
148    use wiremock::{Mock, MockServer, ResponseTemplate};
149
150    #[tokio::test]
151    async fn test_prompt() {
152        let mock_server = MockServer::start().await;
153
154        // Server returns empty body on success
155        Mock::given(method("POST"))
156            .and(path("/session/s1/message"))
157            .respond_with(ResponseTemplate::new(200))
158            .mount(&mock_server)
159            .await;
160
161        let http = HttpClient::new(HttpConfig {
162            base_url: mock_server.uri(),
163            directory: None,
164            timeout: Duration::from_secs(30),
165        })
166        .unwrap();
167
168        let messages = MessagesApi::new(http);
169        let result = messages
170            .prompt(
171                "s1",
172                &PromptRequest {
173                    parts: vec![PromptPart::Text {
174                        text: "Hello".to_string(),
175                        synthetic: None,
176                        ignored: None,
177                        metadata: None,
178                    }],
179                    message_id: None,
180                    model: None,
181                    agent: None,
182                    no_reply: None,
183                    system: None,
184                    variant: None,
185                },
186            )
187            .await;
188        assert!(result.is_ok());
189    }
190
191    #[tokio::test]
192    async fn test_list_messages() {
193        let mock_server = MockServer::start().await;
194
195        Mock::given(method("GET"))
196            .and(path("/session/s1/message"))
197            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
198                {
199                    "info": {"id": "m1", "sessionId": "s1", "role": "user", "time": {"created": 1234567890}},
200                    "parts": []
201                },
202                {
203                    "info": {"id": "m2", "sessionId": "s1", "role": "assistant", "time": {"created": 1234567891}},
204                    "parts": []
205                }
206            ])))
207            .mount(&mock_server)
208            .await;
209
210        let http = HttpClient::new(HttpConfig {
211            base_url: mock_server.uri(),
212            directory: None,
213            timeout: Duration::from_secs(30),
214        })
215        .unwrap();
216
217        let messages = MessagesApi::new(http);
218        let list = messages.list("s1").await.unwrap();
219        assert_eq!(list.len(), 2);
220        assert_eq!(list[0].role(), "user");
221        assert_eq!(list[1].role(), "assistant");
222    }
223
224    #[tokio::test]
225    async fn test_prompt_async() {
226        let mock_server = MockServer::start().await;
227
228        // Server returns empty body on success
229        Mock::given(method("POST"))
230            .and(path("/session/s1/prompt_async"))
231            .respond_with(ResponseTemplate::new(200))
232            .mount(&mock_server)
233            .await;
234
235        let http = HttpClient::new(HttpConfig {
236            base_url: mock_server.uri(),
237            directory: None,
238            timeout: Duration::from_secs(30),
239        })
240        .unwrap();
241
242        let messages = MessagesApi::new(http);
243        let result = messages
244            .prompt_async(
245                "s1",
246                &PromptRequest {
247                    parts: vec![PromptPart::Text {
248                        text: "Hello async".to_string(),
249                        synthetic: None,
250                        ignored: None,
251                        metadata: None,
252                    }],
253                    message_id: None,
254                    model: None,
255                    agent: None,
256                    no_reply: None,
257                    system: None,
258                    variant: None,
259                },
260            )
261            .await;
262        assert!(result.is_ok());
263    }
264
265    #[tokio::test]
266    async fn test_send_text_async() {
267        let mock_server = MockServer::start().await;
268
269        // Server returns empty body on success
270        Mock::given(method("POST"))
271            .and(path("/session/s1/prompt_async"))
272            .respond_with(ResponseTemplate::new(200))
273            .mount(&mock_server)
274            .await;
275
276        let http = HttpClient::new(HttpConfig {
277            base_url: mock_server.uri(),
278            directory: None,
279            timeout: Duration::from_secs(30),
280        })
281        .unwrap();
282
283        let messages = MessagesApi::new(http);
284        let result = messages
285            .send_text_async(
286                "s1",
287                "Hello from helper",
288                Some(ModelRef {
289                    provider_id: "opencode".to_string(),
290                    model_id: "kimi-k2.5-free".to_string(),
291                }),
292            )
293            .await;
294        assert!(result.is_ok());
295    }
296
297    #[tokio::test]
298    async fn test_command() {
299        let mock_server = MockServer::start().await;
300
301        Mock::given(method("POST"))
302            .and(path("/session/s1/command"))
303            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
304                "status": "executed"
305            })))
306            .mount(&mock_server)
307            .await;
308
309        let http = HttpClient::new(HttpConfig {
310            base_url: mock_server.uri(),
311            directory: None,
312            timeout: Duration::from_secs(30),
313        })
314        .unwrap();
315
316        let messages = MessagesApi::new(http);
317        let result = messages
318            .command(
319                "s1",
320                &CommandRequest {
321                    command: "test_command".to_string(),
322                    args: None,
323                },
324            )
325            .await
326            .unwrap();
327        assert_eq!(result.status, Some("executed".to_string()));
328    }
329
330    #[tokio::test]
331    async fn test_shell() {
332        let mock_server = MockServer::start().await;
333
334        Mock::given(method("POST"))
335            .and(path("/session/s1/shell"))
336            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
337                "status": "running"
338            })))
339            .mount(&mock_server)
340            .await;
341
342        let http = HttpClient::new(HttpConfig {
343            base_url: mock_server.uri(),
344            directory: None,
345            timeout: Duration::from_secs(30),
346        })
347        .unwrap();
348
349        let messages = MessagesApi::new(http);
350        let result = messages
351            .shell(
352                "s1",
353                &ShellRequest {
354                    command: "echo hello".to_string(),
355                    model: None,
356                },
357            )
358            .await
359            .unwrap();
360        assert_eq!(result.status, Some("running".to_string()));
361    }
362
363    // ==================== Error Case Tests ====================
364
365    #[tokio::test]
366    async fn test_prompt_session_not_found() {
367        let mock_server = MockServer::start().await;
368
369        Mock::given(method("POST"))
370            .and(path("/session/missing/message"))
371            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
372                "name": "NotFound",
373                "message": "Session not found"
374            })))
375            .mount(&mock_server)
376            .await;
377
378        let http = HttpClient::new(HttpConfig {
379            base_url: mock_server.uri(),
380            directory: None,
381            timeout: Duration::from_secs(30),
382        })
383        .unwrap();
384
385        let messages = MessagesApi::new(http);
386        let result = messages
387            .prompt(
388                "missing",
389                &PromptRequest {
390                    parts: vec![PromptPart::Text {
391                        text: "test".to_string(),
392                        synthetic: None,
393                        ignored: None,
394                        metadata: None,
395                    }],
396                    message_id: None,
397                    model: None,
398                    agent: None,
399                    no_reply: None,
400                    system: None,
401                    variant: None,
402                },
403            )
404            .await;
405        assert!(result.is_err());
406        assert!(result.unwrap_err().is_not_found());
407    }
408
409    #[tokio::test]
410    async fn test_prompt_validation_error() {
411        let mock_server = MockServer::start().await;
412
413        Mock::given(method("POST"))
414            .and(path("/session/s1/message"))
415            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
416                "name": "ValidationError",
417                "message": "Invalid prompt format"
418            })))
419            .mount(&mock_server)
420            .await;
421
422        let http = HttpClient::new(HttpConfig {
423            base_url: mock_server.uri(),
424            directory: None,
425            timeout: Duration::from_secs(30),
426        })
427        .unwrap();
428
429        let messages = MessagesApi::new(http);
430        let result = messages
431            .prompt(
432                "s1",
433                &PromptRequest {
434                    parts: vec![],
435                    message_id: None,
436                    model: None,
437                    agent: None,
438                    no_reply: None,
439                    system: None,
440                    variant: None,
441                },
442            )
443            .await;
444        assert!(result.is_err());
445        assert!(result.unwrap_err().is_validation_error());
446    }
447
448    #[tokio::test]
449    async fn test_list_messages_not_found() {
450        let mock_server = MockServer::start().await;
451
452        Mock::given(method("GET"))
453            .and(path("/session/missing/message"))
454            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
455                "name": "NotFound",
456                "message": "Session not found"
457            })))
458            .mount(&mock_server)
459            .await;
460
461        let http = HttpClient::new(HttpConfig {
462            base_url: mock_server.uri(),
463            directory: None,
464            timeout: Duration::from_secs(30),
465        })
466        .unwrap();
467
468        let messages = MessagesApi::new(http);
469        let result = messages.list("missing").await;
470        assert!(result.is_err());
471        assert!(result.unwrap_err().is_not_found());
472    }
473
474    #[tokio::test]
475    async fn test_get_message_not_found() {
476        let mock_server = MockServer::start().await;
477
478        Mock::given(method("GET"))
479            .and(path("/session/s1/message/missing"))
480            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
481                "name": "NotFound",
482                "message": "Message not found"
483            })))
484            .mount(&mock_server)
485            .await;
486
487        let http = HttpClient::new(HttpConfig {
488            base_url: mock_server.uri(),
489            directory: None,
490            timeout: Duration::from_secs(30),
491        })
492        .unwrap();
493
494        let messages = MessagesApi::new(http);
495        let result = messages.get("s1", "missing").await;
496        assert!(result.is_err());
497        assert!(result.unwrap_err().is_not_found());
498    }
499
500    #[tokio::test]
501    async fn test_prompt_async_server_error() {
502        let mock_server = MockServer::start().await;
503
504        Mock::given(method("POST"))
505            .and(path("/session/s1/prompt_async"))
506            .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({
507                "name": "InternalError",
508                "message": "Failed to queue prompt"
509            })))
510            .mount(&mock_server)
511            .await;
512
513        let http = HttpClient::new(HttpConfig {
514            base_url: mock_server.uri(),
515            directory: None,
516            timeout: Duration::from_secs(30),
517        })
518        .unwrap();
519
520        let messages = MessagesApi::new(http);
521        let result = messages
522            .prompt_async(
523                "s1",
524                &PromptRequest {
525                    parts: vec![PromptPart::Text {
526                        text: "test".to_string(),
527                        synthetic: None,
528                        ignored: None,
529                        metadata: None,
530                    }],
531                    message_id: None,
532                    model: None,
533                    agent: None,
534                    no_reply: None,
535                    system: None,
536                    variant: None,
537                },
538            )
539            .await;
540        assert!(result.is_err());
541        assert!(result.unwrap_err().is_server_error());
542    }
543
544    #[tokio::test]
545    async fn test_command_not_found() {
546        let mock_server = MockServer::start().await;
547
548        Mock::given(method("POST"))
549            .and(path("/session/missing/command"))
550            .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({
551                "name": "NotFound",
552                "message": "Session not found"
553            })))
554            .mount(&mock_server)
555            .await;
556
557        let http = HttpClient::new(HttpConfig {
558            base_url: mock_server.uri(),
559            directory: None,
560            timeout: Duration::from_secs(30),
561        })
562        .unwrap();
563
564        let messages = MessagesApi::new(http);
565        let result = messages
566            .command(
567                "missing",
568                &CommandRequest {
569                    command: "test".to_string(),
570                    args: None,
571                },
572            )
573            .await;
574        assert!(result.is_err());
575        assert!(result.unwrap_err().is_not_found());
576    }
577
578    #[tokio::test]
579    async fn test_shell_validation_error() {
580        let mock_server = MockServer::start().await;
581
582        Mock::given(method("POST"))
583            .and(path("/session/s1/shell"))
584            .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
585                "name": "ValidationError",
586                "message": "Invalid shell command"
587            })))
588            .mount(&mock_server)
589            .await;
590
591        let http = HttpClient::new(HttpConfig {
592            base_url: mock_server.uri(),
593            directory: None,
594            timeout: Duration::from_secs(30),
595        })
596        .unwrap();
597
598        let messages = MessagesApi::new(http);
599        let result = messages
600            .shell(
601                "s1",
602                &ShellRequest {
603                    command: "".to_string(),
604                    model: None,
605                },
606            )
607            .await;
608        assert!(result.is_err());
609        assert!(result.unwrap_err().is_validation_error());
610    }
611}