From 8a9e3b4fe3b47aaf4fdaf4c59f78457aa9f2518b Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 29 Apr 2026 22:24:18 +0900 Subject: [PATCH] =?UTF-8?q?session-log-decouple-item=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/session-store/src/lib.rs | 2 + crates/session-store/src/logged_item.rs | 309 ++++++++++++++++++++ crates/session-store/src/session.rs | 17 +- crates/session-store/src/session_log.rs | 42 +-- crates/session-store/tests/fs_store_test.rs | 7 +- crates/tui/src/picker.rs | 15 +- 6 files changed, 362 insertions(+), 30 deletions(-) create mode 100644 crates/session-store/src/logged_item.rs diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index 5978fc0d..848e51d6 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -28,12 +28,14 @@ pub mod event_trace; pub mod fs_store; +pub mod logged_item; pub mod session; pub mod session_log; pub mod store; pub use event_trace::TraceEntry; pub use fs_store::FsStore; +pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use session::{ SessionStartState, create_compacted_session, create_session, create_session_with_id, ensure_head_or_fork, fork, fork_at, restore, save_config_changed, save_delta, save_extension, diff --git a/crates/session-store/src/logged_item.rs b/crates/session-store/src/logged_item.rs new file mode 100644 index 00000000..a1bc2bf6 --- /dev/null +++ b/crates/session-store/src/logged_item.rs @@ -0,0 +1,309 @@ +//! Persistence-stable mirror of `llm_worker::Item`. +//! +//! `LogEntry` does not embed `Item` directly because that couples the on-disk +//! schema to the LLM worker's internal type — a field rename or addition there +//! would break every existing log. Instead, history-bearing variants serialize +//! a [`LoggedItem`] that lives in this crate, and conversions translate at the +//! save / replay boundaries. +//! +//! Fields kept here are limited to what is needed to reconstruct a worker +//! `Item` for replay. `id` and `status` annotations are intentionally dropped +//! (they are output-side metadata; replayed items synthesize fresh `None`). +//! `Reasoning::encrypted_content` is preserved because OpenAI Responses ZDR +//! requires it on stateless re-send. + +use llm_worker::llm_client::types::{ContentPart, Item, Role}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LoggedItem { + Message { + role: LoggedRole, + content: Vec, + }, + ToolCall { + call_id: String, + name: String, + arguments: String, + }, + ToolResult { + call_id: String, + summary: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + content: Option, + }, + Reasoning { + text: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + summary: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + encrypted_content: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LoggedRole { + User, + Assistant, + System, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum LoggedContentPart { + Text { text: String }, + Refusal { refusal: String }, +} + +// --------------------------------------------------------------------------- +// Item ↔ LoggedItem +// --------------------------------------------------------------------------- + +impl From<&Item> for LoggedItem { + fn from(item: &Item) -> Self { + match item { + Item::Message { role, content, .. } => Self::Message { + role: (*role).into(), + content: content.iter().map(LoggedContentPart::from).collect(), + }, + Item::ToolCall { + call_id, + name, + arguments, + .. + } => Self::ToolCall { + call_id: call_id.clone(), + name: name.clone(), + arguments: arguments.clone(), + }, + Item::ToolResult { + call_id, + summary, + content, + .. + } => Self::ToolResult { + call_id: call_id.clone(), + summary: summary.clone(), + content: content.clone(), + }, + Item::Reasoning { + text, + summary, + encrypted_content, + .. + } => Self::Reasoning { + text: text.clone(), + summary: summary.clone(), + encrypted_content: encrypted_content.clone(), + }, + } + } +} + +impl From for LoggedItem { + fn from(item: Item) -> Self { + Self::from(&item) + } +} + +impl From for Item { + fn from(logged: LoggedItem) -> Self { + match logged { + LoggedItem::Message { role, content } => Item::Message { + id: None, + role: role.into(), + content: content.into_iter().map(Into::into).collect(), + status: None, + }, + LoggedItem::ToolCall { + call_id, + name, + arguments, + } => Item::ToolCall { + id: None, + call_id, + name, + arguments, + status: None, + }, + LoggedItem::ToolResult { + call_id, + summary, + content, + } => Item::ToolResult { + id: None, + call_id, + summary, + content, + }, + LoggedItem::Reasoning { + text, + summary, + encrypted_content, + } => Item::Reasoning { + id: None, + text, + summary, + encrypted_content, + status: None, + }, + } + } +} + +/// Convert a slice of worker items into logged form. +pub fn to_logged(items: &[Item]) -> Vec { + items.iter().map(LoggedItem::from).collect() +} + +/// Convert logged items back into worker form. +pub fn from_logged(items: Vec) -> Vec { + items.into_iter().map(Item::from).collect() +} + +// --------------------------------------------------------------------------- +// Role ↔ LoggedRole +// --------------------------------------------------------------------------- + +impl From for LoggedRole { + fn from(role: Role) -> Self { + match role { + Role::User => Self::User, + Role::Assistant => Self::Assistant, + Role::System => Self::System, + } + } +} + +impl From for Role { + fn from(role: LoggedRole) -> Self { + match role { + LoggedRole::User => Self::User, + LoggedRole::Assistant => Self::Assistant, + LoggedRole::System => Self::System, + } + } +} + +// --------------------------------------------------------------------------- +// ContentPart ↔ LoggedContentPart +// --------------------------------------------------------------------------- + +impl From<&ContentPart> for LoggedContentPart { + fn from(part: &ContentPart) -> Self { + match part { + ContentPart::Text { text } => Self::Text { text: text.clone() }, + ContentPart::Refusal { refusal } => Self::Refusal { + refusal: refusal.clone(), + }, + } + } +} + +impl From for ContentPart { + fn from(part: LoggedContentPart) -> Self { + match part { + LoggedContentPart::Text { text } => Self::Text { text }, + LoggedContentPart::Refusal { refusal } => Self::Refusal { refusal }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip_user_message_text() { + let original = Item::user_message("hello"); + let logged: LoggedItem = (&original).into(); + let restored: Item = logged.into(); + // id / status are dropped by design; compare semantically. + match restored { + Item::Message { role, content, .. } => { + assert_eq!(role, Role::User); + assert_eq!(content.len(), 1); + match &content[0] { + ContentPart::Text { text } => assert_eq!(text, "hello"), + other => panic!("unexpected content: {other:?}"), + } + } + other => panic!("unexpected variant: {other:?}"), + } + } + + #[test] + fn round_trip_tool_call() { + let original = Item::tool_call("call_42", "get_weather", r#"{"city":"Tokyo"}"#); + let logged: LoggedItem = (&original).into(); + let json = serde_json::to_string(&logged).unwrap(); + let parsed: LoggedItem = serde_json::from_str(&json).unwrap(); + match Item::from(parsed) { + Item::ToolCall { + call_id, + name, + arguments, + .. + } => { + assert_eq!(call_id, "call_42"); + assert_eq!(name, "get_weather"); + assert_eq!(arguments, r#"{"city":"Tokyo"}"#); + } + other => panic!("unexpected variant: {other:?}"), + } + } + + #[test] + fn round_trip_reasoning_preserves_encrypted_content() { + let original = Item::reasoning("step-by-step") + .with_reasoning_summary(vec!["s1".into(), "s2".into()]) + .with_encrypted_content("opaque-blob"); + let logged: LoggedItem = (&original).into(); + let json = serde_json::to_string(&logged).unwrap(); + let parsed: LoggedItem = serde_json::from_str(&json).unwrap(); + match Item::from(parsed) { + Item::Reasoning { + text, + summary, + encrypted_content, + .. + } => { + assert_eq!(text, "step-by-step"); + assert_eq!(summary, vec!["s1".to_string(), "s2".to_string()]); + assert_eq!(encrypted_content.as_deref(), Some("opaque-blob")); + } + other => panic!("unexpected variant: {other:?}"), + } + } + + #[test] + fn round_trip_tool_result_with_content() { + let original = Item::tool_result_with_content("call_1", "ok", "full output"); + let logged: LoggedItem = (&original).into(); + match Item::from(logged) { + Item::ToolResult { + call_id, + summary, + content, + .. + } => { + assert_eq!(call_id, "call_1"); + assert_eq!(summary, "ok"); + assert_eq!(content.as_deref(), Some("full output")); + } + other => panic!("unexpected variant: {other:?}"), + } + } + + #[test] + fn message_serialization_uses_kind_tag() { + let logged: LoggedItem = (&Item::assistant_message("hi")).into(); + let value: serde_json::Value = serde_json::to_value(&logged).unwrap(); + assert_eq!(value["kind"], "message"); + assert_eq!(value["role"], "assistant"); + assert_eq!(value["content"][0]["kind"], "text"); + assert_eq!(value["content"][0]["text"], "hi"); + } +} diff --git a/crates/session-store/src/session.rs b/crates/session-store/src/session.rs index 8c1e4cb7..8194f80f 100644 --- a/crates/session-store/src/session.rs +++ b/crates/session-store/src/session.rs @@ -5,6 +5,7 @@ //! functions after state-mutating operations. use crate::SessionId; +use crate::logged_item::{LoggedItem, to_logged}; use crate::session_log::{self, EntryHash, HashedEntry, LogEntry, SessionOrigin}; use crate::store::{Store, StoreError}; use llm_worker::WorkerResult; @@ -44,7 +45,7 @@ pub async fn create_session_with_id( ts: session_log::now_millis(), system_prompt: state.system_prompt.map(String::from), config: state.config.clone(), - history: state.history.to_vec(), + history: to_logged(state.history), forked_from: None, compacted_from: None, }; @@ -73,7 +74,7 @@ pub async fn create_compacted_session( ts: session_log::now_millis(), system_prompt: state.system_prompt.map(String::from), config: state.config.clone(), - history: state.history.to_vec(), + history: to_logged(state.history), forked_from: None, compacted_from: Some(SessionOrigin { session_id: source_session_id, @@ -121,7 +122,7 @@ pub async fn ensure_head_or_fork( ts: session_log::now_millis(), system_prompt: state.system_prompt.map(String::from), config: state.config.clone(), - history: state.history.to_vec(), + history: to_logged(state.history), forked_from: None, compacted_from: None, }; @@ -179,7 +180,7 @@ pub async fn save_delta( head_hash, LogEntry::ToolResults { ts, - items: new_items[start..i].to_vec(), + items: to_logged(&new_items[start..i]), }, ) .await?; @@ -198,7 +199,7 @@ pub async fn save_delta( head_hash, LogEntry::AssistantItems { ts, - items: new_items[start..i].to_vec(), + items: to_logged(&new_items[start..i]), }, ) .await?; @@ -209,7 +210,7 @@ pub async fn save_delta( head_hash, LogEntry::HookInjectedItems { ts, - items: vec![new_items[i].clone()], + items: vec![LoggedItem::from(&new_items[i])], }, ) .await?; @@ -369,7 +370,7 @@ pub async fn fork( ts: session_log::now_millis(), system_prompt: state.system_prompt.map(String::from), config: state.config.clone(), - history: state.history.to_vec(), + history: to_logged(state.history), forked_from: None, compacted_from: None, }; @@ -402,7 +403,7 @@ pub async fn fork_at( ts: session_log::now_millis(), system_prompt: state.system_prompt, config: state.config, - history: state.history, + history: to_logged(&state.history), forked_from: Some(session_log::SessionOrigin { session_id: source_id, at_hash: at_hash.clone(), diff --git a/crates/session-store/src/session_log.rs b/crates/session-store/src/session_log.rs index 98f9bb77..8152cc8d 100644 --- a/crates/session-store/src/session_log.rs +++ b/crates/session-store/src/session_log.rs @@ -13,6 +13,8 @@ use llm_worker::{UsageRecord, WorkerResult}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use crate::logged_item::LoggedItem; + /// SHA-256 hash identifying a specific log entry in the chain. /// /// Computed as `sha256(prev_hash_bytes || canonical_json(entry))`. @@ -100,7 +102,7 @@ pub enum LogEntry { ts: u64, system_prompt: Option, config: RequestConfig, - history: Vec, + history: Vec, /// Origin: forked from another session at a specific entry. #[serde(default, skip_serializing_if = "Option::is_none")] forked_from: Option, @@ -113,13 +115,13 @@ pub enum LogEntry { UserInput { ts: u64, item: Item }, /// Assistant response items added to history (worker.rs:1040-1041). - AssistantItems { ts: u64, items: Vec }, + AssistantItems { ts: u64, items: Vec }, /// Tool execution results added to history (worker.rs:897-900, 1072-1076). - ToolResults { ts: u64, items: Vec }, + ToolResults { ts: u64, items: Vec }, /// Items injected by `on_turn_end` hook via `ContinueWithMessages` (worker.rs:1055). - HookInjectedItems { ts: u64, items: Vec }, + HookInjectedItems { ts: u64, items: Vec }, /// Turn boundary. Records the turn count after increment. TurnEnd { ts: u64, turn_count: usize }, @@ -234,19 +236,25 @@ pub fn collect_state(entries: &[HashedEntry]) -> RestoredState { } => { state.system_prompt = system_prompt.clone(); state.config = config.clone(); - state.history = history.clone(); + state.history = history.iter().cloned().map(Item::from).collect(); } LogEntry::UserInput { item, .. } => { state.history.push(item.clone()); } LogEntry::AssistantItems { items, .. } => { - state.history.extend(items.iter().cloned()); + state + .history + .extend(items.iter().cloned().map(Item::from)); } LogEntry::ToolResults { items, .. } => { - state.history.extend(items.iter().cloned()); + state + .history + .extend(items.iter().cloned().map(Item::from)); } LogEntry::HookInjectedItems { items, .. } => { - state.history.extend(items.iter().cloned()); + state + .history + .extend(items.iter().cloned().map(Item::from)); } LogEntry::TurnEnd { turn_count, .. } => { state.turn_count = *turn_count; @@ -333,7 +341,7 @@ mod tests { ts: 1000, system_prompt: Some("You are helpful.".into()), config: RequestConfig::default().with_max_tokens(1024), - history: vec![Item::user_message("seed")], + history: vec![Item::user_message("seed").into()], forked_from: None, compacted_from: None, }]); @@ -361,7 +369,7 @@ mod tests { }, LogEntry::AssistantItems { ts: 3000, - items: vec![Item::assistant_message("Hi!")], + items: vec![Item::assistant_message("Hi!").into()], }, LogEntry::TurnEnd { ts: 3100, @@ -396,19 +404,17 @@ mod tests { }, LogEntry::AssistantItems { ts: 3000, - items: vec![Item::tool_call( - "call_1", - "get_weather", - r#"{"city":"Tokyo"}"#, - )], + items: vec![ + Item::tool_call("call_1", "get_weather", r#"{"city":"Tokyo"}"#).into(), + ], }, LogEntry::ToolResults { ts: 3500, - items: vec![Item::tool_result("call_1", "Sunny, 25C")], + items: vec![Item::tool_result("call_1", "Sunny, 25C").into()], }, LogEntry::AssistantItems { ts: 4000, - items: vec![Item::assistant_message("It's sunny in Tokyo!")], + items: vec![Item::assistant_message("It's sunny in Tokyo!").into()], }, LogEntry::TurnEnd { ts: 4100, @@ -503,7 +509,7 @@ mod tests { }, LogEntry::AssistantItems { ts: 2200, - items: vec![Item::assistant_message("yo")], + items: vec![Item::assistant_message("yo").into()], }, LogEntry::LlmUsage { ts: 3100, diff --git a/crates/session-store/tests/fs_store_test.rs b/crates/session-store/tests/fs_store_test.rs index ec494494..40eef011 100644 --- a/crates/session-store/tests/fs_store_test.rs +++ b/crates/session-store/tests/fs_store_test.rs @@ -25,7 +25,7 @@ async fn round_trip_write_and_read() { }, LogEntry::AssistantItems { ts: 3000, - items: vec![Item::assistant_message("Hi there!")], + items: vec![Item::assistant_message("Hi there!").into()], }, LogEntry::TurnEnd { ts: 3100, @@ -74,7 +74,10 @@ async fn create_session_writes_all_entries() { ts: 1000, system_prompt: None, config: RequestConfig::default(), - history: vec![Item::user_message("seed"), Item::assistant_message("ok")], + history: vec![ + Item::user_message("seed").into(), + Item::assistant_message("ok").into(), + ], forked_from: None, compacted_from: None, }]); diff --git a/crates/tui/src/picker.rs b/crates/tui/src/picker.rs index c29ca30a..73b66eb4 100644 --- a/crates/tui/src/picker.rs +++ b/crates/tui/src/picker.rs @@ -21,7 +21,8 @@ use ratatui::widgets::Paragraph; use ratatui::{Frame, TerminalOptions, Viewport}; use pod_registry::lookup_session; use session_store::{ - ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store, + ContentPart, FsStore, HashedEntry, Item, LogEntry, LoggedContentPart, LoggedItem, SessionId, + Store, }; const MAX_ROWS: usize = 10; @@ -181,7 +182,7 @@ fn last_message_preview(entries: &[HashedEntry]) -> Option { } } LogEntry::AssistantItems { items, .. } => { - if let Some(text) = items.iter().find_map(first_text) { + if let Some(text) = items.iter().find_map(first_text_logged) { return Some(format!("assistant: {}", trim_one_line(&text, 60))); } } @@ -201,6 +202,16 @@ fn first_text(item: &Item) -> Option { } } +fn first_text_logged(item: &LoggedItem) -> Option { + match item { + LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p { + LoggedContentPart::Text { text } => Some(text.clone()), + _ => None, + }), + _ => None, + } +} + fn trim_one_line(s: &str, max_chars: usize) -> String { let collapsed: String = s.chars().map(|c| if c == '\n' { ' ' } else { c }).collect(); if collapsed.chars().count() <= max_chars {