360 lines
12 KiB
Rust
360 lines
12 KiB
Rust
//! 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>,
|
|
/// Anthropic extended thinking signature。新世代 Claude で round-trip
|
|
/// 必須。OpenAI Responses など他 scheme では `None`。
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
signature: 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,
|
|
signature,
|
|
..
|
|
} => Self::Reasoning {
|
|
text: text.clone(),
|
|
summary: summary.clone(),
|
|
encrypted_content: encrypted_content.clone(),
|
|
signature: signature.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,
|
|
signature,
|
|
} => Item::Reasoning {
|
|
id: None,
|
|
text,
|
|
summary,
|
|
encrypted_content,
|
|
signature,
|
|
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_reasoning_preserves_signature() {
|
|
// 新世代 Claude の thinking signature が history.json に永続化され、
|
|
// resume 後の Item::Reasoning に復元されること。
|
|
let original = Item::reasoning("inner thought").with_signature("SIG-OPUS-XYZ");
|
|
let logged: LoggedItem = (&original).into();
|
|
let json = serde_json::to_string(&logged).unwrap();
|
|
// wire 形式に signature キーが乗ること(古い形式との互換のため
|
|
// 値が None のときは省略される。Some の値は載る)
|
|
assert!(
|
|
json.contains("SIG-OPUS-XYZ"),
|
|
"serialised JSON must carry signature: {json}",
|
|
);
|
|
let parsed: LoggedItem = serde_json::from_str(&json).unwrap();
|
|
match Item::from(parsed) {
|
|
Item::Reasoning {
|
|
text, signature, ..
|
|
} => {
|
|
assert_eq!(text, "inner thought");
|
|
assert_eq!(signature.as_deref(), Some("SIG-OPUS-XYZ"));
|
|
}
|
|
other => panic!("unexpected variant: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn legacy_reasoning_without_signature_field_deserializes() {
|
|
// signature フィールドが無い旧形式の history.json を読み込んでも
|
|
// None としてロードできる(後方互換性)。
|
|
let legacy_json = r#"{"kind":"reasoning","text":"old","summary":[],"encrypted_content":null}"#;
|
|
let parsed: LoggedItem = serde_json::from_str(legacy_json).unwrap();
|
|
match Item::from(parsed) {
|
|
Item::Reasoning {
|
|
text, signature, ..
|
|
} => {
|
|
assert_eq!(text, "old");
|
|
assert!(signature.is_none());
|
|
}
|
|
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");
|
|
}
|
|
}
|