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    /// URL-friendly session slug.
23    #[serde(default)]
24    pub slug: String,
25    /// Project identifier.
26    #[serde(rename = "projectID", default)]
27    pub project_id: String,
28    /// Working directory for this session.
29    #[serde(default)]
30    pub directory: String,
31    /// Timing information.
32    pub time: SessionTime,
33    /// Human-readable session title.
34    pub title: String,
35    /// Session schema version.
36    pub version: String,
37    /// Parent session identifier (for branched sessions).
38    #[serde(rename = "parentID")]
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub parent_id: Option<String>,
41    /// Revert metadata, if the session was reverted.
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub revert: Option<SessionRevert>,
44    /// Share metadata, if the session was shared.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub share: Option<SessionShare>,
47    /// Summary of changes in this session.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub summary: Option<SessionSummary>,
50    /// Permission rules for this session.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub permission: Option<PermissionRuleset>,
53}
54
55/// Timing information for a [`Session`].
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57pub struct SessionTime {
58    /// Epoch timestamp when the session was created.
59    pub created: f64,
60    /// Epoch timestamp when the session was last updated.
61    pub updated: f64,
62    /// Epoch timestamp when compaction started.
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub compacting: Option<f64>,
65    /// Epoch timestamp when the session was archived.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub archived: Option<f64>,
68}
69
70/// Revert metadata attached to a [`Session`].
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
72pub struct SessionRevert {
73    /// The message that was reverted to.
74    #[serde(rename = "messageID")]
75    pub message_id: String,
76    /// Optional diff content.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub diff: Option<String>,
79    /// Optional part identifier.
80    #[serde(rename = "partID")]
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub part_id: Option<String>,
83    /// Optional snapshot content.
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub snapshot: Option<String>,
86}
87
88/// Share metadata attached to a [`Session`].
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90pub struct SessionShare {
91    /// Public URL of the shared session.
92    pub url: String,
93}
94
95/// Status of a file diff.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
97#[serde(rename_all = "lowercase")]
98pub enum FileDiffStatus {
99    /// File was added.
100    Added,
101    /// File was deleted.
102    Deleted,
103    /// File was modified.
104    Modified,
105}
106
107/// A file diff within a session summary.
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct FileDiff {
110    /// The file path.
111    pub file: String,
112    /// Content before the change.
113    pub before: String,
114    /// Content after the change.
115    pub after: String,
116    /// Number of added lines.
117    pub additions: f64,
118    /// Number of deleted lines.
119    pub deletions: f64,
120    /// Optional diff status.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub status: Option<FileDiffStatus>,
123}
124
125/// Summary of changes in a session.
126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
127pub struct SessionSummary {
128    /// Number of additions.
129    pub additions: f64,
130    /// Number of deletions.
131    pub deletions: f64,
132    /// Number of files changed.
133    pub files: f64,
134    /// Optional list of file diffs.
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub diffs: Option<Vec<FileDiff>>,
137}
138
139/// A permission rule governing tool access.
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
141pub struct PermissionRule {
142    /// The permission name.
143    pub permission: String,
144    /// Glob pattern for matching.
145    pub pattern: String,
146    /// Action to take (use string for flexibility with `PermissionAction`).
147    pub action: String,
148}
149
150/// A set of permission rules.
151pub type PermissionRuleset = Vec<PermissionRule>;
152
153// ---------------------------------------------------------------------------
154// Output Format
155// ---------------------------------------------------------------------------
156
157/// Output format specification — text or JSON schema.
158#[allow(clippy::derive_partial_eq_without_eq)]
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
160#[serde(tag = "type")]
161pub enum OutputFormat {
162    /// Plain text output.
163    #[serde(rename = "text")]
164    Text,
165    /// JSON schema output.
166    #[serde(rename = "json_schema")]
167    JsonSchema {
168        /// The JSON schema.
169        schema: serde_json::Value,
170        /// Number of retries for structured output.
171        #[serde(rename = "retryCount", skip_serializing_if = "Option::is_none")]
172        retry_count: Option<u64>,
173    },
174}
175
176// ---------------------------------------------------------------------------
177// User Message Supporting Types
178// ---------------------------------------------------------------------------
179
180/// Summary information for a user message.
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
182pub struct UserMessageSummary {
183    /// Optional summary title.
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub title: Option<String>,
186    /// Optional summary body.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub body: Option<String>,
189    /// File diffs.
190    pub diffs: Vec<FileDiff>,
191}
192
193/// Model information for a user message.
194#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
195pub struct UserMessageModel {
196    /// Provider identifier.
197    #[serde(rename = "providerID")]
198    pub provider_id: String,
199    /// Model identifier.
200    #[serde(rename = "modelID")]
201    pub model_id: String,
202}
203
204// ---------------------------------------------------------------------------
205// Messages
206// ---------------------------------------------------------------------------
207
208/// A user-sent message.
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
210pub struct UserMessage {
211    /// Unique message identifier.
212    pub id: String,
213    /// The session this message belongs to.
214    #[serde(rename = "sessionID")]
215    pub session_id: String,
216    /// Timing information.
217    pub time: UserMessageTime,
218    /// Agent name.
219    #[serde(default)]
220    pub agent: String,
221    /// Model information.
222    #[serde(default)]
223    pub model: UserMessageModel,
224    /// Output format specification.
225    #[serde(skip_serializing_if = "Option::is_none")]
226    pub format: Option<OutputFormat>,
227    /// Summary information.
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub summary: Option<UserMessageSummary>,
230    /// System prompt.
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub system: Option<String>,
233    /// Map of tool names to enabled state.
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub tools: Option<HashMap<String, bool>>,
236    /// Variant identifier.
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub variant: Option<String>,
239}
240
241/// Timing information for a [`UserMessage`].
242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243pub struct UserMessageTime {
244    /// Epoch timestamp when the message was created.
245    pub created: f64,
246}
247
248/// An assistant-generated message.
249#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250pub struct AssistantMessage {
251    /// Unique message identifier.
252    #[serde(default)]
253    pub id: String,
254    /// Monetary cost of generating this message.
255    #[serde(default)]
256    pub cost: f64,
257    /// The mode used for generation.
258    #[serde(default)]
259    pub mode: String,
260    /// The model identifier used.
261    #[serde(rename = "modelID", default)]
262    pub model_id: String,
263    /// Filesystem paths relevant to this message.
264    #[serde(default)]
265    pub path: AssistantMessagePath,
266    /// The provider identifier used.
267    #[serde(rename = "providerID", default)]
268    pub provider_id: String,
269    /// The session this message belongs to.
270    #[serde(rename = "sessionID", default)]
271    pub session_id: String,
272    /// Parent message identifier.
273    #[serde(rename = "parentID", default)]
274    pub parent_id: String,
275    /// Agent name.
276    #[serde(default)]
277    pub agent: String,
278    /// System prompt segments (not in `OpenAPI` spec, kept for backward compatibility).
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub system: Vec<String>,
281    /// Timing information.
282    #[serde(default)]
283    pub time: AssistantMessageTime,
284    /// Token usage breakdown.
285    #[serde(default)]
286    pub tokens: AssistantMessageTokens,
287    /// Optional error that occurred during generation.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub error: Option<SessionError>,
290    /// Whether this message is a summary.
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub summary: Option<bool>,
293    /// Variant identifier.
294    #[serde(skip_serializing_if = "Option::is_none")]
295    pub variant: Option<String>,
296    /// Finish reason.
297    #[serde(skip_serializing_if = "Option::is_none")]
298    pub finish: Option<String>,
299    /// Structured output data.
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub structured: Option<serde_json::Value>,
302}
303
304/// Filesystem paths for an [`AssistantMessage`].
305#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
306pub struct AssistantMessagePath {
307    /// Current working directory.
308    #[serde(default)]
309    pub cwd: String,
310    /// Project root directory.
311    #[serde(default)]
312    pub root: String,
313}
314
315/// Timing information for an [`AssistantMessage`].
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
317pub struct AssistantMessageTime {
318    /// Epoch timestamp when the message was created.
319    #[serde(default)]
320    pub created: f64,
321    /// Epoch timestamp when generation completed.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub completed: Option<f64>,
324}
325
326/// Token usage breakdown for an [`AssistantMessage`].
327#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
328pub struct AssistantMessageTokens {
329    /// Cache token details.
330    #[serde(default)]
331    pub cache: TokenCache,
332    /// Number of input tokens.
333    #[serde(default)]
334    pub input: u64,
335    /// Number of output tokens.
336    #[serde(default)]
337    pub output: u64,
338    /// Number of reasoning tokens.
339    #[serde(default)]
340    pub reasoning: u64,
341    /// Total number of tokens.
342    #[serde(default)]
343    pub total: u64,
344}
345
346/// Cache token breakdown.
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
348pub struct TokenCache {
349    /// Number of tokens read from cache.
350    #[serde(default)]
351    pub read: u64,
352    /// Number of tokens written to cache.
353    #[serde(default)]
354    pub write: u64,
355}
356
357/// A message in a session — either from the user or the assistant.
358#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
359#[serde(tag = "role")]
360pub enum Message {
361    /// A user-sent message.
362    #[serde(rename = "user")]
363    User(Box<UserMessage>),
364    /// An assistant-generated message.
365    #[serde(rename = "assistant")]
366    Assistant(Box<AssistantMessage>),
367}
368
369// ---------------------------------------------------------------------------
370// Parts
371// ---------------------------------------------------------------------------
372
373/// A text part within a message.
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
375pub struct TextPart {
376    /// Unique part identifier.
377    pub id: String,
378    /// The message this part belongs to.
379    #[serde(rename = "messageID")]
380    pub message_id: String,
381    /// The session this part belongs to.
382    #[serde(rename = "sessionID")]
383    pub session_id: String,
384    /// The text content.
385    pub text: String,
386    /// Whether this part was synthetically generated.
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub synthetic: Option<bool>,
389    /// Timing information.
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub time: Option<TextPartTime>,
392}
393
394/// Timing information for a [`TextPart`].
395#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
396pub struct TextPartTime {
397    /// Epoch timestamp when text streaming started.
398    pub start: f64,
399    /// Epoch timestamp when text streaming ended.
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub end: Option<f64>,
402}
403
404/// A file attachment part within a message.
405#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
406pub struct FilePart {
407    /// Unique part identifier.
408    pub id: String,
409    /// The message this part belongs to.
410    #[serde(rename = "messageID")]
411    pub message_id: String,
412    /// MIME type of the file.
413    pub mime: String,
414    /// The session this part belongs to.
415    #[serde(rename = "sessionID")]
416    pub session_id: String,
417    /// URL to the file content.
418    pub url: String,
419    /// Optional human-readable filename.
420    #[serde(skip_serializing_if = "Option::is_none")]
421    pub filename: Option<String>,
422    /// Optional source information for the file.
423    #[serde(skip_serializing_if = "Option::is_none")]
424    pub source: Option<FilePartSource>,
425}
426
427/// A tool invocation part within a message.
428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
429pub struct ToolPart {
430    /// Unique part identifier.
431    pub id: String,
432    /// Tool call identifier.
433    #[serde(rename = "callID")]
434    pub call_id: String,
435    /// The message this part belongs to.
436    #[serde(rename = "messageID")]
437    pub message_id: String,
438    /// The session this part belongs to.
439    #[serde(rename = "sessionID")]
440    pub session_id: String,
441    /// Current state of the tool invocation.
442    pub state: ToolState,
443    /// Name of the tool.
444    pub tool: String,
445}
446
447/// Marks the beginning of a reasoning step.
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
449pub struct StepStartPart {
450    /// Unique part identifier.
451    pub id: String,
452    /// The message this part belongs to.
453    #[serde(rename = "messageID")]
454    pub message_id: String,
455    /// The session this part belongs to.
456    #[serde(rename = "sessionID")]
457    pub session_id: String,
458}
459
460/// Marks the end of a reasoning step with cost and token info.
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
462pub struct StepFinishPart {
463    /// Unique part identifier.
464    pub id: String,
465    /// Monetary cost of this step.
466    pub cost: f64,
467    /// The message this part belongs to.
468    #[serde(rename = "messageID")]
469    pub message_id: String,
470    /// The session this part belongs to.
471    #[serde(rename = "sessionID")]
472    pub session_id: String,
473    /// Token usage for this step.
474    pub tokens: StepFinishTokens,
475}
476
477/// Token usage for a [`StepFinishPart`].
478#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
479pub struct StepFinishTokens {
480    /// Cache token details.
481    pub cache: TokenCache,
482    /// Number of input tokens.
483    pub input: u64,
484    /// Number of output tokens.
485    pub output: u64,
486    /// Number of reasoning tokens.
487    pub reasoning: u64,
488}
489
490/// A snapshot of the session state.
491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
492pub struct SnapshotPart {
493    /// Unique part identifier.
494    pub id: String,
495    /// The message this part belongs to.
496    #[serde(rename = "messageID")]
497    pub message_id: String,
498    /// The session this part belongs to.
499    #[serde(rename = "sessionID")]
500    pub session_id: String,
501    /// Snapshot content.
502    pub snapshot: String,
503}
504
505/// A patch describing file modifications.
506#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
507pub struct PatchPart {
508    /// Unique part identifier.
509    pub id: String,
510    /// List of affected file paths.
511    pub files: Vec<String>,
512    /// Hash of the patch content.
513    pub hash: String,
514    /// The message this part belongs to.
515    #[serde(rename = "messageID")]
516    pub message_id: String,
517    /// The session this part belongs to.
518    #[serde(rename = "sessionID")]
519    pub session_id: String,
520}
521
522/// A subtask delegation part within a message.
523#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
524pub struct SubtaskPart {
525    /// Unique part identifier.
526    pub id: String,
527    /// The session this part belongs to.
528    #[serde(rename = "sessionID")]
529    pub session_id: String,
530    /// The message this part belongs to.
531    #[serde(rename = "messageID")]
532    pub message_id: String,
533    /// The prompt for the subtask.
534    pub prompt: String,
535    /// Human-readable description of the subtask.
536    pub description: String,
537    /// The agent handling this subtask.
538    pub agent: String,
539    /// Optional model information.
540    #[serde(skip_serializing_if = "Option::is_none")]
541    pub model: Option<SubtaskPartModel>,
542    /// Optional command to run.
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub command: Option<String>,
545}
546
547/// Model information for a [`SubtaskPart`].
548#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
549pub struct SubtaskPartModel {
550    /// Provider identifier.
551    #[serde(rename = "providerID")]
552    pub provider_id: String,
553    /// Model identifier.
554    #[serde(rename = "modelID")]
555    pub model_id: String,
556}
557
558/// A reasoning/thinking step part within a message.
559#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
560pub struct ReasoningPart {
561    /// Unique part identifier.
562    pub id: String,
563    /// The session this part belongs to.
564    #[serde(rename = "sessionID")]
565    pub session_id: String,
566    /// The message this part belongs to.
567    #[serde(rename = "messageID")]
568    pub message_id: String,
569    /// The reasoning text content.
570    pub text: String,
571    /// Optional metadata.
572    #[serde(skip_serializing_if = "Option::is_none")]
573    pub metadata: Option<HashMap<String, serde_json::Value>>,
574    /// Timing information.
575    pub time: ReasoningPartTime,
576}
577
578/// Timing information for a [`ReasoningPart`].
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
580pub struct ReasoningPartTime {
581    /// Epoch timestamp when reasoning started.
582    pub start: f64,
583    /// Epoch timestamp when reasoning ended.
584    #[serde(skip_serializing_if = "Option::is_none")]
585    pub end: Option<f64>,
586}
587
588/// An agent delegation part within a message.
589#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
590pub struct AgentPart {
591    /// Unique part identifier.
592    pub id: String,
593    /// The session this part belongs to.
594    #[serde(rename = "sessionID")]
595    pub session_id: String,
596    /// The message this part belongs to.
597    #[serde(rename = "messageID")]
598    pub message_id: String,
599    /// The name of the agent.
600    pub name: String,
601    /// Optional source information.
602    #[serde(skip_serializing_if = "Option::is_none")]
603    pub source: Option<AgentPartSource>,
604}
605
606/// Source information for an [`AgentPart`].
607#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
608pub struct AgentPartSource {
609    /// The source value.
610    pub value: String,
611    /// Start offset.
612    pub start: i64,
613    /// End offset.
614    pub end: i64,
615}
616
617/// A compaction marker part within a message.
618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
619pub struct CompactionPart {
620    /// Unique part identifier.
621    pub id: String,
622    /// The session this part belongs to.
623    #[serde(rename = "sessionID")]
624    pub session_id: String,
625    /// The message this part belongs to.
626    #[serde(rename = "messageID")]
627    pub message_id: String,
628    /// Whether this compaction was automatic.
629    pub auto: bool,
630}
631
632/// A retry part within a message, indicating a failed attempt.
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
634pub struct RetryPart {
635    /// Unique part identifier.
636    pub id: String,
637    /// The session this part belongs to.
638    #[serde(rename = "sessionID")]
639    pub session_id: String,
640    /// The message this part belongs to.
641    #[serde(rename = "messageID")]
642    pub message_id: String,
643    /// The attempt number.
644    pub attempt: f64,
645    /// The error that occurred.
646    pub error: serde_json::Value,
647    /// Timing information.
648    pub time: RetryPartTime,
649}
650
651/// Timing information for a [`RetryPart`].
652#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
653pub struct RetryPartTime {
654    /// Epoch timestamp when the retry was created.
655    pub created: f64,
656}
657
658/// A part within a message — discriminated by `type`.
659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
660#[serde(tag = "type")]
661pub enum Part {
662    /// A text content part.
663    #[serde(rename = "text")]
664    Text(TextPart),
665    /// A file attachment part.
666    #[serde(rename = "file")]
667    File(FilePart),
668    /// A tool invocation part.
669    #[serde(rename = "tool")]
670    Tool(ToolPart),
671    /// Start of a reasoning step.
672    #[serde(rename = "step-start")]
673    StepStart(StepStartPart),
674    /// End of a reasoning step.
675    #[serde(rename = "step-finish")]
676    StepFinish(StepFinishPart),
677    /// A session state snapshot.
678    #[serde(rename = "snapshot")]
679    Snapshot(SnapshotPart),
680    /// A file patch.
681    #[serde(rename = "patch")]
682    Patch(PatchPart),
683    /// A subtask delegation.
684    #[serde(rename = "subtask")]
685    Subtask(SubtaskPart),
686    /// A reasoning/thinking step.
687    #[serde(rename = "reasoning")]
688    Reasoning(ReasoningPart),
689    /// An agent delegation.
690    #[serde(rename = "agent")]
691    Agent(AgentPart),
692    /// A compaction marker.
693    #[serde(rename = "compaction")]
694    Compaction(CompactionPart),
695    /// A retry attempt.
696    #[serde(rename = "retry")]
697    Retry(RetryPart),
698    /// Any unknown part variant returned by newer server versions.
699    #[serde(other)]
700    Unknown,
701}
702
703// ---------------------------------------------------------------------------
704// Tool States
705// ---------------------------------------------------------------------------
706
707/// A pending tool invocation.
708#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
709pub struct ToolStatePending {}
710
711/// A currently-running tool invocation.
712#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
713pub struct ToolStateRunning {
714    /// Timing information.
715    pub time: ToolStateRunningTime,
716    /// Optional input data.
717    #[serde(skip_serializing_if = "Option::is_none")]
718    pub input: Option<serde_json::Value>,
719    /// Optional provider-specific metadata.
720    #[serde(skip_serializing_if = "Option::is_none")]
721    pub metadata: Option<HashMap<String, serde_json::Value>>,
722    /// Optional human-readable title.
723    #[serde(skip_serializing_if = "Option::is_none")]
724    pub title: Option<String>,
725}
726
727/// Timing for [`ToolStateRunning`].
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
729pub struct ToolStateRunningTime {
730    /// Epoch timestamp when the tool started running.
731    pub start: f64,
732}
733
734/// A successfully completed tool invocation.
735#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
736pub struct ToolStateCompleted {
737    /// Input data passed to the tool.
738    pub input: HashMap<String, serde_json::Value>,
739    /// Provider-specific metadata.
740    pub metadata: HashMap<String, serde_json::Value>,
741    /// Tool output text.
742    pub output: String,
743    /// Timing information.
744    pub time: ToolStateCompletedTime,
745    /// Human-readable title.
746    pub title: String,
747}
748
749/// Timing for [`ToolStateCompleted`].
750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
751pub struct ToolStateCompletedTime {
752    /// Epoch timestamp when the tool finished.
753    pub end: f64,
754    /// Epoch timestamp when the tool started.
755    pub start: f64,
756}
757
758/// A tool invocation that resulted in an error.
759#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
760pub struct ToolStateError {
761    /// Error description.
762    pub error: String,
763    /// Input data passed to the tool.
764    pub input: HashMap<String, serde_json::Value>,
765    /// Timing information.
766    pub time: ToolStateErrorTime,
767}
768
769/// Timing for [`ToolStateError`].
770#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
771pub struct ToolStateErrorTime {
772    /// Epoch timestamp when the tool finished with an error.
773    pub end: f64,
774    /// Epoch timestamp when the tool started.
775    pub start: f64,
776}
777
778/// The current state of a tool invocation — discriminated by `status`.
779#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
780#[serde(tag = "status")]
781pub enum ToolState {
782    /// The tool is waiting to execute.
783    #[serde(rename = "pending")]
784    Pending(ToolStatePending),
785    /// The tool is currently executing.
786    #[serde(rename = "running")]
787    Running(ToolStateRunning),
788    /// The tool completed successfully.
789    #[serde(rename = "completed")]
790    Completed(ToolStateCompleted),
791    /// The tool finished with an error.
792    #[serde(rename = "error")]
793    Error(ToolStateError),
794}
795
796// ---------------------------------------------------------------------------
797// File Part Source Types
798// ---------------------------------------------------------------------------
799
800/// Text content extracted from a source.
801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
802pub struct FilePartSourceText {
803    /// End offset (byte or character index).
804    pub end: u64,
805    /// Start offset (byte or character index).
806    pub start: u64,
807    /// The extracted text value.
808    pub value: String,
809}
810
811/// A file-based source.
812#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
813pub struct FileSource {
814    /// Filesystem path.
815    pub path: String,
816    /// Extracted text content.
817    pub text: FilePartSourceText,
818}
819
820/// A symbol-based source (e.g. function, class).
821#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
822pub struct SymbolSource {
823    /// Symbol kind (numeric identifier from the language server).
824    pub kind: u64,
825    /// Symbol name.
826    pub name: String,
827    /// Filesystem path containing the symbol.
828    pub path: String,
829    /// Character range of the symbol.
830    pub range: SymbolSourceRange,
831    /// Extracted text content.
832    pub text: FilePartSourceText,
833}
834
835/// Range of a [`SymbolSource`].
836#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
837pub struct SymbolSourceRange {
838    /// End position.
839    pub end: SymbolSourcePosition,
840    /// Start position.
841    pub start: SymbolSourcePosition,
842}
843
844/// A line/character position within a file.
845#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
846pub struct SymbolSourcePosition {
847    /// Zero-based character offset on the line.
848    pub character: u64,
849    /// Zero-based line number.
850    pub line: u64,
851}
852
853/// Source of a file part — either a file or a symbol.
854#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
855#[serde(tag = "type")]
856pub enum FilePartSource {
857    /// A plain file source.
858    #[serde(rename = "file")]
859    File(FileSource),
860    /// A symbol source (function, class, etc.).
861    #[serde(rename = "symbol")]
862    Symbol(SymbolSource),
863}
864
865// ---------------------------------------------------------------------------
866// Input Types
867// ---------------------------------------------------------------------------
868
869/// A text input part for the chat endpoint.
870#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
871pub struct TextPartInput {
872    /// The text content.
873    pub text: String,
874    /// Optional part identifier.
875    #[serde(skip_serializing_if = "Option::is_none")]
876    pub id: Option<String>,
877    /// Whether this input was synthetically generated.
878    #[serde(skip_serializing_if = "Option::is_none")]
879    pub synthetic: Option<bool>,
880    /// Whether this input should be ignored.
881    #[serde(skip_serializing_if = "Option::is_none")]
882    pub ignored: Option<bool>,
883    /// Optional timing information.
884    #[serde(skip_serializing_if = "Option::is_none")]
885    pub time: Option<TextPartInputTime>,
886    /// Optional metadata.
887    #[serde(skip_serializing_if = "Option::is_none")]
888    pub metadata: Option<HashMap<String, serde_json::Value>>,
889}
890
891/// Timing information for a [`TextPartInput`].
892#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
893pub struct TextPartInputTime {
894    /// Epoch timestamp when text input started.
895    pub start: f64,
896    /// Epoch timestamp when text input ended.
897    #[serde(skip_serializing_if = "Option::is_none")]
898    pub end: Option<f64>,
899}
900
901/// A file input part for the chat endpoint.
902#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
903pub struct FilePartInput {
904    /// MIME type of the file.
905    pub mime: String,
906    /// URL to the file content.
907    pub url: String,
908    /// Optional part identifier.
909    #[serde(skip_serializing_if = "Option::is_none")]
910    pub id: Option<String>,
911    /// Optional human-readable filename.
912    #[serde(skip_serializing_if = "Option::is_none")]
913    pub filename: Option<String>,
914    /// Optional source information.
915    #[serde(skip_serializing_if = "Option::is_none")]
916    pub source: Option<FilePartSource>,
917}
918
919/// An agent input part for the chat endpoint.
920#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
921pub struct AgentPartInput {
922    /// Agent name.
923    pub name: String,
924    /// Optional part identifier.
925    #[serde(skip_serializing_if = "Option::is_none")]
926    pub id: Option<String>,
927    /// Optional source information.
928    #[serde(skip_serializing_if = "Option::is_none")]
929    pub source: Option<AgentPartSource>,
930}
931
932/// A subtask input part for the chat endpoint.
933#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
934pub struct SubtaskPartInput {
935    /// The prompt text.
936    pub prompt: String,
937    /// Description of the subtask.
938    pub description: String,
939    /// Agent to handle the subtask.
940    pub agent: String,
941    /// Optional part identifier.
942    #[serde(skip_serializing_if = "Option::is_none")]
943    pub id: Option<String>,
944    /// Optional model selection.
945    #[serde(skip_serializing_if = "Option::is_none")]
946    pub model: Option<SessionChatModel>,
947    /// Optional command.
948    #[serde(skip_serializing_if = "Option::is_none")]
949    pub command: Option<String>,
950}
951
952/// An input part — text, file, agent, or subtask — discriminated by `type`.
953#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
954#[serde(tag = "type")]
955pub enum PartInput {
956    /// A text input.
957    #[serde(rename = "text")]
958    Text(TextPartInput),
959    /// A file input.
960    #[serde(rename = "file")]
961    File(FilePartInput),
962    /// An agent delegation input.
963    #[serde(rename = "agent")]
964    Agent(AgentPartInput),
965    /// A subtask delegation input.
966    #[serde(rename = "subtask")]
967    Subtask(SubtaskPartInput),
968}
969
970// ---------------------------------------------------------------------------
971// Response Types
972// ---------------------------------------------------------------------------
973
974/// A single item in the session messages response.
975#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
976pub struct SessionMessagesResponseItem {
977    /// The message metadata.
978    pub info: Message,
979    /// The parts that compose this message.
980    pub parts: Vec<Part>,
981}
982
983/// Response type for listing session messages.
984pub type SessionMessagesResponse = Vec<SessionMessagesResponseItem>;
985
986/// Response type for listing sessions.
987pub type SessionListResponse = Vec<Session>;
988
989/// Response type for deleting a session.
990pub type SessionDeleteResponse = bool;
991
992/// Response type for aborting a session.
993pub type SessionAbortResponse = bool;
994
995/// Response type for initialising a session.
996pub type SessionInitResponse = bool;
997
998/// Response type for summarising a session.
999pub type SessionSummarizeResponse = bool;
1000
1001// ---------------------------------------------------------------------------
1002// Param Types
1003// ---------------------------------------------------------------------------
1004
1005/// Model selection for the chat endpoint.
1006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1007pub struct SessionChatModel {
1008    /// Provider identifier.
1009    #[serde(rename = "providerID")]
1010    pub provider_id: String,
1011    /// Model identifier.
1012    #[serde(rename = "modelID")]
1013    pub model_id: String,
1014}
1015
1016/// Parameters for the chat endpoint (`POST /session/{id}/message`).
1017#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1018pub struct SessionChatParams {
1019    /// Input parts (text, file, agent, subtask).
1020    pub parts: Vec<PartInput>,
1021    /// Optional model selection (nested providerID + modelID).
1022    #[serde(skip_serializing_if = "Option::is_none")]
1023    pub model: Option<SessionChatModel>,
1024    /// Optional message identifier for continuing a conversation.
1025    #[serde(rename = "messageID")]
1026    #[serde(skip_serializing_if = "Option::is_none")]
1027    pub message_id: Option<String>,
1028    /// Optional agent override.
1029    #[serde(skip_serializing_if = "Option::is_none")]
1030    pub agent: Option<String>,
1031    /// Whether to suppress the reply.
1032    #[serde(rename = "noReply")]
1033    #[serde(skip_serializing_if = "Option::is_none")]
1034    pub no_reply: Option<bool>,
1035    /// Optional output format.
1036    #[serde(skip_serializing_if = "Option::is_none")]
1037    pub format: Option<OutputFormat>,
1038    /// Optional system prompt override.
1039    #[serde(skip_serializing_if = "Option::is_none")]
1040    pub system: Option<String>,
1041    /// Optional variant.
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    pub variant: Option<String>,
1044    /// Optional map of tool names to their enabled state.
1045    #[serde(skip_serializing_if = "Option::is_none")]
1046    pub tools: Option<HashMap<String, bool>>,
1047}
1048
1049/// Parameters for session initialisation (`POST /session/{id}/init`).
1050#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1051pub struct SessionInitParams {
1052    /// The message identifier.
1053    #[serde(rename = "messageID")]
1054    pub message_id: String,
1055    /// The model to use.
1056    #[serde(rename = "modelID")]
1057    pub model_id: String,
1058    /// The provider to use.
1059    #[serde(rename = "providerID")]
1060    pub provider_id: String,
1061}
1062
1063/// Parameters for reverting a session (`POST /session/{id}/revert`).
1064#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1065pub struct SessionRevertParams {
1066    /// The message to revert to.
1067    #[serde(rename = "messageID")]
1068    pub message_id: String,
1069    /// Optional part identifier to revert to.
1070    #[serde(rename = "partID")]
1071    #[serde(skip_serializing_if = "Option::is_none")]
1072    pub part_id: Option<String>,
1073}
1074
1075/// Parameters for summarising a session (`POST /session/{id}/summarize`).
1076#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1077pub struct SessionSummarizeParams {
1078    /// The model to use for summarisation.
1079    #[serde(rename = "modelID")]
1080    pub model_id: String,
1081    /// The provider to use for summarisation.
1082    #[serde(rename = "providerID")]
1083    pub provider_id: String,
1084}
1085
1086// ---------------------------------------------------------------------------
1087// SessionResource
1088// ---------------------------------------------------------------------------
1089
1090/// Provides access to the Session-related API endpoints.
1091pub struct SessionResource<'a> {
1092    client: &'a Opencode,
1093}
1094
1095impl<'a> SessionResource<'a> {
1096    /// Create a new `SessionResource` bound to the given client.
1097    pub(crate) const fn new(client: &'a Opencode) -> Self {
1098        Self { client }
1099    }
1100
1101    /// Create a new session (`POST /session`).
1102    pub async fn create(&self, options: Option<&RequestOptions>) -> Result<Session, OpencodeError> {
1103        self.client.post::<Session, ()>("/session", None, options).await
1104    }
1105
1106    /// List all sessions (`GET /session`).
1107    pub async fn list(
1108        &self,
1109        options: Option<&RequestOptions>,
1110    ) -> Result<SessionListResponse, OpencodeError> {
1111        self.client.get("/session", options).await
1112    }
1113
1114    /// Delete a session (`DELETE /session/{id}`).
1115    pub async fn delete(
1116        &self,
1117        id: &str,
1118        options: Option<&RequestOptions>,
1119    ) -> Result<SessionDeleteResponse, OpencodeError> {
1120        self.client.delete::<bool, ()>(&format!("/session/{id}"), None, options).await
1121    }
1122
1123    /// Abort a running session (`POST /session/{id}/abort`).
1124    pub async fn abort(
1125        &self,
1126        id: &str,
1127        options: Option<&RequestOptions>,
1128    ) -> Result<SessionAbortResponse, OpencodeError> {
1129        self.client.post::<bool, ()>(&format!("/session/{id}/abort"), None, options).await
1130    }
1131
1132    /// Send a chat message (`POST /session/{id}/message`).
1133    pub async fn chat(
1134        &self,
1135        id: &str,
1136        params: &SessionChatParams,
1137        options: Option<&RequestOptions>,
1138    ) -> Result<SessionMessagesResponseItem, OpencodeError> {
1139        self.client.post(&format!("/session/{id}/message"), Some(params), options).await
1140    }
1141
1142    /// Initialise a session (`POST /session/{id}/init`).
1143    pub async fn init(
1144        &self,
1145        id: &str,
1146        params: &SessionInitParams,
1147        options: Option<&RequestOptions>,
1148    ) -> Result<SessionInitResponse, OpencodeError> {
1149        self.client.post(&format!("/session/{id}/init"), Some(params), options).await
1150    }
1151
1152    /// List messages in a session (`GET /session/{id}/message`).
1153    pub async fn messages(
1154        &self,
1155        id: &str,
1156        options: Option<&RequestOptions>,
1157    ) -> Result<SessionMessagesResponse, OpencodeError> {
1158        self.client.get(&format!("/session/{id}/message"), options).await
1159    }
1160
1161    /// Revert a session to a previous state (`POST /session/{id}/revert`).
1162    pub async fn revert(
1163        &self,
1164        id: &str,
1165        params: &SessionRevertParams,
1166        options: Option<&RequestOptions>,
1167    ) -> Result<Session, OpencodeError> {
1168        self.client.post(&format!("/session/{id}/revert"), Some(params), options).await
1169    }
1170
1171    /// Share a session (`POST /session/{id}/share`).
1172    pub async fn share(
1173        &self,
1174        id: &str,
1175        options: Option<&RequestOptions>,
1176    ) -> Result<Session, OpencodeError> {
1177        self.client.post::<Session, ()>(&format!("/session/{id}/share"), None, options).await
1178    }
1179
1180    /// Summarise a session (`POST /session/{id}/summarize`).
1181    pub async fn summarize(
1182        &self,
1183        id: &str,
1184        params: &SessionSummarizeParams,
1185        options: Option<&RequestOptions>,
1186    ) -> Result<SessionSummarizeResponse, OpencodeError> {
1187        self.client.post(&format!("/session/{id}/summarize"), Some(params), options).await
1188    }
1189
1190    /// Unrevert a session (`POST /session/{id}/unrevert`).
1191    pub async fn unrevert(
1192        &self,
1193        id: &str,
1194        options: Option<&RequestOptions>,
1195    ) -> Result<Session, OpencodeError> {
1196        self.client.post::<Session, ()>(&format!("/session/{id}/unrevert"), None, options).await
1197    }
1198
1199    /// Unshare a session (`DELETE /session/{id}/share`).
1200    pub async fn unshare(
1201        &self,
1202        id: &str,
1203        options: Option<&RequestOptions>,
1204    ) -> Result<Session, OpencodeError> {
1205        self.client.delete::<Session, ()>(&format!("/session/{id}/share"), None, options).await
1206    }
1207}
1208
1209// ---------------------------------------------------------------------------
1210// Tests
1211// ---------------------------------------------------------------------------
1212
1213#[cfg(test)]
1214mod tests {
1215    use serde_json::json;
1216
1217    use super::*;
1218
1219    // -- Session round-trips --
1220
1221    #[test]
1222    fn session_full_round_trip() {
1223        let session = Session {
1224            id: "sess_001".into(),
1225            slug: "my-session".into(),
1226            project_id: "proj_001".into(),
1227            directory: "/home/user/project".into(),
1228            time: SessionTime {
1229                created: 1_700_000_000.0,
1230                updated: 1_700_001_000.0,
1231                compacting: None,
1232                archived: None,
1233            },
1234            title: "My Session".into(),
1235            version: "1".into(),
1236            parent_id: Some("sess_000".into()),
1237            revert: Some(SessionRevert {
1238                message_id: "msg_001".into(),
1239                diff: Some("--- a/file\n+++ b/file".into()),
1240                part_id: Some("part_001".into()),
1241                snapshot: Some("snapshot_data".into()),
1242            }),
1243            share: Some(SessionShare { url: "https://example.com/share/abc".into() }),
1244            summary: None,
1245            permission: None,
1246        };
1247        let json_str = serde_json::to_string(&session).unwrap();
1248        assert!(json_str.contains("parentID"));
1249        assert!(json_str.contains("messageID"));
1250        assert!(json_str.contains("partID"));
1251        let back: Session = serde_json::from_str(&json_str).unwrap();
1252        assert_eq!(session, back);
1253    }
1254
1255    #[test]
1256    fn session_minimal_round_trip() {
1257        let session = Session {
1258            id: "sess_002".into(),
1259            slug: "empty".into(),
1260            project_id: "proj_002".into(),
1261            directory: "/tmp".into(),
1262            time: SessionTime {
1263                created: 1_700_000_000.0,
1264                updated: 1_700_000_000.0,
1265                compacting: None,
1266                archived: None,
1267            },
1268            title: "Empty".into(),
1269            version: "1".into(),
1270            parent_id: None,
1271            revert: None,
1272            share: None,
1273            summary: None,
1274            permission: None,
1275        };
1276        let json_str = serde_json::to_string(&session).unwrap();
1277        assert!(!json_str.contains("parentID"));
1278        assert!(!json_str.contains("revert"));
1279        assert!(!json_str.contains("share"));
1280        let back: Session = serde_json::from_str(&json_str).unwrap();
1281        assert_eq!(session, back);
1282    }
1283
1284    // -- Message round-trips --
1285
1286    #[test]
1287    fn user_message_round_trip() {
1288        let msg = UserMessage {
1289            id: "msg_u001".into(),
1290            session_id: "sess_001".into(),
1291            time: UserMessageTime { created: 1_700_000_100.0 },
1292            agent: "coder".into(),
1293            model: UserMessageModel { provider_id: "openai".into(), model_id: "gpt-4o".into() },
1294            format: None,
1295            summary: None,
1296            system: None,
1297            tools: None,
1298            variant: None,
1299        };
1300        let json_str = serde_json::to_string(&msg).unwrap();
1301        assert!(json_str.contains("sessionID"));
1302        assert!(json_str.contains("agent"));
1303        assert!(json_str.contains("providerID"));
1304        assert!(json_str.contains("modelID"));
1305        let back: UserMessage = serde_json::from_str(&json_str).unwrap();
1306        assert_eq!(msg, back);
1307    }
1308
1309    #[test]
1310    fn assistant_message_round_trip() {
1311        let msg = AssistantMessage {
1312            id: "msg_a001".into(),
1313            cost: 0.0032,
1314            mode: "code".into(),
1315            model_id: "gpt-4o".into(),
1316            path: AssistantMessagePath {
1317                cwd: "/home/user/project".into(),
1318                root: "/home/user/project".into(),
1319            },
1320            provider_id: "openai".into(),
1321            session_id: "sess_001".into(),
1322            parent_id: "msg_parent".into(),
1323            agent: "default".into(),
1324            system: vec!["You are a helpful assistant.".into()],
1325            time: AssistantMessageTime {
1326                created: 1_700_000_200.0,
1327                completed: Some(1_700_000_210.0),
1328            },
1329            tokens: AssistantMessageTokens {
1330                cache: TokenCache { read: 100, write: 50 },
1331                input: 500,
1332                output: 200,
1333                reasoning: 0,
1334                total: 700,
1335            },
1336            error: None,
1337            summary: None,
1338            variant: None,
1339            finish: None,
1340            structured: None,
1341        };
1342        let json_str = serde_json::to_string(&msg).unwrap();
1343        assert!(json_str.contains("modelID"));
1344        assert!(json_str.contains("providerID"));
1345        assert!(json_str.contains("sessionID"));
1346        assert!(json_str.contains("parentID"));
1347        assert!(json_str.contains("agent"));
1348        let back: AssistantMessage = serde_json::from_str(&json_str).unwrap();
1349        assert_eq!(msg, back);
1350    }
1351
1352    #[test]
1353    fn assistant_message_with_error() {
1354        let msg = AssistantMessage {
1355            id: "msg_a002".into(),
1356            cost: 0.0,
1357            mode: "code".into(),
1358            model_id: "gpt-4o".into(),
1359            path: AssistantMessagePath { cwd: "/tmp".into(), root: "/tmp".into() },
1360            provider_id: "openai".into(),
1361            session_id: "sess_001".into(),
1362            parent_id: "msg_parent".into(),
1363            agent: "coder".into(),
1364            system: vec![],
1365            time: AssistantMessageTime { created: 1_700_000_300.0, completed: None },
1366            tokens: AssistantMessageTokens {
1367                cache: TokenCache { read: 0, write: 0 },
1368                input: 0,
1369                output: 0,
1370                reasoning: 0,
1371                total: 0,
1372            },
1373            error: Some(SessionError::ProviderAuthError {
1374                data: super::super::shared::ProviderAuthErrorData {
1375                    message: "invalid key".into(),
1376                    provider_id: "openai".into(),
1377                },
1378            }),
1379            summary: Some(true),
1380            variant: None,
1381            finish: None,
1382            structured: None,
1383        };
1384        let json_str = serde_json::to_string(&msg).unwrap();
1385        assert!(json_str.contains("ProviderAuthError"));
1386        let back: AssistantMessage = serde_json::from_str(&json_str).unwrap();
1387        assert_eq!(msg, back);
1388    }
1389
1390    // -- Message enum --
1391
1392    #[test]
1393    fn message_enum_user_variant() {
1394        let msg = Message::User(Box::new(UserMessage {
1395            id: "msg_u002".into(),
1396            session_id: "sess_001".into(),
1397            time: UserMessageTime { created: 1_700_000_100.0 },
1398            agent: "coder".into(),
1399            model: UserMessageModel { provider_id: "openai".into(), model_id: "gpt-4o".into() },
1400            format: None,
1401            summary: None,
1402            system: None,
1403            tools: None,
1404            variant: None,
1405        }));
1406        let json_str = serde_json::to_string(&msg).unwrap();
1407        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1408        assert_eq!(v["role"], "user");
1409        let back: Message = serde_json::from_str(&json_str).unwrap();
1410        assert_eq!(msg, back);
1411    }
1412
1413    #[test]
1414    fn message_enum_assistant_variant() {
1415        let msg = Message::Assistant(Box::new(AssistantMessage {
1416            id: "msg_a003".into(),
1417            cost: 0.001,
1418            mode: "default".into(),
1419            model_id: "claude-3-opus".into(),
1420            path: AssistantMessagePath { cwd: "/home".into(), root: "/home".into() },
1421            provider_id: "anthropic".into(),
1422            session_id: "sess_002".into(),
1423            parent_id: "msg_a002".into(),
1424            agent: "reviewer".into(),
1425            system: vec![],
1426            time: AssistantMessageTime {
1427                created: 1_700_000_500.0,
1428                completed: Some(1_700_000_510.0),
1429            },
1430            tokens: AssistantMessageTokens {
1431                cache: TokenCache { read: 10, write: 5 },
1432                input: 100,
1433                output: 50,
1434                reasoning: 20,
1435                total: 170,
1436            },
1437            error: None,
1438            summary: None,
1439            variant: None,
1440            finish: None,
1441            structured: None,
1442        }));
1443        let json_str = serde_json::to_string(&msg).unwrap();
1444        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1445        assert_eq!(v["role"], "assistant");
1446        let back: Message = serde_json::from_str(&json_str).unwrap();
1447        assert_eq!(msg, back);
1448    }
1449
1450    // -- Part enum variants --
1451
1452    #[test]
1453    fn part_text_round_trip() {
1454        let part = Part::Text(TextPart {
1455            id: "p_001".into(),
1456            message_id: "msg_a001".into(),
1457            session_id: "sess_001".into(),
1458            text: "Hello, world!".into(),
1459            synthetic: None,
1460            time: Some(TextPartTime { start: 1_700_000_200.0, end: Some(1_700_000_201.0) }),
1461        });
1462        let json_str = serde_json::to_string(&part).unwrap();
1463        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1464        assert_eq!(v["type"], "text");
1465        let back: Part = serde_json::from_str(&json_str).unwrap();
1466        assert_eq!(part, back);
1467    }
1468
1469    #[test]
1470    fn part_tool_round_trip() {
1471        let part = Part::Tool(ToolPart {
1472            id: "p_002".into(),
1473            call_id: "call_001".into(),
1474            message_id: "msg_a001".into(),
1475            session_id: "sess_001".into(),
1476            state: ToolState::Completed(ToolStateCompleted {
1477                input: HashMap::from([("cmd".into(), json!("ls"))]),
1478                metadata: HashMap::new(),
1479                output: "file1.rs\nfile2.rs".into(),
1480                time: ToolStateCompletedTime { end: 1_700_000_205.0, start: 1_700_000_202.0 },
1481                title: "bash".into(),
1482            }),
1483            tool: "bash".into(),
1484        });
1485        let json_str = serde_json::to_string(&part).unwrap();
1486        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1487        assert_eq!(v["type"], "tool");
1488        assert_eq!(v["state"]["status"], "completed");
1489        let back: Part = serde_json::from_str(&json_str).unwrap();
1490        assert_eq!(part, back);
1491    }
1492
1493    #[test]
1494    fn part_step_start_round_trip() {
1495        let part = Part::StepStart(StepStartPart {
1496            id: "p_003".into(),
1497            message_id: "msg_a001".into(),
1498            session_id: "sess_001".into(),
1499        });
1500        let json_str = serde_json::to_string(&part).unwrap();
1501        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1502        assert_eq!(v["type"], "step-start");
1503        let back: Part = serde_json::from_str(&json_str).unwrap();
1504        assert_eq!(part, back);
1505    }
1506
1507    #[test]
1508    fn part_step_finish_round_trip() {
1509        let part = Part::StepFinish(StepFinishPart {
1510            id: "p_004".into(),
1511            cost: 0.001,
1512            message_id: "msg_a001".into(),
1513            session_id: "sess_001".into(),
1514            tokens: StepFinishTokens {
1515                cache: TokenCache { read: 10, write: 5 },
1516                input: 100,
1517                output: 50,
1518                reasoning: 0,
1519            },
1520        });
1521        let json_str = serde_json::to_string(&part).unwrap();
1522        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1523        assert_eq!(v["type"], "step-finish");
1524        let back: Part = serde_json::from_str(&json_str).unwrap();
1525        assert_eq!(part, back);
1526    }
1527
1528    #[test]
1529    fn part_patch_round_trip() {
1530        let part = Part::Patch(PatchPart {
1531            id: "p_005".into(),
1532            files: vec!["src/main.rs".into(), "Cargo.toml".into()],
1533            hash: "abc123".into(),
1534            message_id: "msg_a001".into(),
1535            session_id: "sess_001".into(),
1536        });
1537        let json_str = serde_json::to_string(&part).unwrap();
1538        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1539        assert_eq!(v["type"], "patch");
1540        let back: Part = serde_json::from_str(&json_str).unwrap();
1541        assert_eq!(part, back);
1542    }
1543
1544    #[test]
1545    fn part_snapshot_round_trip() {
1546        let part = Part::Snapshot(SnapshotPart {
1547            id: "p_006".into(),
1548            message_id: "msg_a001".into(),
1549            session_id: "sess_001".into(),
1550            snapshot: "{\"state\":\"data\"}".into(),
1551        });
1552        let json_str = serde_json::to_string(&part).unwrap();
1553        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1554        assert_eq!(v["type"], "snapshot");
1555        let back: Part = serde_json::from_str(&json_str).unwrap();
1556        assert_eq!(part, back);
1557    }
1558
1559    #[test]
1560    fn part_file_round_trip() {
1561        let part = Part::File(FilePart {
1562            id: "p_007".into(),
1563            message_id: "msg_a001".into(),
1564            mime: "image/png".into(),
1565            session_id: "sess_001".into(),
1566            url: "https://example.com/img.png".into(),
1567            filename: Some("screenshot.png".into()),
1568            source: None,
1569        });
1570        let json_str = serde_json::to_string(&part).unwrap();
1571        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1572        assert_eq!(v["type"], "file");
1573        let back: Part = serde_json::from_str(&json_str).unwrap();
1574        assert_eq!(part, back);
1575    }
1576
1577    // -- ToolState enum --
1578
1579    #[test]
1580    fn tool_state_pending() {
1581        let state = ToolState::Pending(ToolStatePending {});
1582        let json_str = serde_json::to_string(&state).unwrap();
1583        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1584        assert_eq!(v["status"], "pending");
1585        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1586        assert_eq!(state, back);
1587    }
1588
1589    #[test]
1590    fn tool_state_running() {
1591        let state = ToolState::Running(ToolStateRunning {
1592            time: ToolStateRunningTime { start: 1_700_000_200.0 },
1593            input: Some(json!({"command": "echo hello"})),
1594            metadata: Some(HashMap::from([("key".into(), json!("value"))])),
1595            title: Some("Running bash".into()),
1596        });
1597        let json_str = serde_json::to_string(&state).unwrap();
1598        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1599        assert_eq!(v["status"], "running");
1600        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1601        assert_eq!(state, back);
1602    }
1603
1604    #[test]
1605    fn tool_state_completed() {
1606        let state = ToolState::Completed(ToolStateCompleted {
1607            input: HashMap::from([("cmd".into(), json!("ls -la"))]),
1608            metadata: HashMap::from([("exit_code".into(), json!(0))]),
1609            output: "total 42\ndrwxr-xr-x ...".into(),
1610            time: ToolStateCompletedTime { end: 1_700_000_210.0, start: 1_700_000_200.0 },
1611            title: "bash".into(),
1612        });
1613        let json_str = serde_json::to_string(&state).unwrap();
1614        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1615        assert_eq!(v["status"], "completed");
1616        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1617        assert_eq!(state, back);
1618    }
1619
1620    #[test]
1621    fn tool_state_error() {
1622        let state = ToolState::Error(ToolStateError {
1623            error: "command not found".into(),
1624            input: HashMap::from([("cmd".into(), json!("nonexistent"))]),
1625            time: ToolStateErrorTime { end: 1_700_000_201.0, start: 1_700_000_200.0 },
1626        });
1627        let json_str = serde_json::to_string(&state).unwrap();
1628        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1629        assert_eq!(v["status"], "error");
1630        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1631        assert_eq!(state, back);
1632    }
1633
1634    // -- FilePartSource enum --
1635
1636    #[test]
1637    fn file_part_source_file_variant() {
1638        let src = FilePartSource::File(FileSource {
1639            path: "/home/user/main.rs".into(),
1640            text: FilePartSourceText { end: 100, start: 0, value: "fn main() {}".into() },
1641        });
1642        let json_str = serde_json::to_string(&src).unwrap();
1643        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1644        assert_eq!(v["type"], "file");
1645        let back: FilePartSource = serde_json::from_str(&json_str).unwrap();
1646        assert_eq!(src, back);
1647    }
1648
1649    #[test]
1650    fn file_part_source_symbol_variant() {
1651        let src = FilePartSource::Symbol(SymbolSource {
1652            kind: 12,
1653            name: "main".into(),
1654            path: "/home/user/main.rs".into(),
1655            range: SymbolSourceRange {
1656                end: SymbolSourcePosition { character: 1, line: 2 },
1657                start: SymbolSourcePosition { character: 0, line: 0 },
1658            },
1659            text: FilePartSourceText {
1660                end: 50,
1661                start: 0,
1662                value: "fn main() {\n    println!(\"hello\");\n}".into(),
1663            },
1664        });
1665        let json_str = serde_json::to_string(&src).unwrap();
1666        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1667        assert_eq!(v["type"], "symbol");
1668        let back: FilePartSource = serde_json::from_str(&json_str).unwrap();
1669        assert_eq!(src, back);
1670    }
1671
1672    // -- SessionChatParams --
1673
1674    #[test]
1675    fn session_chat_params_full_round_trip() {
1676        let params = SessionChatParams {
1677            parts: vec![
1678                PartInput::Text(TextPartInput {
1679                    text: "Hello!".into(),
1680                    id: Some("input_001".into()),
1681                    synthetic: None,
1682                    ignored: None,
1683                    time: Some(TextPartInputTime { start: 1_700_000_000.0, end: None }),
1684                    metadata: None,
1685                }),
1686                PartInput::File(FilePartInput {
1687                    mime: "text/plain".into(),
1688                    url: "file:///tmp/test.txt".into(),
1689                    id: None,
1690                    filename: Some("test.txt".into()),
1691                    source: None,
1692                }),
1693            ],
1694            model: Some(SessionChatModel {
1695                provider_id: "openai".into(),
1696                model_id: "gpt-4o".into(),
1697            }),
1698            message_id: Some("msg_001".into()),
1699            agent: None,
1700            no_reply: None,
1701            format: None,
1702            system: Some("Be concise.".into()),
1703            variant: None,
1704            tools: Some(HashMap::from([("bash".into(), true)])),
1705        };
1706        let json_str = serde_json::to_string(&params).unwrap();
1707        assert!(json_str.contains("modelID"));
1708        assert!(json_str.contains("providerID"));
1709        assert!(json_str.contains("messageID"));
1710        let back: SessionChatParams = serde_json::from_str(&json_str).unwrap();
1711        assert_eq!(params, back);
1712    }
1713
1714    #[test]
1715    fn session_chat_params_minimal() {
1716        let params = SessionChatParams {
1717            parts: vec![PartInput::Text(TextPartInput {
1718                text: "Hi".into(),
1719                id: None,
1720                synthetic: None,
1721                ignored: None,
1722                time: None,
1723                metadata: None,
1724            })],
1725            model: None,
1726            message_id: None,
1727            agent: None,
1728            no_reply: None,
1729            format: None,
1730            system: None,
1731            variant: None,
1732            tools: None,
1733        };
1734        let json_str = serde_json::to_string(&params).unwrap();
1735        assert!(!json_str.contains("messageID"));
1736        assert!(!json_str.contains("model"));
1737        assert!(!json_str.contains("system"));
1738        assert!(!json_str.contains("tools"));
1739        let back: SessionChatParams = serde_json::from_str(&json_str).unwrap();
1740        assert_eq!(params, back);
1741    }
1742
1743    // -- PartInput enum --
1744
1745    #[test]
1746    fn part_input_text_round_trip() {
1747        let input = PartInput::Text(TextPartInput {
1748            text: "Hello".into(),
1749            id: None,
1750            synthetic: Some(true),
1751            ignored: None,
1752            time: None,
1753            metadata: None,
1754        });
1755        let json_str = serde_json::to_string(&input).unwrap();
1756        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1757        assert_eq!(v["type"], "text");
1758        let back: PartInput = serde_json::from_str(&json_str).unwrap();
1759        assert_eq!(input, back);
1760    }
1761
1762    #[test]
1763    fn part_input_file_round_trip() {
1764        let input = PartInput::File(FilePartInput {
1765            mime: "image/png".into(),
1766            url: "https://example.com/img.png".into(),
1767            id: Some("fi_001".into()),
1768            filename: Some("photo.png".into()),
1769            source: Some(FilePartSource::File(FileSource {
1770                path: "/tmp/photo.png".into(),
1771                text: FilePartSourceText { end: 0, start: 0, value: String::new() },
1772            })),
1773        });
1774        let json_str = serde_json::to_string(&input).unwrap();
1775        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1776        assert_eq!(v["type"], "file");
1777        let back: PartInput = serde_json::from_str(&json_str).unwrap();
1778        assert_eq!(input, back);
1779    }
1780
1781    // -- SessionMessagesResponseItem --
1782
1783    #[test]
1784    fn session_messages_response_item_round_trip() {
1785        let item = SessionMessagesResponseItem {
1786            info: Message::User(Box::new(UserMessage {
1787                id: "msg_u010".into(),
1788                session_id: "sess_001".into(),
1789                time: UserMessageTime { created: 1_700_000_000.0 },
1790                agent: "coder".into(),
1791                model: UserMessageModel { provider_id: "openai".into(), model_id: "gpt-4o".into() },
1792                format: None,
1793                summary: None,
1794                system: None,
1795                tools: None,
1796                variant: None,
1797            })),
1798            parts: vec![Part::Text(TextPart {
1799                id: "p_010".into(),
1800                message_id: "msg_u010".into(),
1801                session_id: "sess_001".into(),
1802                text: "What is Rust?".into(),
1803                synthetic: None,
1804                time: None,
1805            })],
1806        };
1807        let json_str = serde_json::to_string(&item).unwrap();
1808        let back: SessionMessagesResponseItem = serde_json::from_str(&json_str).unwrap();
1809        assert_eq!(item, back);
1810    }
1811
1812    // -- Param types --
1813
1814    #[test]
1815    fn session_init_params_round_trip() {
1816        let params = SessionInitParams {
1817            message_id: "msg_001".into(),
1818            model_id: "gpt-4o".into(),
1819            provider_id: "openai".into(),
1820        };
1821        let json_str = serde_json::to_string(&params).unwrap();
1822        assert!(json_str.contains("messageID"));
1823        assert!(json_str.contains("modelID"));
1824        assert!(json_str.contains("providerID"));
1825        let back: SessionInitParams = serde_json::from_str(&json_str).unwrap();
1826        assert_eq!(params, back);
1827    }
1828
1829    #[test]
1830    fn session_revert_params_round_trip() {
1831        let params =
1832            SessionRevertParams { message_id: "msg_001".into(), part_id: Some("part_001".into()) };
1833        let json_str = serde_json::to_string(&params).unwrap();
1834        assert!(json_str.contains("messageID"));
1835        assert!(json_str.contains("partID"));
1836        let back: SessionRevertParams = serde_json::from_str(&json_str).unwrap();
1837        assert_eq!(params, back);
1838    }
1839
1840    #[test]
1841    fn session_summarize_params_round_trip() {
1842        let params =
1843            SessionSummarizeParams { model_id: "gpt-4o".into(), provider_id: "openai".into() };
1844        let json_str = serde_json::to_string(&params).unwrap();
1845        assert!(json_str.contains("modelID"));
1846        assert!(json_str.contains("providerID"));
1847        let back: SessionSummarizeParams = serde_json::from_str(&json_str).unwrap();
1848        assert_eq!(params, back);
1849    }
1850
1851    // -- Deserialization from JS-compatible JSON --
1852
1853    #[test]
1854    fn deserialize_message_from_js_json() {
1855        let js_json = json!({
1856            "role": "user",
1857            "id": "msg_from_js",
1858            "sessionID": "sess_js",
1859            "time": { "created": 1700000000.0 }
1860        });
1861        let msg: Message = serde_json::from_value(js_json).unwrap();
1862        match msg {
1863            Message::User(u) => {
1864                assert_eq!(u.id, "msg_from_js");
1865                assert_eq!(u.session_id, "sess_js");
1866            }
1867            _ => panic!("expected User variant"),
1868        }
1869    }
1870
1871    #[test]
1872    fn deserialize_part_from_js_json() {
1873        let js_json = json!({
1874            "type": "step-start",
1875            "id": "p_js_001",
1876            "messageID": "msg_js_001",
1877            "sessionID": "sess_js"
1878        });
1879        let part: Part = serde_json::from_value(js_json).unwrap();
1880        match part {
1881            Part::StepStart(s) => {
1882                assert_eq!(s.id, "p_js_001");
1883                assert_eq!(s.message_id, "msg_js_001");
1884            }
1885            _ => panic!("expected StepStart variant"),
1886        }
1887    }
1888
1889    #[test]
1890    fn deserialize_tool_state_from_js_json() {
1891        let js_json = json!({
1892            "status": "error",
1893            "error": "timeout",
1894            "input": { "cmd": "sleep 999" },
1895            "time": { "start": 1700000000.0, "end": 1700000030.0 }
1896        });
1897        let state: ToolState = serde_json::from_value(js_json).unwrap();
1898        match state {
1899            ToolState::Error(e) => {
1900                assert_eq!(e.error, "timeout");
1901            }
1902            _ => panic!("expected Error variant"),
1903        }
1904    }
1905
1906    // -- Edge cases --
1907
1908    #[test]
1909    fn tool_state_running_minimal() {
1910        let state = ToolState::Running(ToolStateRunning {
1911            time: ToolStateRunningTime { start: 1_700_000_000.0 },
1912            input: None,
1913            metadata: None,
1914            title: None,
1915        });
1916        let json_str = serde_json::to_string(&state).unwrap();
1917        assert!(!json_str.contains("input"));
1918        assert!(!json_str.contains("metadata"));
1919        assert!(!json_str.contains("title"));
1920        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1921        assert_eq!(v["status"], "running");
1922        let back: ToolState = serde_json::from_str(&json_str).unwrap();
1923        assert_eq!(state, back);
1924    }
1925
1926    #[test]
1927    fn text_part_no_synthetic_no_time() {
1928        let part = TextPart {
1929            id: "tp_001".into(),
1930            message_id: "msg_001".into(),
1931            session_id: "sess_001".into(),
1932            text: "bare text".into(),
1933            synthetic: None,
1934            time: None,
1935        };
1936        let json_str = serde_json::to_string(&part).unwrap();
1937        assert!(!json_str.contains("synthetic"));
1938        assert!(!json_str.contains("time"));
1939        let back: TextPart = serde_json::from_str(&json_str).unwrap();
1940        assert_eq!(part, back);
1941    }
1942
1943    #[test]
1944    fn file_part_no_filename_no_source() {
1945        let part = FilePart {
1946            id: "fp_001".into(),
1947            message_id: "msg_001".into(),
1948            mime: "application/octet-stream".into(),
1949            session_id: "sess_001".into(),
1950            url: "https://example.com/data.bin".into(),
1951            filename: None,
1952            source: None,
1953        };
1954        let json_str = serde_json::to_string(&part).unwrap();
1955        assert!(!json_str.contains("filename"));
1956        assert!(!json_str.contains("source"));
1957        let back: FilePart = serde_json::from_str(&json_str).unwrap();
1958        assert_eq!(part, back);
1959    }
1960
1961    #[test]
1962    fn part_file_minimal_round_trip() {
1963        let part = Part::File(FilePart {
1964            id: "fp_002".into(),
1965            message_id: "msg_001".into(),
1966            mime: "text/plain".into(),
1967            session_id: "sess_001".into(),
1968            url: "file:///tmp/a.txt".into(),
1969            filename: None,
1970            source: None,
1971        });
1972        let json_str = serde_json::to_string(&part).unwrap();
1973        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1974        assert_eq!(v["type"], "file");
1975        assert!(v.get("filename").is_none());
1976        assert!(v.get("source").is_none());
1977        let back: Part = serde_json::from_str(&json_str).unwrap();
1978        assert_eq!(part, back);
1979    }
1980
1981    #[test]
1982    fn assistant_message_no_error_no_summary() {
1983        let msg = AssistantMessage {
1984            id: "msg_edge".into(),
1985            cost: 0.0,
1986            mode: "plan".into(),
1987            model_id: "o1".into(),
1988            path: AssistantMessagePath { cwd: "/app".into(), root: "/app".into() },
1989            provider_id: "openai".into(),
1990            session_id: "sess_edge".into(),
1991            parent_id: "msg_prev".into(),
1992            agent: "planner".into(),
1993            system: vec![],
1994            time: AssistantMessageTime { created: 1_700_000_000.0, completed: None },
1995            tokens: AssistantMessageTokens {
1996                cache: TokenCache { read: 0, write: 0 },
1997                input: 10,
1998                output: 5,
1999                reasoning: 0,
2000                total: 15,
2001            },
2002            error: None,
2003            summary: None,
2004            variant: None,
2005            finish: None,
2006            structured: None,
2007        };
2008        let json_str = serde_json::to_string(&msg).unwrap();
2009        assert!(!json_str.contains("error"));
2010        assert!(!json_str.contains("summary"));
2011        assert!(!json_str.contains("variant"));
2012        assert!(!json_str.contains("finish"));
2013        assert!(!json_str.contains("structured"));
2014        assert!(!json_str.contains("system"));
2015        let back: AssistantMessage = serde_json::from_str(&json_str).unwrap();
2016        assert_eq!(msg, back);
2017    }
2018
2019    #[test]
2020    fn part_input_text_minimal() {
2021        let input = PartInput::Text(TextPartInput {
2022            text: "hi".into(),
2023            id: None,
2024            synthetic: None,
2025            ignored: None,
2026            time: None,
2027            metadata: None,
2028        });
2029        let json_str = serde_json::to_string(&input).unwrap();
2030        assert!(!json_str.contains("\"id\""));
2031        assert!(!json_str.contains("synthetic"));
2032        assert!(!json_str.contains("ignored"));
2033        assert!(!json_str.contains("time"));
2034        assert!(!json_str.contains("metadata"));
2035        let back: PartInput = serde_json::from_str(&json_str).unwrap();
2036        assert_eq!(input, back);
2037    }
2038
2039    #[test]
2040    fn part_input_file_minimal() {
2041        let input = PartInput::File(FilePartInput {
2042            mime: "text/csv".into(),
2043            url: "file:///data.csv".into(),
2044            id: None,
2045            filename: None,
2046            source: None,
2047        });
2048        let json_str = serde_json::to_string(&input).unwrap();
2049        assert!(!json_str.contains("\"id\""));
2050        assert!(!json_str.contains("filename"));
2051        assert!(!json_str.contains("source"));
2052        let back: PartInput = serde_json::from_str(&json_str).unwrap();
2053        assert_eq!(input, back);
2054    }
2055
2056    #[test]
2057    fn session_revert_minimal() {
2058        let revert = SessionRevert {
2059            message_id: "msg_r001".into(),
2060            diff: None,
2061            part_id: None,
2062            snapshot: None,
2063        };
2064        let json_str = serde_json::to_string(&revert).unwrap();
2065        assert!(!json_str.contains("diff"));
2066        assert!(!json_str.contains("partID"));
2067        assert!(!json_str.contains("snapshot"));
2068        let back: SessionRevert = serde_json::from_str(&json_str).unwrap();
2069        assert_eq!(revert, back);
2070    }
2071
2072    #[test]
2073    fn text_part_time_no_end() {
2074        let t = TextPartTime { start: 1_700_000_000.0, end: None };
2075        let json_str = serde_json::to_string(&t).unwrap();
2076        assert!(!json_str.contains("end"));
2077        let back: TextPartTime = serde_json::from_str(&json_str).unwrap();
2078        assert_eq!(t, back);
2079    }
2080
2081    #[test]
2082    fn assistant_message_time_no_completed() {
2083        let t = AssistantMessageTime { created: 1_700_000_000.0, completed: None };
2084        let json_str = serde_json::to_string(&t).unwrap();
2085        assert!(!json_str.contains("completed"));
2086        let back: AssistantMessageTime = serde_json::from_str(&json_str).unwrap();
2087        assert_eq!(t, back);
2088    }
2089
2090    #[test]
2091    fn session_revert_params_no_part_id() {
2092        let params = SessionRevertParams { message_id: "msg_001".into(), part_id: None };
2093        let json_str = serde_json::to_string(&params).unwrap();
2094        assert!(!json_str.contains("partID"));
2095        let back: SessionRevertParams = serde_json::from_str(&json_str).unwrap();
2096        assert_eq!(params, back);
2097    }
2098
2099    #[test]
2100    fn file_part_with_symbol_source() {
2101        let part = Part::File(FilePart {
2102            id: "fp_sym".into(),
2103            message_id: "msg_001".into(),
2104            mime: "text/x-rust".into(),
2105            session_id: "sess_001".into(),
2106            url: "file:///src/lib.rs".into(),
2107            filename: Some("lib.rs".into()),
2108            source: Some(FilePartSource::Symbol(SymbolSource {
2109                kind: 6,
2110                name: "MyStruct".into(),
2111                path: "/src/lib.rs".into(),
2112                range: SymbolSourceRange {
2113                    end: SymbolSourcePosition { character: 1, line: 10 },
2114                    start: SymbolSourcePosition { character: 0, line: 5 },
2115                },
2116                text: FilePartSourceText {
2117                    end: 200,
2118                    start: 100,
2119                    value: "struct MyStruct {}".into(),
2120                },
2121            })),
2122        });
2123        let json_str = serde_json::to_string(&part).unwrap();
2124        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2125        assert_eq!(v["source"]["type"], "symbol");
2126        let back: Part = serde_json::from_str(&json_str).unwrap();
2127        assert_eq!(part, back);
2128    }
2129
2130    // -- New type round-trips --
2131
2132    #[test]
2133    fn session_summary_round_trip() {
2134        let summary = SessionSummary {
2135            additions: 10.0,
2136            deletions: 3.0,
2137            files: 2.0,
2138            diffs: Some(vec![FileDiff {
2139                file: "src/main.rs".into(),
2140                before: "fn old() {}".into(),
2141                after: "fn new() {}".into(),
2142                additions: 5.0,
2143                deletions: 2.0,
2144                status: Some(FileDiffStatus::Modified),
2145            }]),
2146        };
2147        let json_str = serde_json::to_string(&summary).unwrap();
2148        assert!(json_str.contains("\"status\":\"modified\""));
2149        let back: SessionSummary = serde_json::from_str(&json_str).unwrap();
2150        assert_eq!(summary, back);
2151    }
2152
2153    #[test]
2154    fn session_summary_minimal() {
2155        let summary = SessionSummary { additions: 0.0, deletions: 0.0, files: 0.0, diffs: None };
2156        let json_str = serde_json::to_string(&summary).unwrap();
2157        assert!(!json_str.contains("diffs"));
2158        let back: SessionSummary = serde_json::from_str(&json_str).unwrap();
2159        assert_eq!(summary, back);
2160    }
2161
2162    #[test]
2163    fn file_diff_round_trip() {
2164        let diff = FileDiff {
2165            file: "README.md".into(),
2166            before: "# Old".into(),
2167            after: "# New".into(),
2168            additions: 1.0,
2169            deletions: 1.0,
2170            status: Some(FileDiffStatus::Modified),
2171        };
2172        let json_str = serde_json::to_string(&diff).unwrap();
2173        let back: FileDiff = serde_json::from_str(&json_str).unwrap();
2174        assert_eq!(diff, back);
2175    }
2176
2177    #[test]
2178    fn file_diff_no_status() {
2179        let diff = FileDiff {
2180            file: "new.rs".into(),
2181            before: String::new(),
2182            after: "fn main() {}".into(),
2183            additions: 1.0,
2184            deletions: 0.0,
2185            status: None,
2186        };
2187        let json_str = serde_json::to_string(&diff).unwrap();
2188        assert!(!json_str.contains("status"));
2189        let back: FileDiff = serde_json::from_str(&json_str).unwrap();
2190        assert_eq!(diff, back);
2191    }
2192
2193    #[test]
2194    fn file_diff_status_variants() {
2195        for (variant, expected) in [
2196            (FileDiffStatus::Added, "\"added\""),
2197            (FileDiffStatus::Deleted, "\"deleted\""),
2198            (FileDiffStatus::Modified, "\"modified\""),
2199        ] {
2200            let json_str = serde_json::to_string(&variant).unwrap();
2201            assert_eq!(json_str, expected);
2202            let back: FileDiffStatus = serde_json::from_str(&json_str).unwrap();
2203            assert_eq!(variant, back);
2204        }
2205    }
2206
2207    #[test]
2208    fn permission_rule_round_trip() {
2209        let rule = PermissionRule {
2210            permission: "file:write".into(),
2211            pattern: "src/**".into(),
2212            action: "allow".into(),
2213        };
2214        let json_str = serde_json::to_string(&rule).unwrap();
2215        let back: PermissionRule = serde_json::from_str(&json_str).unwrap();
2216        assert_eq!(rule, back);
2217    }
2218
2219    #[test]
2220    fn permission_ruleset_round_trip() {
2221        let ruleset: PermissionRuleset = vec![
2222            PermissionRule {
2223                permission: "file:write".into(),
2224                pattern: "src/**".into(),
2225                action: "allow".into(),
2226            },
2227            PermissionRule {
2228                permission: "file:read".into(),
2229                pattern: "**".into(),
2230                action: "deny".into(),
2231            },
2232        ];
2233        let json_str = serde_json::to_string(&ruleset).unwrap();
2234        let back: PermissionRuleset = serde_json::from_str(&json_str).unwrap();
2235        assert_eq!(ruleset, back);
2236    }
2237
2238    #[test]
2239    fn session_time_with_compacting_archived() {
2240        let time = SessionTime {
2241            created: 1_700_000_000.0,
2242            updated: 1_700_001_000.0,
2243            compacting: Some(1_700_002_000.0),
2244            archived: Some(1_700_003_000.0),
2245        };
2246        let json_str = serde_json::to_string(&time).unwrap();
2247        assert!(json_str.contains("compacting"));
2248        assert!(json_str.contains("archived"));
2249        let back: SessionTime = serde_json::from_str(&json_str).unwrap();
2250        assert_eq!(time, back);
2251    }
2252
2253    #[test]
2254    fn session_time_without_compacting_archived() {
2255        let time = SessionTime {
2256            created: 1_700_000_000.0,
2257            updated: 1_700_001_000.0,
2258            compacting: None,
2259            archived: None,
2260        };
2261        let json_str = serde_json::to_string(&time).unwrap();
2262        assert!(!json_str.contains("compacting"));
2263        assert!(!json_str.contains("archived"));
2264        let back: SessionTime = serde_json::from_str(&json_str).unwrap();
2265        assert_eq!(time, back);
2266    }
2267
2268    #[test]
2269    fn session_from_spec_compliant_json() {
2270        let json = json!({
2271            "id": "ses_abc123",
2272            "slug": "my-session",
2273            "projectID": "proj_xyz",
2274            "directory": "/home/user/project",
2275            "title": "Full Session",
2276            "version": "2",
2277            "time": {
2278                "created": 1_700_000_000.0,
2279                "updated": 1_700_001_000.0,
2280                "compacting": 1_700_002_000.0,
2281                "archived": 1_700_003_000.0
2282            },
2283            "parentID": "ses_parent",
2284            "summary": {
2285                "additions": 10.0,
2286                "deletions": 3.0,
2287                "files": 2.0,
2288                "diffs": [
2289                    {
2290                        "file": "src/main.rs",
2291                        "before": "old code",
2292                        "after": "new code",
2293                        "additions": 5.0,
2294                        "deletions": 2.0,
2295                        "status": "added"
2296                    }
2297                ]
2298            },
2299            "share": { "url": "https://example.com/share/abc" },
2300            "permission": [
2301                { "permission": "file:write", "pattern": "src/**", "action": "allow" }
2302            ],
2303            "revert": {
2304                "messageID": "msg_001",
2305                "diff": "some diff",
2306                "partID": "part_001",
2307                "snapshot": "snap"
2308            }
2309        });
2310        let session: Session = serde_json::from_value(json).unwrap();
2311        assert_eq!(session.id, "ses_abc123");
2312        assert_eq!(session.slug, "my-session");
2313        assert_eq!(session.project_id, "proj_xyz");
2314        assert_eq!(session.directory, "/home/user/project");
2315        assert_eq!(session.time.compacting, Some(1_700_002_000.0));
2316        assert_eq!(session.time.archived, Some(1_700_003_000.0));
2317        assert!(session.summary.is_some());
2318        let summary = session.summary.unwrap();
2319        assert_eq!(summary.additions, 10.0);
2320        assert_eq!(summary.diffs.as_ref().unwrap().len(), 1);
2321        assert_eq!(summary.diffs.as_ref().unwrap()[0].status, Some(FileDiffStatus::Added));
2322        assert!(session.permission.is_some());
2323        assert_eq!(session.permission.unwrap().len(), 1);
2324        assert_eq!(session.parent_id.as_deref(), Some("ses_parent"));
2325    }
2326
2327    #[test]
2328    fn session_deserialize_without_new_fields() {
2329        // Ensures backwards compatibility: old JSON without slug/projectID/directory still works.
2330        let json = json!({
2331            "id": "ses_old",
2332            "title": "Old Session",
2333            "version": "1",
2334            "time": { "created": 100.0, "updated": 200.0 }
2335        });
2336        let session: Session = serde_json::from_value(json).unwrap();
2337        assert_eq!(session.id, "ses_old");
2338        assert_eq!(session.slug, "");
2339        assert_eq!(session.project_id, "");
2340        assert_eq!(session.directory, "");
2341        assert!(session.summary.is_none());
2342        assert!(session.permission.is_none());
2343        assert!(session.time.compacting.is_none());
2344        assert!(session.time.archived.is_none());
2345    }
2346
2347    #[test]
2348    fn assistant_message_from_spec_compliant_json() {
2349        let json = json!({
2350            "id": "msg_spec",
2351            "sessionID": "sess_spec",
2352            "role": "assistant",
2353            "parentID": "msg_parent_spec",
2354            "modelID": "gpt-4o",
2355            "providerID": "openai",
2356            "mode": "code",
2357            "agent": "coder",
2358            "path": { "cwd": "/project", "root": "/project" },
2359            "cost": 0.005,
2360            "time": { "created": 1_700_000_000.0, "completed": 1_700_000_010.0 },
2361            "tokens": {
2362                "total": 1500,
2363                "input": 1000,
2364                "output": 400,
2365                "reasoning": 100,
2366                "cache": { "read": 500, "write": 200 }
2367            }
2368        });
2369        let msg: AssistantMessage = serde_json::from_value(json).unwrap();
2370        assert_eq!(msg.id, "msg_spec");
2371        assert_eq!(msg.parent_id, "msg_parent_spec");
2372        assert_eq!(msg.agent, "coder");
2373        assert_eq!(msg.tokens.total, 1500);
2374        assert_eq!(msg.tokens.input, 1000);
2375        assert_eq!(msg.tokens.output, 400);
2376        assert_eq!(msg.tokens.reasoning, 100);
2377        assert_eq!(msg.tokens.cache.read, 500);
2378        assert_eq!(msg.tokens.cache.write, 200);
2379        assert!(msg.variant.is_none());
2380        assert!(msg.finish.is_none());
2381        assert!(msg.structured.is_none());
2382    }
2383
2384    #[test]
2385    fn assistant_message_with_optional_fields_populated() {
2386        let json = json!({
2387            "id": "msg_opt",
2388            "sessionID": "sess_opt",
2389            "parentID": "msg_p",
2390            "modelID": "claude-3-opus",
2391            "providerID": "anthropic",
2392            "mode": "code",
2393            "agent": "reviewer",
2394            "path": { "cwd": "/home", "root": "/home" },
2395            "cost": 0.01,
2396            "time": { "created": 1_700_000_000.0 },
2397            "tokens": {
2398                "total": 500,
2399                "input": 300,
2400                "output": 150,
2401                "reasoning": 50,
2402                "cache": { "read": 100, "write": 50 }
2403            },
2404            "variant": "v2",
2405            "finish": "stop",
2406            "structured": { "key": "value" }
2407        });
2408        let msg: AssistantMessage = serde_json::from_value(json).unwrap();
2409        assert_eq!(msg.variant.as_deref(), Some("v2"));
2410        assert_eq!(msg.finish.as_deref(), Some("stop"));
2411        assert_eq!(msg.structured.as_ref().unwrap()["key"], "value");
2412        assert_eq!(msg.parent_id, "msg_p");
2413        assert_eq!(msg.agent, "reviewer");
2414        assert_eq!(msg.tokens.total, 500);
2415    }
2416
2417    // -- UserMessage new fields --
2418
2419    #[test]
2420    fn user_message_from_spec_json() {
2421        let json = json!({
2422            "id": "msg_u_spec",
2423            "sessionID": "sess_spec",
2424            "role": "user",
2425            "time": { "created": 1_700_000_000.0 },
2426            "agent": "coder",
2427            "model": { "providerID": "openai", "modelID": "gpt-4o" },
2428            "format": { "type": "text" },
2429            "summary": {
2430                "title": "Summary Title",
2431                "body": "Summary body text",
2432                "diffs": [
2433                    {
2434                        "file": "src/main.rs",
2435                        "before": "old",
2436                        "after": "new",
2437                        "additions": 1.0,
2438                        "deletions": 1.0,
2439                        "status": "modified"
2440                    }
2441                ]
2442            },
2443            "system": "Be concise.",
2444            "tools": { "bash": true, "read_file": false },
2445            "variant": "v2"
2446        });
2447        let msg: UserMessage = serde_json::from_value(json).unwrap();
2448        assert_eq!(msg.id, "msg_u_spec");
2449        assert_eq!(msg.session_id, "sess_spec");
2450        assert_eq!(msg.agent, "coder");
2451        assert_eq!(msg.model.provider_id, "openai");
2452        assert_eq!(msg.model.model_id, "gpt-4o");
2453        assert!(matches!(msg.format, Some(OutputFormat::Text)));
2454        let summary = msg.summary.unwrap();
2455        assert_eq!(summary.title.as_deref(), Some("Summary Title"));
2456        assert_eq!(summary.body.as_deref(), Some("Summary body text"));
2457        assert_eq!(summary.diffs.len(), 1);
2458        assert_eq!(msg.system.as_deref(), Some("Be concise."));
2459        let tools = msg.tools.unwrap();
2460        assert_eq!(tools.get("bash"), Some(&true));
2461        assert_eq!(tools.get("read_file"), Some(&false));
2462        assert_eq!(msg.variant.as_deref(), Some("v2"));
2463    }
2464
2465    #[test]
2466    fn user_message_with_output_format() {
2467        // Text variant
2468        let msg_text = UserMessage {
2469            id: "msg_fmt_text".into(),
2470            session_id: "sess_001".into(),
2471            time: UserMessageTime { created: 1_700_000_000.0 },
2472            agent: "coder".into(),
2473            model: UserMessageModel { provider_id: "openai".into(), model_id: "gpt-4o".into() },
2474            format: Some(OutputFormat::Text),
2475            summary: None,
2476            system: None,
2477            tools: None,
2478            variant: None,
2479        };
2480        let json_str = serde_json::to_string(&msg_text).unwrap();
2481        assert!(json_str.contains("\"type\":\"text\""));
2482        let back: UserMessage = serde_json::from_str(&json_str).unwrap();
2483        assert_eq!(msg_text, back);
2484
2485        // JsonSchema variant
2486        let msg_schema = UserMessage {
2487            id: "msg_fmt_schema".into(),
2488            session_id: "sess_001".into(),
2489            time: UserMessageTime { created: 1_700_000_000.0 },
2490            agent: "coder".into(),
2491            model: UserMessageModel {
2492                provider_id: "anthropic".into(),
2493                model_id: "claude-3-opus".into(),
2494            },
2495            format: Some(OutputFormat::JsonSchema {
2496                schema: json!({ "type": "object", "properties": { "answer": { "type": "string" } } }),
2497                retry_count: Some(3),
2498            }),
2499            summary: None,
2500            system: None,
2501            tools: None,
2502            variant: None,
2503        };
2504        let json_str = serde_json::to_string(&msg_schema).unwrap();
2505        assert!(json_str.contains("json_schema"));
2506        assert!(json_str.contains("retryCount"));
2507        let back: UserMessage = serde_json::from_str(&json_str).unwrap();
2508        assert_eq!(msg_schema, back);
2509    }
2510
2511    #[test]
2512    fn user_message_with_summary() {
2513        let msg = UserMessage {
2514            id: "msg_sum".into(),
2515            session_id: "sess_001".into(),
2516            time: UserMessageTime { created: 1_700_000_000.0 },
2517            agent: "reviewer".into(),
2518            model: UserMessageModel { provider_id: "openai".into(), model_id: "gpt-4o".into() },
2519            format: None,
2520            summary: Some(UserMessageSummary {
2521                title: Some("Refactored main".into()),
2522                body: Some("Cleaned up imports".into()),
2523                diffs: vec![FileDiff {
2524                    file: "src/main.rs".into(),
2525                    before: "use old;".into(),
2526                    after: "use new;".into(),
2527                    additions: 1.0,
2528                    deletions: 1.0,
2529                    status: Some(FileDiffStatus::Modified),
2530                }],
2531            }),
2532            system: None,
2533            tools: None,
2534            variant: None,
2535        };
2536        let json_str = serde_json::to_string(&msg).unwrap();
2537        assert!(json_str.contains("Refactored main"));
2538        assert!(json_str.contains("Cleaned up imports"));
2539        let back: UserMessage = serde_json::from_str(&json_str).unwrap();
2540        assert_eq!(msg, back);
2541    }
2542
2543    // -- New Part variant round-trips --
2544
2545    #[test]
2546    fn part_subtask_round_trip() {
2547        let part = Part::Subtask(SubtaskPart {
2548            id: "p_sub_001".into(),
2549            session_id: "sess_001".into(),
2550            message_id: "msg_a001".into(),
2551            prompt: "Fix the bug".into(),
2552            description: "Fix the null pointer bug in parser".into(),
2553            agent: "coder".into(),
2554            model: Some(SubtaskPartModel {
2555                provider_id: "openai".into(),
2556                model_id: "gpt-4o".into(),
2557            }),
2558            command: Some("cargo test".into()),
2559        });
2560        let json_str = serde_json::to_string(&part).unwrap();
2561        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2562        assert_eq!(v["type"], "subtask");
2563        assert_eq!(v["sessionID"], "sess_001");
2564        assert_eq!(v["messageID"], "msg_a001");
2565        let back: Part = serde_json::from_str(&json_str).unwrap();
2566        assert_eq!(part, back);
2567
2568        // Minimal (no model, no command)
2569        let minimal = Part::Subtask(SubtaskPart {
2570            id: "p_sub_002".into(),
2571            session_id: "sess_001".into(),
2572            message_id: "msg_a001".into(),
2573            prompt: "Do it".into(),
2574            description: "desc".into(),
2575            agent: "coder".into(),
2576            model: None,
2577            command: None,
2578        });
2579        let json_str = serde_json::to_string(&minimal).unwrap();
2580        assert!(!json_str.contains("model"));
2581        assert!(!json_str.contains("command"));
2582        let back: Part = serde_json::from_str(&json_str).unwrap();
2583        assert_eq!(minimal, back);
2584    }
2585
2586    #[test]
2587    fn part_reasoning_round_trip() {
2588        let part = Part::Reasoning(ReasoningPart {
2589            id: "p_reason_001".into(),
2590            session_id: "sess_001".into(),
2591            message_id: "msg_a001".into(),
2592            text: "Let me think about this...".into(),
2593            metadata: Some(HashMap::from([("key".into(), json!("value"))])),
2594            time: ReasoningPartTime { start: 1_700_000_200.0, end: Some(1_700_000_201.0) },
2595        });
2596        let json_str = serde_json::to_string(&part).unwrap();
2597        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2598        assert_eq!(v["type"], "reasoning");
2599        assert_eq!(v["sessionID"], "sess_001");
2600        assert_eq!(v["messageID"], "msg_a001");
2601        let back: Part = serde_json::from_str(&json_str).unwrap();
2602        assert_eq!(part, back);
2603
2604        // Minimal (no metadata, no end time)
2605        let minimal = Part::Reasoning(ReasoningPart {
2606            id: "p_reason_002".into(),
2607            session_id: "sess_001".into(),
2608            message_id: "msg_a001".into(),
2609            text: "thinking".into(),
2610            metadata: None,
2611            time: ReasoningPartTime { start: 1_700_000_200.0, end: None },
2612        });
2613        let json_str = serde_json::to_string(&minimal).unwrap();
2614        assert!(!json_str.contains("metadata"));
2615        let back: Part = serde_json::from_str(&json_str).unwrap();
2616        assert_eq!(minimal, back);
2617    }
2618
2619    #[test]
2620    fn part_agent_round_trip() {
2621        let part = Part::Agent(AgentPart {
2622            id: "p_agent_001".into(),
2623            session_id: "sess_001".into(),
2624            message_id: "msg_a001".into(),
2625            name: "coder".into(),
2626            source: Some(AgentPartSource {
2627                value: "some source content".into(),
2628                start: 0,
2629                end: 42,
2630            }),
2631        });
2632        let json_str = serde_json::to_string(&part).unwrap();
2633        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2634        assert_eq!(v["type"], "agent");
2635        assert_eq!(v["sessionID"], "sess_001");
2636        assert_eq!(v["messageID"], "msg_a001");
2637        let back: Part = serde_json::from_str(&json_str).unwrap();
2638        assert_eq!(part, back);
2639
2640        // Minimal (no source)
2641        let minimal = Part::Agent(AgentPart {
2642            id: "p_agent_002".into(),
2643            session_id: "sess_001".into(),
2644            message_id: "msg_a001".into(),
2645            name: "reviewer".into(),
2646            source: None,
2647        });
2648        let json_str = serde_json::to_string(&minimal).unwrap();
2649        assert!(!json_str.contains("source"));
2650        let back: Part = serde_json::from_str(&json_str).unwrap();
2651        assert_eq!(minimal, back);
2652    }
2653
2654    #[test]
2655    fn part_compaction_round_trip() {
2656        let part = Part::Compaction(CompactionPart {
2657            id: "p_compact_001".into(),
2658            session_id: "sess_001".into(),
2659            message_id: "msg_a001".into(),
2660            auto: true,
2661        });
2662        let json_str = serde_json::to_string(&part).unwrap();
2663        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2664        assert_eq!(v["type"], "compaction");
2665        assert_eq!(v["sessionID"], "sess_001");
2666        assert_eq!(v["messageID"], "msg_a001");
2667        assert_eq!(v["auto"], true);
2668        let back: Part = serde_json::from_str(&json_str).unwrap();
2669        assert_eq!(part, back);
2670
2671        // auto = false
2672        let part_false = Part::Compaction(CompactionPart {
2673            id: "p_compact_002".into(),
2674            session_id: "sess_001".into(),
2675            message_id: "msg_a001".into(),
2676            auto: false,
2677        });
2678        let json_str = serde_json::to_string(&part_false).unwrap();
2679        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2680        assert_eq!(v["auto"], false);
2681        let back: Part = serde_json::from_str(&json_str).unwrap();
2682        assert_eq!(part_false, back);
2683    }
2684
2685    #[test]
2686    fn part_retry_round_trip() {
2687        let part = Part::Retry(RetryPart {
2688            id: "p_retry_001".into(),
2689            session_id: "sess_001".into(),
2690            message_id: "msg_a001".into(),
2691            attempt: 2.0,
2692            error: json!({ "message": "rate limited", "code": 429 }),
2693            time: RetryPartTime { created: 1_700_000_200.0 },
2694        });
2695        let json_str = serde_json::to_string(&part).unwrap();
2696        let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
2697        assert_eq!(v["type"], "retry");
2698        assert_eq!(v["sessionID"], "sess_001");
2699        assert_eq!(v["messageID"], "msg_a001");
2700        assert_eq!(v["attempt"], 2.0);
2701        let back: Part = serde_json::from_str(&json_str).unwrap();
2702        assert_eq!(part, back);
2703    }
2704
2705    #[test]
2706    fn output_format_round_trip() {
2707        // Text
2708        let text = OutputFormat::Text;
2709        let json_str = serde_json::to_string(&text).unwrap();
2710        let back: OutputFormat = serde_json::from_str(&json_str).unwrap();
2711        assert_eq!(text, back);
2712
2713        // JsonSchema without retry_count
2714        let schema_no_retry =
2715            OutputFormat::JsonSchema { schema: json!({ "type": "string" }), retry_count: None };
2716        let json_str = serde_json::to_string(&schema_no_retry).unwrap();
2717        assert!(!json_str.contains("retryCount"));
2718        let back: OutputFormat = serde_json::from_str(&json_str).unwrap();
2719        assert_eq!(schema_no_retry, back);
2720
2721        // JsonSchema with retry_count
2722        let schema_retry =
2723            OutputFormat::JsonSchema { schema: json!({ "type": "object" }), retry_count: Some(2) };
2724        let json_str = serde_json::to_string(&schema_retry).unwrap();
2725        assert!(json_str.contains("retryCount"));
2726        let back: OutputFormat = serde_json::from_str(&json_str).unwrap();
2727        assert_eq!(schema_retry, back);
2728    }
2729}