From ee6d2d210038a633276d9799f4cda7fef4fc39c1 Mon Sep 17 00:00:00 2001 From: Hare Date: Mon, 27 Apr 2026 11:03:58 +0900 Subject: [PATCH] =?UTF-8?q?submit=E3=82=92vec=20segment=E3=82=92=E5=8F=97?= =?UTF-8?q?=E3=81=91=E4=BB=98=E3=81=91=E3=82=8B=E5=BD=A2=E3=81=AB=E5=A4=89?= =?UTF-8?q?=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/pod/examples/pod_cli.rs | 2 +- crates/pod/examples/pod_protocol.rs | 4 +- crates/pod/src/controller.rs | 2 +- crates/pod/src/interrupt_and_run.rs | 3 +- crates/pod/src/pod.rs | 86 +++++++++- crates/pod/src/spawn/comm_tools.rs | 7 +- crates/pod/src/spawn/tool.rs | 2 +- crates/pod/tests/compact_events_test.rs | 8 +- crates/pod/tests/controller_test.rs | 153 +++++++++++++---- crates/pod/tests/pod_comm_tools_test.rs | 5 +- crates/pod/tests/spawn_pod_test.rs | 5 +- .../pod/tests/system_prompt_template_test.rs | 38 ++--- crates/protocol/src/lib.rs | 159 +++++++++++++++++- crates/tui/src/app.rs | 27 ++- crates/tui/src/block.rs | 4 +- crates/tui/src/input.rs | 99 ++++++++++- crates/tui/src/ui.rs | 91 +++++++++- tickets/submit-segment-protocol.md | 5 + tickets/submit-segment-protocol.review.md | 60 +++++++ 19 files changed, 663 insertions(+), 97 deletions(-) create mode 100644 tickets/submit-segment-protocol.review.md diff --git a/crates/pod/examples/pod_cli.rs b/crates/pod/examples/pod_cli.rs index bf8436f7..b647b657 100644 --- a/crates/pod/examples/pod_cli.rs +++ b/crates/pod/examples/pod_cli.rs @@ -57,7 +57,7 @@ async fn main() -> Result<(), Box> { println!("Session: {}", pod.session_id()); // 4. Run a prompt - let result = pod.run("What is the capital of France?").await?; + let result = pod.run_text("What is the capital of France?").await?; match result { PodRunResult::Finished => println!("(finished)"), PodRunResult::Paused => println!("(paused)"), diff --git a/crates/pod/examples/pod_protocol.rs b/crates/pod/examples/pod_protocol.rs index baf5421d..d03633da 100644 --- a/crates/pod/examples/pod_protocol.rs +++ b/crates/pod/examples/pod_protocol.rs @@ -93,9 +93,7 @@ async fn main() -> Result<(), Box> { // Send a run method handle - .send(Method::Run { - input: "What is the capital of France?".into(), - }) + .send(Method::run_text("What is the capital of France?")) .await?; // Wait for completion diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index 97595e47..59243c81 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -284,7 +284,7 @@ impl PodController { // render the turn header + user line from a // single source of truth. let _ = event_tx.send(Event::UserMessage { - text: input.clone(), + segments: input.clone(), }); let was_paused = status_before == PodStatus::Paused; shared_state.set_status(PodStatus::Running); diff --git a/crates/pod/src/interrupt_and_run.rs b/crates/pod/src/interrupt_and_run.rs index d6af09a5..1b0591f1 100644 --- a/crates/pod/src/interrupt_and_run.rs +++ b/crates/pod/src/interrupt_and_run.rs @@ -11,6 +11,7 @@ use llm_worker::Item; use llm_worker::llm_client::client::LlmClient; +use protocol::Segment; use session_store::Store; use crate::pod::{Pod, PodError, PodRunResult}; @@ -25,7 +26,7 @@ impl Pod { /// rationale around synthetic tool results. pub async fn interrupt_and_run( &mut self, - input: impl Into, + input: Vec, ) -> Result { let tool_result_summary = self .prompts() diff --git a/crates/pod/src/pod.rs b/crates/pod/src/pod.rs index f99e354d..87f312f1 100644 --- a/crates/pod/src/pod.rs +++ b/crates/pod/src/pod.rs @@ -28,7 +28,7 @@ use crate::runtime::dir; use crate::runtime::scope_lock::{self, ScopeAllocationGuard, ScopeLockError}; use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate}; use crate::compact::usage_tracker::UsageTracker; -use protocol::{Event, AlertLevel, AlertSource}; +use protocol::{AlertLevel, AlertSource, Event, Segment}; use tokio::sync::broadcast; use async_trait::async_trait; use llm_worker::interceptor::PreRequestAction; @@ -553,27 +553,107 @@ impl Pod { Ok(()) } + /// Convenience: run with a single `Segment::Text`. + /// + /// Equivalent to `run(vec![Segment::text(s)])`. The dumb-client + /// counterpart of [`protocol::Method::run_text`]; primarily for + /// tests and tools that have only a string in hand. + pub async fn run_text( + &mut self, + s: impl Into, + ) -> Result { + self.run(vec![Segment::text(s)]).await + } + /// Send user input and run until the LLM turn completes. /// + /// `input` is a typed segment list (see [`protocol::Segment`]). The + /// Pod flattens it into a single user-message string for the + /// underlying Worker, expanding paste content inline and surfacing + /// alerts for any segment kind the current Pod has no resolver for + /// (file refs, knowledge refs, workflow invocations, unknown + /// variants from a newer client). + /// /// If the between-turns compaction threshold is exceeded mid-run, /// the Worker is aborted, history is compacted, and execution resumes /// automatically. - pub async fn run(&mut self, input: impl Into) -> Result { + pub async fn run(&mut self, input: Vec) -> Result { self.ensure_interceptor_installed(); self.ensure_system_prompt_materialized()?; self.ensure_session_head().await?; + let flattened = self.flatten_segments(&input); + let history_before = self.worker.as_ref().unwrap().history().len(); // lock → run → unlock let worker = self.worker.take().expect("worker taken during run"); let mut locked = worker.lock(); - let result = locked.run(input).await; + let result = locked.run(flattened).await; self.worker = Some(locked.unlock()); self.handle_worker_result(result, history_before).await } + /// Flatten a typed segment list into the single string the Worker + /// receives as the user message. Inlines text and paste content; + /// substitutes `[unresolved : ]` 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. + 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::FileRef { path } => { + self.alert( + AlertLevel::Warn, + AlertSource::Pod, + format!( + "file ref @{path} cannot be resolved \ + (resolver not yet implemented); passed to LLM as placeholder" + ), + ); + out.push_str(&format!("[unresolved file ref: {path}]")); + } + Segment::KnowledgeRef { slug } => { + self.alert( + AlertLevel::Warn, + AlertSource::Pod, + format!( + "knowledge ref #{slug} cannot be resolved \ + (resolver not yet implemented); passed to LLM as placeholder" + ), + ); + out.push_str(&format!("[unresolved knowledge ref: {slug}]")); + } + Segment::WorkflowInvoke { slug } => { + self.alert( + AlertLevel::Warn, + AlertSource::Pod, + format!( + "workflow /{slug} cannot be resolved \ + (resolver not yet implemented); passed to LLM as placeholder" + ), + ); + out.push_str(&format!("[unresolved workflow invoke: {slug}]")); + } + Segment::Unknown => { + self.alert( + AlertLevel::Warn, + AlertSource::Pod, + "received unknown segment kind from a newer client; \ + passed to LLM as placeholder" + .into(), + ); + out.push_str("[unknown input segment]"); + } + } + } + out + } + /// Run a turn triggered by `Method::Notify` while the Pod is idle. /// /// Unlike [`run`](Self::run), no user message is appended to diff --git a/crates/pod/src/spawn/comm_tools.rs b/crates/pod/src/spawn/comm_tools.rs index f9d4292f..041a4c7c 100644 --- a/crates/pod/src/spawn/comm_tools.rs +++ b/crates/pod/src/spawn/comm_tools.rs @@ -358,7 +358,12 @@ async fn send_run_and_confirm(socket: &Path, input: String) -> Result<(), SendRu let (r, w) = stream.into_split(); let mut writer = JsonLineWriter::new(w); let mut reader = JsonLineReader::new(r); - tokio::time::timeout(SOCKET_OP_TIMEOUT, writer.write(&Method::Run { input })) + tokio::time::timeout( + SOCKET_OP_TIMEOUT, + writer.write(&Method::Run { + input: vec![protocol::Segment::text(input)], + }), + ) .await .map_err(|_| SendRunError::Io("write timed out".into()))? .map_err(|e| SendRunError::Io(format!("write: {e}")))?; diff --git a/crates/pod/src/spawn/tool.rs b/crates/pod/src/spawn/tool.rs index 9cee8d75..3ba7998b 100644 --- a/crates/pod/src/spawn/tool.rs +++ b/crates/pod/src/spawn/tool.rs @@ -424,7 +424,7 @@ async fn send_run(socket: &Path, task: &str) -> Result<(), ToolError> { let (_reader, writer) = stream.into_split(); let mut w = JsonLineWriter::new(writer); w.write(&Method::Run { - input: task.to_string(), + input: vec![protocol::Segment::text(task)], }) .await .map_err(|e| ToolError::ExecutionFailed(format!("send Method::Run: {e}")))?; diff --git a/crates/pod/tests/compact_events_test.rs b/crates/pod/tests/compact_events_test.rs index a6a394fe..61372f84 100644 --- a/crates/pod/tests/compact_events_test.rs +++ b/crates/pod/tests/compact_events_test.rs @@ -192,7 +192,7 @@ async fn post_run_compact_success_broadcasts_start_and_done() { let (tx, mut rx) = broadcast::channel::(64); pod.attach_event_tx(tx); - pod.run("first").await.unwrap(); + pod.run_text("first").await.unwrap(); // Drain run events so only compact events remain in `rx`. let _ = drain(&mut rx); @@ -248,12 +248,12 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() { pod.attach_event_tx(tx); // First run populates usage_history above the request threshold. - pod.run("first").await.unwrap(); + pod.run_text("first").await.unwrap(); let _ = drain(&mut rx); // Second run: pre_llm_request yields immediately, Worker returns // Yielded, handle_worker_result routes into do_compact_and_resume. - pod.run("second").await.unwrap(); + pod.run_text("second").await.unwrap(); let events = drain(&mut rx); let kinds: Vec<&str> = events @@ -291,7 +291,7 @@ async fn post_run_compact_failure_broadcasts_start_and_failed() { let (tx, mut rx) = broadcast::channel::(64); pod.attach_event_tx(tx); - pod.run("first").await.unwrap(); + pod.run_text("first").await.unwrap(); let _ = drain(&mut rx); // Best-effort: returns Ok(()) even on failure, but emits CompactFailed. diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs index e3f0f030..759c053c 100644 --- a/crates/pod/tests/controller_test.rs +++ b/crates/pod/tests/controller_test.rs @@ -170,9 +170,7 @@ async fn run_updates_shared_state_to_idle_after_completion() { let handle = spawn_controller(pod).await; handle - .send(Method::Run { - input: "Hello".into(), - }) + .send(Method::run_text("Hello")) .await .unwrap(); @@ -189,9 +187,7 @@ async fn run_populates_history() { let handle = spawn_controller(pod).await; handle - .send(Method::Run { - input: "Hello".into(), - }) + .send(Method::run_text("Hello")) .await .unwrap(); @@ -212,9 +208,7 @@ async fn events_are_broadcast() { let mut rx = handle.subscribe(); handle - .send(Method::Run { - input: "Hello".into(), - }) + .send(Method::run_text("Hello")) .await .unwrap(); @@ -265,17 +259,13 @@ async fn double_run_returns_error() { // Send first run handle - .send(Method::Run { - input: "first".into(), - }) + .send(Method::run_text("first")) .await .unwrap(); // Immediately send second run (should get error) handle - .send(Method::Run { - input: "second".into(), - }) + .send(Method::run_text("second")) .await .unwrap(); @@ -363,6 +353,119 @@ async fn cancel_without_run_returns_error() { assert!(saw_not_running, "should see not_running error"); } +#[tokio::test] +async fn run_with_paste_segment_inlines_content_and_emits_typed_user_message() { + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + let mut rx = handle.subscribe(); + + // Mixed input: plain text + a paste chip + trailing text. Pod must + // flatten this into one user-message string (paste content inlined, + // no `[Clipboard ...]` label leaking to the LLM); the + // `Event::UserMessage` re-broadcast must carry the typed segments + // unchanged so other clients can re-render the chip. + let segments = vec![ + protocol::Segment::text("see "), + protocol::Segment::Paste { + id: 7, + chars: 11, + lines: 2, + content: "line1\nline2".into(), + }, + protocol::Segment::text(" thanks"), + ]; + handle + .send(Method::Run { + input: segments.clone(), + }) + .await + .unwrap(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + let mut user_event_segments: Option> = None; + loop { + tokio::select! { + event = rx.recv() => match event { + Ok(Event::UserMessage { segments }) => user_event_segments = Some(segments), + Ok(Event::TurnEnd { .. }) => break, + Err(_) => break, + _ => {} + }, + _ = tokio::time::sleep_until(deadline) => break, + } + } + let echoed = user_event_segments.expect("UserMessage event missing"); + assert_eq!(echoed.len(), 3, "all three segments must round-trip"); + assert!(matches!(echoed[1], protocol::Segment::Paste { id: 7, .. })); + + // The Worker received a single user message whose text is the + // flattened body — paste content inlined, no chip label. + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let requests = client_for_assert.captured_requests(); + assert_eq!(requests.len(), 1, "one LLM call expected"); + let user_text = requests[0] + .items + .iter() + .find_map(|i| i.as_text().map(|s| s.to_string())) + .unwrap_or_default(); + assert!(user_text.contains("see line1\nline2 thanks"), "got: {user_text:?}"); + assert!(!user_text.contains("[Clipboard"), "label must not leak: {user_text:?}"); +} + +#[tokio::test] +async fn run_with_unresolved_segment_emits_alert_and_placeholder() { + let client = MockClient::new(simple_text_events()); + let client_for_assert = client.clone(); + let pod = make_pod(client).await; + let handle = spawn_controller(pod).await; + let mut rx = handle.subscribe(); + + let segments = vec![ + protocol::Segment::text("look at "), + protocol::Segment::FileRef { path: "src/lib.rs".into() }, + ]; + handle + .send(Method::Run { input: segments }) + .await + .unwrap(); + + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + let mut saw_alert_for_file_ref = false; + loop { + tokio::select! { + event = rx.recv() => match event { + Ok(Event::Alert(a)) if a.message.contains("file ref @src/lib.rs") => { + saw_alert_for_file_ref = true; + } + Ok(Event::TurnEnd { .. }) => break, + Err(_) => break, + _ => {} + }, + _ = tokio::time::sleep_until(deadline) => break, + } + } + assert!( + saw_alert_for_file_ref, + "an Alert mentioning the unresolved file ref must be emitted" + ); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let requests = client_for_assert.captured_requests(); + let user_text = requests[0] + .items + .iter() + .find_map(|i| i.as_text().map(|s| s.to_string())) + .unwrap_or_default(); + // LLM context carries a placeholder so the model can ask for the + // missing content rather than silently miss the user's intent. + assert!( + user_text.contains("[unresolved file ref: src/lib.rs]"), + "placeholder missing, got: {user_text:?}" + ); +} + #[tokio::test] async fn notify_while_idle_auto_starts_turn_and_injects_system_message() { let client = MockClient::new(simple_text_events()); @@ -425,9 +528,7 @@ async fn notify_while_running_does_not_emit_already_running_error() { let mut rx = handle.subscribe(); handle - .send(Method::Run { - input: "start".into(), - }) + .send(Method::run_text("start")) .await .unwrap(); handle @@ -491,9 +592,7 @@ async fn socket_run_receives_events() { // Send run method via socket writer - .write(&Method::Run { - input: "Hello".into(), - }) + .write(&Method::run_text("Hello")) .await .unwrap(); @@ -641,9 +740,7 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() { let mut rx = handle.subscribe(); handle - .send(Method::Run { - input: "hello".into(), - }) + .send(Method::run_text("hello")) .await .unwrap(); @@ -754,9 +851,7 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() { let mut rx = handle.subscribe(); handle - .send(Method::Run { - input: "first".into(), - }) + .send(Method::run_text("first")) .await .unwrap(); @@ -789,9 +884,7 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() { // `Pod::interrupt_and_run`, which closes the orphan + injects a // system note before the fresh user message. handle - .send(Method::Run { - input: "new request".into(), - }) + .send(Method::run_text("new request")) .await .unwrap(); assert!( diff --git a/crates/pod/tests/pod_comm_tools_test.rs b/crates/pod/tests/pod_comm_tools_test.rs index 8982641e..d3bb0b3c 100644 --- a/crates/pod/tests/pod_comm_tools_test.rs +++ b/crates/pod/tests/pod_comm_tools_test.rs @@ -185,7 +185,10 @@ async fn send_to_pod_delivers_run_method() { let method = received.await.unwrap().expect("expected a method"); match method { - Method::Run { input } => assert_eq!(input, "hello there"), + Method::Run { input } => match input.as_slice() { + [protocol::Segment::Text { content }] => assert_eq!(content, "hello there"), + other => panic!("expected single Text segment, got {other:?}"), + }, other => panic!("expected Run, got {other:?}"), } } diff --git a/crates/pod/tests/spawn_pod_test.rs b/crates/pod/tests/spawn_pod_test.rs index a8e307c7..a08032d3 100644 --- a/crates/pod/tests/spawn_pod_test.rs +++ b/crates/pod/tests/spawn_pod_test.rs @@ -193,7 +193,10 @@ async fn spawn_pod_delegates_scope_and_sends_run() { // Verify the tool delivered Method::Run to the socket. let method = received.await.unwrap().expect("expected one Method line"); match method { - Method::Run { input } => assert_eq!(input, "hello"), + Method::Run { input } => match input.as_slice() { + [protocol::Segment::Text { content }] => assert_eq!(content, "hello"), + other => panic!("expected single Text segment, got {other:?}"), + }, other => panic!("expected Run, got {other:?}"), } diff --git a/crates/pod/tests/system_prompt_template_test.rs b/crates/pod/tests/system_prompt_template_test.rs index 2a214027..5664d176 100644 --- a/crates/pod/tests/system_prompt_template_test.rs +++ b/crates/pod/tests/system_prompt_template_test.rs @@ -160,7 +160,7 @@ async fn materialise_on_first_turn_populates_worker() { ) .await .unwrap(); - pod.run("hi").await.unwrap(); + pod.run_text("hi").await.unwrap(); let rendered = pod .worker() .get_system_prompt() @@ -180,7 +180,7 @@ async fn session_start_state_captures_rendered_prompt() { let (mut pod, pwd) = make_pod_with_body("hello cwd={{ cwd }}", client) .await .unwrap(); - pod.run("hi").await.unwrap(); + pod.run_text("hi").await.unwrap(); let entries = pod.store().read_all(pod.session_id()).await.unwrap(); let first = entries.first().expect("at least one entry"); @@ -199,7 +199,7 @@ async fn session_start_state_captures_rendered_prompt() { async fn render_failure_propagates_as_pod_error() { let client = MockClient::new(vec![single_text_events("ok")]); let (mut pod, _pwd) = make_pod_with_body("{{ ghost }}", client).await.unwrap(); - let err = pod.run("hi").await.unwrap_err(); + let err = pod.run_text("hi").await.unwrap_err(); assert!(matches!(err, PodError::SystemPromptRender { .. })); } @@ -212,9 +212,9 @@ async fn materialise_runs_only_once_across_turns() { let (mut pod, _pwd) = make_pod_with_body("fixed prompt {{ cwd }}", client) .await .unwrap(); - pod.run("one").await.unwrap(); + pod.run_text("one").await.unwrap(); let first = pod.worker().get_system_prompt().unwrap().to_string(); - pod.run("two").await.unwrap(); + pod.run_text("two").await.unwrap(); let second = pod.worker().get_system_prompt().unwrap().to_string(); assert_eq!(first, second); } @@ -225,7 +225,7 @@ async fn agents_md_is_injected_as_trailing_section_when_present() { let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap(); std::fs::write(pwd.join("AGENTS.md"), "# project rules\nbe kind").unwrap(); - pod.run("hi").await.unwrap(); + pod.run_text("hi").await.unwrap(); let rendered = pod.worker().get_system_prompt().unwrap().to_string(); assert!(rendered.starts_with("BODY")); assert!(rendered.contains("## Project instructions (AGENTS.md)")); @@ -237,7 +237,7 @@ async fn agents_md_is_injected_as_trailing_section_when_present() { async fn agents_md_absent_omits_trailing_section() { let client = MockClient::new(vec![single_text_events("ok")]); let (mut pod, _pwd) = make_pod_with_body("BODY", client).await.unwrap(); - pod.run("hi").await.unwrap(); + pod.run_text("hi").await.unwrap(); let rendered = pod.worker().get_system_prompt().unwrap().to_string(); assert!(!rendered.contains("## Project instructions")); assert!(!rendered.contains("AGENTS.md")); @@ -246,20 +246,20 @@ async fn agents_md_absent_omits_trailing_section() { #[tokio::test] async fn agents_md_not_reread_after_compact() { let client = MockClient::new(vec![ - single_text_events("a"), // pod.run("first") - single_text_events("b"), // pod.run("second") + single_text_events("a"), // pod.run_text("first") + single_text_events("b"), // pod.run_text("second") write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use single_text_events("done"), // compact worker: close - single_text_events("c"), // pod.run("third") + single_text_events("c"), // pod.run_text("third") ]); let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap(); let agents_path = pwd.join("AGENTS.md"); std::fs::write(&agents_path, "original").unwrap(); - pod.run("first").await.unwrap(); + pod.run_text("first").await.unwrap(); let before = pod.worker().get_system_prompt().unwrap().to_string(); assert!(before.contains("original")); - pod.run("second").await.unwrap(); + pod.run_text("second").await.unwrap(); // Mutate the file after the first turn — must not affect the cached // system prompt either on a subsequent turn or across compaction. @@ -269,7 +269,7 @@ async fn agents_md_not_reread_after_compact() { assert!(after_compact.contains("original")); assert!(!after_compact.contains("mutated")); - pod.run("third").await.unwrap(); + pod.run_text("third").await.unwrap(); let after_third = pod.worker().get_system_prompt().unwrap().to_string(); assert!(after_third.contains("original")); assert!(!after_third.contains("mutated")); @@ -278,25 +278,25 @@ async fn agents_md_not_reread_after_compact() { #[tokio::test] async fn compact_preserves_system_prompt() { let client = MockClient::new(vec![ - single_text_events("a"), // pod.run("first") - single_text_events("b"), // pod.run("second") + single_text_events("a"), // pod.run_text("first") + single_text_events("b"), // pod.run_text("second") write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use single_text_events("done"), // compact worker: close - single_text_events("c"), // pod.run("third") + single_text_events("c"), // pod.run_text("third") ]); let (mut pod, _pwd) = make_pod_with_body("SP cwd={{ cwd }}", client) .await .unwrap(); - pod.run("first").await.unwrap(); + pod.run_text("first").await.unwrap(); let before = pod.worker().get_system_prompt().unwrap().to_string(); - pod.run("second").await.unwrap(); + pod.run_text("second").await.unwrap(); pod.compact(0).await.unwrap(); let after = pod.worker().get_system_prompt().unwrap().to_string(); assert_eq!(before, after); - pod.run("third").await.unwrap(); + pod.run_text("third").await.unwrap(); assert_eq!(pod.worker().get_system_prompt().unwrap(), after.as_str()); } diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 87ce0c1c..ffbc8ab4 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "method", content = "params", rename_all = "snake_case")] pub enum Method { - Run { input: String }, + Run { input: Vec }, /// Human-readable text injected into the target Pod's LLM context /// as a non-blocking system message. No side effects beyond LLM /// context; use `PodEvent` for typed lifecycle reports. @@ -76,6 +76,72 @@ pub enum PodEvent { }, } +// --------------------------------------------------------------------------- +// Segment — typed pieces of a user submission +// --------------------------------------------------------------------------- + +/// One typed piece of a user submission. +/// +/// `Method::Run` and `Event::UserMessage` carry `Vec`. Dumb +/// clients (CLI piping, scripts) only need to produce a single +/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms +/// (paste chips, file refs, knowledge refs, workflow invocations) and +/// send them through directly so the Pod side never has to re-parse a +/// flattened string. +/// +/// Forward compat: payloads with unknown `kind` deserialize to +/// `Segment::Unknown`. Pod treats this the same as known-but-unresolved +/// variants — emits an alert and inserts a `[unknown input segment]` +/// placeholder into the LLM context so neither user nor LLM is blind to +/// the dropped intent. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Segment { + /// Free-form text. The fallback every client can produce. + Text { content: String }, + /// Bracketed-paste capture from a TUI-style client. `id`, `chars` + /// and `lines` carry the metadata needed to re-render a + /// `[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage` + /// re-broadcast. + Paste { + id: u32, + chars: u32, + lines: u32, + content: String, + }, + /// `@` file reference. Pod resolves to scope-checked file + /// content when a resolver is registered (resolver implementation + /// out of scope for this ticket). + FileRef { path: String }, + /// `#` Knowledge reference (see `docs/plan/memory.md`). + KnowledgeRef { slug: String }, + /// `/` Workflow invocation (see `docs/plan/workflow.md`). + WorkflowInvoke { slug: String }, + /// Unknown variant from a newer client. Pod treats this as an + /// unresolved input — surfaces an alert and inserts a placeholder. + /// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`. + #[serde(other)] + Unknown, +} + +impl Segment { + /// Convenience constructor for the most common case. + pub fn text(s: impl Into) -> Self { + Self::Text { content: s.into() } + } +} + +impl Method { + /// Convenience: a `Run` carrying a single `Segment::Text`. + /// Used by dumb clients, inter-Pod tools, and tests that only have + /// a string to forward. + pub fn run_text(s: impl Into) -> Self { + Self::Run { + input: vec![Segment::text(s)], + } + } +} + // --------------------------------------------------------------------------- // Event (Pod → Client via Unix Socket broadcast) // --------------------------------------------------------------------------- @@ -93,7 +159,7 @@ pub enum Event { /// Fires exactly once per accepted `Method::Run`, before /// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit. UserMessage { - text: String, + segments: Vec, }, TurnStart { turn: usize, @@ -293,14 +359,84 @@ mod tests { #[test] fn method_run_json_roundtrip() { - let json = r#"{"method":"run","params":{"input":"Hello"}}"#; + let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"Hello"}]}}"#; let method: Method = serde_json::from_str(json).unwrap(); - assert!(matches!(method, Method::Run { ref input } if input == "Hello")); - + match &method { + Method::Run { input } => { + assert_eq!(input.len(), 1); + match &input[0] { + Segment::Text { content } => assert_eq!(content, "Hello"), + other => panic!("expected Text, got {other:?}"), + } + } + other => panic!("expected Run, got {other:?}"), + } let serialized = serde_json::to_string(&method).unwrap(); assert_eq!(serialized, json); } + #[test] + fn method_run_paste_segment_roundtrip() { + let method = Method::Run { + input: vec![ + Segment::text("see "), + Segment::Paste { + id: 7, + chars: 12, + lines: 2, + content: "line1\nline2".into(), + }, + ], + }; + let json = serde_json::to_string(&method).unwrap(); + let decoded: Method = serde_json::from_str(&json).unwrap(); + match decoded { + Method::Run { input } => { + assert_eq!(input.len(), 2); + match &input[1] { + Segment::Paste { + id, + chars, + lines, + content, + } => { + assert_eq!(*id, 7); + assert_eq!(*chars, 12); + assert_eq!(*lines, 2); + assert_eq!(content, "line1\nline2"); + } + other => panic!("expected Paste, got {other:?}"), + } + } + other => panic!("expected Run, got {other:?}"), + } + } + + #[test] + fn segment_unknown_variant_decodes_as_unknown() { + // A future client sends a segment kind this Pod has never heard of. + // Forward compat requirement: deserialization must succeed and the + // unknown payload must surface as `Segment::Unknown` so the Pod + // fallback path (placeholder + alert) can fire. + let json = r#"{"kind":"image_ref","url":"https://example.com/x.png"}"#; + let seg: Segment = serde_json::from_str(json).unwrap(); + assert!(matches!(seg, Segment::Unknown)); + } + + #[test] + fn method_run_with_unknown_segment_decodes() { + let json = r#"{"method":"run","params":{"input":[{"kind":"text","content":"hi"},{"kind":"future_thing","x":1}]}}"#; + let method: Method = serde_json::from_str(json).unwrap(); + match method { + Method::Run { input } => { + assert_eq!(input.len(), 2); + assert!(matches!(input[0], Segment::Text { .. })); + assert!(matches!(input[1], Segment::Unknown)); + } + other => panic!("expected Run, got {other:?}"), + } + } + #[test] fn method_without_params() { let json = r#"{"method":"resume"}"#; @@ -612,16 +748,23 @@ mod tests { #[test] fn event_user_message_roundtrip() { let event = Event::UserMessage { - text: "hello 世界".into(), + segments: vec![Segment::text("hello 世界")], }; let json = serde_json::to_string(&event).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert_eq!(parsed["event"], "user_message"); - assert_eq!(parsed["data"]["text"], "hello 世界"); + assert_eq!(parsed["data"]["segments"][0]["kind"], "text"); + assert_eq!(parsed["data"]["segments"][0]["content"], "hello 世界"); let decoded: Event = serde_json::from_str(&json).unwrap(); match decoded { - Event::UserMessage { text } => assert_eq!(text, "hello 世界"), + Event::UserMessage { segments } => { + assert_eq!(segments.len(), 1); + match &segments[0] { + Segment::Text { content } => assert_eq!(content, "hello 世界"), + other => panic!("expected Text, got {other:?}"), + } + } other => panic!("expected UserMessage, got {other:?}"), } } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 835605a7..cad6ec84 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,4 +1,4 @@ -use protocol::{Event, Method, AlertLevel, AlertSource, RunResult}; +use protocol::{AlertLevel, AlertSource, Event, Method, RunResult, Segment}; use crate::block::{Block, CompactEvent, ToolCallBlock, ToolCallState}; use crate::cache::FileCache; @@ -62,8 +62,8 @@ impl App { } pub fn submit_input(&mut self) -> Option { - let text = self.input.submit_text().trim().to_owned(); - if text.is_empty() { + let segments = self.input.submit_segments(); + if segments_are_blank(&segments) { // Empty Enter only does something meaningful when the Pod // is paused: resume the interrupted turn. Otherwise no-op. if self.paused { @@ -77,7 +77,7 @@ impl App { // client subscribed to the Pod). Locally we only clear the // input buffer and forward the method. self.input.clear(); - Some(Method::Run { input: text }) + Some(Method::Run { input: segments }) } pub fn push_error(&mut self, message: impl Into) { @@ -90,12 +90,12 @@ impl App { pub fn handle_pod_event(&mut self, event: Event) { match event { - Event::UserMessage { text } => { + Event::UserMessage { segments } => { self.turn_index += 1; self.blocks.push(Block::TurnHeader { turn: self.turn_index, }); - self.blocks.push(Block::UserMessage { text }); + self.blocks.push(Block::UserMessage { segments }); self.assistant_streaming = false; } Event::TurnStart { .. } => { @@ -370,7 +370,9 @@ impl App { turn: self.turn_index, }); if !text.is_empty() { - self.blocks.push(Block::UserMessage { text }); + self.blocks.push(Block::UserMessage { + segments: vec![Segment::text(text)], + }); } } "assistant" if !text.is_empty() => { @@ -488,6 +490,17 @@ fn strip_cat_n_prefix(formatted: &str) -> String { out } +/// True if the submitted segment list carries no user-visible content +/// (only whitespace / newlines, no paste, no typed atoms). Used to +/// decide whether an empty Enter should be a no-op or trigger a +/// `Resume` when the Pod is paused. +fn segments_are_blank(segments: &[Segment]) -> bool { + segments.iter().all(|s| match s { + Segment::Text { content } => content.trim().is_empty(), + _ => false, + }) +} + pub fn alert_source_label(source: AlertSource) -> &'static str { match source { AlertSource::Pod => "pod", diff --git a/crates/tui/src/block.rs b/crates/tui/src/block.rs index 5c3201fc..d3029d72 100644 --- a/crates/tui/src/block.rs +++ b/crates/tui/src/block.rs @@ -7,7 +7,7 @@ #![allow(dead_code)] // Phase 5 will consume `output` in detail mode. -use protocol::{Greeting, AlertLevel, AlertSource}; +use protocol::{AlertLevel, AlertSource, Greeting, Segment}; pub enum Block { Greeting(Greeting), @@ -15,7 +15,7 @@ pub enum Block { turn: usize, }, UserMessage { - text: String, + segments: Vec, }, AssistantText { text: String, diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs index 8dc5a7b2..86cfa413 100644 --- a/crates/tui/src/input.rs +++ b/crates/tui/src/input.rs @@ -190,16 +190,33 @@ impl InputBuffer { (start, self.cursor - start) } - /// Flatten atoms into the text sent to the Pod: paste atoms expand - /// to their original content; no `[Clipboard ...]` labels survive. - pub fn submit_text(&self) -> String { - let mut out = String::new(); + /// Build the typed `Vec` sent over the protocol. Adjacent + /// `Atom::Char`s are concatenated into a single `Segment::Text`; + /// each `Atom::Paste` becomes a standalone `Segment::Paste` so the + /// `[Clipboard #N | X chars, Y lines]` chip can be reconstructed by + /// any client subscribed to the resulting `Event::UserMessage`. + pub fn submit_segments(&self) -> Vec { + let mut out = Vec::new(); + let mut buf = String::new(); for a in &self.atoms { match a { - Atom::Char(c) => out.push(*c), - Atom::Paste(p) => out.push_str(&p.content), + Atom::Char(c) => buf.push(*c), + Atom::Paste(p) => { + if !buf.is_empty() { + out.push(protocol::Segment::text(std::mem::take(&mut buf))); + } + out.push(protocol::Segment::Paste { + id: p.id, + chars: p.chars as u32, + lines: p.lines as u32, + content: p.content.clone(), + }); + } } } + if !buf.is_empty() { + out.push(protocol::Segment::text(buf)); + } out } @@ -402,3 +419,73 @@ pub struct InputRender { pub cursor_row: u16, pub cursor_col: u16, } + +#[cfg(test)] +mod submit_segments_tests { + use super::*; + use protocol::Segment; + + #[test] + fn pure_text_collapses_to_one_text_segment() { + let mut buf = InputBuffer::new(); + for c in "hello".chars() { + buf.insert_char(c); + } + let segs = buf.submit_segments(); + assert_eq!(segs.len(), 1); + match &segs[0] { + Segment::Text { content } => assert_eq!(content, "hello"), + other => panic!("expected Text, got {other:?}"), + } + } + + #[test] + fn paste_emits_separate_segment_with_metadata() { + let mut buf = InputBuffer::new(); + for c in "see ".chars() { + buf.insert_char(c); + } + buf.insert_paste("line1\nline2".into()); + for c in " end".chars() { + buf.insert_char(c); + } + let segs = buf.submit_segments(); + assert_eq!(segs.len(), 3); + match &segs[0] { + Segment::Text { content } => assert_eq!(content, "see "), + other => panic!("expected Text, got {other:?}"), + } + match &segs[1] { + Segment::Paste { + chars, + lines, + content, + .. + } => { + assert_eq!(content, "line1\nline2"); + assert_eq!(*chars, "line1\nline2".chars().count() as u32); + assert_eq!(*lines, 2); + } + other => panic!("expected Paste, got {other:?}"), + } + match &segs[2] { + Segment::Text { content } => assert_eq!(content, " end"), + other => panic!("expected Text, got {other:?}"), + } + } + + #[test] + fn empty_buffer_yields_empty_segments() { + let buf = InputBuffer::new(); + assert!(buf.submit_segments().is_empty()); + } + + #[test] + fn leading_paste_does_not_emit_empty_text() { + let mut buf = InputBuffer::new(); + buf.insert_paste("X".into()); + let segs = buf.submit_segments(); + assert_eq!(segs.len(), 1); + assert!(matches!(segs[0], Segment::Paste { .. })); + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 57fc03b8..f740a5a1 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -20,7 +20,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; -use protocol::{Greeting, AlertLevel}; +use protocol::{AlertLevel, Greeting, Segment}; use crate::app::{App, fmt_tokens, alert_source_label}; use crate::block::{Block, CompactEvent}; @@ -299,13 +299,7 @@ fn render_block_into( kind_style(MessageKind::TurnHeader), ))); } - Block::UserMessage { text } => match mode { - Mode::Overview => push_overview_line(lines, text, width, MessageKind::User, "> "), - // User input and assistant prose are the primary readable - // content of a turn — never compressed in detail / normal. - // Only `overview` folds them to a single line. - _ => push_padded_lines(lines, text, MessageKind::User), - }, + Block::UserMessage { segments } => render_user_message(lines, segments, width, mode), Block::AssistantText { text } => match mode { Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""), _ => push_padded_lines(lines, text, MessageKind::Assistant), @@ -363,6 +357,87 @@ fn push_padded_lines(lines: &mut Vec>, text: &str, kind: MessageKi } } +/// Render `Block::UserMessage` from typed segments. Paste atoms are +/// reconstructed as `[Clipboard #N | X chars, Y lines]` chips in +/// magenta — matching the input-area presentation — so the user can +/// recognise their own paste in the scrollback. User-entered text uses +/// the standard `MessageKind::User` style; other segment kinds (file / +/// knowledge / workflow refs, unknown variants) render as inline +/// identifiers in the user style and are expected to be rare until the +/// completion ticket lands. +fn render_user_message( + lines: &mut Vec>, + segments: &[Segment], + width: u16, + mode: Mode, +) { + if matches!(mode, Mode::Overview) { + let text = segments + .iter() + .map(segment_display_text) + .collect::>() + .join(""); + push_overview_line(lines, &text, width, MessageKind::User, "> "); + return; + } + + let user_style = kind_style(MessageKind::User); + let paste_style = Style::default().fg(Color::Magenta); + let mut current: Vec> = Vec::new(); + + for seg in segments { + match seg { + Segment::Text { content } => { + let mut iter = content.split('\n').peekable(); + while let Some(line) = iter.next() { + if !line.is_empty() { + current.push(Span::styled(line.to_owned(), user_style)); + } + if iter.peek().is_some() { + lines.push(Line::from(std::mem::take(&mut current))); + } + } + } + Segment::Paste { + id, + chars, + lines: line_count, + .. + } => { + current.push(Span::styled( + format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"), + paste_style, + )); + } + other => { + current.push(Span::styled(segment_display_text(other), user_style)); + } + } + } + if !current.is_empty() { + lines.push(Line::from(current)); + } +} + +/// One-line textual rendering of a segment, used by `Mode::Overview` +/// (which collapses everything to a single string) and as the fallback +/// inline rendering for non-paste, non-text segments. +fn segment_display_text(seg: &Segment) -> String { + match seg { + Segment::Text { content } => content.replace('\n', " "), + Segment::Paste { + id, + chars, + lines, + .. + } => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"), + Segment::FileRef { path } => format!("@{path}"), + Segment::KnowledgeRef { slug } => format!("#{slug}"), + Segment::WorkflowInvoke { slug } => format!("/{slug}"), + Segment::Unknown => "[unknown segment]".to_owned(), + } +} + /// Single-line summary for overview mode. The output is clipped to /// exactly one rendered terminal row at `width` columns — the first /// non-empty logical line is truncated (with `…`) to fit alongside an diff --git a/tickets/submit-segment-protocol.md b/tickets/submit-segment-protocol.md index c8104445..3970be14 100644 --- a/tickets/submit-segment-protocol.md +++ b/tickets/submit-segment-protocol.md @@ -80,3 +80,8 @@ text しか作れない client が引き続き存在しても良いことを pro - `crates/protocol/src/lib.rs`(`Method::Run`, `Event::UserMessage`) - `crates/tui/src/input.rs`(`Atom::Paste`, `submit_text`) - `crates/tui/src/app.rs`(`submit_input`, `Block::UserMessage` 描画) + +## Review +- 状態: Approve +- レビュー詳細: [./submit-segment-protocol.review.md](./submit-segment-protocol.review.md) +- 日付: 2026-04-27 diff --git a/tickets/submit-segment-protocol.review.md b/tickets/submit-segment-protocol.review.md new file mode 100644 index 00000000..086bac58 --- /dev/null +++ b/tickets/submit-segment-protocol.review.md @@ -0,0 +1,60 @@ +# Review: サブミット入力 protocol Segment 化 + +## 前提・要件の確認 + +### protocol +- `Method::Run` と `Event::UserMessage` が `Vec` で wire を通る: + - `crates/protocol/src/lib.rs:14` (`Run { input: Vec }`) / `:162` (`UserMessage { segments: Vec }`)。`tag = "kind"` の internally-tagged enum で Text/Paste/FileRef/KnowledgeRef/WorkflowInvoke/Unknown を定義 (`:97-125`)。完了。 +- 全 5 variant 定義: 完了 (`:101-119`)。 +- forward compatibility: `#[serde(other)]` で `Segment::Unknown` に吸収 (`:123`)。専用テスト 2 本 (`:416-438`) で deserialize 成功と `Method::Run` 内での共存を確認。完了。 +- dumb client 用ヘルパー: `Segment::text` (`:129`) と `Method::run_text` (`:138`) を用意し、ドキュメントコメントで「`vec![Segment::Text(_)]` のみで動く」前提を明記 (`:84-96`)。完了。 +- `Event::UserMessage roundtrip` の更新版テスト (`:749-770`) も追加済み。 + +### Pod 側 resolve +- `Pod::run` が `Vec` を受け、`flatten_segments` で単一文字列に展開 (`crates/pod/src/pod.rs:580-655`)。Text/Paste は inline、FileRef/KnowledgeRef/WorkflowInvoke/Unknown は `[unresolved : ]` プレースホルダに置換し同時に `Alert(Warn, Pod, …)` を発火。要件の **2 経路同時通知** を満たす。 +- `Pod::run_text` shim (`:561-566`) と `Method::run_text` の対応関係も整合。 +- `interrupt_and_run` も `Vec` シグネチャに揃え、内部で `self.run(input)` に委譲 (`crates/pod/src/interrupt_and_run.rs:27-47`)。 +- Controller 側で `Method::Run { input }` を受けると `Event::UserMessage { segments: input.clone() }` を broadcast し、その後 `pod.run(input)` / `interrupt_and_run(input)` に渡す (`crates/pod/src/controller.rs:273-299`)。Event 経路は typed のまま再放送される。 + +### TUI 側 +- `InputBuffer::submit_segments` が `Atom::Char` を 1 つの `Segment::Text` に collapse、`Atom::Paste` を独立した `Segment::Paste` に分離 (`crates/tui/src/input.rs:198-221`)。テスト 4 本 (`:428-490`) で純テキスト・前後 Text に挟まれた Paste・空入力・先頭 Paste のケースをカバー。 +- `submit_input` は `submit_segments()` を経由して `Method::Run { input: segments }` を送出 (`crates/tui/src/app.rs:64-81`)。空入力 (`segments_are_blank`, `:497-502`) の Pause/Resume 動作も保たれている。 +- `Block::UserMessage` が `segments: Vec` に置き換わり (`crates/tui/src/block.rs:17-19`)、`render_user_message` (`crates/tui/src/ui.rs:368-420`) が paste セグメントを magenta `[Clipboard #N | X chars, Y lines]` で再構築、Overview モードは `segment_display_text` で one-liner にする (`:425-439`)。Unknown variant は `[unknown segment]` 表示。 +- `Event::UserMessage { segments }` ハンドラが typed を直接 `Block::UserMessage` に積む (`crates/tui/src/app.rs:93-100`)。 +- `restore_history` の user message 側はテキストのみを `vec![Segment::text(text)]` でラップ (`:373-376`) — 後述の non-blocking 指摘あり。 + +### unknown variant / 未登録 resolver +- `flatten_segments` が unknown / FileRef / KnowledgeRef / WorkflowInvoke すべてに対し placeholder + `Alert` を発行 (`crates/pod/src/pod.rs:609-651`)。決定済みルール通り。 +- 統合テスト `run_with_unresolved_segment_emits_alert_and_placeholder` (`crates/pod/tests/controller_test.rs:417-467`) が `FileRef` ケースで Alert と placeholder の両発火を end-to-end で立証。 +- `run_with_paste_segment_inlines_content_and_emits_typed_user_message` (`:357-415`) が paste 経路の hybrid 性質 (LLM には inline 本文・Event::UserMessage には typed segments) を立証し、ラベルの LLM への漏洩がないことも明示的に assert (`:414`)。 + +### ライフサイクル/ビルド条件 +- 既存テスト群は `Method::run_text` / `Pod::run_text` への置換で sweep 済み (`crates/pod/examples/*.rs`、`crates/pod/tests/*.rs` 全般)。残存する直接 `Method::Run { input: ... }` は (a) submit_input の本流、(b) 新統合テスト、(c) FFI 側のアサート (`spawn_pod_test.rs:196`、`pod_comm_tools_test.rs:188`) のみで、いずれも Vec 形に揃っている。 +- `Worker::run(String)` 自体は LLM-worker 層の低レベル API なので変更しない判断は妥当。Pod が flatten 一回で接続する単一経路 (要件) と整合。 +- ビルド・テストは緑で、警告は事前から存在する `llm-worker/timeline.rs` の `end_scope` のみ — 本チケットによる退行なし。 + +## アーキテクチャ・スコープ + +- レイヤ境界: `Segment` / placeholder / Alert の生成は **Pod 層** に閉じており、`llm-worker` には漏れていない (Worker は引き続き String を受け取る)。`MEMORY.md` の「llm-worker は低レベル基盤に留める」方針を守れている。 +- TUI 側は `submit_segments` が新責務として追加されただけで、parser や resolver は持ち込まれていない。submit-tui-completion で扱う `@`/`#`/`/` 補完は範囲外、適切に分離されている。 +- 新規の resolver trait は導入されておらず、要件通り「variant 定義 + 未登録時フォールバック」で着地している。後続チケット (memory / workflow) のための余分な抽象化なし。 +- `flatten_segments` を `Pod::run` から呼ぶ形に閉じ込めた点も妥当: 本文展開ロジックを 1 箇所に集中。 +- 新依存の追加なし、新クレートの追加なし。`alerter.rs` / `notify_buffer.rs` は同チケットの notification-naming-cleanup のリネームを取り込んだ既存リファクタの一部であり、本チケットの範囲とは独立して整理されている (本チケット由来ではない)。 + +## 指摘事項 + +### Blocking +なし。 + +### Non-blocking / Follow-up +- **session log replay は paste チップを失う**: 要件本文の「session log / Event::UserMessage 上ではラベル化情報を保持」のうち、Event 経路は満たしているが session log は inlined テキストのみを保持し、`restore_history` で `vec![Segment::text(text)]` に潰れる (`crates/tui/src/app.rs:373-376`)。完了条件の方では「Event 経由の再描画では `[Clipboard #N | ...]` が復元される」と Event 経路に絞られているため本チケットでは合格判断としたが、後で GUI / 別クライアントが履歴 fetch する場面で paste 識別が失われる。完全に保持するには Worker history か session_store に typed segments を別途保存する必要があるため、後続チケット (例えば native-gui-mvp や memory 関連) で「session log にも segment metadata を残すか」を扱うのがよい。 +- **`Segment::Unknown` の end-to-end 統合テストがない**: protocol レベルの deserialize テストはあるが、Pod 統合テストでは FileRef だけが Alert + placeholder の両発火を確認している。Unknown は同一の `flatten_segments` 分岐を通るので回帰耐性は十分だが、forward-compat の信頼の証として 1 ケース足してもよい。 +- **`Event::UserMessage` 経由の paste チップ復元は live subscriber のみが受け取れる**: 後発接続クライアントは `GetHistory` で取れるのが Worker の `Item::Message`(flatten 済み文字列)だけ。上記 1 点目と同じ根の話。 + +### Nits +- `Segment::Unknown` を再シリアライズすると `{"kind":"unknown"}` になり情報損失するが、本チケットでは forward-compat の片方向だけで十分という整理 (`crates/protocol/src/lib.rs:121-122` のコメントに明記済み)。意図通り。 +- `flatten_segments` の placeholder 文言 (`[unresolved file ref: …]` 等) は LLM 側プロンプトの一部になる。将来 prompt catalog に逃がすかは別途検討すべきかも (現時点では英語固定で OK)。 +- `crates/tui/src/ui.rs:404` で `lines: line_count` と `lines` フィールドのシャドウイングを避けるため `line_count` にリネームしているのは適切。 + +## 判断 +**Approve** — チケットの要件と完了条件はすべて満たされており、forward-compat と hybrid フォールバックの両経路が end-to-end のテストで立証されている。session log 側の paste チップ保持は要件本文に言及があるものの完了条件は Event 経路に絞られているため、本チケット範囲外として後続チケットでフォローすればよい。コードベースを歪める方向の追加抽象化は持ち込まれておらず、Pod / TUI / protocol の責務分離も保たれている。