Compare commits

...

6 Commits

23 changed files with 946 additions and 133 deletions

1
Cargo.lock generated
View File

@ -2922,6 +2922,7 @@ dependencies = [
"futures",
"hex",
"llm-worker",
"protocol",
"serde",
"serde_json",
"sha2 0.11.0",

View File

@ -12,9 +12,9 @@
- [ ] フルスクリーン化によるオーバーホール → [tickets/tui-fullscreen-overhaul.md](tickets/tui-fullscreen-overhaul.md)
- [ ] Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md)
- [ ] ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md)
- [ ] 入力欄の単語単位カーソル移動・削除 → [tickets/tui-input-word-motion.md](tickets/tui-input-word-motion.md)
- [ ] サブミット入力
- [ ] TUI 補完 + 型付き atom 化 → [tickets/submit-tui-completion.md](tickets/submit-tui-completion.md)
- [ ] セッションログの Item 依存切り離し → [tickets/session-log-decouple-item.md](tickets/session-log-decouple-item.md)
- [ ] セッションログの Segment 保持 → [tickets/session-log-segments.md](tickets/session-log-segments.md)
- [ ] メモリ機構
- [ ] Phase 2 consolidation → [tickets/memory-phase2-consolidation.md](tickets/memory-phase2-consolidation.md)

View File

@ -276,6 +276,7 @@ impl PodController {
manifest_toml.clone(),
greeting,
));
shared_state.set_user_segments(pod.user_segments().to_vec());
runtime_dir.write_manifest(&manifest_toml).await?;
runtime_dir.write_status(&shared_state).await?;
runtime_dir.write_history(&shared_state).await?;
@ -323,7 +324,9 @@ impl PodController {
// Broadcast the accepted user message so every
// subscriber (including the submitter) can
// render the turn header + user line from a
// single source of truth.
// single source of truth. shared_state's
// `user_segments` is re-synced from `pod` after
// the run completes, so we don't push here.
let _ = event_tx.send(Event::UserMessage {
segments: input.clone(),
});
@ -372,6 +375,7 @@ impl PodController {
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;
@ -430,6 +434,7 @@ impl PodController {
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;
@ -485,6 +490,7 @@ impl PodController {
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;
@ -582,6 +588,7 @@ impl PodController {
let items = pod.worker().history().to_vec();
shared_state.update_history(items);
shared_state.set_user_segments(pod.user_segments().to_vec());
shared_state.set_status(new_status);
let _ = runtime_dir.write_status(&shared_state).await;
let _ = runtime_dir.write_history(&shared_state).await;

View File

@ -91,9 +91,40 @@ async fn handle_connection(stream: tokio::net::UnixStream, handle: PodHandle) {
match method {
Ok(Some(Method::GetHistory)) => {
let items = handle.shared_state.history();
let segments_per_user = handle.shared_state.user_segments();
// Embed `segments` on user-message JSON values so
// the TUI can re-render typed atoms on restore.
// Alignment: segments are recorded only for
// submissions made during the live session, never
// for seed history loaded via `SessionStart.history`
// (post-compaction). The seed user_messages always
// come first in worker history, so the last
// `segments_per_user.len()` user_messages are the
// ones that map 1:1 to the segments list.
let total_user_msgs =
items.iter().filter(|i| i.is_user_message()).count();
let skip = total_user_msgs.saturating_sub(segments_per_user.len());
let mut user_idx = 0usize;
let values = items
.iter()
.map(|item| serde_json::to_value(item).expect("Item is Serialize"))
.map(|item| {
let mut value =
serde_json::to_value(item).expect("Item is Serialize");
if item.is_user_message() {
if user_idx >= skip {
let seg_idx = user_idx - skip;
if let Some(obj) = value.as_object_mut() {
let segs = serde_json::to_value(
&segments_per_user[seg_idx],
)
.expect("Segment is Serialize");
obj.insert("segments".into(), segs);
}
}
user_idx += 1;
}
value
})
.collect();
let greeting = handle.shared_state.greeting.clone();
if writer

View File

@ -135,6 +135,13 @@ pub struct Pod<C: LlmClient, St: Store> {
/// Restored from `RestoredState.extensions` on `restore`, updated
/// after each successful extract via `save_extension`.
extract_pointer: Mutex<Option<memory::ExtractPointerPayload>>,
/// Typed user submissions in submit order. K-th entry corresponds to
/// the K-th `Item::user_message` in `worker.history()` (modulo seed
/// history loaded via `SessionStart.history`, whose original segments
/// are not preserved). Populated from log on `restore_from_manifest`,
/// appended after `save_user_input` on each `run`. Mirrored to
/// `PodSharedState` by the controller for `Event::History` use.
user_segments: Vec<Vec<Segment>>,
}
impl<C: LlmClient, St: Store> Pod<C, St> {
@ -184,6 +191,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None),
user_segments: Vec::new(),
};
pod.apply_prune_from_manifest();
Ok(pod)
@ -280,6 +288,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
/// and reset by [`compact`](Self::compact) (the new compacted
/// session has a fresh log with no `LogEntry::Extension` entries).
/// Cheap clone via `Option<Clone>`.
/// Snapshot of the typed user segments tracked alongside worker
/// history. The K-th entry corresponds to the K-th `Item::user_message`
/// derived from `LogEntry::UserInput` entries (post-compaction); seed
/// history loaded via `SessionStart.history` does not contribute,
/// which is acceptable because the original segments are unrecoverable.
pub fn user_segments(&self) -> &[Vec<Segment>] {
&self.user_segments
}
pub fn extract_pointer(&self) -> Option<memory::ExtractPointerPayload> {
self.extract_pointer
.lock()
@ -585,6 +602,18 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
self.ensure_system_prompt_materialized()?;
self.ensure_session_head().await?;
// Persist the user input as typed segments before the worker
// pushes its flattened copy into history. save_delta deliberately
// skips the resulting `is_user_message()` item to avoid double-write.
session_store::save_user_input(
&self.store,
self.session_id,
&mut self.head_hash,
input.clone(),
)
.await?;
self.user_segments.push(input.clone());
let flattened = self.flatten_segments(&input);
let history_before = self.worker.as_ref().unwrap().history().len();
@ -599,16 +628,15 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
}
/// Flatten a typed segment list into the single string the Worker
/// receives as the user message. Inlines text and paste content;
/// substitutes `[unresolved <kind>: <key>]` placeholders for
/// segments that have no resolver, and emits a user-facing alert so
/// neither the LLM nor the human is blind to the dropped intent.
/// receives as the user message, and emit user-facing alerts for
/// segments that fall through to placeholder (file/knowledge/workflow
/// refs without a resolver, or unknown variants from a newer client).
/// The text reconstruction itself comes from `Segment::flatten_to_text`,
/// shared with replay paths that should not re-alert.
fn flatten_segments(&self, segments: &[Segment]) -> String {
let mut out = String::new();
for seg in segments {
match seg {
Segment::Text { content } => out.push_str(content),
Segment::Paste { content, .. } => out.push_str(content),
Segment::Text { .. } | Segment::Paste { .. } => {}
Segment::FileRef { path } => {
self.alert(
AlertLevel::Warn,
@ -618,7 +646,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
(resolver not yet implemented); passed to LLM as placeholder"
),
);
out.push_str(&format!("[unresolved file ref: {path}]"));
}
Segment::KnowledgeRef { slug } => {
self.alert(
@ -629,7 +656,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
(resolver not yet implemented); passed to LLM as placeholder"
),
);
out.push_str(&format!("[unresolved knowledge ref: {slug}]"));
}
Segment::WorkflowInvoke { slug } => {
self.alert(
@ -640,7 +666,6 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
(resolver not yet implemented); passed to LLM as placeholder"
),
);
out.push_str(&format!("[unresolved workflow invoke: {slug}]"));
}
Segment::Unknown => {
self.alert(
@ -650,11 +675,10 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
passed to LLM as placeholder"
.into(),
);
out.push_str("[unknown input segment]");
}
}
}
out
Segment::flatten_to_text(segments)
}
/// Run a turn triggered by `Method::Notify` while the Pod is idle.
@ -1123,6 +1147,13 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
))
});
// Count surviving user_messages before consuming `retained_items`
// — needed to align `self.user_segments` after the swap below.
let retained_user_msgs = retained_items
.iter()
.filter(|i| i.is_user_message())
.count();
// Build new history: [summary, ...auto-read, references, ...retained].
let mut new_history = Vec::with_capacity(
1 + auto_read_messages.len()
@ -1173,6 +1204,19 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
if self.scope_allocation.is_some() {
pod_registry::update_session(&self.manifest.pod.name, new_session_id)?;
}
// Align user_segments with the post-compaction history. Items
// before `retain_from` (now folded into the summary) lose their
// segments; only the user_messages surviving in retained_items
// keep them. They are always the trailing K entries of
// `self.user_segments` because submissions are appended in order.
let drop_n = self
.user_segments
.len()
.saturating_sub(retained_user_msgs);
if drop_n > 0 {
self.user_segments.drain(..drop_n);
}
let worker = self.worker.as_mut().unwrap();
worker.set_history(new_history);
// Anchor the prompt cache at the summary item so that Anthropic
@ -1539,6 +1583,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None),
user_segments: Vec::new(),
};
pod.apply_prune_from_manifest();
Ok(pod)
@ -1591,6 +1636,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(None),
user_segments: Vec::new(),
};
pod.apply_prune_from_manifest();
Ok(pod)
@ -1698,6 +1744,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Mutex::new(extract_pointer),
user_segments: state.user_segments,
};
pod.apply_prune_from_manifest();
Ok(pod)

