1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
//! Rich session entry types for persisting non-message events.
//!
//! [`SessionEntry`] is a discriminated union of all entry types that can be
//! stored in a session JSONL file. Only the [`SessionEntry::Message`] variant
//! is sent to the LLM — all other variants are audit/display metadata.
use serde::{Deserialize, Serialize};
use swink_agent::{LlmMessage, ModelSpec};
/// A single entry in a persisted session.
///
/// Serialized with an adjacently-tagged representation: `{"entry_type": "...", "data": {...}}`.
/// Old-format lines (raw `LlmMessage` without `entry_type`) are deserialized as
/// [`SessionEntry::Message`] via a custom fallback in [`SessionEntry::parse`].
#[non_exhaustive]
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "entry_type", content = "data", rename_all = "snake_case")]
pub enum SessionEntry {
/// An LLM message (user, assistant, tool result). The only variant sent to the LLM.
Message(LlmMessage),
/// Records a model switch during the session.
ModelChange {
from: ModelSpec,
to: ModelSpec,
timestamp: u64,
},
/// Records a thinking level change.
ThinkingLevelChange {
from: String,
to: String,
timestamp: u64,
},
/// Records a context compaction event.
Compaction {
dropped_count: usize,
tokens_before: usize,
tokens_after: usize,
timestamp: u64,
},
/// A user bookmark/annotation on a specific message.
Label {
text: String,
message_index: usize,
timestamp: u64,
},
/// Arbitrary structured data for extensibility.
Custom {
type_name: String,
data: serde_json::Value,
timestamp: u64,
},
}
impl SessionEntry {
/// Parse a JSONL line into a `SessionEntry`.
///
/// If the line has an `entry_type` field, it is deserialized as a tagged enum.
/// Otherwise, it is treated as a raw `LlmMessage` (backward compatibility).
pub fn parse(line: &str) -> Result<Self, serde_json::Error> {
// Try tagged format first
let value: serde_json::Value = serde_json::from_str(line)?;
if value.get("entry_type").is_some() {
return serde_json::from_value(value);
}
// Fallback: old-format raw LlmMessage
serde_json::from_value::<LlmMessage>(value).map(SessionEntry::Message)
}
/// Returns the contained `LlmMessage` if this is a `Message` variant.
pub const fn as_message(&self) -> Option<&LlmMessage> {
match self {
Self::Message(msg) => Some(msg),
_ => None,
}
}
/// Extract only `Message` entries from a slice, returning the `LlmMessage` values.
pub fn messages(entries: &[Self]) -> Vec<&LlmMessage> {
entries.iter().filter_map(Self::as_message).collect()
}
/// Returns the serde discriminator string for this entry variant.
///
/// Matches the `rename_all = "snake_case"` representation used in serialization.
pub const fn entry_type_name(&self) -> &'static str {
match self {
Self::Message(_) => "message",
Self::ModelChange { .. } => "model_change",
Self::ThinkingLevelChange { .. } => "thinking_level_change",
Self::Compaction { .. } => "compaction",
Self::Label { .. } => "label",
Self::Custom { .. } => "custom",
}
}
/// Returns the timestamp of this entry, if it has one.
///
/// `Message` entries derive their timestamp from the inner `LlmMessage`.
pub const fn timestamp(&self) -> Option<u64> {
match self {
// `LlmMessage` is `#[non_exhaustive]`: a variant this build
// doesn't recognise carries no known timestamp field, so report
// `None` rather than guessing at one.
Self::Message(msg) => match msg {
LlmMessage::User(m) => Some(m.timestamp),
LlmMessage::Assistant(m) => Some(m.timestamp),
LlmMessage::ToolResult(m) => Some(m.timestamp),
_ => None,
},
Self::ModelChange { timestamp, .. }
| Self::ThinkingLevelChange { timestamp, .. }
| Self::Compaction { timestamp, .. }
| Self::Label { timestamp, .. }
| Self::Custom { timestamp, .. } => Some(*timestamp),
}
}
}
const _: () = {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<SessionEntry>();
};
#[cfg(test)]
#[path = "entry_tests.rs"]
mod tests;