| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520 |
- use std::collections::BTreeMap;
- use std::fmt::{Display, Formatter};
- use std::fs;
- use std::path::Path;
- use crate::json::{JsonError, JsonValue};
- use crate::usage::TokenUsage;
- #[derive(Debug, Clone, Copy, PartialEq, Eq)]
- pub enum MessageRole {
- System,
- User,
- Assistant,
- Tool,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub enum ContentBlock {
- Text {
- text: String,
- },
- ToolUse {
- id: String,
- name: String,
- input: String,
- },
- ToolResult {
- tool_use_id: String,
- tool_name: String,
- output: String,
- is_error: bool,
- },
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct ConversationMessage {
- pub role: MessageRole,
- pub blocks: Vec<ContentBlock>,
- pub usage: Option<TokenUsage>,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct SessionMetadata {
- pub started_at: String,
- pub model: String,
- pub message_count: u32,
- pub last_prompt: Option<String>,
- }
- #[derive(Debug, Clone, PartialEq, Eq)]
- pub struct Session {
- pub version: u32,
- pub messages: Vec<ConversationMessage>,
- pub metadata: Option<SessionMetadata>,
- }
- #[derive(Debug)]
- pub enum SessionError {
- Io(std::io::Error),
- Json(JsonError),
- Format(String),
- }
- impl Display for SessionError {
- fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
- match self {
- Self::Io(error) => write!(f, "{error}"),
- Self::Json(error) => write!(f, "{error}"),
- Self::Format(error) => write!(f, "{error}"),
- }
- }
- }
- impl std::error::Error for SessionError {}
- impl From<std::io::Error> for SessionError {
- fn from(value: std::io::Error) -> Self {
- Self::Io(value)
- }
- }
- impl From<JsonError> for SessionError {
- fn from(value: JsonError) -> Self {
- Self::Json(value)
- }
- }
- impl Session {
- #[must_use]
- pub fn new() -> Self {
- Self {
- version: 1,
- messages: Vec::new(),
- metadata: None,
- }
- }
- pub fn save_to_path(&self, path: impl AsRef<Path>) -> Result<(), SessionError> {
- fs::write(path, self.to_json().render())?;
- Ok(())
- }
- pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, SessionError> {
- let contents = fs::read_to_string(path)?;
- Self::from_json(&JsonValue::parse(&contents)?)
- }
- #[must_use]
- pub fn to_json(&self) -> JsonValue {
- let mut object = BTreeMap::new();
- object.insert(
- "version".to_string(),
- JsonValue::Number(i64::from(self.version)),
- );
- object.insert(
- "messages".to_string(),
- JsonValue::Array(
- self.messages
- .iter()
- .map(ConversationMessage::to_json)
- .collect(),
- ),
- );
- if let Some(metadata) = &self.metadata {
- object.insert("metadata".to_string(), metadata.to_json());
- }
- JsonValue::Object(object)
- }
- pub fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
- let object = value
- .as_object()
- .ok_or_else(|| SessionError::Format("session must be an object".to_string()))?;
- let version = object
- .get("version")
- .and_then(JsonValue::as_i64)
- .ok_or_else(|| SessionError::Format("missing version".to_string()))?;
- let version = u32::try_from(version)
- .map_err(|_| SessionError::Format("version out of range".to_string()))?;
- let messages = object
- .get("messages")
- .and_then(JsonValue::as_array)
- .ok_or_else(|| SessionError::Format("missing messages".to_string()))?
- .iter()
- .map(ConversationMessage::from_json)
- .collect::<Result<Vec<_>, _>>()?;
- let metadata = object
- .get("metadata")
- .map(SessionMetadata::from_json)
- .transpose()?;
- Ok(Self {
- version,
- messages,
- metadata,
- })
- }
- }
- impl Default for Session {
- fn default() -> Self {
- Self::new()
- }
- }
- impl SessionMetadata {
- #[must_use]
- pub fn to_json(&self) -> JsonValue {
- let mut object = BTreeMap::new();
- object.insert(
- "started_at".to_string(),
- JsonValue::String(self.started_at.clone()),
- );
- object.insert("model".to_string(), JsonValue::String(self.model.clone()));
- object.insert(
- "message_count".to_string(),
- JsonValue::Number(i64::from(self.message_count)),
- );
- if let Some(last_prompt) = &self.last_prompt {
- object.insert(
- "last_prompt".to_string(),
- JsonValue::String(last_prompt.clone()),
- );
- }
- JsonValue::Object(object)
- }
- fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
- let object = value.as_object().ok_or_else(|| {
- SessionError::Format("session metadata must be an object".to_string())
- })?;
- Ok(Self {
- started_at: required_string(object, "started_at")?,
- model: required_string(object, "model")?,
- message_count: required_u32(object, "message_count")?,
- last_prompt: optional_string(object, "last_prompt"),
- })
- }
- }
- impl ConversationMessage {
- #[must_use]
- pub fn user_text(text: impl Into<String>) -> Self {
- Self {
- role: MessageRole::User,
- blocks: vec![ContentBlock::Text { text: text.into() }],
- usage: None,
- }
- }
- #[must_use]
- pub fn assistant(blocks: Vec<ContentBlock>) -> Self {
- Self {
- role: MessageRole::Assistant,
- blocks,
- usage: None,
- }
- }
- #[must_use]
- pub fn assistant_with_usage(blocks: Vec<ContentBlock>, usage: Option<TokenUsage>) -> Self {
- Self {
- role: MessageRole::Assistant,
- blocks,
- usage,
- }
- }
- #[must_use]
- pub fn tool_result(
- tool_use_id: impl Into<String>,
- tool_name: impl Into<String>,
- output: impl Into<String>,
- is_error: bool,
- ) -> Self {
- Self {
- role: MessageRole::Tool,
- blocks: vec![ContentBlock::ToolResult {
- tool_use_id: tool_use_id.into(),
- tool_name: tool_name.into(),
- output: output.into(),
- is_error,
- }],
- usage: None,
- }
- }
- #[must_use]
- pub fn to_json(&self) -> JsonValue {
- let mut object = BTreeMap::new();
- object.insert(
- "role".to_string(),
- JsonValue::String(
- match self.role {
- MessageRole::System => "system",
- MessageRole::User => "user",
- MessageRole::Assistant => "assistant",
- MessageRole::Tool => "tool",
- }
- .to_string(),
- ),
- );
- object.insert(
- "blocks".to_string(),
- JsonValue::Array(self.blocks.iter().map(ContentBlock::to_json).collect()),
- );
- if let Some(usage) = self.usage {
- object.insert("usage".to_string(), usage_to_json(usage));
- }
- JsonValue::Object(object)
- }
- fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
- let object = value
- .as_object()
- .ok_or_else(|| SessionError::Format("message must be an object".to_string()))?;
- let role = match object
- .get("role")
- .and_then(JsonValue::as_str)
- .ok_or_else(|| SessionError::Format("missing role".to_string()))?
- {
- "system" => MessageRole::System,
- "user" => MessageRole::User,
- "assistant" => MessageRole::Assistant,
- "tool" => MessageRole::Tool,
- other => {
- return Err(SessionError::Format(format!(
- "unsupported message role: {other}"
- )))
- }
- };
- let blocks = object
- .get("blocks")
- .and_then(JsonValue::as_array)
- .ok_or_else(|| SessionError::Format("missing blocks".to_string()))?
- .iter()
- .map(ContentBlock::from_json)
- .collect::<Result<Vec<_>, _>>()?;
- let usage = object.get("usage").map(usage_from_json).transpose()?;
- Ok(Self {
- role,
- blocks,
- usage,
- })
- }
- }
- impl ContentBlock {
- #[must_use]
- pub fn to_json(&self) -> JsonValue {
- let mut object = BTreeMap::new();
- match self {
- Self::Text { text } => {
- object.insert("type".to_string(), JsonValue::String("text".to_string()));
- object.insert("text".to_string(), JsonValue::String(text.clone()));
- }
- Self::ToolUse { id, name, input } => {
- object.insert(
- "type".to_string(),
- JsonValue::String("tool_use".to_string()),
- );
- object.insert("id".to_string(), JsonValue::String(id.clone()));
- object.insert("name".to_string(), JsonValue::String(name.clone()));
- object.insert("input".to_string(), JsonValue::String(input.clone()));
- }
- Self::ToolResult {
- tool_use_id,
- tool_name,
- output,
- is_error,
- } => {
- object.insert(
- "type".to_string(),
- JsonValue::String("tool_result".to_string()),
- );
- object.insert(
- "tool_use_id".to_string(),
- JsonValue::String(tool_use_id.clone()),
- );
- object.insert(
- "tool_name".to_string(),
- JsonValue::String(tool_name.clone()),
- );
- object.insert("output".to_string(), JsonValue::String(output.clone()));
- object.insert("is_error".to_string(), JsonValue::Bool(*is_error));
- }
- }
- JsonValue::Object(object)
- }
- fn from_json(value: &JsonValue) -> Result<Self, SessionError> {
- let object = value
- .as_object()
- .ok_or_else(|| SessionError::Format("block must be an object".to_string()))?;
- match object
- .get("type")
- .and_then(JsonValue::as_str)
- .ok_or_else(|| SessionError::Format("missing block type".to_string()))?
- {
- "text" => Ok(Self::Text {
- text: required_string(object, "text")?,
- }),
- "tool_use" => Ok(Self::ToolUse {
- id: required_string(object, "id")?,
- name: required_string(object, "name")?,
- input: required_string(object, "input")?,
- }),
- "tool_result" => Ok(Self::ToolResult {
- tool_use_id: required_string(object, "tool_use_id")?,
- tool_name: required_string(object, "tool_name")?,
- output: required_string(object, "output")?,
- is_error: object
- .get("is_error")
- .and_then(JsonValue::as_bool)
- .ok_or_else(|| SessionError::Format("missing is_error".to_string()))?,
- }),
- other => Err(SessionError::Format(format!(
- "unsupported block type: {other}"
- ))),
- }
- }
- }
- fn usage_to_json(usage: TokenUsage) -> JsonValue {
- let mut object = BTreeMap::new();
- object.insert(
- "input_tokens".to_string(),
- JsonValue::Number(i64::from(usage.input_tokens)),
- );
- object.insert(
- "output_tokens".to_string(),
- JsonValue::Number(i64::from(usage.output_tokens)),
- );
- object.insert(
- "cache_creation_input_tokens".to_string(),
- JsonValue::Number(i64::from(usage.cache_creation_input_tokens)),
- );
- object.insert(
- "cache_read_input_tokens".to_string(),
- JsonValue::Number(i64::from(usage.cache_read_input_tokens)),
- );
- JsonValue::Object(object)
- }
- fn usage_from_json(value: &JsonValue) -> Result<TokenUsage, SessionError> {
- let object = value
- .as_object()
- .ok_or_else(|| SessionError::Format("usage must be an object".to_string()))?;
- Ok(TokenUsage {
- input_tokens: required_u32(object, "input_tokens")?,
- output_tokens: required_u32(object, "output_tokens")?,
- cache_creation_input_tokens: required_u32(object, "cache_creation_input_tokens")?,
- cache_read_input_tokens: required_u32(object, "cache_read_input_tokens")?,
- })
- }
- fn required_string(
- object: &BTreeMap<String, JsonValue>,
- key: &str,
- ) -> Result<String, SessionError> {
- object
- .get(key)
- .and_then(JsonValue::as_str)
- .map(ToOwned::to_owned)
- .ok_or_else(|| SessionError::Format(format!("missing {key}")))
- }
- fn optional_string(object: &BTreeMap<String, JsonValue>, key: &str) -> Option<String> {
- object
- .get(key)
- .and_then(JsonValue::as_str)
- .map(ToOwned::to_owned)
- }
- fn required_u32(object: &BTreeMap<String, JsonValue>, key: &str) -> Result<u32, SessionError> {
- let value = object
- .get(key)
- .and_then(JsonValue::as_i64)
- .ok_or_else(|| SessionError::Format(format!("missing {key}")))?;
- u32::try_from(value).map_err(|_| SessionError::Format(format!("{key} out of range")))
- }
- #[cfg(test)]
- mod tests {
- use super::{ContentBlock, ConversationMessage, MessageRole, Session, SessionMetadata};
- use crate::json::JsonValue;
- use crate::usage::TokenUsage;
- use std::fs;
- use std::time::{SystemTime, UNIX_EPOCH};
- #[test]
- fn persists_and_restores_session_json() {
- let mut session = Session::new();
- session.metadata = Some(SessionMetadata {
- started_at: "2026-04-01T00:00:00Z".to_string(),
- model: "claude-sonnet".to_string(),
- message_count: 3,
- last_prompt: Some("hello".to_string()),
- });
- session
- .messages
- .push(ConversationMessage::user_text("hello"));
- session
- .messages
- .push(ConversationMessage::assistant_with_usage(
- vec![
- ContentBlock::Text {
- text: "thinking".to_string(),
- },
- ContentBlock::ToolUse {
- id: "tool-1".to_string(),
- name: "bash".to_string(),
- input: "echo hi".to_string(),
- },
- ],
- Some(TokenUsage {
- input_tokens: 10,
- output_tokens: 4,
- cache_creation_input_tokens: 1,
- cache_read_input_tokens: 2,
- }),
- ));
- session.messages.push(ConversationMessage::tool_result(
- "tool-1", "bash", "hi", false,
- ));
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("system time should be after epoch")
- .as_nanos();
- let path = std::env::temp_dir().join(format!("runtime-session-{nanos}.json"));
- session.save_to_path(&path).expect("session should save");
- let restored = Session::load_from_path(&path).expect("session should load");
- fs::remove_file(&path).expect("temp file should be removable");
- assert_eq!(restored, session);
- assert_eq!(restored.messages[2].role, MessageRole::Tool);
- assert_eq!(
- restored.messages[1].usage.expect("usage").total_tokens(),
- 17
- );
- assert_eq!(restored.metadata, session.metadata);
- }
- #[test]
- fn loads_legacy_session_without_metadata() {
- let legacy = r#"{
- "version": 1,
- "messages": [
- {
- "role": "user",
- "blocks": [{"type": "text", "text": "hello"}]
- }
- ]
- }"#;
- let restored = Session::from_json(&JsonValue::parse(legacy).expect("legacy json"))
- .expect("legacy session should parse");
- assert_eq!(restored.messages.len(), 1);
- assert!(restored.metadata.is_none());
- }
- }
|