View File

@ -1,6 +1,7 @@
use std::sync::RwLock;
use llm_worker::llm_client::types::Item;
use protocol::Segment;
use serde::{Deserialize, Serialize};
use session_store::SessionId;
@ -15,6 +16,12 @@ pub struct PodSharedState {
pub greeting: protocol::Greeting,
pub status: RwLock<PodStatus>,
pub history: RwLock<Vec<Item>>,
/// Typed user submissions in submit order. The K-th entry corresponds
/// to the K-th `Item::user_message` in `history` (modulo seed history
/// loaded from a pre-compaction `SessionStart.history`, whose original
/// segments are not preserved). Surfaced via `Event::History` so
/// clients can re-render typed atoms on session restore.
pub user_segments: RwLock<Vec<Vec<Segment>>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@ -39,6 +46,26 @@ impl PodSharedState {
greeting,
status: RwLock::new(PodStatus::Idle),
history: RwLock::new(Vec::new()),
user_segments: RwLock::new(Vec::new()),
}
}
pub fn user_segments(&self) -> Vec<Vec<Segment>> {
self.user_segments
.read()
.map(|s| s.clone())
.unwrap_or_default()
}
pub fn set_user_segments(&self, segments: Vec<Vec<Segment>>) {
if let Ok(mut s) = self.user_segments.write() {
*s = segments;
}
}
pub fn push_user_segments(&self, segments: Vec<Segment>) {
if let Ok(mut s) = self.user_segments.write() {
s.push(segments);
}
}

View File

