Skip to main content

kiss_coding/session/
entry.rs

1//! Session JSONL entry types, wire-compatible with pi's session format v3.
2
3use 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/// First line of every session file. Not part of the entry tree.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12#[serde(rename_all = "camelCase")]
13pub struct SessionHeader {
14    #[serde(rename = "type")]
15    pub entry_type: String, // always "session"
16    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    /// Unknown fields from foreign sessions survive a rewrite.
23    #[serde(flatten)]
24    pub extra: Map<String, Value>,
25}
26
27/// Common id/parent/timestamp fields on every tree entry.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct EntryBase {
31    /// 8-char hex id.
32    pub id: String,
33    pub parent_id: Option<String>,
34    /// ISO-8601 timestamp.
35    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        /// Post-compaction context checkpoint (newer format).
88        #[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    /// Harness/extension state. Never part of LLM context.
111    #[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    /// Injected message that does participate in LLM context.
122    #[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}