session-log-decouple-item実装

This commit is contained in:
Keisuke Hirata 2026-04-29 22:24:18 +09:00
parent bd8154204a
commit 1ab6dbcee3
No known key found for this signature in database
6 changed files with 362 additions and 30 deletions

View File

@ -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,

View File

@ -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<LoggedContentPart>,
},
ToolCall {
call_id: String,
name: String,
arguments: String,
},
ToolResult {
call_id: String,
summary: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
content: Option<String>,
},
Reasoning {
text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
summary: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
encrypted_content: Option<String>,
},
}
#[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<Item> for LoggedItem {
fn from(item: Item) -> Self {
Self::from(&item)
}
}
impl From<LoggedItem> 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<LoggedItem> {
items.iter().map(LoggedItem::from).collect()
}
/// Convert logged items back into worker form.
pub fn from_logged(items: Vec<LoggedItem>) -> Vec<Item> {
items.into_iter().map(Item::from).collect()
}
// ---------------------------------------------------------------------------
// Role ↔ LoggedRole
// ---------------------------------------------------------------------------
impl From<Role> for LoggedRole {
fn from(role: Role) -> Self {
match role {
Role::User => Self::User,
Role::Assistant => Self::Assistant,
Role::System => Self::System,
}
}
}
impl From<LoggedRole> 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<LoggedContentPart> 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");
}
}

View File

@ -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(),

View File

@ -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<String>,
config: RequestConfig,
history: Vec<Item>,
history: Vec<LoggedItem>,
/// Origin: forked from another session at a specific entry.
#[serde(default, skip_serializing_if = "Option::is_none")]
forked_from: Option<SessionOrigin>,
@ -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<Item> },
AssistantItems { ts: u64, items: Vec<LoggedItem> },
/// Tool execution results added to history (worker.rs:897-900, 1072-1076).
ToolResults { ts: u64, items: Vec<Item> },
ToolResults { ts: u64, items: Vec<LoggedItem> },
/// Items injected by `on_turn_end` hook via `ContinueWithMessages` (worker.rs:1055).
HookInjectedItems { ts: u64, items: Vec<Item> },
HookInjectedItems { ts: u64, items: Vec<LoggedItem> },
/// 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,

View File

@ -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,
}]);

View File

@ -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<String> {
}
}
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<String> {
}
}
fn first_text_logged(item: &LoggedItem) -> Option<String> {
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 {