@ -275,6 +275,37 @@ async fn agents_md_not_reread_after_compact() {
assert!(!after_third.contains("mutated"));
}
#[tokio::test]
async fn compact_aligns_user_segments_with_retained_history() {
// retained_tokens=0 folds the entire conversation into the summary,
// so retained_items has zero user_messages and self.user_segments
// must be drained to match. A subsequent run() then appends fresh
// segments cleanly without ghost entries from the pre-compaction era.
let client = MockClient::new(vec![
single_text_events("a"),
single_text_events("b"),
write_summary_tool_use_events("call-1", "compacted summary"),
single_text_events("done"),
single_text_events("c"),
]);
let (mut pod, _pwd) = make_pod_with_body("BODY", client).await.unwrap();
pod.run_text("first").await.unwrap();
pod.run_text("second").await.unwrap();
assert_eq!(pod.user_segments().len(), 2);
pod.compact(0).await.unwrap();
assert_eq!(
pod.user_segments().len(),
0,
"compact(0) folds every user_message into the summary, so segments \
must be drained to match retained_items"
);
pod.run_text("third").await.unwrap();
assert_eq!(pod.user_segments().len(), 1);
}
#[tokio::test]
async fn compact_preserves_system_prompt() {
let client = MockClient::new(vec![

View File

@ -133,6 +133,37 @@ impl Segment {
pub fn text(s: impl Into<String>) -> Self {
Self::Text { content: s.into() }
}
/// Flatten a segment slice into the single string the LLM receives
/// as a user message. Pure — no I/O, no alerts. Callers that need
/// to surface user-visible alerts for unresolved refs should do so
/// alongside this call (Pod does so at submit time).
///
/// Unresolved variants (`FileRef` / `KnowledgeRef` / `WorkflowInvoke`)
/// and `Unknown` map to `[unresolved <kind>: <key>]` placeholders so
/// the LLM sees an explicit token rather than silent omission.
pub fn flatten_to_text(segments: &[Segment]) -> String {
let mut out = String::new();
for seg in segments {
match seg {
Segment::Text { content } => out.push_str(content),
Segment::Paste { content, .. } => out.push_str(content),
Segment::FileRef { path } => {
out.push_str(&format!("[unresolved file ref: {path}]"));
}
Segment::KnowledgeRef { slug } => {
out.push_str(&format!("[unresolved knowledge ref: {slug}]"));
}
Segment::WorkflowInvoke { slug } => {
out.push_str(&format!("[unresolved workflow invoke: {slug}]"));
}
Segment::Unknown => {
out.push_str("[unknown input segment]");
}
}
}
out
}
}
impl Method {

View File

@ -15,6 +15,7 @@ uuid = { version = "1", features = ["v7", "serde"] }
thiserror = "2.0"
sha2 = "0.11.0"
hex = "0.4.3"
protocol = { version = "0.1.0", path = "../protocol" }
[dev-dependencies]
tokio = { version = "1.49", features = ["macros", "rt-multi-thread", "fs", "io-util"] }

View File

@ -28,16 +28,18 @@
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,
save_run_completed, save_run_errored, save_turn_end, save_usage,
save_run_completed, save_run_errored, save_turn_end, save_usage, save_user_input,
};
pub use llm_worker::UsageRecord;
pub use llm_worker::llm_client::types::{ContentPart, Item, Role};

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,11 +5,13 @@
//! 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;
use llm_worker::llm_client::RequestConfig;
use llm_worker::llm_client::types::Item;
use protocol::Segment;
/// State snapshot for creating a SessionStart entry.
pub struct SessionStartState<'a> {
@ -44,7 +46,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 +75,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 +123,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,
};
@ -137,10 +139,37 @@ pub async fn ensure_head_or_fork(
Ok(())
}
/// Log a `UserInput` entry from the original typed `Vec<Segment>`.
///
/// Submit-time entry. Pod calls this at the head of a `Run` turn before
/// the worker pushes its flattened user message into history; replay
/// derives the worker `Item::user_message` from these segments via
/// [`Segment::flatten_to_text`].
pub async fn save_user_input(
store: &impl Store,
session_id: SessionId,
head_hash: &mut Option<EntryHash>,
segments: Vec<Segment>,
) -> Result<(), StoreError> {
append_entry(
store,
session_id,
head_hash,
LogEntry::UserInput {
ts: session_log::now_millis(),
segments,
},
)
.await
}
/// Log the history delta — new items added since the previous snapshot.
///
/// Classifies items into UserInput, AssistantItems, ToolResults, and
/// HookInjectedItems entries automatically.
/// Classifies items into AssistantItems, ToolResults, and HookInjectedItems
/// entries automatically. User messages are skipped because they are
/// persisted upfront via [`save_user_input`] at submit time; the worker
/// pushes a flattened copy into its history that arrives here in
/// `new_items` and would otherwise produce a duplicate `UserInput` entry.
pub async fn save_delta(
store: &impl Store,
session_id: SessionId,
@ -157,16 +186,7 @@ pub async fn save_delta(
while i < new_items.len() {
let item = &new_items[i];
if item.is_user_message() {
append_entry(
store,
session_id,
head_hash,
LogEntry::UserInput {
ts,
item: new_items[i].clone(),
},
)
.await?;
// Already persisted by save_user_input at submit time.
i += 1;
} else if item.is_tool_result() {
let start = i;
@ -179,7 +199,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 +218,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 +229,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 +389,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 +422,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

@ -10,9 +10,12 @@
use llm_worker::llm_client::types::{Item, RequestConfig};
use llm_worker::{UsageRecord, WorkerResult};
use protocol::Segment;
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 +103,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>,
@ -109,17 +112,21 @@ pub enum LogEntry {
compacted_from: Option<SessionOrigin>,
},
/// User input pushed to history (worker.rs:229).
UserInput { ts: u64, item: Item },
/// User input accepted at submit time. Carries the original typed
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
/// file/knowledge refs, workflow invocations) on session restore.
/// Replay flattens these into a `Item::user_message` for the worker
/// history; the worker layer never sees segments directly.
UserInput { ts: u64, segments: Vec<Segment> },
/// 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 },
@ -207,6 +214,13 @@ pub struct RestoredState {
/// `LogEntry::Extension` を replay 順に積んだもの。`(domain, payload)`。
/// session-store は domain を不透明扱いし、各ドメインが自前で fold する。
pub extensions: Vec<(String, serde_json::Value)>,
/// User submissions in original typed form, in submit order.
/// One entry per `LogEntry::UserInput`; the K-th entry corresponds to
/// the K-th `Item::user_message` derived during replay (modulo
/// pre-compaction history seeded via `SessionStart.history`, whose
/// original segments are not preserved). Used by clients to re-render
/// typed atoms (paste chips, refs) on session restore.
pub user_segments: Vec<Vec<Segment>>,
}
/// Replay a sequence of hashed entries to reconstruct worker state.
@ -220,6 +234,7 @@ pub fn collect_state(entries: &[HashedEntry]) -> RestoredState {
head_hash: None,
usage_history: Vec::new(),
extensions: Vec::new(),
user_segments: Vec::new(),
};
for hashed in entries {
@ -234,19 +249,27 @@ 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::UserInput { segments, .. } => {
let text = Segment::flatten_to_text(segments);
state.history.push(Item::user_message(text));
state.user_segments.push(segments.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 +356,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,
}]);
@ -357,11 +380,11 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
item: Item::user_message("Hello"),
segments: vec![Segment::text("Hello")],
},
LogEntry::AssistantItems {
ts: 3000,
items: vec![Item::assistant_message("Hi!")],
items: vec![Item::assistant_message("Hi!").into()],
},
LogEntry::TurnEnd {
ts: 3100,
@ -392,23 +415,21 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
item: Item::user_message("Check weather"),
segments: vec![Segment::text("Check weather")],
},
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,
@ -454,7 +475,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
item: Item::user_message("Hello"),
segments: vec![Segment::text("Hello")],
},
];
let chain_a = build_chain(&raw);
@ -467,11 +488,11 @@ mod tests {
fn different_content_produces_different_hash() {
let entry_a = LogEntry::UserInput {
ts: 1000,
item: Item::user_message("Hello"),
segments: vec![Segment::text("Hello")],
};
let entry_b = LogEntry::UserInput {
ts: 1000,
item: Item::user_message("World"),
segments: vec![Segment::text("World")],
};
let hash_a = compute_hash(None, &entry_a);
let hash_b = compute_hash(None, &entry_b);
@ -491,7 +512,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
item: Item::user_message("hi"),
segments: vec![Segment::text("hi")],
},
LogEntry::LlmUsage {
ts: 2100,
@ -503,7 +524,7 @@ mod tests {
},
LogEntry::AssistantItems {
ts: 2200,
items: vec![Item::assistant_message("yo")],
items: vec![Item::assistant_message("yo").into()],
},
LogEntry::LlmUsage {
ts: 3100,
@ -539,7 +560,7 @@ mod tests {
},
LogEntry::UserInput {
ts: 2000,
item: Item::user_message("hi"),
segments: vec![Segment::text("hi")],
},
]);
let state = collect_state(&entries);
@ -652,4 +673,80 @@ mod tests {
let parsed = EntryHash::from_hex(&hex).unwrap();
assert_eq!(hash, parsed);
}
/// Mixed segments survive a JSON round-trip through `LogEntry::UserInput`,
/// and `collect_state` derives `Item::user_message` from the flattened
/// text while preserving the original segments separately. This covers
/// the segments → flatten → Item replay path from the ticket.
#[test]
fn replay_user_input_segments_round_trip() {
let segments = vec![
Segment::Text {
content: "see ".into(),
},
Segment::Paste {
id: 1,
chars: 12,
lines: 2,
content: "line1\nline2".into(),
},
Segment::FileRef {
path: "src/main.rs".into(),
},
];
let entry = LogEntry::UserInput {
ts: 4242,
segments: segments.clone(),
};
// Hash + JSON round-trip preserves the variant byte-for-byte.
let json = serde_json::to_string(&entry).unwrap();
let parsed: LogEntry = serde_json::from_str(&json).unwrap();
let entries = build_chain(&[
LogEntry::SessionStart {
ts: 1,
system_prompt: None,
config: RequestConfig::default(),
history: vec![],
forked_from: None,
compacted_from: None,
},
parsed,
]);
let state = collect_state(&entries);
// Worker history gets a flattened user_message item.
assert_eq!(state.history.len(), 1);
match &state.history[0] {
Item::Message { role, content, .. } => {
assert!(matches!(role, llm_worker::Role::User));
assert_eq!(content.len(), 1);
match &content[0] {
llm_worker::ContentPart::Text { text } => {
assert_eq!(
text,
"see line1\nline2[unresolved file ref: src/main.rs]"
);
}
other => panic!("unexpected content: {other:?}"),
}
}
other => panic!("unexpected variant: {other:?}"),
}
// Segments survive verbatim for client-side restore.
assert_eq!(state.user_segments.len(), 1);
assert_eq!(state.user_segments[0].len(), 3);
match &state.user_segments[0][1] {
Segment::Paste {
id,
chars,
lines,
content,
} => {
assert_eq!(*id, 1);
assert_eq!(*chars, 12);
assert_eq!(*lines, 2);
assert_eq!(content, "line1\nline2");
}
other => panic!("expected Paste, got {other:?}"),
}
}
}

View File

@ -21,11 +21,11 @@ async fn round_trip_write_and_read() {
},
LogEntry::UserInput {
ts: 2000,
item: Item::user_message("Hello"),
segments: vec![protocol::Segment::text("Hello")],
},
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,
}]);
@ -207,7 +210,7 @@ async fn read_head_hash_returns_last_entry_hash() {
},
LogEntry::UserInput {
ts: 2000,
item: Item::user_message("Hello"),
segments: vec![protocol::Segment::text("Hello")],
},
]);

