1use kiss_agent::AgentMessage;
4use kiss_ai::{ThinkingLevel, Usage};
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7
8pub const SESSION_VERSION: u32 = 3;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct SessionHeader {
14 #[serde(rename = "type")]
15 pub entry_type: String, pub version: u32,
17 pub id: String,
18 pub timestamp: String,
19 pub cwd: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub parent_session: Option<String>,
22 #[serde(flatten)]
24 pub extra: Map<String, Value>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct EntryBase {
31 pub id: String,
33 pub parent_id: Option<String>,
34 pub timestamp: String,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39#[serde(tag = "type", rename_all = "snake_case")]
40pub enum SessionEntry {
41 #[serde(rename_all = "camelCase")]
42 Message {
43 #[serde(flatten)]
44 base: EntryBase,
45 message: AgentMessage,
46 #[serde(flatten)]
47 extra: Map<String, Value>,
48 },
49 #[serde(rename_all = "camelCase")]
50 ModelChange {
51 #[serde(flatten)]
52 base: EntryBase,
53 provider: String,
54 model_id: String,
55 #[serde(flatten)]
56 extra: Map<String, Value>,
57 },
58 #[serde(rename_all = "camelCase")]
59 ThinkingLevelChange {
60 #[serde(flatten)]
61 base: EntryBase,
62 thinking_level: ThinkingLevel,
63 #[serde(flatten)]
64 extra: Map<String, Value>,
65 },
66 #[serde(rename_all = "camelCase")]
67 Usage {
68 #[serde(flatten)]
69 base: EntryBase,
70 kind: String,
71 provider: String,
72 model: String,
73 usage: Usage,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 note: Option<String>,
76 #[serde(flatten)]
77 extra: Map<String, Value>,
78 },
79 #[serde(rename_all = "camelCase")]
80 Compaction {
81 #[serde(flatten)]
82 base: EntryBase,
83 summary: String,
84 tokens_before: u64,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
86 first_kept_entry_id: Option<String>,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
89 retained_tail: Option<Vec<AgentMessage>>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
91 usage: Option<Usage>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
93 details: Option<Value>,
94 #[serde(flatten)]
95 extra: Map<String, Value>,
96 },
97 #[serde(rename_all = "camelCase")]
98 BranchSummary {
99 #[serde(flatten)]
100 base: EntryBase,
101 from_id: String,
102 summary: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 usage: Option<Usage>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 details: Option<Value>,
107 #[serde(flatten)]
108 extra: Map<String, Value>,
109 },
110 #[serde(rename_all = "camelCase")]
112 Custom {
113 #[serde(flatten)]
114 base: EntryBase,
115 custom_type: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 data: Option<Value>,
118 #[serde(flatten)]
119 extra: Map<String, Value>,
120 },
121 #[serde(rename_all = "camelCase")]
123 CustomMessage {
124 #[serde(flatten)]
125 base: EntryBase,
126 custom_type: String,
127 content: kiss_ai::UserContent,
128 display: bool,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 details: Option<Value>,
131 #[serde(flatten)]
132 extra: Map<String, Value>,
133 },
134 #[serde(rename_all = "camelCase")]
135 Label {
136 #[serde(flatten)]
137 base: EntryBase,
138 target_id: String,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
140 label: Option<String>,
141 #[serde(flatten)]
142 extra: Map<String, Value>,
143 },
144 #[serde(rename_all = "camelCase")]
145 SessionInfo {
146 #[serde(flatten)]
147 base: EntryBase,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 name: Option<String>,
150 #[serde(flatten)]
151 extra: Map<String, Value>,
152 },
153}
154
155impl SessionEntry {
156 pub fn base(&self) -> &EntryBase {
157 match self {
158 SessionEntry::Message { base, .. }
159 | SessionEntry::ModelChange { base, .. }
160 | SessionEntry::ThinkingLevelChange { base, .. }
161 | SessionEntry::Usage { base, .. }
162 | SessionEntry::Compaction { base, .. }
163 | SessionEntry::BranchSummary { base, .. }
164 | SessionEntry::Custom { base, .. }
165 | SessionEntry::CustomMessage { base, .. }
166 | SessionEntry::Label { base, .. }
167 | SessionEntry::SessionInfo { base, .. } => base,
168 }
169 }
170
171 pub fn id(&self) -> &str {
172 &self.base().id
173 }
174
175 pub fn parent_id(&self) -> Option<&str> {
176 self.base().parent_id.as_deref()
177 }
178}
179
180pub fn new_entry_id() -> String {
181 let n: u32 = rand::random();
182 format!("{n:08x}")
183}
184
185pub fn iso_now() -> String {
186 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192
193 #[test]
194 fn entry_wire_shape() {
195 let entry = SessionEntry::Message {
196 base: EntryBase {
197 id: "a1b2c3d4".into(),
198 parent_id: None,
199 timestamp: iso_now(),
200 },
201 message: AgentMessage::user("hello"),
202 extra: Map::new(),
203 };
204 let v = serde_json::to_value(&entry).unwrap();
205 assert_eq!(v["type"], "message");
206 assert_eq!(v["parentId"], Value::Null);
207 assert_eq!(v["message"]["role"], "user");
208
209 let parsed: SessionEntry = serde_json::from_value(v).unwrap();
210 assert_eq!(parsed.id(), "a1b2c3d4");
211 }
212
213 #[test]
214 fn unknown_fields_preserved() {
215 let line = r#"{"type":"label","id":"x","parentId":"y","timestamp":"t","targetId":"z","label":"L","futureField":42}"#;
216 let entry: SessionEntry = serde_json::from_str(line).unwrap();
217 let out = serde_json::to_value(&entry).unwrap();
218 assert_eq!(out["futureField"], 42);
219 }
220}