session-log-segments実装

This commit is contained in:
2026-04-29 22:42:10 +09:00
parent 8a9e3b4fe3
commit e3b36371e9
14 changed files with 310 additions and 65 deletions
+6 -1
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,11 @@ 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. Mirror the segments
// into shared_state so subsequent History fetches
// can re-attach them to the corresponding worker
// user_message item.
shared_state.push_user_segments(input.clone());
let _ = event_tx.send(Event::UserMessage {
segments: input.clone(),
});
+32 -1
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
+39 -12
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.
@@ -1539,6 +1563,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 +1616,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 +1724,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)
+27
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);
}
}