View File

@ -99,6 +99,18 @@ async fn run_and_persist(
head_hash: &mut Option<EntryHash>,
input: &str,
) -> (Worker<MockLlmClient>, llm_worker::WorkerResult) {
// Mirror Pod's run-entry contract: log the user input as segments
// before the worker pushes its flattened user_message; save_delta
// skips the resulting user_message item to avoid double-write.
session_store::save_user_input(
store,
session_id,
head_hash,
vec![protocol::Segment::text(input)],
)
.await
.unwrap();
let history_before = worker.history().len();
let mut locked = worker.lock();
@ -458,7 +470,7 @@ async fn session_auto_forks_on_conflict() {
// Simulate another Pod writing to the same session behind our back
let extra_entry = LogEntry::UserInput {
ts: 9999,
item: Item::user_message("Interloper"),
segments: vec![protocol::Segment::text("Interloper")],
};
let current_head = store.read_head_hash(original_sid).await.unwrap();
let hash = session_store::compute_hash(current_head.as_ref(), &extra_entry);
@ -492,12 +504,8 @@ async fn session_auto_forks_on_conflict() {
// Original session should still have the interloper entry
let original_entries = store.read_all(original_sid).await.unwrap();
let has_interloper = original_entries.iter().any(|e| {
if let LogEntry::UserInput { item, .. } = &e.entry {
item.is_user_message()
} else {
false
}
});
let has_interloper = original_entries
.iter()
.any(|e| matches!(&e.entry, LogEntry::UserInput { .. }));
assert!(has_interloper);
}

View File

@ -452,10 +452,24 @@ impl App {
self.blocks.push(Block::TurnHeader {
turn: self.turn_index,
});
if !text.is_empty() {
self.blocks.push(Block::UserMessage {
segments: vec![Segment::text(text)],
// Pod attaches the original `Vec<Segment>` to
// user messages from live submissions, so we
// can rebuild typed atoms (paste chips, refs)
// here. Seed history loaded post-compaction
// has no `segments` field — fall back to a
// single text segment.
let segments = item
.get("segments")
.and_then(|v| serde_json::from_value::<Vec<Segment>>(v.clone()).ok())
.unwrap_or_else(|| {
if text.is_empty() {
Vec::new()
} else {
vec![Segment::text(text.clone())]
}
});
if !segments.is_empty() {
self.blocks.push(Block::UserMessage { segments });
}
}
"assistant" if !text.is_empty() => {

View File

@ -71,7 +71,9 @@ fn char_class(c: char) -> AtomClass {
let cp = c as u32;
match cp {
0x3040..=0x309F => AtomClass::Word(WordKind::Hiragana),
0x30A0..=0x30FF | 0x31F0..=0x31FF => AtomClass::Word(WordKind::Katakana),
0x30A0..=0x30FF | 0x31F0..=0x31FF | 0xFF65..=0xFF9F => {
AtomClass::Word(WordKind::Katakana)
}
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FFFF => {
AtomClass::Word(WordKind::Han)
}
@ -719,6 +721,23 @@ mod word_motion_tests {
assert_eq!(cursor(&buf), 0); // start of "日本語"
}
#[test]
fn halfwidth_katakana_is_treated_as_katakana() {
// 半角カナ「アイウエオ」は5 atom、すべて Katakana 種別。
let mut buf = buf_from("アイウエオfoo");
buf.cursor = 0;
buf.move_word_right();
assert_eq!(cursor(&buf), 5); // after "アイウエオ"
buf.move_word_right();
assert_eq!(cursor(&buf), 8); // after "foo"
// 全角と半角のカタカナは同じ Katakana 種別なので1単語につながる。
let mut buf2 = buf_from("カタカナ");
buf2.cursor = 0;
buf2.move_word_right();
assert_eq!(cursor(&buf2), 4);
}
#[test]
fn katakana_separates_from_ascii() {
let mut buf = buf_from("カタカナsecret");

View File

@ -21,7 +21,7 @@ use ratatui::widgets::Paragraph;
use ratatui::{Frame, TerminalOptions, Viewport};
use pod_registry::lookup_session;
use session_store::{
ContentPart, FsStore, HashedEntry, Item, LogEntry, SessionId, Store,
FsStore, HashedEntry, LogEntry, LoggedContentPart, LoggedItem, SessionId, Store,
};
const MAX_ROWS: usize = 10;
@ -175,13 +175,14 @@ async fn build_preview(store: &FsStore, id: SessionId) -> String {
fn last_message_preview(entries: &[HashedEntry]) -> Option<String> {
for hashed in entries.iter().rev() {
match &hashed.entry {
LogEntry::UserInput { item, .. } => {
if let Some(text) = first_text(item) {
LogEntry::UserInput { segments, .. } => {
let text = protocol::Segment::flatten_to_text(segments);
if !text.is_empty() {
return Some(format!("user: {}", trim_one_line(&text, 60)));
}
}
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)));
}
}
@ -191,10 +192,10 @@ fn last_message_preview(entries: &[HashedEntry]) -> Option<String> {
None
}
fn first_text(item: &Item) -> Option<String> {
fn first_text_logged(item: &LoggedItem) -> Option<String> {
match item {
Item::Message { content, .. } => content.iter().find_map(|p| match p {
ContentPart::Text { text } => Some(text.clone()),
LoggedItem::Message { content, .. } => content.iter().find_map(|p| match p {
LoggedContentPart::Text { text } => Some(text.clone()),
_ => None,
}),
_ => None,

View File

@ -0,0 +1,88 @@
# Session log の Item 依存を切り離す
## 背景
`crates/session-store``LogEntry` は、history 構成パートをすべて llm-worker の `Item` enum でそのままシリアライズしている:
- `UserInput.item: Item`
- `AssistantItems.items: Vec<Item>`
- `ToolResults.items: Vec<Item>`
- `HookInjectedItems.items: Vec<Item>`
- `SessionStart.history: Vec<Item>`
これは worker 内部型と永続フォーマットを結合しているため、`Item` / `ContentPart` / `Reasoning` のフィールド追加・名称変更が即ログ非互換になる。永続データは llm-worker の進化と独立に安定したスキーマを持つべき。
`tickets/session-log-segments.md`user message を `Vec<Segment>` で残す)の前提として、まず session-store が自分のスキーマを持つように剥がす。
後方互換は持たない(既存 jsonl は捨てる)。新スキーマで一新する。
## 方針
### session-store に独自の logged 型を置く
llm-worker の `Item` をそのまま流用せず、session-store 内に永続用の型を切り出す:
```rust
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum LoggedItem {
Message { role: LoggedRole, content: Vec<LoggedContentPart>, .. },
ToolCall { call_id, name, arguments, .. },
ToolResult { call_id, summary, content, .. },
Reasoning { text, summary, encrypted_content, .. },
}
```
具体形は実装で決める。要件:
- replay に必要な field のみ持つ(`ItemId` や `ItemStatus` のように LLM 送信 / round-trip に効かないものは原則持たない。ただし ZDR 用 `encrypted_content` のような stateless 再送に必要なものは保持する)
- worker `Item` のフィールド追加でログ非互換を起こさない
- snake_case JSON、既存 LogEntry の他フィールドと整合
### 変換器
- `From<&Item> for LoggedItem`worker → logged、save 経路)
- `LoggedItem::into_item()`logged → worker、replay 経路)
- session-store crate 内の責務。worker / pod は変換を意識しない
worker → logged の変換で落ちる field が出るのは構わない永続化に不要なら捨てるが、replay → 再 save で wire-equivalent な Item が再生される構造にする。
### LogEntry の差し替え
- `AssistantItems.items` / `ToolResults.items` / `HookInjectedItems.items` / `SessionStart.history``Vec<LoggedItem>` に置換
- `collect_state` は logged → Item 変換を通して `RestoredState.history` を組む
- `save_delta` は Item → logged 変換を通して書き込む
`UserInput.item` は触らない。直後の `session-log-segments` で segments に置き換わるため、ここで logged 化しても 1 ステップで再変更になる。
### ログの hash chain
`compute_hash``LogEntry` を JSON シリアライズして SHA-256 を取る。スキーマが変わるのでハッシュ値は別物になる。新規セッションから新スキーマで書き始める前提(既存ログを読まないので問題ない)。
## 範囲外
- 後方互換(既存 jsonl ログの読み込み)
- `UserInput.item` の差し替え(`session-log-segments` で対応)
- ログフォーマットのバージョニング機構 — 必要になったら追加
- llm-worker の `Item` 構造変更
- compaction / fork / restore 経路自体の再設計
## 完了条件
- session-store crate が `LoggedItem` 系の独自型を export し、`Item` への依存が UserInput を除いて消える
- `collect_state` で組まれた `RestoredState.history` が従来と同じ Item 列を返す(既存の worker / pod テストが通る)
- `save_delta` の外側 API は変えず、内部で Item → LoggedItem 変換を通す
- session-store の単体テストを新スキーマに合わせて書き換え、すべて合格
- round-trip テストを 1 本追加: `Item → LoggedItem → JSON → LoggedItem → Item` で意味的に等価
- 既存のビルド・全テストが新スキーマで合格
## 参照
- 後続: `tickets/session-log-segments.md`
- 影響範囲: `crates/session-store/src/session_log.rs`, `crates/session-store/src/session.rs`, `crates/session-store/tests/*`
- 不変: `crates/llm-worker/src/llm_client/types.rs``Item` / `ContentPart` 等)、`crates/pod``save_delta` の呼び出し側)
## Review
- 状態: Approve
- レビュー詳細: [./session-log-decouple-item.review.md](./session-log-decouple-item.review.md)
- 日付: 2026-04-29

View File

@ -0,0 +1,38 @@
# Review: Session log の Item 依存を切り離す
## 前提・要件の確認
- **session-store crate が `LoggedItem` 系の独自型を export し、`Item` への依存が UserInput を除いて消える**
満たされている。`crates/session-store/src/logged_item.rs` に `LoggedItem` / `LoggedRole` / `LoggedContentPart` を新設し、`crates/session-store/src/lib.rs:31-43` で公開。`LogEntry` の `AssistantItems` / `ToolResults` / `HookInjectedItems` / `SessionStart.history``Vec<LoggedItem>` に置換済み(`session_log.rs:106,123,126,129`)。`UserInput` のみ後続チケットの責務として `Item` 参照が残るが、本チケットでは触らない方針通り。
- **`collect_state` で組まれた `RestoredState.history` が従来と同じ Item 列を返す**
満たされている。`session_log.rs:252,262,267,272` で `Item::from(LoggedItem)` を介して再構築。既存の worker / pod テスト(`cargo test --workspace` 全合格)と置換テスト群で確認済み。
- **`save_delta` の外側 API は変えず、内部で Item → LoggedItem 変換を通す**
満たされている。`session.rs:178` のシグネチャ `&[Item]` は不変、`to_logged()` 経由で永続化(`session.rs:202,221,232`)。
- **session-store の単体テストを新スキーマに合わせて書き換え**
満たされている。`fs_store_test.rs` / `session_test.rs``.into()` 経由に更新。
- **Round-trip テスト 1 本追加**
超過達成。`logged_item.rs` 内に 5 本の round-trip テストuser_message / tool_call / reasoning+ZDR / tool_result_with_content / kind タグ確認)を追加。
- **既存ビルド・全テストが新スキーマで合格**
満たされている。`cargo test --workspace` 全合格。
## アーキテクチャ・スコープ
- 永続スキーマと worker 内部型の分離を logged_item モジュールで完結させている。From/Into 変換は session-store の責務として閉じており、pod / worker から見れば save_delta の API は不変。レイヤ違反なし。
- LLM ZDR の制約Reasoning::encrypted_content の保持)が頭注(`logged_item.rs:11-13`)に明記され、テスト(`round_trip_reasoning_preserves_encrypted_content`でも担保されている。「replay に必要な field のみ持つ」という方針判断の妥当性を担保する記述として適切。
- `id: ItemId` / `status: ItemStatus` を意図的に落とし、replay 時に `None` で synthesize する方針はチケット記載と一致。output-side metadata と replay-side metadata の分離が綺麗に効いている。
- `LogEntry::Extension` などの既存ログ拡張点に手を入れていない点も適切(スコープ厳守)。
- session-store の `lib.rs``llm_worker::{Item, ContentPart, Role}` を再 export しているが、これは `RestoredState.history: Vec<Item>` を消費する側のために残している既存挙動の維持で、ticket の趣旨(`Item` 依存を「ログスキーマから」剥がす)には反しない。
## 指摘事項
### Non-blocking / Follow-up
- `lib.rs:45``pub use llm_worker::llm_client::types::{ContentPart, Item, Role};` は外部利用箇所が見当たらない(`grep` 結果。dead 再エクスポートは将来のクリーンアップ余地としてメモ。本チケットの範疇では削除不要。
### Nits
- `from_logged``pub` で公開されているが、現状 `collect_state` 経由の `iter().cloned().map(Into::into)` パターンしか使われていない。slice 版が必要になるまで public API として残す価値があるかは要観察。
## 判断
**Approve** — チケットの完了条件はすべて満たされ、round-trip テストも要件以上に厚く配置されている。スキーマ分離の方針通り Item 依存は UserInput を除いて消えた。後続 `session-log-segments` の前提が綺麗に整っている。

View File

@ -8,13 +8,26 @@
LLM 側の入力は flatten 済み文字列のままで良いworker / llm-client 層は変更しない)。永続化と client 復元経路にだけ segment を残す。
## 前提チケット
- `tickets/session-log-decouple-item.md` — session-store が llm-worker `Item` から独立した永続スキーマを持つようにする。本チケットはその上で `UserInput``Item` から `Vec<Segment>` に置き換える。
## 要件
- セッションログに user message の元 `Vec<Segment>` を残す。worker の `Item` 表現や LLM 送信 payload は変更しない(`flatten_segments` の結果を引き続き食わせる)。
- セッションログに user message を `Vec<Segment>` として残す。worker の `Item` 表現や LLM 送信 payload は変更しない(`flatten_segments` の結果を引き続き食わせる)。
- `LogEntry::UserInput` から `item: Item` を取り除き、`segments: Vec<Segment>` のみ持つ形に置き換える。replay 時には `flatten_segments` で 1 文字列にして `Item::user_message(text)` を派生させ worker history に積む。
- 復元経路で client が segments を取り戻せるようにする。最低限、TUI の `restore_history` が paste segment を典型の magenta `[Clipboard #N | ...]` チップとして再構築できること。
- forward compat: 旧フォーマットsegments を持たないログ行)を読んでも panic / parse error にならず、従来通り text 1 個の segment として復元できること
- 後方互換は持たない。既存 jsonl ログは捨てる前提
- 現行の compaction / fork / restore のフローを壊さない。
### Pod と save_delta の責務分割
`save_delta` は worker history の差分を分類して `LogEntry::UserInput` を書いていたが、segments は worker history に存在しない。Pod 側で `Method::Run` 入口直後(`flatten_segments` 直前)に segments で `LogEntry::UserInput` を直接書き、`save_delta` からは user_message 分類を外すassistant / tool_result / hook_injected のみ扱う)。
### Event::History への segments 載せ方
Pod が吐く `Event::History.items: Vec<serde_json::Value>` の user message オブジェクトに `segments` フィールドを embed する(既存の混合 JSON 表現を活かす。TUI 側 `restore_history` は user 分岐で `segments` を読み出して `Block::UserMessage { segments }` を組む。
## 範囲外
- paste prunesegment メタデータを使って context から落とす話)。別チケットで扱う。
@ -23,15 +36,23 @@ LLM 側の入力は flatten 済み文字列のままで良いworker / llm-cli
## 完了条件
- セッションログに segment 情報が persist される(新規セッションから書かれる行は segments を含む)。
- セッションログに segment 情報が persist される(`LogEntry::UserInput` が `{ ts, segments }`)。
- TUI で paste を含むメッセージを送信 → セッションを開き直す → magenta チップが復元される。
- segments を持たない旧ログ行も正常に restore でき、テキスト 1 segment として表示される
- 既存ビルド・テストを壊さない。新フォーマットに対する round-trip テスト 1 本は追加する。
- 既存ビルド・テストが新スキーマで合格
- segments → flatten → Item の派生経路を round-trip テストで verify する。
## 参照
- `tickets/submit-segment-protocol.md`(前提)
- 前提: `tickets/session-log-decouple-item.md`
- `crates/session-store/src/session_log.rs``LogEntry::UserInput`
- `crates/session-store/src/session.rs``restore`, `RestoredState`
- `crates/session-store/src/session.rs``save_delta`, `restore`, `RestoredState`
- `crates/pod/src/pod.rs``run`, `flatten_segments`, `persist_turn`
- `crates/pod/src/controller.rs``Event::UserMessage` broadcast 経路)
- `crates/tui/src/app.rs``restore_history` — 現状 segment を捨てている地点)
- `crates/protocol/src/lib.rs``Segment`
- `crates/protocol/src/lib.rs``Segment`, `Event::History`
## Review
- 状態: Request changes
- レビュー詳細: [./session-log-segments.review.md](./session-log-segments.review.md)
- 日付: 2026-04-29

View File

@ -0,0 +1,54 @@
# Review: セッションログの Segment 保持
## 前提・要件の確認
- **`LogEntry::UserInput` から `item: Item` を取り除き `segments: Vec<Segment>` のみ持つ**
満たされている。`session_log.rs:120` で置換済み。`collect_state` は `Segment::flatten_to_text` 経由で `Item::user_message(text)` を派生(`session_log.rs:254-258`)。
- **セッションログに Segment を persist し、`Pod` の入口で submit-time に直書き、`save_delta` は user_message を skip**
満たされている。`session.rs:148-164` に `save_user_input` 関数追加。`session.rs:188-190` で `is_user_message()` を skip。`pod.rs:608-615` で `Pod::run` の入口flatten 直前)に `save_user_input` を埋め込み、in-memory `user_segments` に push。
- **復元経路で client が segments を取り戻すTUI で paste チップが復元)**
実装済み。`ipc/server.rs:91-127` で `Method::GetHistory` 応答時に worker history JSON の user_message に `segments` フィールドを embed、`tui/src/app.rs:461-473` の `restore_history``segments` を読み出して `Block::UserMessage` を組み立てる。手動 UI 確認は未消化report 申告通り)。
- **worker / llm-client 層は変更しない**
守られている。`Segment::flatten_to_text` を `protocol` に追加した以外、worker / llm-client API に変更なし。
- **後方互換は持たない**
既存 jsonl の読み込みフォールバックは入れていない(適切)。
- **Round-trip テスト**
`replay_user_input_segments_round_trip` 追加(`session_log.rs:682-751`。Text/Paste/FileRef を含む混合 segments の JSON 往復+`flatten_to_text` 派生+`user_segments` 保持を一気に検証。テスト粒度は要件十分。
- **既存ビルド・テストが新スキーマで合格**
`cargo test --workspace` 全合格を確認。
## アーキテクチャ・スコープ
- `Segment::flatten_to_text``protocol` 側に純粋関数として抜き、Pod 側の `flatten_segments` を「アラート発火 + flatten_to_text 呼び出し」へ整理した分離(`pod.rs:636-682`は正しい。replay 経路がアラートを再発火させない設計が両立している。
- session-store が `protocol::Segment` に依存する構造は妥当。`Cargo.toml` には `cargo add` で追加されておりコミットの差分通り、依存方向session-store → protocolも既存の依存グラフ的に問題ない。
- `RestoredState.user_segments` を別フィールドとして並走させ、replay 経路では Item と Segment を二重管理する設計は、worker history の責務LLM への渡し物)と client 復元の責務typed atom 描画)の分離として理にかなっている。
- IPC server で `is_user_message()` 数と `user_segments` 数の差分から「seed history は最初に詰まれる」という性質を使った右寄せ整列(`ipc/server.rs:101-126`は、ticket 記載の前提("seed history は segments を持たない、live submission のみ持つ")の素直な実装。
## 指摘事項
### Blocking
- **`Pod::compact()` 後に `self.user_segments` がクリアされない**`pod.rs:1165-1224`
`compact()` は worker history を `[summary, ...retained_items]` に置換するが、`self.user_segments` は触られていない。プロセス継続中に compaction が走った場合、次の `Method::GetHistory``ipc/server.rs:103``total_user_msgs.saturating_sub(segments_per_user.len())` が想定外の値になり、segments と worker user_message が錯誤マッチする。
例: 5 user_msg を投げた後 compaction で 1 だけ retained のとき、`total=1, segs=5 → skip=0` となり、retained の user_msg に 1 番目の古い segments が貼られる。
対策案: `compact()` 内で `retained_items` 中の `is_user_message()` 件数 K を数え、`self.user_segments = self.user_segments.split_off(self.user_segments.len() - K)` 相当に切り詰めるK=0 なら clear。controller 側 `shared_state` も同期するため、compact 後に `shared_state.set_user_segments(pod.user_segments().to_vec())` を呼び直す経路が要る。
なお post-compaction 後にプロセスを再起動して restore する場合は、新セッションの jsonl に UserInput が無いので `state.user_segments` は空になり、自動的に整列が直る。問題は**プロセス継続中の compaction → 次の GetHistory** の窓に限定されるが、TUI の通常運用フローで踏みうる。
### Non-blocking / Follow-up
- **`save_user_input` 失敗時の shared_state ghost segment**`controller.rs:331` と `pod.rs:608-615` の順序)
Controller は `Method::Run` 受信直後に `shared_state.push_user_segments(input.clone())` してから run_future を await する。`save_user_input` が I/O エラーで失敗すると、`pod.user_segments` には push されないが `shared_state.user_segments` には残り、worker.history にも user_msg は積まれない(`save_user_input` の失敗で `pod.run` が早期 return するため)。次回 GetHistory で 1 段ずれる可能性。実害は store I/O 障害のレアケースに限定されるが、`run_future` 完了後に `shared_state.set_user_segments(pod.user_segments().to_vec())` で同期し直すのが安全。
- **seed history の segments 喪失**`pod.rs:294-295`, `session_log.rs:217-223` のコメント参照)
ticket の範囲外として明示済み・コメントでも記述されている設計判断。compaction を境に paste チップの典型 magenta 復元が単純テキストにフォールバックする挙動はユーザー視点で受容可能だが、UX として把握しておく価値あり。後続で必要なら `SessionStart.history` 側にも segments を持たせる拡張余地あり。
- **chain hash の確定性**
`Segment` の serde 実装は HashMap 不使用・field 順序固定で、`session_log.rs:466-499` の `hash_chain_is_deterministic` / `different_content_produces_different_hash` で確認済み。スキーマ変更で hash 値は別物になるが、新スキーマ内での安定性は壊れていない(要件通り)。
- 手動 UI 確認paste chip の magenta 復元の自動テスト化は未消化。session-store + flatten 経路は単体テストでカバーされているが、TUI の `restore_history` 経路は serde_json::Value からの復元アサーションが望ましい follow-up。
### Nits
- `pod.rs:615``self.user_segments.push(input.clone())` は直前で `save_user_input``input.clone()` を渡しているため `input` を 2 回 clone している。`Vec<Segment>` の clone はそこそこ重い(特に Paste の contentので、`save_user_input(... segments)` の引数を所有 `Vec<Segment>` のまま受けて push 後に消費するか、`save_user_input` を `&[Segment]` に変えてから push 一回にする選択肢。マイクロ最適化なので必須ではない。
- `ipc/server.rs:105-114` のコメント「seed user_messages always come first」は、`ensure_head_or_fork` の auto-fork 経路で発生しうる "seed + 一部 segments のあとさらに live segments" のケースまで明示してくれると将来の保守者に親切(実際には auto-fork 後も pod.user_segments は live 分のみ累積する形で右寄せが成立しているが、コメントを読むと一瞬不安になる)。
## 判断
**Request changes** — `Pod::compact()` 後に `self.user_segments` を切り詰めない件Blockingが、ticket の完了条件「現行の compaction / fork / restore のフローを壊さない」に直接抵触する。修正は数行shared_state 再同期で済む規模。それ以外の要件は満たされており、修正後は Approve 想定。

View File

@ -1,37 +0,0 @@
# TUI: 入力欄の単語単位カーソル移動・削除
## 背景
TUI の入力欄では現在、`Left/Right` で1文字単位の移動、`Home/End` で行端への移動ができるが、単語単位で飛ぶ手段がない。`Backspace` も1文字ずつしか消せない。長めの行を編集するときに左右キーや Backspace を押し続けることになりテンポが悪い。
シェルやエディタで広く使われている `Ctrl+Left` / `Ctrl+Right` での単語単位移動と、`Ctrl+Backspace` での単語単位削除を提供したい。
## 要件
- `Ctrl+Left` で1単語ぶん後ろにカーソルが飛ぶ。
- `Ctrl+Right` で1単語ぶん前にカーソルが飛ぶ。
- 「単語」の境界は文字種ベースで判定する:
- **ASCII**: 英数字とアンダースコア (`_`)
- **ひらがな** (U+3040..U+309F)
- **カタカナ** (U+30A0..U+30FF, U+31F0..U+31FF)
- **漢字** (CJK Unified Ideographs: U+3400..U+4DBF, U+4E00..U+9FFF, U+F900..U+FAFF, U+20000..U+2FFFF)
- **その他の単語文字**: 上記に該当せず `char::is_alphanumeric()` が trueアクセント付きラテン、キリル、ハングル等をひとまとめ
- 上記以外(空白・句読点・改行)は区切り。
- 同じ種別の連続は1単語、種別が切り替わる位置で境界となる。形態素解析は使わない送り仮名の途中で切れることは許容、VSCode/emacs と同等の挙動)。
- ペースト atom (`Atom::Paste`) は不可分な1単語として扱うカーソルが内部に入らない既存の不変条件を維持する
- 既存の `Ctrl+Home/End` (履歴のスクロール)や `Ctrl+[` / `Ctrl+]` (ターンジャンプ)と衝突しないこと。
- 既存の `Left/Right`1文字移動`Backspace`1文字削除の挙動は変えない。
- `Ctrl+Backspace` でカーソルから1単語ぶん手前を削除する。境界判定は `Ctrl+Left` と同じ(同じロジックを共有)。
## 完了条件
- `crates/tui``Ctrl+Left` / `Ctrl+Right` が単語単位移動として動作する。
- `Ctrl+Backspace` で単語単位削除が動作する。
- 単語境界の判定にユニットテストが付いている(空・連続スペース・`\n` をまたぐ・Paste をまたぐ・ひらがな/カタカナ/漢字/ASCII の混在)。
- 既存テストが通る。
## 範囲外
- `Ctrl+Delete` / `Alt+d` などによる単語単位の前方削除(別チケット候補)。
- `Alt+Left/Right` など他の単語移動キーバインドの追加。
- 形態素解析による日本語の単語分割(辞書サイズ・起動コストの観点で TUI には過剰)。送り仮名や複合語の途中で切れる挙動は許容する。