Skip to main content

opencode_sdk_rs/resources/
session.rs

1//! Session resource types and methods mirroring the JS SDK's `resources/session.ts`.
2
3use std::collections::HashMap;
4
5use serde::{Deserialize, Serialize};
6
7use super::shared::SessionError;
8use crate::{
9    client::{Opencode, RequestOptions},
10    error::OpencodeError,
11};
12
13// ---------------------------------------------------------------------------
14// Session
15// ---------------------------------------------------------------------------
16
17/// A conversation session.
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
19pub struct Session {
20    /// Unique session identifier.
21    pub id: String,
22    /// Timing information.
23    pub time: SessionTime,
24    /// Human-readable session title.
25    pub title: String,
26    /// Session schema version.
27    pub version: String,
28    /// Parent session identifier (for branched sessions).
29    #[serde(rename = "parentID")]
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub parent_id: Option<String>,
32    /// Revert metadata, if the session was reverted.
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub revert: Option<SessionRevert>,
35    /// Share metadata, if the session was shared.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub share: Option<SessionShare>,
38}
39
40/// Timing information for a [`Session`].
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct SessionTime {
43    /// Epoch timestamp when the session was created.
44    pub created: f64,
45    /// Epoch timestamp when the session was last updated.
46    pub updated: f64,
47}
48
49/// Revert metadata attached to a [`Session`].
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
51pub struct SessionRevert {
52    /// The message that was reverted to.
53    #[serde(rename = "messageID")]
54    pub message_id: String,
55    /// Optional diff content.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub diff: Option<String>,
58    /// Optional part identifier.
59    #[serde(rename = "partID")]
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub part_id: Option<String>,
62    /// Optional snapshot content.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub snapshot: Option<String>,
65}
66
67/// Share metadata attached to a [`Session`].
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
69pub struct SessionShare {
70    /// Public URL of the shared session.
71    pub url: String,
72}
73
74// ---------------------------------------------------------------------------
75// Messages
76// ---------------------------------------------------------------------------
77
78/// A user-sent message.
79#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
80pub struct UserMessage {
81    /// Unique message identifier.
82    pub id: String,
83    /// The session this message belongs to.
84    #[serde(rename = "sessionID")]
85    pub session_id: String,
86    /// Timing information.
87    pub time: UserMessageTime,
88}
89
90/// Timing information for a [`UserMessage`].
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
92pub struct UserMessageTime {
93    /// Epoch timestamp when the message was created.
94    pub created: f64,
95}
96
97/// An assistant-generated message.
98#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
99pub struct AssistantMessage {
100    /// Unique message identifier.
101    pub id: String,
102    /// Monetary cost of generating this message.
103    pub cost: f64,
104    /// The mode used for generation.
105    pub mode: String,
106    /// The model identifier used.
107    #[serde(rename = "modelID")]
108    pub model_id: String,
109    /// Filesystem paths relevant to this message.
110    pub path: AssistantMessagePath,
111    /// The provider identifier used.
112    #[serde(rename = "providerID")]
113    pub provider_id: String,
114    /// The session this message belongs to.
115    #[serde(rename = "sessionID")]
116    pub session_id: String,
117    /// System prompt segments.
118    pub system: Vec<String>,
119    /// Timing information.
120    pub time: AssistantMessageTime,
121    /// Token usage breakdown.
122    pub tokens: AssistantMessageTokens,
123    /// Optional error that occurred during generation.
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub error: Option<SessionError>,
126    /// Whether this message is a summary.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub summary: Option<bool>,
129}
130
131/// Filesystem paths for an [`AssistantMessage`].
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133pub struct AssistantMessagePath {
134    /// Current working directory.
135    pub cwd: String,
136    /// Project root directory.
137    pub root: String,
138}
139
140/// Timing information for an [`AssistantMessage`].
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct AssistantMessageTime {
143    /// Epoch timestamp when the message was created.
144    pub created: f64,
145    /// Epoch timestamp when generation completed.
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub completed: Option<f64>,
148}
149
150/// Token usage breakdown for an [`AssistantMessage`].
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct AssistantMessageTokens {
153    /// Cache token details.
154    pub cache: TokenCache,
155    /// Number of input tokens.
156    pub input: u64,
157    /// Number of output tokens.
158    pub output: u64,
159    /// Number of reasoning tokens.
160    pub reasoning: u64,
161}
162
163/// Cache token breakdown.
164#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
165pub struct TokenCache {
166    /// Number of tokens read from cache.
167    pub read: u64,
168    /// Number of tokens written to cache.
169    pub write: u64,
170}
171
172/// A message in a session — either from the user or the assistant.
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
174#[serde(tag = "role")]
175pub enum Message {
176    /// A user-sent message.
177    #[serde(rename = "user")]
178    User(UserMessage),
179    /// An assistant-generated message.
180    #[serde(rename = "assistant")]
181    Assistant(Box<AssistantMessage>),
182}
183
184// ---------------------------------------------------------------------------
185// Parts
186// ---------------------------------------------------------------------------
187
188/// A text part within a message.
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
190pub struct TextPart {
191    /// Unique part identifier.
192    pub id: String,
193    /// The message this part belongs to.
194    #[serde(rename = "messageID")]
195    pub message_id: String,
196    /// The session this part belongs to.
197    #[serde(rename = "sessionID")]
198    pub session_id: String,
199    /// The text content.
200    pub text: String,
201    /// Whether this part was synthetically generated.
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub synthetic: Option<bool>,
204    /// Timing information.
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub time: Option<TextPartTime>,
207}
208
209/// Timing information for a [`TextPart`].
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211pub struct TextPartTime {
212    /// Epoch timestamp when text streaming started.
213    pub start: f64,
214    /// Epoch timestamp when text streaming ended.
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub end: Option<f64>,
217}
218
219/// A file attachment part within a message.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221pub struct FilePart {
222    /// Unique part identifier.
223    pub id: String,
224    /// The message this part belongs to.
225    #[serde(rename = "messageID")]
226    pub message_id: String,
227    /// MIME type of the file.
228    pub mime: String,
229    /// The session this part belongs to.
230    #[serde(rename = "sessionID")]
231    pub session_id: String,
232    /// URL to the file content.
233    pub url: String,
234    /// Optional human-readable filename.
235    #[serde(skip_serializing_if = "Option::is_none")]
236    pub filename: Option<String>,
237    /// Optional source information for the file.
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub source: Option<FilePartSource>,
240}
241
242/// A tool invocation part within a message.
243#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
244pub struct ToolPart {
245    /// Unique part identifier.
246    pub id: String,
247    /// Tool call identifier.
248    #[serde(rename = "callID")]
249    pub call_id: String,
250    /// The message this part belongs to.
251    #[serde(rename = "messageID")]
252    pub message_id: String,
253    /// The session this part belongs to.
254    #[serde(rename = "sessionID")]
255    pub session_id: String,
256    /// Current state of the tool invocation.
257    pub state: ToolState,
258    /// Name of the tool.
259    pub tool: String,
260}
261
262/// Marks the beginning of a reasoning step.
263#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
264pub struct StepStartPart {
265    /// Unique part identifier.
266    pub id: String,
267    /// The message this part belongs to.
268    #[serde(rename = "messageID")]
269    pub message_id: String,
270    /// The session this part belongs to.
271    #[serde(rename = "sessionID")]
272    pub session_id: String,
273}
274
275/// Marks the end of a reasoning step with cost and token info.
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
277pub struct StepFinishPart {
278    /// Unique part identifier.
279    pub id: String,
280    /// Monetary cost of this step.
281    pub cost: f64,
282    /// The message this part belongs to.
283    #[serde(rename = "messageID")]
284    pub message_id: String,
285    /// The session this part belongs to.
286    #[serde(rename = "sessionID")]
287    pub session_id: String,
288    /// Token usage for this step.
289    pub tokens: StepFinishTokens,
290}
291
292/// Token usage for a [`StepFinishPart`].
293#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
294pub struct StepFinishTokens {
295    /// Cache token details.
296    pub cache: TokenCache,
297    /// Number of input tokens.
298    pub input: u64,
299    /// Number of output tokens.
300    pub output: u64,
301    /// Number of reasoning tokens.
302    pub reasoning: u64,
303}
304
305/// A snapshot of the session state.
306#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
307pub struct SnapshotPart {
308    /// Unique part identifier.
309    pub id: String,
310    /// The message this part belongs to.
311    #[serde(rename = "messageID")]
312    pub message_id: String,
313    /// The session this part belongs to.
314    #[serde(rename = "sessionID")]
315    pub session_id: String,
316    /// Snapshot content.
317    pub snapshot: String,
318}
319
320/// A patch describing file modifications.
321#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322pub struct PatchPart {
323    /// Unique part identifier.
324    pub id: String,
325    /// List of affected file paths.
326    pub files: Vec<String>,
327    /// Hash of the patch content.
328    pub hash: String,
329    /// The message this part belongs to.
330    #[serde(rename = "messageID")]
331    pub message_id: String,
332    /// The session this part belongs to.
333    #[serde(rename = "sessionID")]
334    pub session_id: String,
335}
336
337/// A part within a message — discriminated by `type`.
338#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339#[serde(tag = "type")]
340pub enum Part {
341    /// A text content part.
342    #[serde(rename = "text")]
343    Text(TextPart),
344    /// A file attachment part.
345    #[serde(rename = "file")]
346    File(FilePart),
347    /// A tool invocation part.
348    #[serde(rename = "tool")]
349    Tool(ToolPart),
350    /// Start of a reasoning step.
351    #[serde(rename = "step-start")]
352    StepStart(StepStartPart),
353    /// End of a reasoning step.
354    #[serde(rename = "step-finish")]
355    StepFinish(StepFinishPart),
356    /// A session state snapshot.
357    #[serde(rename = "snapshot")]
358    Snapshot(SnapshotPart),
359    /// A file patch.
360    #[serde(rename = "patch")]
361    Patch(PatchPart),
362}
363
364// ---------------------------------------------------------------------------
365// Tool States
366// ---------------------------------------------------------------------------
367
368/// A pending tool invocation.
369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
370pub struct ToolStatePending {}
371
372/// A currently-running tool invocation.
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374pub struct ToolStateRunning {
375    /// Timing information.
376    pub time: ToolStateRunningTime,
377    /// Optional input data.
378    #[serde(skip_serializing_if = "Option::is_none")]
379    pub input: Option<serde_json::Value>,
380    /// Optional provider-specific metadata.
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub metadata: Option<HashMap<String, serde_json::Value>>,
383    /// Optional human-readable title.
384    #[serde(skip_serializing_if = "Option::is_none")]
385    pub title: Option<String>,
386}
387
388/// Timing for [`ToolStateRunning`].
389#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
390pub struct ToolStateRunningTime {
391    /// Epoch timestamp when the tool started running.
392    pub start: f64,
393}
394
395/// A successfully completed tool invocation.
396#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
397pub struct ToolStateCompleted {
398    /// Input data passed to the tool.
399    pub input: HashMap<String, serde_json::Value>,
400    /// Provider-specific metadata.
401    pub metadata: HashMap<String, serde_json::Value>,
402    /// Tool output text.
403    pub output: String,
404    /// Timing information.
405    pub time: ToolStateCompletedTime,
406    /// Human-readable title.
407    pub title: String,
408}
409
410/// Timing for [`ToolStateCompleted`].
411#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
412pub struct ToolStateCompletedTime {
413    /// Epoch timestamp when the tool finished.
414    pub end: f64,
415    /// Epoch timestamp when the tool started.
416    pub start: f64,
417}
418
419/// A tool invocation that resulted in an error.
420#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
421pub struct ToolStateError {
422    /// Error description.
423    pub error: String,
424    /// Input data passed to the tool.
425    pub input: HashMap<String, serde_json::Value>,
426    /// Timing information.
427    pub time: ToolStateErrorTime,
428}
429
430/// Timing for [`ToolStateError`].
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432pub struct ToolStateErrorTime {
433    /// Epoch timestamp when the tool finished with an error.
434    pub end: f64,
435    /// Epoch timestamp when the tool started.
436    pub start: f64,
437}
438
439/// The current state of a tool invocation — discriminated by `status`.
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
441#[serde(tag = "status")]
442pub enum ToolState {
443    /// The tool is waiting to execute.
444    #[serde(rename = "pending")]
445    Pending(ToolStatePending),
446    /// The tool is currently executing.
447    #[serde(rename = "running")]
448    Running(ToolStateRunning),
449    /// The tool completed successfully.
450    #[serde(rename = "completed")]
451    Completed(ToolStateCompleted),
452    /// The tool finished with an error.
453    #[serde(rename = "error")]
454    Error(ToolStateError),
455}
456
457// ---------------------------------------------------------------------------
458// File Part Source Types
459// ---------------------------------------------------------------------------
460
461/// Text content extracted from a source.
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
463pub struct FilePartSourceText {
464    /// End offset (byte or character index).
465    pub end: u64,
466    /// Start offset (byte or character index).
467    pub start: u64,
468    /// The extracted text value.
469    pub value: String,
470}
471
472/// A file-based source.
473#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
474pub struct FileSource {
475    /// Filesystem path.
476    pub path: String,
477    /// Extracted text content.
478    pub text: FilePartSourceText,
479}
480
481/// A symbol-based source (e.g. function, class).
482#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
483pub struct SymbolSource {
484    /// Symbol kind (numeric identifier from the language server).
485    pub kind: u64,
486    /// Symbol name.
487    pub name: String,
488    /// Filesystem path containing the symbol.
489    pub path: String,
490    /// Character range of the symbol.
491    pub range: SymbolSourceRange,
492    /// Extracted text content.
493    pub text: FilePartSourceText,
494}
495
496/// Range of a [`SymbolSource`].
497#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
498pub struct SymbolSourceRange {
499    /// End position.
500    pub end: SymbolSourcePosition,
501    /// Start position.
502    pub start: SymbolSourcePosition,
503}
504
505/// A line/character position within a file.
506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
507pub struct SymbolSourcePosition {
508    /// Zero-based character offset on the line.
509    pub character: u64,
510    /// Zero-based line number.
511    pub line: u64,
512}
513
514/// Source of a file part — either a file or a symbol.
515#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
516#[serde(tag = "type")]
517pub enum FilePartSource {
518    /// A plain file source.
519    #[serde(rename = "file")]
520    File(FileSource),
521    /// A symbol source (function, class, etc.).
522    #[serde(rename = "symbol")]
523    Symbol(SymbolSource),
524}
525
526// ---------------------------------------------------------------------------
527// Input Types
528// ---------------------------------------------------------------------------
529
530/// A text input part for the chat endpoint.
531#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
532pub struct TextPartInput {
533    /// The text content.
534    pub text: String,
535    /// Optional part identifier.
536    #[serde(skip_serializing_if = "Option::is_none")]
537    pub id: Option<String>,
538    /// Whether this input was synthetically generated.
539    #[serde(skip_serializing_if = "Option::is_none")]
540    pub synthetic: Option<bool>,
541    /// Optional timing information.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub time: Option<TextPartInputTime>,
544}
545
546/// Timing information for a [`TextPartInput`].
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
548pub struct TextPartInputTime {
549    /// Epoch timestamp when text input started.
550    pub start: f64,
551    /// Epoch timestamp when text input ended.
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub end: Option<f64>,
554}
555
556/// A file input part for the chat endpoint.
557#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
558pub struct FilePartInput {
559    /// MIME type of the file.
560    pub mime: String,
561    /// URL to the file content.
562    pub url: String,
563    /// Optional part identifier.
564    #[serde(skip_serializing_if = "Option::is_none")]
565    pub id: Option<String>,
566    /// Optional human-readable filename.
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub filename: Option<String>,
569    /// Optional source information.
570    #[serde(skip_serializing_if = "Option::is_none")]
571    pub source: Option<FilePartSource>,
572}
573
574/// An input part — either text or file — discriminated by `type`.
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
576#[serde(tag = "type")]
577pub enum PartInput {
578    /// A text input.
579    #[serde(rename = "text")]
580    Text(TextPartInput),
581    /// A file input.
582    #[serde(rename = "file")]
583    File(FilePartInput),
584}
585
586// ---------------------------------------------------------------------------
587// Response Types
588// ---------------------------------------------------------------------------
589
590/// A single item in the session messages response.
591#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
592pub struct SessionMessagesResponseItem {
593    /// The message metadata.
594    pub info: Message,
595    /// The parts that compose this message.
596    pub parts: Vec<Part>,
597}
598
599/// Response type for listing session messages.
600pub type SessionMessagesResponse = Vec<SessionMessagesResponseItem>;
601
602/// Response type for listing sessions.
603pub type SessionListResponse = Vec<Session>;
604
605/// Response type for deleting a session.
606pub type SessionDeleteResponse = bool;
607
608/// Response type for aborting a session.
609pub type SessionAbortResponse = bool;
610
611/// Response type for initialising a session.
612pub type SessionInitResponse = bool;
613
614/// Response type for summarising a session.
615pub type SessionSummarizeResponse = bool;
616
617// ---------------------------------------------------------------------------
618// Param Types
619// ---------------------------------------------------------------------------
620
621/// Parameters for the chat endpoint (`POST /session/{id}/message`).
622#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
623pub struct SessionChatParams {
624    /// The model to use.
625    #[serde(rename = "modelID")]
626    pub model_id: String,
627    /// Input parts (text and/or file).
628    pub parts: Vec<PartInput>,
629    /// The provider to use.
630    #[serde(rename = "providerID")]
631    pub provider_id: String,
632    /// Optional message identifier for continuing a conversation.
633    #[serde(rename = "messageID")]
634    #[serde(skip_serializing_if = "Option::is_none")]
635    pub message_id: Option<String>,
636    /// Optional mode override.
637    #[serde(skip_serializing_if = "Option::is_none")]
638    pub mode: Option<String>,
639    /// Optional system prompt override.
640    #[serde(skip_serializing_if = "Option::is_none")]
641    pub system: Option<String>,
642    /// Optional map of tool names to their enabled state.
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub tools: Option<HashMap<String, bool>>,
645}
646
647/// Parameters for session initialisation (`POST /session/{id}/init`).
648#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
649pub struct SessionInitParams {
650    /// The message identifier.
651    #[serde(rename = "messageID")]
652    pub message_id: String,
653    /// The model to use.
654    #[serde(rename = "modelID")]
655    pub model_id: String,
656    /// The provider to use.
657    #[serde(rename = "providerID")]
658    pub provider_id: String,
659}
660
661/// Parameters for reverting a session (`POST /session/{id}/revert`).
662#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
663pub struct SessionRevertParams {
664    /// The message to revert to.
665    #[serde(rename = "messageID")]
666    pub message_id: String,
667    /// Optional part identifier to revert to.
668    #[serde(rename = "partID")]
669    #[serde(skip_serializing_if = "Option::is_none")]
670    pub part_id: Option<String>,
671}
672
673/// Parameters for summarising a session (`POST /session/{id}/summarize`).
674#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
675pub struct SessionSummarizeParams {
676    /// The model to use for summarisation.
677    #[serde(rename = "modelID")]
678    pub model_id: String,
679    /// The provider to use for summarisation.
680    #[serde(rename = "providerID")]
681    pub provider_id: String,
682}
683
684// ---------------------------------------------------------------------------
685// SessionResource
686// ---------------------------------------------------------------------------
687
688/// Provides access to the Session-related API endpoints.
689pub struct SessionResource<'a> {
690    client: &'a Opencode,
691}
692
693impl<'a> SessionResource<'a> {
694    /// Create a new `SessionResource` bound to the given client.
695    pub(crate) const fn new(client: &'a Opencode) -> Self {
696        Self { client }
697    }
698
699    /// Create a new session (`POST /session`).
700    pub async fn create(&self, options: Option<&RequestOptions>) -> Result<Session, OpencodeError> {
701        self.client.post::<Session, ()>("/session", None, options).await
702    }
703
704    /// List all sessions (`GET /session`).
705    pub async fn list(
706        &self,
707        options: Option<&RequestOptions>,
708    ) -> Result<SessionListResponse, OpencodeError> {
709        self.client.get("/session", options).await
710    }
711
712    /// Delete a session (`DELETE /session/{id}`).
713    pub async fn delete(
714        &self,
715        id: &str,
716        options: Option<&RequestOptions>,
717    ) -> Result<SessionDeleteResponse, OpencodeError> {
718        self.client.delete::<bool, ()>(&format!("/session/{id}"), None, options).await
719    }
720
721    /// Abort a running session (`POST /session/{id}/abort`).
722    pub async fn abort(
723        &self,
724        id: &str,
725        options: Option<&RequestOptions>,
726    ) -> Result<SessionAbortResponse, OpencodeError> {
727        self.client.post::<bool, ()>(&format!("/session/{id}/abort"), None, options).await
728    }
729
730    /// Send a chat message (`POST /session/{id}/message`).
731    pub async fn chat(
732        &self,
733        id: &str,
734        params: &SessionChatParams,
735        options: Option<&RequestOptions>,
736    ) -> Result<AssistantMessage, OpencodeError> {
737        self.client.post(&format!("/session/{id}/message"), Some(params), options).await
738    }
739
740    /// Initialise a session (`POST /session/{id}/init`).
741    pub async fn init(
742        &self,
743        id: &str,
744        params: &SessionInitParams,
745        options: Option<&RequestOptions>,
746    ) -> Result<SessionInitResponse, OpencodeError> {
747        self.client.post(&format!("/session/{id}/init"), Some(params), options).await
748    }
749
750    /// List messages in a session (`GET /session/{id}/message`).
751    pub async fn messages(
752        &self,
753        id: &str,
754        options: Option<&RequestOptions>,
755    ) -> Result<SessionMessagesResponse, OpencodeError> {
756        self.client.get(&format!("/session/{id}/message"), options).await
757    }
758
759    /// Revert a session to a previous state (`POST /session/{id}/revert`).
760    pub async fn revert(
761        &self,
762        id: &str,
763        params: &SessionRevertParams,
764        options: Option<&RequestOptions>,
765    ) -> Result<Session, OpencodeError> {
766        self.client.post(&format!("/session/{id}/revert"), Some(params), options).await
767    }
768
769    /// Share a session (`POST /session/{id}/share`).
770    pub async fn share(
771        &self,
772        id: &str,
773        options: Option<&RequestOptions>,
774    ) -> Result<Session, OpencodeError> {
775        self.client.post::<Session, ()>(&format!("/session/{id}/share"), None, options).await
776    }
777
778    /// Summarise a session (`POST /session/{id}/summarize`).
779    pub async fn summarize(
780        &self,
781        id: &str,
782        params: &SessionSummarizeParams,
783        options: Option<&RequestOptions>,
784    ) -> Result<SessionSummarizeResponse, OpencodeError> {
785        self.client.post(&format!("/session/{id}/summarize"), Some(params), options).await
786    }
787
788    /// Unrevert a session (`POST /session/{id}/unrevert`).
789    pub async fn unrevert(
790        &self,
791        id: &str,
792        options: Option<&RequestOptions>,
793    ) -> Result<Session, OpencodeError> {
794        self.client.post::<Session, ()>(&format!("/session/{id}/unrevert"), None, options).await
795    }
796
797    /// Unshare a session (`DELETE /session/{id}/share`).
798    pub async fn unshare(
799        &self,
800        id: &str,
801        options: Option<&RequestOptions>,
802    ) -> Result<Session, OpencodeError> {
803        self.client.delete::<Session, ()>(&format!("/session/{id}/share"), None, options).await
804    }
805}
806
807// ---------------------------------------------------------------------------
808// Tests
809// ---------------------------------------------------------------------------
810
811#[cfg(test)]
812mod tests {
813    use serde_json::json;
814
815    use super::*;
816
817    // -- Session round-trips --
818
819    #[test]
820    fn session_full_round_trip() {
821        let session = Session {
822            id: "sess_001".into(),
823            time: SessionTime { created: 1_700_000_000.0, updated: 1_700_001_000.0 },
824            title: "My Session".into(),
825            version: "1".into(),
826            parent_id: Some("sess_000".into()),
827            revert: Some(SessionRevert {
828                message_id: "msg_001".into(),
829                diff: Some("--- a/file\n+++ b/file".into()),
830                part_id: Some("part_001".into()),
831                snapshot: Some("snapshot_data".into()),
832            }),
833            share: Some(SessionShare { url: "https://example.com/share/abc".into() }),
834        };
835        let json_str = serde_json::to_string(&session).unwrap();
836        assert!(json_str.contains("parentID"));
837        assert!(json_str.contains("messageID"));
838        assert!(json_str.contains("partID"));
839        let back: Session = serde_json::from_str(&json_str).unwrap();
840        assert_eq!(session, back);
841    }
842
843    #[test]
844    fn session_minimal_round_trip() {
845        let session = Session {
846            id: "sess_002".into(),
847            time: SessionTime { created: 1_700_000_000.0, updated: 1_700_000_000.0 },
848            title: "Empty".into(),
849            version: "1".into(),
850            parent_id: None,
851            revert: None,
852            share: None,
853        };
854        let json_str = serde_json::to_string(&session).unwrap();
855        assert!(!json_str.contains("parentID"));
856        assert!(!json_str.contains("revert"));
857        assert!(!json_str.contains("share"));
858        let back: Session = serde_json::from_str(&json_str).unwrap();
859        assert_eq!(session, back);
860    }
861
862    // -- Message round-trips --
863
864    #[test]
865    fn user_message_round_trip() {
866        let msg = UserMessage {
867            id: "msg_u001".into(),
868            session_id: "sess_001".into(),
869            time: UserMessageTime { created: 1_700_000_100.0 },
870        };
871        let json_str = serde_json::to_string(&msg).unwrap();
872        assert!(json_str.contains("sessionID"));
873        let back: UserMessage = serde_json::from_str(&json_str).unwrap();
874        assert_eq!(msg, back);
875    }
876
877    #[test]
878    fn assistant_message_round_trip() {
879        let msg = AssistantMessage {
880            id: "msg_a001".into(),
881            cost: 0.0032,
882            mode: "code".into(),
883            model_id: "gpt-4o".into(),
884            path: AssistantMessagePath {
885                cwd: "/home/user/project".into(),
886                root: "/home/user/project".into(),
887            },
888            provider_id: "openai".into(),
889            session_id: "sess_001".into(),
890            system: vec!["You are a helpful assistant.".into()],
891            time: AssistantMessageTime {
892                created: 1_700_000_200.0,
893                completed: Some(1_700_000_210.0),
894            },
895            tokens: AssistantMessageTokens {
896                cache: TokenCache { read: 100, write: 50 },
897                input: 500,
898                output: 200,
899                reasoning: 0,
900            },
901            error: None,
902            summary: None,
903        };
904        let json_str = serde_json::to_string(&msg).unwrap();
905        assert!(json_str.contains("modelID"));
906        assert!(json_str.contains("providerID"));
907        assert!(json_str.contains("sessionID"));
908        let back: AssistantMessage = serde_json::from_str(&json_str).unwrap();
909        assert_eq!(msg, back);
910    }
911
912    #[test]
913    fn assistant_message_with_error() {
914        let msg = AssistantMessage {
915            id: "msg_a002".into(),
916            cost: 0.0,
917            mode: "code".into(),
918            model_id: "gpt-4o".into(),
919            path: AssistantMessagePath { cwd: "/tmp".into(), root: "/tmp".into() },
920            provider_id: "openai".into(),
921            session_id: "sess_001".into(),
922            system: vec![],
923            time: AssistantMessageTime { created: 1_700_000_300.0, completed: None },
924            tokens: AssistantMessageTokens {
925                cache: TokenCache { read: 0, write: 0 },
926                input: 0,
927                output: 0,
928                reasoning: 0,
929            },
930            error: Some(SessionError::ProviderAuthError {
931                data: super::super::shared::ProviderAuthErrorData {
932                    message: "invalid key".into(),
933                    provider_id: "openai".into(),
934                },
935            }),
936            summary: Some(true),
937        };
938        let json_str = serde_json::to_string(&msg).unwrap();
939        assert!(json_str.contains("ProviderAuthError"));
940        let back: AssistantMessage = serde_json::from_str(&json_str).unwrap();
941        assert_eq!(msg, back);
942    }
943
944    // -- Message enum --
945
946    #[test]
947    fn message_enum_user_variant() {
948        let msg = Message::User(UserMessage {
949            id: "msg_u002".into(),
950            session_id: "sess_001".into(),
951            time: UserMessageTime { created: 1_700_000_100.0 },
952        });
953        let json_str = serde_json::to_string(&msg).unwrap();
954        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
955        assert_eq!(v["role"], "user");
956        let back: Message = serde_json::from_str(&json_str).unwrap();
957        assert_eq!(msg, back);
958    }
959
960    #[test]
961    fn message_enum_assistant_variant() {
962        let msg = Message::Assistant(Box::new(AssistantMessage {
963            id: "msg_a003".into(),
964            cost: 0.001,
965            mode: "default".into(),
966            model_id: "claude-3-opus".into(),
967            path: AssistantMessagePath { cwd: "/home".into(), root: "/home".into() },
968            provider_id: "anthropic".into(),
969            session_id: "sess_002".into(),
970            system: vec![],
971            time: AssistantMessageTime {
972                created: 1_700_000_500.0,
973                completed: Some(1_700_000_510.0),
974            },
975            tokens: AssistantMessageTokens {
976                cache: TokenCache { read: 10, write: 5 },
977                input: 100,
978                output: 50,
979                reasoning: 20,
980            },
981            error: None,
982            summary: None,
983        }));
984        let json_str = serde_json::to_string(&msg).unwrap();
985        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
986        assert_eq!(v["role"], "assistant");
987        let back: Message = serde_json::from_str(&json_str).unwrap();
988        assert_eq!(msg, back);
989    }
990
991    // -- Part enum variants --
992
993    #[test]
994    fn part_text_round_trip() {
995        let part = Part::Text(TextPart {
996            id: "p_001".into(),
997            message_id: "msg_a001".into(),
998            session_id: "sess_001".into(),
999            text: "Hello, world!".into(),
1000            synthetic: None,
1001            time: Some(TextPartTime { start: 1_700_000_200.0, end: Some(1_700_000_201.0) }),
1002        });
1003        let json_str = serde_json::to_string(&part).unwrap();
1004        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1005        assert_eq!(v["type"], "text");
1006        let back: Part = serde_json::from_str(&json_str).unwrap();
1007        assert_eq!(part, back);
1008    }
1009
1010    #[test]
1011    fn part_tool_round_trip() {
1012        let part = Part::Tool(ToolPart {
1013            id: "p_002".into(),
1014            call_id: "call_001".into(),
1015            message_id: "msg_a001".into(),
1016            session_id: "sess_001".into(),
1017            state: ToolState::Completed(ToolStateCompleted {
1018                input: HashMap::from([("cmd".into(), json!("ls"))]),
1019                metadata: HashMap::new(),
1020                output: "file1.rs\nfile2.rs".into(),
1021                time: ToolStateCompletedTime { end: 1_700_000_205.0, start: 1_700_000_202.0 },
1022                title: "bash".into(),
1023            }),
1024            tool: "bash".into(),
1025        });
1026        let json_str = serde_json::to_string(&part).unwrap();
1027        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1028        assert_eq!(v["type"], "tool");
1029        assert_eq!(v["state"]["status"], "completed");
1030        let back: Part = serde_json::from_str(&json_str).unwrap();
1031        assert_eq!(part, back);
1032    }
1033
1034    #[test]
1035    fn part_step_start_round_trip() {
1036        let part = Part::StepStart(StepStartPart {
1037            id: "p_003".into(),
1038            message_id: "msg_a001".into(),
1039            session_id: "sess_001".into(),
1040        });
1041        let json_str = serde_json::to_string(&part).unwrap();
1042        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1043        assert_eq!(v["type"], "step-start");
1044        let back: Part = serde_json::from_str(&json_str).unwrap();
1045        assert_eq!(part, back);
1046    }
1047
1048    #[test]
1049    fn part_step_finish_round_trip() {
1050        let part = Part::StepFinish(StepFinishPart {
1051            id: "p_004".into(),
1052            cost: 0.001,
1053            message_id: "msg_a001".into(),
1054            session_id: "sess_001".into(),
1055            tokens: StepFinishTokens {
1056                cache: TokenCache { read: 10, write: 5 },
1057                input: 100,
1058                output: 50,
1059                reasoning: 0,
1060            },
1061        });
1062        let json_str = serde_json::to_string(&part).unwrap();
1063        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1064        assert_eq!(v["type"], "step-finish");
1065        let back: Part = serde_json::from_str(&json_str).unwrap();
1066        assert_eq!(part, back);
1067    }
1068
1069    #[test]
1070    fn part_patch_round_trip() {
1071        let part = Part::Patch(PatchPart {
1072            id: "p_005".into(),
1073            files: vec!["src/main.rs".into(), "Cargo.toml".into()],
1074            hash: "abc123".into(),
1075            message_id: "msg_a001".into(),
1076            session_id: "sess_001".into(),
1077        });
1078        let json_str = serde_json::to_string(&part).unwrap();
1079        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1080        assert_eq!(v["type"], "patch");
1081        let back: Part = serde_json::from_str(&json_str).unwrap();
1082        assert_eq!(part, back);
1083    }
1084
1085    #[test]
1086    fn part_snapshot_round_trip() {
1087        let part = Part::Snapshot(SnapshotPart {
1088            id: "p_006".into(),
1089            message_id: "msg_a001".into(),
1090            session_id: "sess_001".into(),
1091            snapshot: "{\"state\":\"data\"}".into(),
1092        });
1093        let json_str = serde_json::to_string(&part).unwrap();
1094        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1095        assert_eq!(v["type"], "snapshot");
1096        let back: Part = serde_json::from_str(&json_str).unwrap();
1097        assert_eq!(part, back);
1098    }
1099
1100    #[test]
1101    fn part_file_round_trip() {
1102        let part = Part::File(FilePart {
1103            id: "p_007".into(),
1104            message_id: "msg_a001".into(),
1105            mime: "image/png".into(),
1106            session_id: "sess_001".into(),
1107            url: "https://example.com/img.png".into(),
1108            filename: Some("screenshot.png".into()),
1109            source: None,
1110        });
1111        let json_str = serde_json::to_string(&part).unwrap();
1112        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1113        assert_eq!(v["type"], "file");
1114        let back: Part = serde_json::from_str(&json_str).unwrap();
1115        assert_eq!(part, back);
1116    }
1117
1118    // -- ToolState enum --
1119
1120    #[test]
1121    fn tool_state_pending() {
1122        let state = ToolState::Pending(ToolStatePending {});
1123        let json_str = serde_json::to_string(&state).unwrap();
1124        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1125        assert_eq!(v["status"], "pending");
1126        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1127        assert_eq!(state, back);
1128    }
1129
1130    #[test]
1131    fn tool_state_running() {
1132        let state = ToolState::Running(ToolStateRunning {
1133            time: ToolStateRunningTime { start: 1_700_000_200.0 },
1134            input: Some(json!({"command": "echo hello"})),
1135            metadata: Some(HashMap::from([("key".into(), json!("value"))])),
1136            title: Some("Running bash".into()),
1137        });
1138        let json_str = serde_json::to_string(&state).unwrap();
1139        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1140        assert_eq!(v["status"], "running");
1141        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1142        assert_eq!(state, back);
1143    }
1144
1145    #[test]
1146    fn tool_state_completed() {
1147        let state = ToolState::Completed(ToolStateCompleted {
1148            input: HashMap::from([("cmd".into(), json!("ls -la"))]),
1149            metadata: HashMap::from([("exit_code".into(), json!(0))]),
1150            output: "total 42\ndrwxr-xr-x ...".into(),
1151            time: ToolStateCompletedTime { end: 1_700_000_210.0, start: 1_700_000_200.0 },
1152            title: "bash".into(),
1153        });
1154        let json_str = serde_json::to_string(&state).unwrap();
1155        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1156        assert_eq!(v["status"], "completed");
1157        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1158        assert_eq!(state, back);
1159    }
1160
1161    #[test]
1162    fn tool_state_error() {
1163        let state = ToolState::Error(ToolStateError {
1164            error: "command not found".into(),
1165            input: HashMap::from([("cmd".into(), json!("nonexistent"))]),
1166            time: ToolStateErrorTime { end: 1_700_000_201.0, start: 1_700_000_200.0 },
1167        });
1168        let json_str = serde_json::to_string(&state).unwrap();
1169        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1170        assert_eq!(v["status"], "error");
1171        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1172        assert_eq!(state, back);
1173    }
1174
1175    // -- FilePartSource enum --
1176
1177    #[test]
1178    fn file_part_source_file_variant() {
1179        let src = FilePartSource::File(FileSource {
1180            path: "/home/user/main.rs".into(),
1181            text: FilePartSourceText { end: 100, start: 0, value: "fn main() {}".into() },
1182        });
1183        let json_str = serde_json::to_string(&src).unwrap();
1184        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1185        assert_eq!(v["type"], "file");
1186        let back: FilePartSource = serde_json::from_str(&json_str).unwrap();
1187        assert_eq!(src, back);
1188    }
1189
1190    #[test]
1191    fn file_part_source_symbol_variant() {
1192        let src = FilePartSource::Symbol(SymbolSource {
1193            kind: 12,
1194            name: "main".into(),
1195            path: "/home/user/main.rs".into(),
1196            range: SymbolSourceRange {
1197                end: SymbolSourcePosition { character: 1, line: 2 },
1198                start: SymbolSourcePosition { character: 0, line: 0 },
1199            },
1200            text: FilePartSourceText {
1201                end: 50,
1202                start: 0,
1203                value: "fn main() {\n    println!(\"hello\");\n}".into(),
1204            },
1205        });
1206        let json_str = serde_json::to_string(&src).unwrap();
1207        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1208        assert_eq!(v["type"], "symbol");
1209        let back: FilePartSource = serde_json::from_str(&json_str).unwrap();
1210        assert_eq!(src, back);
1211    }
1212
1213    // -- SessionChatParams --
1214
1215    #[test]
1216    fn session_chat_params_full_round_trip() {
1217        let params = SessionChatParams {
1218            model_id: "gpt-4o".into(),
1219            parts: vec![
1220                PartInput::Text(TextPartInput {
1221                    text: "Hello!".into(),
1222                    id: Some("input_001".into()),
1223                    synthetic: None,
1224                    time: Some(TextPartInputTime { start: 1_700_000_000.0, end: None }),
1225                }),
1226                PartInput::File(FilePartInput {
1227                    mime: "text/plain".into(),
1228                    url: "file:///tmp/test.txt".into(),
1229                    id: None,
1230                    filename: Some("test.txt".into()),
1231                    source: None,
1232                }),
1233            ],
1234            provider_id: "openai".into(),
1235            message_id: Some("msg_001".into()),
1236            mode: Some("code".into()),
1237            system: Some("Be concise.".into()),
1238            tools: Some(HashMap::from([("bash".into(), true)])),
1239        };
1240        let json_str = serde_json::to_string(&params).unwrap();
1241        assert!(json_str.contains("modelID"));
1242        assert!(json_str.contains("providerID"));
1243        assert!(json_str.contains("messageID"));
1244        let back: SessionChatParams = serde_json::from_str(&json_str).unwrap();
1245        assert_eq!(params, back);
1246    }
1247
1248    #[test]
1249    fn session_chat_params_minimal() {
1250        let params = SessionChatParams {
1251            model_id: "gpt-4o".into(),
1252            parts: vec![PartInput::Text(TextPartInput {
1253                text: "Hi".into(),
1254                id: None,
1255                synthetic: None,
1256                time: None,
1257            })],
1258            provider_id: "openai".into(),
1259            message_id: None,
1260            mode: None,
1261            system: None,
1262            tools: None,
1263        };
1264        let json_str = serde_json::to_string(&params).unwrap();
1265        assert!(!json_str.contains("messageID"));
1266        assert!(!json_str.contains("\"mode\""));
1267        assert!(!json_str.contains("system"));
1268        assert!(!json_str.contains("tools"));
1269        let back: SessionChatParams = serde_json::from_str(&json_str).unwrap();
1270        assert_eq!(params, back);
1271    }
1272
1273    // -- PartInput enum --
1274
1275    #[test]
1276    fn part_input_text_round_trip() {
1277        let input = PartInput::Text(TextPartInput {
1278            text: "Hello".into(),
1279            id: None,
1280            synthetic: Some(true),
1281            time: None,
1282        });
1283        let json_str = serde_json::to_string(&input).unwrap();
1284        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1285        assert_eq!(v["type"], "text");
1286        let back: PartInput = serde_json::from_str(&json_str).unwrap();
1287        assert_eq!(input, back);
1288    }
1289
1290    #[test]
1291    fn part_input_file_round_trip() {
1292        let input = PartInput::File(FilePartInput {
1293            mime: "image/png".into(),
1294            url: "https://example.com/img.png".into(),
1295            id: Some("fi_001".into()),
1296            filename: Some("photo.png".into()),
1297            source: Some(FilePartSource::File(FileSource {
1298                path: "/tmp/photo.png".into(),
1299                text: FilePartSourceText { end: 0, start: 0, value: String::new() },
1300            })),
1301        });
1302        let json_str = serde_json::to_string(&input).unwrap();
1303        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1304        assert_eq!(v["type"], "file");
1305        let back: PartInput = serde_json::from_str(&json_str).unwrap();
1306        assert_eq!(input, back);
1307    }
1308
1309    // -- SessionMessagesResponseItem --
1310
1311    #[test]
1312    fn session_messages_response_item_round_trip() {
1313        let item = SessionMessagesResponseItem {
1314            info: Message::User(UserMessage {
1315                id: "msg_u010".into(),
1316                session_id: "sess_001".into(),
1317                time: UserMessageTime { created: 1_700_000_000.0 },
1318            }),
1319            parts: vec![Part::Text(TextPart {
1320                id: "p_010".into(),
1321                message_id: "msg_u010".into(),
1322                session_id: "sess_001".into(),
1323                text: "What is Rust?".into(),
1324                synthetic: None,
1325                time: None,
1326            })],
1327        };
1328        let json_str = serde_json::to_string(&item).unwrap();
1329        let back: SessionMessagesResponseItem = serde_json::from_str(&json_str).unwrap();
1330        assert_eq!(item, back);
1331    }
1332
1333    // -- Param types --
1334
1335    #[test]
1336    fn session_init_params_round_trip() {
1337        let params = SessionInitParams {
1338            message_id: "msg_001".into(),
1339            model_id: "gpt-4o".into(),
1340            provider_id: "openai".into(),
1341        };
1342        let json_str = serde_json::to_string(&params).unwrap();
1343        assert!(json_str.contains("messageID"));
1344        assert!(json_str.contains("modelID"));
1345        assert!(json_str.contains("providerID"));
1346        let back: SessionInitParams = serde_json::from_str(&json_str).unwrap();
1347        assert_eq!(params, back);
1348    }
1349
1350    #[test]
1351    fn session_revert_params_round_trip() {
1352        let params =
1353            SessionRevertParams { message_id: "msg_001".into(), part_id: Some("part_001".into()) };
1354        let json_str = serde_json::to_string(&params).unwrap();
1355        assert!(json_str.contains("messageID"));
1356        assert!(json_str.contains("partID"));
1357        let back: SessionRevertParams = serde_json::from_str(&json_str).unwrap();
1358        assert_eq!(params, back);
1359    }
1360
1361    #[test]
1362    fn session_summarize_params_round_trip() {
1363        let params =
1364            SessionSummarizeParams { model_id: "gpt-4o".into(), provider_id: "openai".into() };
1365        let json_str = serde_json::to_string(&params).unwrap();
1366        assert!(json_str.contains("modelID"));
1367        assert!(json_str.contains("providerID"));
1368        let back: SessionSummarizeParams = serde_json::from_str(&json_str).unwrap();
1369        assert_eq!(params, back);
1370    }
1371
1372    // -- Deserialization from JS-compatible JSON --
1373
1374    #[test]
1375    fn deserialize_message_from_js_json() {
1376        let js_json = json!({
1377            "role": "user",
1378            "id": "msg_from_js",
1379            "sessionID": "sess_js",
1380            "time": { "created": 1700000000.0 }
1381        });
1382        let msg: Message = serde_json::from_value(js_json).unwrap();
1383        match msg {
1384            Message::User(u) => {
1385                assert_eq!(u.id, "msg_from_js");
1386                assert_eq!(u.session_id, "sess_js");
1387            }
1388            _ => panic!("expected User variant"),
1389        }
1390    }
1391
1392    #[test]
1393    fn deserialize_part_from_js_json() {
1394        let js_json = json!({
1395            "type": "step-start",
1396            "id": "p_js_001",
1397            "messageID": "msg_js_001",
1398            "sessionID": "sess_js"
1399        });
1400        let part: Part = serde_json::from_value(js_json).unwrap();
1401        match part {
1402            Part::StepStart(s) => {
1403                assert_eq!(s.id, "p_js_001");
1404                assert_eq!(s.message_id, "msg_js_001");
1405            }
1406            _ => panic!("expected StepStart variant"),
1407        }
1408    }
1409
1410    #[test]
1411    fn deserialize_tool_state_from_js_json() {
1412        let js_json = json!({
1413            "status": "error",
1414            "error": "timeout",
1415            "input": { "cmd": "sleep 999" },
1416            "time": { "start": 1700000000.0, "end": 1700000030.0 }
1417        });
1418        let state: ToolState = serde_json::from_value(js_json).unwrap();
1419        match state {
1420            ToolState::Error(e) => {
1421                assert_eq!(e.error, "timeout");
1422            }
1423            _ => panic!("expected Error variant"),
1424        }
1425    }
1426
1427    // -- Edge cases --
1428
1429    #[test]
1430    fn tool_state_running_minimal() {
1431        let state = ToolState::Running(ToolStateRunning {
1432            time: ToolStateRunningTime { start: 1_700_000_000.0 },
1433            input: None,
1434            metadata: None,
1435            title: None,
1436        });
1437        let json_str = serde_json::to_string(&state).unwrap();
1438        assert!(!json_str.contains("input"));
1439        assert!(!json_str.contains("metadata"));
1440        assert!(!json_str.contains("title"));
1441        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1442        assert_eq!(v["status"], "running");
1443        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1444        assert_eq!(state, back);
1445    }
1446
1447    #[test]
1448    fn text_part_no_synthetic_no_time() {
1449        let part = TextPart {
1450            id: "tp_001".into(),
1451            message_id: "msg_001".into(),
1452            session_id: "sess_001".into(),
1453            text: "bare text".into(),
1454            synthetic: None,
1455            time: None,
1456        };
1457        let json_str = serde_json::to_string(&part).unwrap();
1458        assert!(!json_str.contains("synthetic"));
1459        assert!(!json_str.contains("time"));
1460        let back: TextPart = serde_json::from_str(&json_str).unwrap();
1461        assert_eq!(part, back);
1462    }
1463
1464    #[test]
1465    fn file_part_no_filename_no_source() {
1466        let part = FilePart {
1467            id: "fp_001".into(),
1468            message_id: "msg_001".into(),
1469            mime: "application/octet-stream".into(),
1470            session_id: "sess_001".into(),
1471            url: "https://example.com/data.bin".into(),
1472            filename: None,
1473            source: None,
1474        };
1475        let json_str = serde_json::to_string(&part).unwrap();
1476        assert!(!json_str.contains("filename"));
1477        assert!(!json_str.contains("source"));
1478        let back: FilePart = serde_json::from_str(&json_str).unwrap();
1479        assert_eq!(part, back);
1480    }
1481
1482    #[test]
1483    fn part_file_minimal_round_trip() {
1484        let part = Part::File(FilePart {
1485            id: "fp_002".into(),
1486            message_id: "msg_001".into(),
1487            mime: "text/plain".into(),
1488            session_id: "sess_001".into(),
1489            url: "file:///tmp/a.txt".into(),
1490            filename: None,
1491            source: None,
1492        });
1493        let json_str = serde_json::to_string(&part).unwrap();
1494        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1495        assert_eq!(v["type"], "file");
1496        assert!(v.get("filename").is_none());
1497        assert!(v.get("source").is_none());
1498        let back: Part = serde_json::from_str(&json_str).unwrap();
1499        assert_eq!(part, back);
1500    }
1501
1502    #[test]
1503    fn assistant_message_no_error_no_summary() {
1504        let msg = AssistantMessage {
1505            id: "msg_edge".into(),
1506            cost: 0.0,
1507            mode: "plan".into(),
1508            model_id: "o1".into(),
1509            path: AssistantMessagePath { cwd: "/app".into(), root: "/app".into() },
1510            provider_id: "openai".into(),
1511            session_id: "sess_edge".into(),
1512            system: vec![],
1513            time: AssistantMessageTime { created: 1_700_000_000.0, completed: None },
1514            tokens: AssistantMessageTokens {
1515                cache: TokenCache { read: 0, write: 0 },
1516                input: 10,
1517                output: 5,
1518                reasoning: 0,
1519            },
1520            error: None,
1521            summary: None,
1522        };
1523        let json_str = serde_json::to_string(&msg).unwrap();
1524        assert!(!json_str.contains("error"));
1525        assert!(!json_str.contains("summary"));
1526        let back: AssistantMessage = serde_json::from_str(&json_str).unwrap();
1527        assert_eq!(msg, back);
1528    }
1529
1530    #[test]
1531    fn part_input_text_minimal() {
1532        let input = PartInput::Text(TextPartInput {
1533            text: "hi".into(),
1534            id: None,
1535            synthetic: None,
1536            time: None,
1537        });
1538        let json_str = serde_json::to_string(&input).unwrap();
1539        assert!(!json_str.contains("\"id\""));
1540        assert!(!json_str.contains("synthetic"));
1541        assert!(!json_str.contains("time"));
1542        let back: PartInput = serde_json::from_str(&json_str).unwrap();
1543        assert_eq!(input, back);
1544    }
1545
1546    #[test]
1547    fn part_input_file_minimal() {
1548        let input = PartInput::File(FilePartInput {
1549            mime: "text/csv".into(),
1550            url: "file:///data.csv".into(),
1551            id: None,
1552            filename: None,
1553            source: None,
1554        });
1555        let json_str = serde_json::to_string(&input).unwrap();
1556        assert!(!json_str.contains("\"id\""));
1557        assert!(!json_str.contains("filename"));
1558        assert!(!json_str.contains("source"));
1559        let back: PartInput = serde_json::from_str(&json_str).unwrap();
1560        assert_eq!(input, back);
1561    }
1562
1563    #[test]
1564    fn session_revert_minimal() {
1565        let revert = SessionRevert {
1566            message_id: "msg_r001".into(),
1567            diff: None,
1568            part_id: None,
1569            snapshot: None,
1570        };
1571        let json_str = serde_json::to_string(&revert).unwrap();
1572        assert!(!json_str.contains("diff"));
1573        assert!(!json_str.contains("partID"));
1574        assert!(!json_str.contains("snapshot"));
1575        let back: SessionRevert = serde_json::from_str(&json_str).unwrap();
1576        assert_eq!(revert, back);
1577    }
1578
1579    #[test]
1580    fn text_part_time_no_end() {
1581        let t = TextPartTime { start: 1_700_000_000.0, end: None };
1582        let json_str = serde_json::to_string(&t).unwrap();
1583        assert!(!json_str.contains("end"));
1584        let back: TextPartTime = serde_json::from_str(&json_str).unwrap();
1585        assert_eq!(t, back);
1586    }
1587
1588    #[test]
1589    fn assistant_message_time_no_completed() {
1590        let t = AssistantMessageTime { created: 1_700_000_000.0, completed: None };
1591        let json_str = serde_json::to_string(&t).unwrap();
1592        assert!(!json_str.contains("completed"));
1593        let back: AssistantMessageTime = serde_json::from_str(&json_str).unwrap();
1594        assert_eq!(t, back);
1595    }
1596
1597    #[test]
1598    fn session_revert_params_no_part_id() {
1599        let params = SessionRevertParams { message_id: "msg_001".into(), part_id: None };
1600        let json_str = serde_json::to_string(&params).unwrap();
1601        assert!(!json_str.contains("partID"));
1602        let back: SessionRevertParams = serde_json::from_str(&json_str).unwrap();
1603        assert_eq!(params, back);
1604    }
1605
1606    #[test]
1607    fn file_part_with_symbol_source() {
1608        let part = Part::File(FilePart {
1609            id: "fp_sym".into(),
1610            message_id: "msg_001".into(),
1611            mime: "text/x-rust".into(),
1612            session_id: "sess_001".into(),
1613            url: "file:///src/lib.rs".into(),
1614            filename: Some("lib.rs".into()),
1615            source: Some(FilePartSource::Symbol(SymbolSource {
1616                kind: 6,
1617                name: "MyStruct".into(),
1618                path: "/src/lib.rs".into(),
1619                range: SymbolSourceRange {
1620                    end: SymbolSourcePosition { character: 1, line: 10 },
1621                    start: SymbolSourcePosition { character: 0, line: 5 },
1622                },
1623                text: FilePartSourceText {
1624                    end: 200,
1625                    start: 100,
1626                    value: "struct MyStruct {}".into(),
1627                },
1628            })),
1629        });
1630        let json_str = serde_json::to_string(&part).unwrap();
1631        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1632        assert_eq!(v["source"]["type"], "symbol");
1633        let back: Part = serde_json::from_str(&json_str).unwrap();
1634        assert_eq!(part, back);
1635    }
1636}