submitをvec segmentを受け付ける形に変更
This commit is contained in:
parent
e0c4dbdc73
commit
2722e0b7ba
|
|
@ -57,7 +57,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
println!("Session: {}", pod.session_id());
|
println!("Session: {}", pod.session_id());
|
||||||
|
|
||||||
// 4. Run a prompt
|
// 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 {
|
match result {
|
||||||
PodRunResult::Finished => println!("(finished)"),
|
PodRunResult::Finished => println!("(finished)"),
|
||||||
PodRunResult::Paused => println!("(paused)"),
|
PodRunResult::Paused => println!("(paused)"),
|
||||||
|
|
|
||||||
|
|
@ -93,9 +93,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
|
||||||
// Send a run method
|
// Send a run method
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("What is the capital of France?"))
|
||||||
input: "What is the capital of France?".into(),
|
|
||||||
})
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Wait for completion
|
// Wait for completion
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,7 @@ impl PodController {
|
||||||
// render the turn header + user line from a
|
// render the turn header + user line from a
|
||||||
// single source of truth.
|
// single source of truth.
|
||||||
let _ = event_tx.send(Event::UserMessage {
|
let _ = event_tx.send(Event::UserMessage {
|
||||||
text: input.clone(),
|
segments: input.clone(),
|
||||||
});
|
});
|
||||||
let was_paused = status_before == PodStatus::Paused;
|
let was_paused = status_before == PodStatus::Paused;
|
||||||
shared_state.set_status(PodStatus::Running);
|
shared_state.set_status(PodStatus::Running);
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
|
|
||||||
use llm_worker::Item;
|
use llm_worker::Item;
|
||||||
use llm_worker::llm_client::client::LlmClient;
|
use llm_worker::llm_client::client::LlmClient;
|
||||||
|
use protocol::Segment;
|
||||||
use session_store::Store;
|
use session_store::Store;
|
||||||
|
|
||||||
use crate::pod::{Pod, PodError, PodRunResult};
|
use crate::pod::{Pod, PodError, PodRunResult};
|
||||||
|
|
@ -25,7 +26,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
/// rationale around synthetic tool results.
|
/// rationale around synthetic tool results.
|
||||||
pub async fn interrupt_and_run(
|
pub async fn interrupt_and_run(
|
||||||
&mut self,
|
&mut self,
|
||||||
input: impl Into<String>,
|
input: Vec<Segment>,
|
||||||
) -> Result<PodRunResult, PodError> {
|
) -> Result<PodRunResult, PodError> {
|
||||||
let tool_result_summary = self
|
let tool_result_summary = self
|
||||||
.prompts()
|
.prompts()
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ use crate::runtime::dir;
|
||||||
use crate::runtime::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
|
use crate::runtime::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
|
||||||
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
use crate::prompt::system::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use protocol::{Event, AlertLevel, AlertSource};
|
use protocol::{AlertLevel, AlertSource, Event, Segment};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use llm_worker::interceptor::PreRequestAction;
|
use llm_worker::interceptor::PreRequestAction;
|
||||||
|
|
@ -553,27 +553,107 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
Ok(())
|
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<String>,
|
||||||
|
) -> Result<PodRunResult, PodError> {
|
||||||
|
self.run(vec![Segment::text(s)]).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Send user input and run until the LLM turn completes.
|
/// 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,
|
/// If the between-turns compaction threshold is exceeded mid-run,
|
||||||
/// the Worker is aborted, history is compacted, and execution resumes
|
/// the Worker is aborted, history is compacted, and execution resumes
|
||||||
/// automatically.
|
/// automatically.
|
||||||
pub async fn run(&mut self, input: impl Into<String>) -> Result<PodRunResult, PodError> {
|
pub async fn run(&mut self, input: Vec<Segment>) -> Result<PodRunResult, PodError> {
|
||||||
self.ensure_interceptor_installed();
|
self.ensure_interceptor_installed();
|
||||||
self.ensure_system_prompt_materialized()?;
|
self.ensure_system_prompt_materialized()?;
|
||||||
self.ensure_session_head().await?;
|
self.ensure_session_head().await?;
|
||||||
|
|
||||||
|
let flattened = self.flatten_segments(&input);
|
||||||
|
|
||||||
let history_before = self.worker.as_ref().unwrap().history().len();
|
let history_before = self.worker.as_ref().unwrap().history().len();
|
||||||
|
|
||||||
// lock → run → unlock
|
// lock → run → unlock
|
||||||
let worker = self.worker.take().expect("worker taken during run");
|
let worker = self.worker.take().expect("worker taken during run");
|
||||||
let mut locked = worker.lock();
|
let mut locked = worker.lock();
|
||||||
let result = locked.run(input).await;
|
let result = locked.run(flattened).await;
|
||||||
self.worker = Some(locked.unlock());
|
self.worker = Some(locked.unlock());
|
||||||
|
|
||||||
self.handle_worker_result(result, history_before).await
|
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 <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.
|
||||||
|
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.
|
/// Run a turn triggered by `Method::Notify` while the Pod is idle.
|
||||||
///
|
///
|
||||||
/// Unlike [`run`](Self::run), no user message is appended to
|
/// Unlike [`run`](Self::run), no user message is appended to
|
||||||
|
|
|
||||||
|
|
@ -358,7 +358,12 @@ async fn send_run_and_confirm(socket: &Path, input: String) -> Result<(), SendRu
|
||||||
let (r, w) = stream.into_split();
|
let (r, w) = stream.into_split();
|
||||||
let mut writer = JsonLineWriter::new(w);
|
let mut writer = JsonLineWriter::new(w);
|
||||||
let mut reader = JsonLineReader::new(r);
|
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
|
.await
|
||||||
.map_err(|_| SendRunError::Io("write timed out".into()))?
|
.map_err(|_| SendRunError::Io("write timed out".into()))?
|
||||||
.map_err(|e| SendRunError::Io(format!("write: {e}")))?;
|
.map_err(|e| SendRunError::Io(format!("write: {e}")))?;
|
||||||
|
|
|
||||||
|
|
@ -424,7 +424,7 @@ async fn send_run(socket: &Path, task: &str) -> Result<(), ToolError> {
|
||||||
let (_reader, writer) = stream.into_split();
|
let (_reader, writer) = stream.into_split();
|
||||||
let mut w = JsonLineWriter::new(writer);
|
let mut w = JsonLineWriter::new(writer);
|
||||||
w.write(&Method::Run {
|
w.write(&Method::Run {
|
||||||
input: task.to_string(),
|
input: vec![protocol::Segment::text(task)],
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("send Method::Run: {e}")))?;
|
.map_err(|e| ToolError::ExecutionFailed(format!("send Method::Run: {e}")))?;
|
||||||
|
|
|
||||||
|
|
@ -192,7 +192,7 @@ async fn post_run_compact_success_broadcasts_start_and_done() {
|
||||||
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||||
pod.attach_event_tx(tx);
|
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`.
|
// Drain run events so only compact events remain in `rx`.
|
||||||
let _ = drain(&mut rx);
|
let _ = drain(&mut rx);
|
||||||
|
|
||||||
|
|
@ -248,12 +248,12 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
||||||
pod.attach_event_tx(tx);
|
pod.attach_event_tx(tx);
|
||||||
|
|
||||||
// First run populates usage_history above the request threshold.
|
// First run populates usage_history above the request threshold.
|
||||||
pod.run("first").await.unwrap();
|
pod.run_text("first").await.unwrap();
|
||||||
let _ = drain(&mut rx);
|
let _ = drain(&mut rx);
|
||||||
|
|
||||||
// Second run: pre_llm_request yields immediately, Worker returns
|
// Second run: pre_llm_request yields immediately, Worker returns
|
||||||
// Yielded, handle_worker_result routes into do_compact_and_resume.
|
// 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 events = drain(&mut rx);
|
||||||
let kinds: Vec<&str> = events
|
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::<Event>(64);
|
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||||
pod.attach_event_tx(tx);
|
pod.attach_event_tx(tx);
|
||||||
|
|
||||||
pod.run("first").await.unwrap();
|
pod.run_text("first").await.unwrap();
|
||||||
let _ = drain(&mut rx);
|
let _ = drain(&mut rx);
|
||||||
|
|
||||||
// Best-effort: returns Ok(()) even on failure, but emits CompactFailed.
|
// Best-effort: returns Ok(()) even on failure, but emits CompactFailed.
|
||||||
|
|
|
||||||
|
|
@ -170,9 +170,7 @@ async fn run_updates_shared_state_to_idle_after_completion() {
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("Hello"))
|
||||||
input: "Hello".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -189,9 +187,7 @@ async fn run_populates_history() {
|
||||||
let handle = spawn_controller(pod).await;
|
let handle = spawn_controller(pod).await;
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("Hello"))
|
||||||
input: "Hello".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -212,9 +208,7 @@ async fn events_are_broadcast() {
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("Hello"))
|
||||||
input: "Hello".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -265,17 +259,13 @@ async fn double_run_returns_error() {
|
||||||
|
|
||||||
// Send first run
|
// Send first run
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("first"))
|
||||||
input: "first".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Immediately send second run (should get error)
|
// Immediately send second run (should get error)
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("second"))
|
||||||
input: "second".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -363,6 +353,119 @@ async fn cancel_without_run_returns_error() {
|
||||||
assert!(saw_not_running, "should see not_running 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<Vec<protocol::Segment>> = 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]
|
#[tokio::test]
|
||||||
async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
|
||||||
let client = MockClient::new(simple_text_events());
|
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();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("start"))
|
||||||
input: "start".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
handle
|
handle
|
||||||
|
|
@ -491,9 +592,7 @@ async fn socket_run_receives_events() {
|
||||||
|
|
||||||
// Send run method via socket
|
// Send run method via socket
|
||||||
writer
|
writer
|
||||||
.write(&Method::Run {
|
.write(&Method::run_text("Hello"))
|
||||||
input: "Hello".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -641,9 +740,7 @@ async fn pause_then_resume_transitions_and_preserves_history_consistency() {
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("hello"))
|
||||||
input: "hello".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
@ -754,9 +851,7 @@ async fn paused_then_run_closes_orphan_tool_use_for_next_request() {
|
||||||
let mut rx = handle.subscribe();
|
let mut rx = handle.subscribe();
|
||||||
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("first"))
|
||||||
input: "first".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.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
|
// `Pod::interrupt_and_run`, which closes the orphan + injects a
|
||||||
// system note before the fresh user message.
|
// system note before the fresh user message.
|
||||||
handle
|
handle
|
||||||
.send(Method::Run {
|
.send(Method::run_text("new request"))
|
||||||
input: "new request".into(),
|
|
||||||
})
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,10 @@ async fn send_to_pod_delivers_run_method() {
|
||||||
|
|
||||||
let method = received.await.unwrap().expect("expected a method");
|
let method = received.await.unwrap().expect("expected a method");
|
||||||
match 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:?}"),
|
other => panic!("expected Run, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,10 @@ async fn spawn_pod_delegates_scope_and_sends_run() {
|
||||||
// Verify the tool delivered Method::Run to the socket.
|
// Verify the tool delivered Method::Run to the socket.
|
||||||
let method = received.await.unwrap().expect("expected one Method line");
|
let method = received.await.unwrap().expect("expected one Method line");
|
||||||
match method {
|
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:?}"),
|
other => panic!("expected Run, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ async fn materialise_on_first_turn_populates_worker() {
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
pod.run("hi").await.unwrap();
|
pod.run_text("hi").await.unwrap();
|
||||||
let rendered = pod
|
let rendered = pod
|
||||||
.worker()
|
.worker()
|
||||||
.get_system_prompt()
|
.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)
|
let (mut pod, pwd) = make_pod_with_body("hello cwd={{ cwd }}", client)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
pod.run("hi").await.unwrap();
|
pod.run_text("hi").await.unwrap();
|
||||||
|
|
||||||
let entries = pod.store().read_all(pod.session_id()).await.unwrap();
|
let entries = pod.store().read_all(pod.session_id()).await.unwrap();
|
||||||
let first = entries.first().expect("at least one entry");
|
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() {
|
async fn render_failure_propagates_as_pod_error() {
|
||||||
let client = MockClient::new(vec![single_text_events("ok")]);
|
let client = MockClient::new(vec![single_text_events("ok")]);
|
||||||
let (mut pod, _pwd) = make_pod_with_body("{{ ghost }}", client).await.unwrap();
|
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 { .. }));
|
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)
|
let (mut pod, _pwd) = make_pod_with_body("fixed prompt {{ cwd }}", client)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
pod.run("one").await.unwrap();
|
pod.run_text("one").await.unwrap();
|
||||||
let first = pod.worker().get_system_prompt().unwrap().to_string();
|
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();
|
let second = pod.worker().get_system_prompt().unwrap().to_string();
|
||||||
assert_eq!(first, second);
|
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();
|
let (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap();
|
||||||
std::fs::write(pwd.join("AGENTS.md"), "# project rules\nbe kind").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();
|
let rendered = pod.worker().get_system_prompt().unwrap().to_string();
|
||||||
assert!(rendered.starts_with("BODY"));
|
assert!(rendered.starts_with("BODY"));
|
||||||
assert!(rendered.contains("## Project instructions (AGENTS.md)"));
|
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() {
|
async fn agents_md_absent_omits_trailing_section() {
|
||||||
let client = MockClient::new(vec![single_text_events("ok")]);
|
let client = MockClient::new(vec![single_text_events("ok")]);
|
||||||
let (mut pod, _pwd) = make_pod_with_body("BODY", client).await.unwrap();
|
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();
|
let rendered = pod.worker().get_system_prompt().unwrap().to_string();
|
||||||
assert!(!rendered.contains("## Project instructions"));
|
assert!(!rendered.contains("## Project instructions"));
|
||||||
assert!(!rendered.contains("AGENTS.md"));
|
assert!(!rendered.contains("AGENTS.md"));
|
||||||
|
|
@ -246,20 +246,20 @@ async fn agents_md_absent_omits_trailing_section() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn agents_md_not_reread_after_compact() {
|
async fn agents_md_not_reread_after_compact() {
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
single_text_events("a"), // pod.run("first")
|
single_text_events("a"), // pod.run_text("first")
|
||||||
single_text_events("b"), // pod.run("second")
|
single_text_events("b"), // pod.run_text("second")
|
||||||
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
||||||
single_text_events("done"), // compact worker: close
|
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 (mut pod, pwd) = make_pod_with_body("BODY", client).await.unwrap();
|
||||||
let agents_path = pwd.join("AGENTS.md");
|
let agents_path = pwd.join("AGENTS.md");
|
||||||
std::fs::write(&agents_path, "original").unwrap();
|
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();
|
let before = pod.worker().get_system_prompt().unwrap().to_string();
|
||||||
assert!(before.contains("original"));
|
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
|
// Mutate the file after the first turn — must not affect the cached
|
||||||
// system prompt either on a subsequent turn or across compaction.
|
// 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("original"));
|
||||||
assert!(!after_compact.contains("mutated"));
|
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();
|
let after_third = pod.worker().get_system_prompt().unwrap().to_string();
|
||||||
assert!(after_third.contains("original"));
|
assert!(after_third.contains("original"));
|
||||||
assert!(!after_third.contains("mutated"));
|
assert!(!after_third.contains("mutated"));
|
||||||
|
|
@ -278,25 +278,25 @@ async fn agents_md_not_reread_after_compact() {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn compact_preserves_system_prompt() {
|
async fn compact_preserves_system_prompt() {
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
single_text_events("a"), // pod.run("first")
|
single_text_events("a"), // pod.run_text("first")
|
||||||
single_text_events("b"), // pod.run("second")
|
single_text_events("b"), // pod.run_text("second")
|
||||||
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
write_summary_tool_use_events("call-1", "compacted summary"), // compact worker: tool_use
|
||||||
single_text_events("done"), // compact worker: close
|
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)
|
let (mut pod, _pwd) = make_pod_with_body("SP cwd={{ cwd }}", client)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
pod.run("first").await.unwrap();
|
pod.run_text("first").await.unwrap();
|
||||||
let before = pod.worker().get_system_prompt().unwrap().to_string();
|
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();
|
pod.compact(0).await.unwrap();
|
||||||
|
|
||||||
let after = pod.worker().get_system_prompt().unwrap().to_string();
|
let after = pod.worker().get_system_prompt().unwrap().to_string();
|
||||||
assert_eq!(before, after);
|
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());
|
assert_eq!(pod.worker().get_system_prompt().unwrap(), after.as_str());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
#[serde(tag = "method", content = "params", rename_all = "snake_case")]
|
||||||
pub enum Method {
|
pub enum Method {
|
||||||
Run { input: String },
|
Run { input: Vec<Segment> },
|
||||||
/// Human-readable text injected into the target Pod's LLM context
|
/// Human-readable text injected into the target Pod's LLM context
|
||||||
/// as a non-blocking system message. No side effects beyond LLM
|
/// as a non-blocking system message. No side effects beyond LLM
|
||||||
/// context; use `PodEvent` for typed lifecycle reports.
|
/// 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<Segment>`. 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,
|
||||||
|
},
|
||||||
|
/// `@<path>` 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 },
|
||||||
|
/// `#<slug>` Knowledge reference (see `docs/plan/memory.md`).
|
||||||
|
KnowledgeRef { slug: String },
|
||||||
|
/// `/<slug>` 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<String>) -> 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<String>) -> Self {
|
||||||
|
Self::Run {
|
||||||
|
input: vec![Segment::text(s)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Event (Pod → Client via Unix Socket broadcast)
|
// Event (Pod → Client via Unix Socket broadcast)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -93,7 +159,7 @@ pub enum Event {
|
||||||
/// Fires exactly once per accepted `Method::Run`, before
|
/// Fires exactly once per accepted `Method::Run`, before
|
||||||
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit.
|
/// `TurnStart`. Rejected runs (e.g. `AlreadyRunning`) do not emit.
|
||||||
UserMessage {
|
UserMessage {
|
||||||
text: String,
|
segments: Vec<Segment>,
|
||||||
},
|
},
|
||||||
TurnStart {
|
TurnStart {
|
||||||
turn: usize,
|
turn: usize,
|
||||||
|
|
@ -293,14 +359,84 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn method_run_json_roundtrip() {
|
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();
|
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();
|
let serialized = serde_json::to_string(&method).unwrap();
|
||||||
assert_eq!(serialized, json);
|
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]
|
#[test]
|
||||||
fn method_without_params() {
|
fn method_without_params() {
|
||||||
let json = r#"{"method":"resume"}"#;
|
let json = r#"{"method":"resume"}"#;
|
||||||
|
|
@ -612,16 +748,23 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn event_user_message_roundtrip() {
|
fn event_user_message_roundtrip() {
|
||||||
let event = Event::UserMessage {
|
let event = Event::UserMessage {
|
||||||
text: "hello 世界".into(),
|
segments: vec![Segment::text("hello 世界")],
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "user_message");
|
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();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
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:?}"),
|
other => panic!("expected UserMessage, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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::block::{Block, CompactEvent, ToolCallBlock, ToolCallState};
|
||||||
use crate::cache::FileCache;
|
use crate::cache::FileCache;
|
||||||
|
|
@ -62,8 +62,8 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn submit_input(&mut self) -> Option<Method> {
|
pub fn submit_input(&mut self) -> Option<Method> {
|
||||||
let text = self.input.submit_text().trim().to_owned();
|
let segments = self.input.submit_segments();
|
||||||
if text.is_empty() {
|
if segments_are_blank(&segments) {
|
||||||
// Empty Enter only does something meaningful when the Pod
|
// Empty Enter only does something meaningful when the Pod
|
||||||
// is paused: resume the interrupted turn. Otherwise no-op.
|
// is paused: resume the interrupted turn. Otherwise no-op.
|
||||||
if self.paused {
|
if self.paused {
|
||||||
|
|
@ -77,7 +77,7 @@ impl App {
|
||||||
// client subscribed to the Pod). Locally we only clear the
|
// client subscribed to the Pod). Locally we only clear the
|
||||||
// input buffer and forward the method.
|
// input buffer and forward the method.
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
Some(Method::Run { input: text })
|
Some(Method::Run { input: segments })
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn push_error(&mut self, message: impl Into<String>) {
|
pub fn push_error(&mut self, message: impl Into<String>) {
|
||||||
|
|
@ -90,12 +90,12 @@ impl App {
|
||||||
|
|
||||||
pub fn handle_pod_event(&mut self, event: Event) {
|
pub fn handle_pod_event(&mut self, event: Event) {
|
||||||
match event {
|
match event {
|
||||||
Event::UserMessage { text } => {
|
Event::UserMessage { segments } => {
|
||||||
self.turn_index += 1;
|
self.turn_index += 1;
|
||||||
self.blocks.push(Block::TurnHeader {
|
self.blocks.push(Block::TurnHeader {
|
||||||
turn: self.turn_index,
|
turn: self.turn_index,
|
||||||
});
|
});
|
||||||
self.blocks.push(Block::UserMessage { text });
|
self.blocks.push(Block::UserMessage { segments });
|
||||||
self.assistant_streaming = false;
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
Event::TurnStart { .. } => {
|
Event::TurnStart { .. } => {
|
||||||
|
|
@ -370,7 +370,9 @@ impl App {
|
||||||
turn: self.turn_index,
|
turn: self.turn_index,
|
||||||
});
|
});
|
||||||
if !text.is_empty() {
|
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() => {
|
"assistant" if !text.is_empty() => {
|
||||||
|
|
@ -488,6 +490,17 @@ fn strip_cat_n_prefix(formatted: &str) -> String {
|
||||||
out
|
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 {
|
pub fn alert_source_label(source: AlertSource) -> &'static str {
|
||||||
match source {
|
match source {
|
||||||
AlertSource::Pod => "pod",
|
AlertSource::Pod => "pod",
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
#![allow(dead_code)] // Phase 5 will consume `output` in detail mode.
|
#![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 {
|
pub enum Block {
|
||||||
Greeting(Greeting),
|
Greeting(Greeting),
|
||||||
|
|
@ -15,7 +15,7 @@ pub enum Block {
|
||||||
turn: usize,
|
turn: usize,
|
||||||
},
|
},
|
||||||
UserMessage {
|
UserMessage {
|
||||||
text: String,
|
segments: Vec<Segment>,
|
||||||
},
|
},
|
||||||
AssistantText {
|
AssistantText {
|
||||||
text: String,
|
text: String,
|
||||||
|
|
|
||||||
|
|
@ -190,16 +190,33 @@ impl InputBuffer {
|
||||||
(start, self.cursor - start)
|
(start, self.cursor - start)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flatten atoms into the text sent to the Pod: paste atoms expand
|
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
|
||||||
/// to their original content; no `[Clipboard ...]` labels survive.
|
/// `Atom::Char`s are concatenated into a single `Segment::Text`;
|
||||||
pub fn submit_text(&self) -> String {
|
/// each `Atom::Paste` becomes a standalone `Segment::Paste` so the
|
||||||
let mut out = String::new();
|
/// `[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<protocol::Segment> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut buf = String::new();
|
||||||
for a in &self.atoms {
|
for a in &self.atoms {
|
||||||
match a {
|
match a {
|
||||||
Atom::Char(c) => out.push(*c),
|
Atom::Char(c) => buf.push(*c),
|
||||||
Atom::Paste(p) => out.push_str(&p.content),
|
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
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -402,3 +419,73 @@ pub struct InputRender {
|
||||||
pub cursor_row: u16,
|
pub cursor_row: u16,
|
||||||
pub cursor_col: 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 { .. }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
|
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
|
||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
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::app::{App, fmt_tokens, alert_source_label};
|
||||||
use crate::block::{Block, CompactEvent};
|
use crate::block::{Block, CompactEvent};
|
||||||
|
|
@ -299,13 +299,7 @@ fn render_block_into(
|
||||||
kind_style(MessageKind::TurnHeader),
|
kind_style(MessageKind::TurnHeader),
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
Block::UserMessage { text } => match mode {
|
Block::UserMessage { segments } => render_user_message(lines, segments, width, 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::AssistantText { text } => match mode {
|
Block::AssistantText { text } => match mode {
|
||||||
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
|
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
|
||||||
_ => push_padded_lines(lines, text, MessageKind::Assistant),
|
_ => push_padded_lines(lines, text, MessageKind::Assistant),
|
||||||
|
|
@ -363,6 +357,87 @@ fn push_padded_lines(lines: &mut Vec<Line<'static>>, 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<Line<'static>>,
|
||||||
|
segments: &[Segment],
|
||||||
|
width: u16,
|
||||||
|
mode: Mode,
|
||||||
|
) {
|
||||||
|
if matches!(mode, Mode::Overview) {
|
||||||
|
let text = segments
|
||||||
|
.iter()
|
||||||
|
.map(segment_display_text)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<Span<'static>> = 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
|
/// Single-line summary for overview mode. The output is clipped to
|
||||||
/// exactly one rendered terminal row at `width` columns — the first
|
/// exactly one rendered terminal row at `width` columns — the first
|
||||||
/// non-empty logical line is truncated (with `…`) to fit alongside an
|
/// non-empty logical line is truncated (with `…`) to fit alongside an
|
||||||
|
|
|
||||||
|
|
@ -80,3 +80,8 @@ text しか作れない client が引き続き存在しても良いことを pro
|
||||||
- `crates/protocol/src/lib.rs`(`Method::Run`, `Event::UserMessage`)
|
- `crates/protocol/src/lib.rs`(`Method::Run`, `Event::UserMessage`)
|
||||||
- `crates/tui/src/input.rs`(`Atom::Paste`, `submit_text`)
|
- `crates/tui/src/input.rs`(`Atom::Paste`, `submit_text`)
|
||||||
- `crates/tui/src/app.rs`(`submit_input`, `Block::UserMessage` 描画)
|
- `crates/tui/src/app.rs`(`submit_input`, `Block::UserMessage` 描画)
|
||||||
|
|
||||||
|
## Review
|
||||||
|
- 状態: Approve
|
||||||
|
- レビュー詳細: [./submit-segment-protocol.review.md](./submit-segment-protocol.review.md)
|
||||||
|
- 日付: 2026-04-27
|
||||||
|
|
|
||||||
60
tickets/submit-segment-protocol.review.md
Normal file
60
tickets/submit-segment-protocol.review.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Review: サブミット入力 protocol Segment 化
|
||||||
|
|
||||||
|
## 前提・要件の確認
|
||||||
|
|
||||||
|
### protocol
|
||||||
|
- `Method::Run` と `Event::UserMessage` が `Vec<Segment>` で wire を通る:
|
||||||
|
- `crates/protocol/src/lib.rs:14` (`Run { input: Vec<Segment> }`) / `:162` (`UserMessage { segments: Vec<Segment> }`)。`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<Segment>` を受け、`flatten_segments` で単一文字列に展開 (`crates/pod/src/pod.rs:580-655`)。Text/Paste は inline、FileRef/KnowledgeRef/WorkflowInvoke/Unknown は `[unresolved <kind>: <key>]` プレースホルダに置換し同時に `Alert(Warn, Pod, …)` を発火。要件の **2 経路同時通知** を満たす。
|
||||||
|
- `Pod::run_text` shim (`:561-566`) と `Method::run_text` の対応関係も整合。
|
||||||
|
- `interrupt_and_run` も `Vec<Segment>` シグネチャに揃え、内部で `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<Segment>` に置き換わり (`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<Segment> 形に揃っている。
|
||||||
|
- `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 の責務分離も保たれている。
|
||||||
Loading…
Reference in New Issue
Block a user