Compare commits
6 Commits
862c38d7f7
...
c70b0bdc5d
| Author | SHA1 | Date | |
|---|---|---|---|
| c70b0bdc5d | |||
| 010edf2c94 | |||
| 1ab6dbcee3 | |||
| bd8154204a | |||
| 7c2ef374e6 | |||
| 119a73c112 |
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -2922,6 +2922,7 @@ dependencies = [
|
|||
"futures",
|
||||
"hex",
|
||||
"llm-worker",
|
||||
"protocol",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
|
|
|
|||
2
TODO.md
2
TODO.md
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
309
crates/session-store/src/logged_item.rs
Normal file
309
crates/session-store/src/logged_item.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")],
|
||||
},
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
88
tickets/session-log-decouple-item.md
Normal file
88
tickets/session-log-decouple-item.md
Normal 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
|
||||
38
tickets/session-log-decouple-item.review.md
Normal file
38
tickets/session-log-decouple-item.review.md
Normal 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` の前提が綺麗に整っている。
|
||||
|
|
@ -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 prune(segment メタデータを使って 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
|
||||
|
|
|
|||
54
tickets/session-log-segments.review.md
Normal file
54
tickets/session-log-segments.review.md
Normal 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 想定。
|
||||
|
|
@ -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 には過剰)。送り仮名や複合語の途中で切れる挙動は許容する。
|
||||
Loading…
Reference in New Issue
Block a user