From 8870af800f952c8d8086af6cef21b415c7531eee Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 4 May 2026 12:04:09 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E3=82=B7=E3=82=B9=E3=83=86?= =?UTF-8?q?=E3=83=A0=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8=E3=82=92?= =?UTF-8?q?TUI=E3=81=A7=E8=A1=A8=E7=A4=BA=E3=81=95=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/llm-worker/src/worker.rs | 32 +- crates/pod/src/controller.rs | 19 ++ crates/pod/src/pod.rs | 47 ++- crates/pod/tests/compact_events_test.rs | 51 ++++ crates/protocol/src/lib.rs | 9 + crates/tui/src/app.rs | 317 ++++++++++++-------- crates/tui/src/block.rs | 6 + crates/tui/src/ui.rs | 79 ++++- tickets/tui-system-message-render.md | 5 + tickets/tui-system-message-render.review.md | 48 +++ 10 files changed, 475 insertions(+), 138 deletions(-) create mode 100644 tickets/tui-system-message-render.review.md diff --git a/crates/llm-worker/src/worker.rs b/crates/llm-worker/src/worker.rs index 6337742f..48154efc 100644 --- a/crates/llm-worker/src/worker.rs +++ b/crates/llm-worker/src/worker.rs @@ -168,6 +168,10 @@ pub struct Worker { /// truncation have been applied — i.e. on the same data that /// enters history. tool_result_cbs: Vec>, + /// History-append callbacks. Invoked for non-streamed items when they + /// are appended to persistent worker history, so upper layers can + /// broadcast those items using history itself as the source of truth. + history_append_cbs: Vec>, /// Request configuration (max_tokens, temperature, etc.) request_config: RequestConfig, /// Whether the previous run was interrupted @@ -346,6 +350,25 @@ impl Worker { } } + /// Register a callback invoked for items appended directly to worker + /// history outside streaming timeline callbacks. + pub fn on_history_append(&mut self, callback: impl Fn(&Item) + Send + Sync + 'static) { + self.history_append_cbs.push(Box::new(callback)); + } + + fn emit_history_append(&self, item: &Item) { + for cb in &self.history_append_cbs { + cb(item); + } + } + + fn extend_history_with_callbacks(&mut self, items: impl IntoIterator) { + for item in items { + self.emit_history_append(&item); + self.history.push(item); + } + } + /// Register a turn-end callback (receives 0-based turn number). pub fn on_turn_end(&mut self, callback: impl Fn(usize) + Send + Sync + 'static) { self.turn_end_cbs.push(Box::new(callback)); @@ -863,7 +886,7 @@ impl Worker { // get persisted by the upper layer that owns history.json. let pending = self.interceptor.pending_history_appends().await; if !pending.is_empty() { - self.history.extend(pending); + self.extend_history_with_callbacks(pending); } // Clone the history into a per-request context. Everything @@ -962,7 +985,7 @@ impl Worker { return Ok(WorkerResult::Finished); } TurnEndAction::ContinueWithMessages(additional) => { - self.history.extend(additional); + self.extend_history_with_callbacks(additional); continue; } TurnEndAction::Pause => { @@ -1118,6 +1141,7 @@ impl Worker { turn_end_cbs: Vec::new(), warning_cbs: Vec::new(), tool_result_cbs: Vec::new(), + history_append_cbs: Vec::new(), request_config: RequestConfig::default(), last_run_interrupted: false, cancel_tx, @@ -1375,6 +1399,7 @@ impl Worker { turn_end_cbs: self.turn_end_cbs, warning_cbs: self.warning_cbs, tool_result_cbs: self.tool_result_cbs, + history_append_cbs: self.history_append_cbs, request_config: self.request_config, last_run_interrupted: self.last_run_interrupted, @@ -1415,7 +1440,7 @@ impl Worker { }; self.history.push(user_item); if !extras.is_empty() { - self.history.extend(extras); + self.extend_history_with_callbacks(extras); } let result = self.run_turn_loop().await; self.finalize_interruption(result).await @@ -1456,6 +1481,7 @@ impl Worker { turn_end_cbs: self.turn_end_cbs, warning_cbs: self.warning_cbs, tool_result_cbs: self.tool_result_cbs, + history_append_cbs: self.history_append_cbs, request_config: self.request_config, last_run_interrupted: self.last_run_interrupted, diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 4ef0c4e0..8f55b1f9 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use llm_worker::WorkerError; use llm_worker::llm_client::client::LlmClient; +use llm_worker::llm_client::types::{Item, Role}; use session_store::Store; use tokio::sync::{broadcast, mpsc, oneshot}; @@ -19,6 +20,16 @@ use crate::spawn::registry::SpawnedPodRegistry; use crate::spawn::tool::spawn_pod_tool; use protocol::{AlertLevel, AlertSource, ErrorCode, Event, Method, RunResult, TurnResult}; +fn is_system_message_item(item: &Item) -> bool { + matches!( + item, + Item::Message { + role: Role::System, + .. + } + ) +} + // --------------------------------------------------------------------------- // PodHandle — client-facing, Clone-able // --------------------------------------------------------------------------- @@ -245,6 +256,14 @@ impl PodController { alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Worker, message.to_owned()); }); + let tx = event_tx.clone(); + worker.on_history_append(move |item| { + if is_system_message_item(item) { + let value = serde_json::to_value(item).expect("Item is Serialize"); + let _ = tx.send(Event::SystemMessage { item: value }); + } + }); + // Register the builtin file-manipulation tools (Read / Write / // Edit / Glob / Grep / Bash). `ScopedFs` carries the pod- // lifetime scope/pwd; `Tracker` is session-scoped — a fresh diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index f28fb0f8..65d9b295 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -6,7 +6,7 @@ use llm_worker::Item; use llm_worker::llm_client::RequestConfig; use llm_worker::llm_client::client::LlmClient; use llm_worker::state::Mutable; -use llm_worker::{ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult}; +use llm_worker::{Role, ToolOutputLimits, UsageRecord, Worker, WorkerError, WorkerResult}; use session_store::{EntryHash, PodScopeSnapshot, SessionId, SessionStartState, Store, StoreError}; use tracing::{info, warn}; @@ -557,6 +557,20 @@ impl Pod { } } + fn broadcast_system_message_item(&self, item: &Item) { + if !matches!( + item, + Item::Message { + role: Role::System, + .. + } + ) { + return; + } + let value = serde_json::to_value(item).expect("Item is Serialize"); + self.send_event(Event::SystemMessage { item: value }); + } + /// Push a `Method::Notify` (or rendered `Method::PodEvent`) entry /// onto the pending buffer. /// @@ -1466,19 +1480,29 @@ impl Pod { + reference_message.is_some() as usize + retained_items.len(), ); - new_history.push(Item::system_message(format!( - "[Compacted context summary]\n\n{summary_text}" - ))); + let mut compact_introduced_system_messages = + Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize); + let summary_message = + Item::system_message(format!("[Compacted context summary]\n\n{summary_text}")); + compact_introduced_system_messages.push(summary_message.clone()); + compact_introduced_system_messages.extend(auto_read_messages.iter().cloned()); + if let Some(msg) = reference_message.as_ref() { + compact_introduced_system_messages.push(msg.clone()); + } + let task_snapshot_message = Item::system_message(format!( + "[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\ + This is the complete session task list preserved across compaction. \ + The following TaskList tool result presents the same state through the tool lane." + )); + compact_introduced_system_messages.push(task_snapshot_message.clone()); + + new_history.push(summary_message); new_history.extend(auto_read_messages); if let Some(msg) = reference_message { new_history.push(msg); } new_history.extend(retained_items); - new_history.push(Item::system_message(format!( - "[Session TaskStore snapshot]\n\n{task_snapshot_text}\n\n\ - This is the complete session task list preserved across compaction. \ - The following TaskList tool result presents the same state through the tool lane." - ))); + new_history.push(task_snapshot_message); new_history.push(Item::tool_call("compact-tasklist", "TaskList", "{}")); new_history.push(Item::tool_result_with_content( "compact-tasklist", @@ -1531,8 +1555,11 @@ impl Pod { self.user_segments.drain(..drop_n); } + self.worker.as_mut().unwrap().set_history(new_history); + for item in &compact_introduced_system_messages { + self.broadcast_system_message_item(item); + } let worker = self.worker.as_mut().unwrap(); - worker.set_history(new_history); // Anchor the prompt cache at the summary item so that Anthropic // can place a durable `cache_control` breakpoint there — our // compact layout guarantees history[0] is the summary. diff --git a/crates/pod/tests/compact_events_test.rs b/crates/pod/tests/compact_events_test.rs index cee60beb..2b6184d3 100644 --- a/crates/pod/tests/compact_events_test.rs +++ b/crates/pod/tests/compact_events_test.rs @@ -14,6 +14,7 @@ use async_trait::async_trait; use futures::Stream; use llm_worker::Worker; use llm_worker::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; +use llm_worker::llm_client::types::Item; use llm_worker::llm_client::{ClientError, LlmClient, Request}; use protocol::Event; use session_store::FsStore; @@ -176,6 +177,56 @@ fn drain(rx: &mut broadcast::Receiver) -> Vec { out } +fn system_event_text(event: &Event) -> Option<&str> { + match event { + Event::SystemMessage { item } => item["content"] + .as_array() + .and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next()), + _ => None, + } +} + +#[tokio::test] +async fn compact_broadcasts_only_new_system_messages_not_retained_ones() { + let client = MockClient::new(vec![ + single_text_events("hi"), + write_summary_tool_use_events("call-1", "summary"), + single_text_events("done"), + ]); + let mut pod = make_pod(client).await; + + let (tx, mut rx) = broadcast::channel::(64); + pod.attach_event_tx(tx); + + pod.run_text("first").await.unwrap(); + let retained_message = Item::system_message("[Retained system]\nold"); + pod.worker_mut().push_item(retained_message); + let _ = drain(&mut rx); + + pod.compact(10_000).await.unwrap(); + + let events = drain(&mut rx); + let system_texts: Vec<&str> = events.iter().filter_map(system_event_text).collect(); + assert!( + system_texts + .iter() + .any(|text| text.starts_with("[Compacted context summary]")), + "summary system message missing from {system_texts:?}" + ); + assert!( + system_texts + .iter() + .any(|text| text.starts_with("[Session TaskStore snapshot]")), + "task snapshot system message missing from {system_texts:?}" + ); + assert!( + !system_texts + .iter() + .any(|text| text.starts_with("[Retained system]")), + "retained system message should not be rebroadcast: {system_texts:?}" + ); +} + #[tokio::test] async fn post_run_compact_success_broadcasts_start_and_done() { // Responses: (1) first run returns short text, (2) compact worker diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index c70a9ed3..38ed2f2f 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -223,6 +223,15 @@ pub enum Event { Notify { message: String, }, + /// Persisted `role:system` history item that should be rendered by + /// clients through the same path used for `Event::History` replay. + /// + /// The payload is the serialized history item, not an ad-hoc display + /// DTO, so live subscribers and late subscribers have the same source + /// of truth: worker history / history.json. + SystemMessage { + item: serde_json::Value, + }, /// Echo of `Method::PodEvent` received by this Pod. Same rationale /// as `Notify`: subscribers render the event as a log element, /// while a rendered summary is independently injected into the LLM diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index d7cb9f6b..55378935 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -300,6 +300,129 @@ impl App { }); } + fn push_history_item(&mut self, item: &serde_json::Value) { + let item_type = item["type"].as_str().unwrap_or(""); + match item_type { + "message" => { + let role = item["role"].as_str().unwrap_or(""); + let text = message_text(item); + match role { + "user" => { + self.turn_index += 1; + self.blocks.push(Block::TurnHeader { + turn: self.turn_index, + }); + // Pod attaches the original `Vec` 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::>(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() => { + self.blocks.push(Block::AssistantText { text }); + } + "system" if !text.is_empty() => { + self.blocks.push(Block::SystemMessage { text }); + } + _ => {} + } + } + "tool_call" => { + // `Item::ToolCall` serializes the linking key as + // `call_id`; `id` is a separate optional item-level + // identifier. Use `call_id` so this matches how + // Event::ToolCallStart populates the block. + let id = item["call_id"].as_str().unwrap_or("").to_owned(); + let name = item["name"].as_str().unwrap_or("?").to_owned(); + let arguments = item["arguments"].as_str().map(|s| s.to_owned()); + self.blocks.push(Block::ToolCall(ToolCallBlock { + id, + name, + args_stream: arguments.clone().unwrap_or_default(), + arguments, + state: ToolCallState::Executing, + edit_snapshot: None, + })); + } + "reasoning" => { + let text = item["text"].as_str().unwrap_or("").to_owned(); + let body = if text.is_empty() { + item["summary"] + .as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default() + } else { + text + }; + self.blocks.push(Block::Thinking(ThinkingBlock { + text: body, + state: ThinkingState::Finished { elapsed_secs: None }, + })); + } + "tool_result" => { + let id = item["call_id"].as_str().unwrap_or("").to_owned(); + let summary = item["summary"].as_str().unwrap_or("").to_owned(); + let output = item["content"].as_str().map(|s| s.to_owned()); + let is_error = item["is_error"].as_bool().unwrap_or(false); + let (name, args) = self + .find_tool_call_mut(&id) + .map(|b| (b.name.clone(), b.arguments.clone())) + .unwrap_or_default(); + let edit_snapshot = if !is_error && name == "Edit" { + args.as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|v| v["file_path"].as_str().map(|s| s.to_owned())) + .and_then(|path| self.cache.get(&path).map(|s| s.to_owned())) + } else { + None + }; + if let Some(tc) = self.find_tool_call_mut(&id) { + if edit_snapshot.is_some() { + tc.edit_snapshot = edit_snapshot; + } + tc.state = if is_error { + ToolCallState::Error { + summary, + output: output.clone(), + } + } else { + ToolCallState::Done { + summary, + output: output.clone(), + } + }; + if !is_error { + apply_cache_update( + &mut self.cache, + &name, + args.as_deref(), + output.as_deref(), + ); + } + } + } + _ => {} + } + } + pub fn handle_pod_event(&mut self, event: Event) { match event { Event::UserMessage { segments } => { @@ -318,6 +441,10 @@ impl App { self.blocks.push(Block::PodEvent { event }); self.assistant_streaming = false; } + Event::SystemMessage { item } => { + self.push_history_item(&item); + self.assistant_streaming = false; + } Event::TurnStart { .. } => { self.running = true; self.paused = false; @@ -658,130 +785,7 @@ impl App { self.assistant_streaming = false; for item in items { - let item_type = item["type"].as_str().unwrap_or(""); - match item_type { - "message" => { - let role = item["role"].as_str().unwrap_or(""); - let text = item["content"] - .as_array() - .and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next()) - .unwrap_or("") - .to_owned(); - match role { - "user" => { - self.turn_index += 1; - self.blocks.push(Block::TurnHeader { - turn: self.turn_index, - }); - // Pod attaches the original `Vec` 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::>(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() => { - self.blocks.push(Block::AssistantText { text }); - } - _ => {} - } - } - "tool_call" => { - // `Item::ToolCall` serializes the linking key as - // `call_id`; `id` is a separate optional item-level - // identifier. Use `call_id` so this matches how - // Event::ToolCallStart populates the block. - let id = item["call_id"].as_str().unwrap_or("").to_owned(); - let name = item["name"].as_str().unwrap_or("?").to_owned(); - let arguments = item["arguments"].as_str().map(|s| s.to_owned()); - self.blocks.push(Block::ToolCall(ToolCallBlock { - id, - name, - args_stream: arguments.clone().unwrap_or_default(), - arguments, - state: ToolCallState::Executing, - edit_snapshot: None, - })); - } - "reasoning" => { - let text = item["text"].as_str().unwrap_or("").to_owned(); - let body = if text.is_empty() { - item["summary"] - .as_array() - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str()) - .collect::>() - .join("\n") - }) - .unwrap_or_default() - } else { - text - }; - self.blocks.push(Block::Thinking(ThinkingBlock { - text: body, - state: ThinkingState::Finished { elapsed_secs: None }, - })); - } - "tool_result" => { - let id = item["call_id"].as_str().unwrap_or("").to_owned(); - let summary = item["summary"].as_str().unwrap_or("").to_owned(); - let output = item["content"].as_str().map(|s| s.to_owned()); - let is_error = item["is_error"].as_bool().unwrap_or(false); - let (name, args) = self - .find_tool_call_mut(&id) - .map(|b| (b.name.clone(), b.arguments.clone())) - .unwrap_or_default(); - let edit_snapshot = if !is_error && name == "Edit" { - args.as_deref() - .and_then(|s| serde_json::from_str::(s).ok()) - .and_then(|v| v["file_path"].as_str().map(|s| s.to_owned())) - .and_then(|path| self.cache.get(&path).map(|s| s.to_owned())) - } else { - None - }; - if let Some(tc) = self.find_tool_call_mut(&id) { - if edit_snapshot.is_some() { - tc.edit_snapshot = edit_snapshot; - } - tc.state = if is_error { - ToolCallState::Error { - summary, - output: output.clone(), - } - } else { - ToolCallState::Done { - summary, - output: output.clone(), - } - }; - if !is_error { - apply_cache_update( - &mut self.cache, - &name, - args.as_deref(), - output.as_deref(), - ); - } - } - } - _ => {} - } + self.push_history_item(item); } // Any tool_call entries that never got paired with a @@ -811,6 +815,19 @@ pub fn fmt_tokens(n: u64) -> String { } } +fn message_text(item: &serde_json::Value) -> String { + item["content"] + .as_array() + .map(|parts| { + parts + .iter() + .filter_map(|p| p["text"].as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default() +} + /// Strip the `cat -n` line-number gutter that the Read tool prepends to /// its output (one `"{n:>6}\t{content}"` per line) and return the raw /// file body. Lines that don't match the pattern are kept verbatim, so @@ -1173,6 +1190,58 @@ mod completion_flow_tests { }); assert!(app.completion.as_ref().unwrap().entries.is_empty()); } + + #[test] + fn history_restore_renders_system_message_block() { + let mut app = App::new("test".into()); + app.handle_pod_event(Event::History { + greeting: test_greeting(), + items: vec![serde_json::json!({ + "type": "message", + "role": "system", + "content": [{ + "type": "text", + "text": "[File: src/main.rs]\nfn main() {}", + }], + })], + }); + + assert!(matches!( + app.blocks.get(1), + Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}" + )); + } + + #[test] + fn live_system_message_event_uses_history_item_path() { + let mut app = App::new("test".into()); + app.handle_pod_event(Event::SystemMessage { + item: serde_json::json!({ + "type": "message", + "role": "system", + "content": [{ + "type": "text", + "text": "[Workflow /build]\nRun the build", + }], + }), + }); + + assert!(matches!( + app.blocks.as_slice(), + [Block::SystemMessage { text }] if text == "[Workflow /build]\nRun the build" + )); + } + + fn test_greeting() -> protocol::Greeting { + protocol::Greeting { + pod_name: "test".into(), + cwd: "/tmp".into(), + provider: "test-provider".into(), + model: "test-model".into(), + scope_summary: String::new(), + tools: Vec::new(), + } + } } /// Seed / mutate the file-content cache based on a completed tool call. diff --git a/crates/tui/src/block.rs b/crates/tui/src/block.rs index 1692c0a4..31789372 100644 --- a/crates/tui/src/block.rs +++ b/crates/tui/src/block.rs @@ -19,6 +19,12 @@ pub enum Block { UserMessage { segments: Vec, }, + /// Persisted `role:system` history item rendered as an ordinary log + /// element. File refs, auto-read snippets, workflow bodies, and future + /// system-message injections all share this lane. + SystemMessage { + text: String, + }, /// Echo of `Method::Notify` received by this Pod, surfaced as a log /// element so subscribers see the external input that drove any /// following auto-kicked turn. diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index e4497828..1e7c339c 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -361,6 +361,7 @@ fn render_block_into(lines: &mut Vec>, block: &Block, width: u16, ))); } Block::UserMessage { segments } => render_user_message(lines, segments, width, mode), + Block::SystemMessage { text } => render_system_message(lines, text, width, mode), Block::Notify { message } => { let text = format!("[notify] {message}"); match mode { @@ -481,6 +482,77 @@ fn render_user_message( } } +fn render_system_message(lines: &mut Vec>, text: &str, width: u16, mode: Mode) { + let header_style = kind_style(MessageKind::System); + let body_style = Style::default().fg(Color::DarkGray); + let (header, body) = split_system_message(text); + let overview_text = if body.is_empty() { + header.to_owned() + } else { + format!("{header} {body}") + }; + + match mode { + Mode::Overview => push_overview_line(lines, &overview_text, width, MessageKind::System, ""), + Mode::Detail => { + lines.push(Line::from(Span::styled(header.to_owned(), header_style))); + for raw in body.lines() { + lines.push(Line::from(vec![ + Span::styled(" ", body_style), + Span::styled(raw.to_owned(), body_style), + ])); + } + if body.is_empty() && header.is_empty() { + lines.push(Line::from("")); + } + } + Mode::Normal => { + lines.push(Line::from(Span::styled(header.to_owned(), header_style))); + let preview = system_message_preview(body, 4); + for line in preview.lines { + lines.push(Line::from(vec![ + Span::styled(" ", body_style), + Span::styled(line, body_style), + ])); + } + if preview.omitted_lines > 0 { + lines.push(Line::from(vec![ + Span::styled(" ", body_style), + Span::styled( + format!("… ({} more lines)", preview.omitted_lines), + body_style.add_modifier(Modifier::ITALIC), + ), + ])); + } + } + } +} + +fn split_system_message(text: &str) -> (&str, &str) { + match text.split_once('\n') { + Some((header, body)) => (header, body.trim_start_matches('\n')), + None => (text, ""), + } +} + +struct SystemMessagePreview { + lines: Vec, + omitted_lines: usize, +} + +fn system_message_preview(body: &str, max_lines: usize) -> SystemMessagePreview { + let all: Vec<&str> = body.lines().collect(); + let lines = all + .iter() + .take(max_lines) + .map(|line| (*line).to_owned()) + .collect(); + SystemMessagePreview { + lines, + omitted_lines: all.len().saturating_sub(max_lines), + } +} + /// Style + display text for a single chip-style `Segment`. `fallback` /// is used for `Segment::Text` (which the caller handles inline) and /// for `Segment::Unknown` so future variants degrade gracefully. @@ -931,6 +1003,8 @@ pub enum MessageKind { /// Visually distinct from User / Assistant / Notice so it's clear /// the line came from another Pod or operator, not the local user. Notify, + /// Persisted role:system history item preview. + System, Assistant, Thinking, TurnStats, @@ -943,6 +1017,7 @@ pub fn kind_style(kind: MessageKind) -> Style { MessageKind::TurnHeader => Style::default().fg(Color::DarkGray), MessageKind::User => Style::default().fg(Color::Green), MessageKind::Notify => Style::default().fg(Color::Yellow), + MessageKind::System => Style::default().fg(Color::Cyan), MessageKind::Assistant => Style::default().fg(Color::White), MessageKind::Thinking => Style::default() .fg(Color::Magenta) @@ -975,7 +1050,9 @@ fn format_pod_event(event: &PodEvent) -> String { format!("[pod_event] {pod_name} → shut_down") } PodEvent::ScopeSubDelegated { - parent_pod, sub_pod, .. + parent_pod, + sub_pod, + .. } => { format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}") } diff --git a/tickets/tui-system-message-render.md b/tickets/tui-system-message-render.md index 979843bb..ac83ff8a 100644 --- a/tickets/tui-system-message-render.md +++ b/tickets/tui-system-message-render.md @@ -45,3 +45,8 @@ LLM のコンテキストには乗っているが TUI には何も出ない。TU - 別 client が後から subscribe して `Event::History` を受けた場合も、同じログ要素として描画される。 - ライブ event と history 復元の表示が一致する(同じ Block バリアントを通る)。 - 解決失敗時の従来経路(`Alert` / user-invocation エラー)は維持される。 + +## Review +- 状態: Approve +- レビュー詳細: [./tui-system-message-render.review.md](./tui-system-message-render.review.md) +- 日付: 2026-05-04 diff --git a/tickets/tui-system-message-render.review.md b/tickets/tui-system-message-render.review.md new file mode 100644 index 00000000..98021801 --- /dev/null +++ b/tickets/tui-system-message-render.review.md @@ -0,0 +1,48 @@ +# Review: TUI で role:system の system message を表示する + +## 前提・要件の確認 + +- **role:system Item を一律に表示経路に乗せる仕組み**: `Event::SystemMessage { item: serde_json::Value }` を新設し (crates/protocol/src/lib.rs:226–232)、TUI 側に `Block::SystemMessage { text }` を追加 (crates/tui/src/block.rs:22–25)。種別ごとの個別パッチではなく role:system を一律で扱える形になっている。✓ +- **ライブ submit の broadcast**: `Worker::on_history_append` 汎用フック (crates/llm-worker/src/worker.rs:353–369) を新設し、`extend_history_with_callbacks` 経由で `pending_history_appends` / `ContinueWithMessages` / `PromptAction::ContinueWith` の三箇所が共通配線になった (worker.rs:889, 988, 1443)。controller.rs:259–265 で role:system だけを `Event::SystemMessage` に乗せている。`@` / `/` chip 解決後の `Item::system_message` は `pending_attachments` → `PromptAction::ContinueWith(extras)` 経路で必ずこのフックを通る。✓ +- **history.json を単一の情報源**: `Event::SystemMessage` の payload は `serde_json::to_value(&Item)` で `Event::History` と同じ shape。TUI は `App::push_history_item` を共通ヘルパとして抽出し (crates/tui/src/app.rs:303–423)、`handle_pod_event(Event::SystemMessage)` と `restore_history` の両方が同じ Block 生成経路に流れる。`restore_history` の `_ => {}` で握り潰していたバグも解消している (crates/tui/src/app.rs:660 周辺)。✓ +- **Auto-read 含む compact 経路**: compact が新規導入する 4 種 (`[Compacted context summary]`, `[Auto-read file: ...]`, `[Session reference]`, `[Session TaskStore snapshot]`) を `compact_introduced_system_messages` にまとめ、`set_history` 直後に `broadcast_system_message_item` で個別 broadcast (crates/pod/src/pod.rs:1483–1497, 1558–1561)。retain 済みの既存 system message は再 broadcast されないことを `compact_broadcasts_only_new_system_messages_not_retained_ones` テストで担保 (crates/pod/tests/compact_events_test.rs:188–227)。✓ +- **別 client が後から subscribe したケース**: `Event::History` 経路は今回 `push_history_item` に統一され、`role:"system"` を `Block::SystemMessage` として描画するブランチが追加されている (crates/tui/src/app.rs:336–339)。テスト `history_restore_renders_system_message_block` が回っている。✓ +- **解決失敗時の従来経路維持**: `Alert` / user-invocation エラー側のコードは触られていない。✓ +- **本文プレビュー**: `render_system_message` (crates/tui/src/ui.rs:485–550) で header 一行 + 本文 4 行 + `… (N more lines)` の素のプレビュー。Overview / Detail / Normal の 3 モードに対応。`MessageKind::System` の cyan を新設。✓ + +## アーキテクチャ・スコープ + +- **CLAUDE.md「LLM コンテキスト加工原則」との整合**: 本実装は **history → broadcast** という許される方向のみで、context への割り込みは行わない (history を書き換えていない、新規 input を context だけに差し込んでいない)。`broadcast_system_message_item` は `set_history` 後の現在 history 内容を broadcast するだけで、worker.history と一致している。✓ +- **`Worker::on_history_append` の位置づけ**: 「streaming で生成された assistant items / tool result」とは別経路で history に append される非ストリーミング項目専用のフック。assistant items (worker.rs:979)、tool_result (worker.rs:1096–1103) は意図的に通らない。役割が「streaming bypass で history に積まれる項目を上層に観測させる」と明確で、汎用化しすぎていない。✓ +- **role:system フィルタの位置**: フックは Item 全種を流し、フィルタ (`is_system_message_item`) は controller 側のクロージャで掛けている。今回広がる用途 (notify_wrapper, system-reminder 系) はすべて role:system なので、フィルタ位置として妥当。✓ +- **Event payload に `serde_json::Value` を使う判断**: `Event::History.items: Vec` と同じ流儀で、TUI は同じ deser 経路を共有できる。`Item` 型をそのまま expose しなかった点は protocol crate の依存最小化として一貫している。✓ +- **不要な抽象 / 歪み**: なし。新しい crate / モジュール追加もなく、既存の hook/event レーンに沿って最小拡張している。app.rs の `push_history_item` 抽出は重複排除としても妥当。 + +## 指摘事項 + +### Blocking + +- なし。 + +### Non-blocking / Follow-up + +- **Notify が二重表示になる可能性**: `Method::Notify` 受信時に `Event::Notify { message }` を即時送出 (crates/pod/src/controller.rs:480) しつつ、次ターン頭で `pending_history_appends` が `notify_wrapper` 形式の `Item::system_message` を返し、これが `Event::SystemMessage` として **追加で** broadcast される。TUI には `[notify] {message}` (Block::Notify) と `[Notification]…{message}…not a blocking request` (Block::SystemMessage) が両方並ぶ。本チケットの要件の範囲ではないが、`notify-history-persist` 系の表示設計と整合させる別チケットで整理が要る。 +- **compact の broadcast 経路が二系統あるための分かりにくさ**: 通常 append は `Worker::on_history_append` フック経由、compact は `Pod::broadcast_system_message_item` 直接呼び出し、という二層になっている。理由 (compact は `set_history` の wholesale replace で意味的に差分 broadcast したい) は正当だが、将来「他にも history を再構築する操作」が増えたとき、どちらの経路に乗せるかの判断軸が暗黙のままなので、`broadcast_system_message_item` 付近にコメントで「set_history 系の wholesale replace 後に意図的に新規導入 item だけを broadcast するためのレーン」と一行残しておくと将来の読み手が楽になる。 +- **`Event::SystemMessage.item` の型コメント**: protocol.rs:226–232 の docstring は良いが、payload が「Item の serialize 済み JSON (≒ history.json の 1 entry)」であることを 1 行明示すると、TUI 以外の client 実装者が deser 形式を迷わずに済む。 + +### Nits + +- crates/tui/src/app.rs:303 `push_history_item` の `tool_call` ブランチで `name: item["name"].as_str().unwrap_or("?")` の `?` フォールバックは旧コードからの引き継ぎだが、本来 history JSON で name が欠ける状況は考えにくい。今回触ったついでに修正する性質ではないので現状維持で OK。 +- crates/tui/src/ui.rs:1053 `format_pod_event` の `ScopeSubDelegated { parent_pod, sub_pod, .. }` の改行は無関係なフォーマット差分。本筋に影響なし。 +- compact_events_test.rs の新テストは `worker_mut().push_item(...)` で retained system を仕込んでいるが、これは `on_history_append` を回避する経路。テストの意図 (retained は callback を通っていないので broadcast されない) を 1 行コメントで残してもよい。 + +## ビルド / テスト + +- `cargo check --workspace --all-targets`: ✓ (既存 dead_code warning のみ、新規警告なし) +- `cargo test -p pod`: ✓ (compact_events_test の 6 件含めすべて通過、新テスト `compact_broadcasts_only_new_system_messages_not_retained_ones` も通過) +- `cargo test -p tui`: ✓ (新テスト `history_restore_renders_system_message_block`, `live_system_message_event_uses_history_item_path` 通過) +- `cargo test -p llm-worker`: ✓ + +## 判断 + +**Approve** — チケットの要件 (一般的な role:system 表示経路、ライブ / 復元 / 後着 subscribe の三経路を同じ Block バリアントに統一、compact 後の auto-read を含む 4 種を同じレーンに乗せる、解決失敗時は従来経路維持) はすべて満たされている。アーキテクチャ的にも CLAUDE.md の「許される加工」原則に整合し、既存の hook/event レーンに自然に乗る最小拡張で、コードベースを歪めていない。Notify 二重表示は本チケット範囲外で、別 ticket で整理すれば足りる。 From 185db7f8cd1b3d40bb8b74aa01170b8e6030e30e Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 4 May 2026 12:05:50 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs(tickets):=20tui-system-message-render?= =?UTF-8?q?=E5=AE=8C=E4=BA=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO.md | 1 - tickets/tui-system-message-render.md | 52 --------------------- tickets/tui-system-message-render.review.md | 48 ------------------- 3 files changed, 101 deletions(-) delete mode 100644 tickets/tui-system-message-render.md delete mode 100644 tickets/tui-system-message-render.review.md diff --git a/TODO.md b/TODO.md index b8888ffe..77c581e2 100644 --- a/TODO.md +++ b/TODO.md @@ -13,7 +13,6 @@ - Run 中の入力キューイング → [tickets/tui-input-queue.md](tickets/tui-input-queue.md) - ユーザーマニフェストのモデル設定 wizard → [tickets/tui-user-model-setup.md](tickets/tui-user-model-setup.md) - spawn 失敗時に Pod の stderr が TUI に表示されない → [tickets/tui-spawn-error-surface.md](tickets/tui-spawn-error-surface.md) - - role:system の system message を TUI に表示する仕組み → [tickets/tui-system-message-render.md](tickets/tui-system-message-render.md) - 巻き戻されたターンの入力テキストを編集領域に復元 → [tickets/tui-empty-turn-restore.md](tickets/tui-empty-turn-restore.md) - Manifest: Tool Output / File Upload 上限の分離とデフォルト緩和 → [tickets/manifest-output-upload-limits.md](tickets/manifest-output-upload-limits.md) - メモリ機構 diff --git a/tickets/tui-system-message-render.md b/tickets/tui-system-message-render.md deleted file mode 100644 index ac83ff8a..00000000 --- a/tickets/tui-system-message-render.md +++ /dev/null @@ -1,52 +0,0 @@ -# TUI で role:system の system message を表示する - -## 背景 - -Pod は user 入力の `@` chip / `/` chip を submit 時に展開し、`Item::system_message` を `pending_attachments` 経由で worker.history に commit している: - -- `@` ライブ submit: `Pod::run` → `resolve_file_refs` (crates/pod/src/pod.rs:825,852) → `PodFsView::resolve_file_ref` (crates/pod/src/fs_view.rs:119) で `[File: ]\n` を生成 -- compact worker の auto-read: `mark_read_required` で nominate された再読対象が `PodFsView::render_auto_read` (crates/pod/src/fs_view.rs:84) で `[Auto-read file: :]\n` として乗る -- `/` ライブ submit: `Pod::run` → `resolve_workflow_invocations` (crates/pod/src/pod.rs:826,876) → `crate::workflow::resolve_workflow_invocation` (crates/pod/src/workflow/mod.rs:74) で `[Workflow /]\n` と requires Knowledge 毎の `[Workflow / requires Knowledge #]\n` を生成 - -いずれも `PodInterceptor::on_prompt_submit` (crates/pod/src/ipc/interceptor.rs:114) で `PromptAction::ContinueWith(extras)` 経由で worker.history に commit され、history.json に永続化されている (CLAUDE.md「許される加工」原則に整合)。 - -## 問題 - -LLM のコンテキストには乗っているが TUI には何も出ない。TUI 側で role:system の Item が表示経路に乗っていないため: - -1. **ライブ側**: 解決済み system message を運ぶ broadcast event が `protocol::Event` に存在しない (crates/protocol/src/lib.rs:204–326)。`UserMessage` で `@` / `/` chip 自体は表示されるが、解決された本体は出ない。失敗時のみ `Alert` で出るが、`Alert` はユーザー向け一過性通知で永続化されない (crates/protocol/src/lib.rs:328–348) ため表示経路として不適切。 -2. **履歴復元側**: `App::restore_history` (crates/tui/src/app.rs:650–702) の match が `role: "user"` / `"assistant"` 以外を `_ => {}` で握り潰す。history.json に system role で残っているのに resume 時に消える。 - -結果として「`@` や `/` を submit したのに、本当に読まれたのか / 何が context に乗ったのか TUI からは判別できない」状態になっている。 - -## 要件 - -- `Item::system_message` (role:system) を user / assistant メッセージと並列のログ要素として TUI に表示する**一般的な仕組み**を入れる。種別ごとの個別パッチではなく、role:system が来たら一律で表示経路に乗せる形にする。 -- 仕組みとして最小限カバーすべき system message: - - `[File: ]` (ライブ `@`) - - `[Auto-read file: :]` (compact 後の auto-read) - - `[Workflow /]` - - `[Workflow / requires Knowledge #]` -- 表示の単一の情報源は永続化された history (= history.json の role:system Item)。ライブ submit 時 / 履歴復元時 / 別 client subscribe 時 の三経路で同じ Block バリアントを通る。 -- 表示は本文プレビュー(数行 + 残行数 / 切り詰め注記)程度で良い。`[Auto-read file: ...:]` の range ラベルは可視化する。workflow 本体と requires Knowledge は同じ workflow 起動に紐づく一連と分かる粒度で識別できる。 -- 解決失敗時の従来経路は維持: `@` は `Alert`、workflow は user-invocation エラーとして即座に Pod から返る (`Pod::validate_workflow_invocations`)。 - -## 範囲外 / 非目標 - -- `` 注入機構そのものの汎用化や、notify_wrapper 適用後の本文表示。これらは別所(`session-todo-reminder` 等)。本チケットは**表示側の仕組みのみ**で、将来 `` 系が role:system Item として history に乗るようになれば、同じ表示経路にそのまま流れる前提。 -- 表示形式の凝った装飾(シンタックスハイライト / 折り畳み UI)。最初は素のテキストプレビューで十分。 -- `model_invokation: true` のみの workflow(user_invocable=false)の表示は対象外。 - -## 完了条件 - -- `@` を submit すると、user message ブロックに続けて自動読み取り結果が TUI に出る。本文プレビューが視認できる。 -- `/` を submit すると、workflow 起動結果のログ要素が TUI に出る。`requires` がある場合は Knowledge 注入もそれと分かる形で出る。 -- compact 後の resume で `[Auto-read file: ...]` が同じログ要素として復元・表示される。 -- 別 client が後から subscribe して `Event::History` を受けた場合も、同じログ要素として描画される。 -- ライブ event と history 復元の表示が一致する(同じ Block バリアントを通る)。 -- 解決失敗時の従来経路(`Alert` / user-invocation エラー)は維持される。 - -## Review -- 状態: Approve -- レビュー詳細: [./tui-system-message-render.review.md](./tui-system-message-render.review.md) -- 日付: 2026-05-04 diff --git a/tickets/tui-system-message-render.review.md b/tickets/tui-system-message-render.review.md deleted file mode 100644 index 98021801..00000000 --- a/tickets/tui-system-message-render.review.md +++ /dev/null @@ -1,48 +0,0 @@ -# Review: TUI で role:system の system message を表示する - -## 前提・要件の確認 - -- **role:system Item を一律に表示経路に乗せる仕組み**: `Event::SystemMessage { item: serde_json::Value }` を新設し (crates/protocol/src/lib.rs:226–232)、TUI 側に `Block::SystemMessage { text }` を追加 (crates/tui/src/block.rs:22–25)。種別ごとの個別パッチではなく role:system を一律で扱える形になっている。✓ -- **ライブ submit の broadcast**: `Worker::on_history_append` 汎用フック (crates/llm-worker/src/worker.rs:353–369) を新設し、`extend_history_with_callbacks` 経由で `pending_history_appends` / `ContinueWithMessages` / `PromptAction::ContinueWith` の三箇所が共通配線になった (worker.rs:889, 988, 1443)。controller.rs:259–265 で role:system だけを `Event::SystemMessage` に乗せている。`@` / `/` chip 解決後の `Item::system_message` は `pending_attachments` → `PromptAction::ContinueWith(extras)` 経路で必ずこのフックを通る。✓ -- **history.json を単一の情報源**: `Event::SystemMessage` の payload は `serde_json::to_value(&Item)` で `Event::History` と同じ shape。TUI は `App::push_history_item` を共通ヘルパとして抽出し (crates/tui/src/app.rs:303–423)、`handle_pod_event(Event::SystemMessage)` と `restore_history` の両方が同じ Block 生成経路に流れる。`restore_history` の `_ => {}` で握り潰していたバグも解消している (crates/tui/src/app.rs:660 周辺)。✓ -- **Auto-read 含む compact 経路**: compact が新規導入する 4 種 (`[Compacted context summary]`, `[Auto-read file: ...]`, `[Session reference]`, `[Session TaskStore snapshot]`) を `compact_introduced_system_messages` にまとめ、`set_history` 直後に `broadcast_system_message_item` で個別 broadcast (crates/pod/src/pod.rs:1483–1497, 1558–1561)。retain 済みの既存 system message は再 broadcast されないことを `compact_broadcasts_only_new_system_messages_not_retained_ones` テストで担保 (crates/pod/tests/compact_events_test.rs:188–227)。✓ -- **別 client が後から subscribe したケース**: `Event::History` 経路は今回 `push_history_item` に統一され、`role:"system"` を `Block::SystemMessage` として描画するブランチが追加されている (crates/tui/src/app.rs:336–339)。テスト `history_restore_renders_system_message_block` が回っている。✓ -- **解決失敗時の従来経路維持**: `Alert` / user-invocation エラー側のコードは触られていない。✓ -- **本文プレビュー**: `render_system_message` (crates/tui/src/ui.rs:485–550) で header 一行 + 本文 4 行 + `… (N more lines)` の素のプレビュー。Overview / Detail / Normal の 3 モードに対応。`MessageKind::System` の cyan を新設。✓ - -## アーキテクチャ・スコープ - -- **CLAUDE.md「LLM コンテキスト加工原則」との整合**: 本実装は **history → broadcast** という許される方向のみで、context への割り込みは行わない (history を書き換えていない、新規 input を context だけに差し込んでいない)。`broadcast_system_message_item` は `set_history` 後の現在 history 内容を broadcast するだけで、worker.history と一致している。✓ -- **`Worker::on_history_append` の位置づけ**: 「streaming で生成された assistant items / tool result」とは別経路で history に append される非ストリーミング項目専用のフック。assistant items (worker.rs:979)、tool_result (worker.rs:1096–1103) は意図的に通らない。役割が「streaming bypass で history に積まれる項目を上層に観測させる」と明確で、汎用化しすぎていない。✓ -- **role:system フィルタの位置**: フックは Item 全種を流し、フィルタ (`is_system_message_item`) は controller 側のクロージャで掛けている。今回広がる用途 (notify_wrapper, system-reminder 系) はすべて role:system なので、フィルタ位置として妥当。✓ -- **Event payload に `serde_json::Value` を使う判断**: `Event::History.items: Vec` と同じ流儀で、TUI は同じ deser 経路を共有できる。`Item` 型をそのまま expose しなかった点は protocol crate の依存最小化として一貫している。✓ -- **不要な抽象 / 歪み**: なし。新しい crate / モジュール追加もなく、既存の hook/event レーンに沿って最小拡張している。app.rs の `push_history_item` 抽出は重複排除としても妥当。 - -## 指摘事項 - -### Blocking - -- なし。 - -### Non-blocking / Follow-up - -- **Notify が二重表示になる可能性**: `Method::Notify` 受信時に `Event::Notify { message }` を即時送出 (crates/pod/src/controller.rs:480) しつつ、次ターン頭で `pending_history_appends` が `notify_wrapper` 形式の `Item::system_message` を返し、これが `Event::SystemMessage` として **追加で** broadcast される。TUI には `[notify] {message}` (Block::Notify) と `[Notification]…{message}…not a blocking request` (Block::SystemMessage) が両方並ぶ。本チケットの要件の範囲ではないが、`notify-history-persist` 系の表示設計と整合させる別チケットで整理が要る。 -- **compact の broadcast 経路が二系統あるための分かりにくさ**: 通常 append は `Worker::on_history_append` フック経由、compact は `Pod::broadcast_system_message_item` 直接呼び出し、という二層になっている。理由 (compact は `set_history` の wholesale replace で意味的に差分 broadcast したい) は正当だが、将来「他にも history を再構築する操作」が増えたとき、どちらの経路に乗せるかの判断軸が暗黙のままなので、`broadcast_system_message_item` 付近にコメントで「set_history 系の wholesale replace 後に意図的に新規導入 item だけを broadcast するためのレーン」と一行残しておくと将来の読み手が楽になる。 -- **`Event::SystemMessage.item` の型コメント**: protocol.rs:226–232 の docstring は良いが、payload が「Item の serialize 済み JSON (≒ history.json の 1 entry)」であることを 1 行明示すると、TUI 以外の client 実装者が deser 形式を迷わずに済む。 - -### Nits - -- crates/tui/src/app.rs:303 `push_history_item` の `tool_call` ブランチで `name: item["name"].as_str().unwrap_or("?")` の `?` フォールバックは旧コードからの引き継ぎだが、本来 history JSON で name が欠ける状況は考えにくい。今回触ったついでに修正する性質ではないので現状維持で OK。 -- crates/tui/src/ui.rs:1053 `format_pod_event` の `ScopeSubDelegated { parent_pod, sub_pod, .. }` の改行は無関係なフォーマット差分。本筋に影響なし。 -- compact_events_test.rs の新テストは `worker_mut().push_item(...)` で retained system を仕込んでいるが、これは `on_history_append` を回避する経路。テストの意図 (retained は callback を通っていないので broadcast されない) を 1 行コメントで残してもよい。 - -## ビルド / テスト - -- `cargo check --workspace --all-targets`: ✓ (既存 dead_code warning のみ、新規警告なし) -- `cargo test -p pod`: ✓ (compact_events_test の 6 件含めすべて通過、新テスト `compact_broadcasts_only_new_system_messages_not_retained_ones` も通過) -- `cargo test -p tui`: ✓ (新テスト `history_restore_renders_system_message_block`, `live_system_message_event_uses_history_item_path` 通過) -- `cargo test -p llm-worker`: ✓ - -## 判断 - -**Approve** — チケットの要件 (一般的な role:system 表示経路、ライブ / 復元 / 後着 subscribe の三経路を同じ Block バリアントに統一、compact 後の auto-read を含む 4 種を同じレーンに乗せる、解決失敗時は従来経路維持) はすべて満たされている。アーキテクチャ的にも CLAUDE.md の「許される加工」原則に整合し、既存の hook/event レーンに自然に乗る最小拡張で、コードベースを歪めていない。Notify 二重表示は本チケット範囲外で、別 ticket で整理すれば足りる。