feat: TUIに他Podからの通知を表示する

This commit is contained in:
2026-05-03 12:45:05 +09:00
parent b35bb2154f
commit 702ed79517
7 changed files with 177 additions and 8 deletions
+11
View File
@@ -448,6 +448,9 @@ impl PodController {
}
Method::Notify { message } => {
let _ = event_tx.send(Event::Notify {
message: message.clone(),
});
pod.push_notify(message);
if shared_state.get_status() != PodStatus::Idle {
// RUNNING / Paused: the buffer push is the
@@ -609,6 +612,10 @@ impl PodController {
Method::GetHistory | Method::ListCompletions { .. } => {}
Method::PodEvent(event) => {
// Echo the received event to all subscribers so
// every client sees the input that drove any
// following auto-kicked turn.
let _ = event_tx.send(Event::PodEvent(event.clone()));
// (1) system side effects — idempotent and
// tolerant of out-of-order delivery (e.g.
// `TurnEnded` arriving after `ShutDown`).
@@ -809,12 +816,16 @@ where
});
}
Some(Method::Notify { message }) => {
let _ = event_tx.send(Event::Notify {
message: message.clone(),
});
// Route into the buffer; the in-flight turn will
// drain it at its next pre_llm_request.
notify_buffer.push(message);
}
Some(Method::GetHistory | Method::ListCompletions { .. }) => {}
Some(Method::PodEvent(event)) => {
let _ = event_tx.send(Event::PodEvent(event.clone()));
// mpsc is consume-once, so we cannot defer this
// to the next main-loop iteration — drop here
// would lose the event entirely (children fire
+37
View File
@@ -532,12 +532,16 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
.unwrap();
// Wait for the auto-started turn to complete.
let mut saw_notify_echo = false;
let mut saw_turn_end = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
tokio::select! {
event = rx.recv() => {
match event {
Ok(Event::Notify { ref message }) if message == "turn finished" => {
saw_notify_echo = true;
}
Ok(Event::TurnEnd { .. }) => { saw_turn_end = true; break; }
Err(_) => break,
_ => {}
@@ -546,6 +550,10 @@ async fn notify_while_idle_auto_starts_turn_and_injects_system_message() {
_ = tokio::time::sleep_until(deadline) => break,
}
}
assert!(
saw_notify_echo,
"Method::Notify on idle Pod should be echoed as Event::Notify"
);
assert!(saw_turn_end, "auto-triggered turn should complete");
// Status flips back to Idle on the controller thread after RunEnd.
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
@@ -585,12 +593,18 @@ async fn pod_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_mes
.await
.unwrap();
let mut saw_pod_event_echo = false;
let mut saw_turn_end = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
tokio::select! {
event = rx.recv() => {
match event {
Ok(Event::PodEvent(protocol::PodEvent::TurnEnded { ref pod_name }))
if pod_name == "child" =>
{
saw_pod_event_echo = true;
}
Ok(Event::TurnEnd { .. }) => { saw_turn_end = true; break; }
Err(_) => break,
_ => {}
@@ -599,6 +613,10 @@ async fn pod_event_turn_ended_while_idle_auto_starts_turn_and_injects_system_mes
_ = tokio::time::sleep_until(deadline) => break,
}
}
assert!(
saw_pod_event_echo,
"Method::PodEvent on idle Pod should be echoed as Event::PodEvent"
);
assert!(
saw_turn_end,
"PodEvent::TurnEnded on idle Pod should auto-start a turn"
@@ -644,6 +662,8 @@ async fn notify_while_running_does_not_emit_already_running_error() {
.unwrap();
// Drain events until the run ends; AlreadyRunning must never appear.
// The in-flight branch must still echo the Notify as a log element.
let mut saw_notify_echo = false;
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2);
loop {
tokio::select! {
@@ -652,6 +672,9 @@ async fn notify_while_running_does_not_emit_already_running_error() {
Ok(Event::Error { code, .. }) if code == pod::ErrorCode::AlreadyRunning => {
panic!("Notify while running must not produce AlreadyRunning");
}
Ok(Event::Notify { ref message }) if message == "ping" => {
saw_notify_echo = true;
}
Ok(Event::TurnEnd { .. }) => break,
Err(_) => break,
_ => {}
@@ -660,6 +683,10 @@ async fn notify_while_running_does_not_emit_already_running_error() {
_ = tokio::time::sleep_until(deadline) => break,
}
}
assert!(
saw_notify_echo,
"in-flight Notify must still be echoed as Event::Notify"
);
}
#[tokio::test]
@@ -751,6 +778,7 @@ async fn socket_pod_event_turn_ended_while_idle_auto_starts_turn() {
.await
.unwrap();
let mut saw_pod_event_echo = false;
let mut saw_turn_start = false;
let mut saw_turn_end = false;
@@ -759,6 +787,11 @@ async fn socket_pod_event_turn_ended_while_idle_auto_starts_turn() {
tokio::select! {
event = reader.next::<Event>() => {
match event {
Ok(Some(Event::PodEvent(protocol::PodEvent::TurnEnded { pod_name })))
if pod_name == "child" =>
{
saw_pod_event_echo = true;
}
Ok(Some(Event::TurnStart { .. })) => saw_turn_start = true,
Ok(Some(Event::TurnEnd { .. })) => {
saw_turn_end = true;
@@ -772,6 +805,10 @@ async fn socket_pod_event_turn_ended_while_idle_auto_starts_turn() {
}
}
assert!(
saw_pod_event_echo,
"PodEvent::TurnEnded via socket should be echoed as Event::PodEvent"
);
assert!(
saw_turn_start,
"PodEvent::TurnEnded via socket should auto-start a turn"
+51
View File
@@ -214,6 +214,20 @@ pub enum Event {
UserMessage {
segments: Vec<Segment>,
},
/// Echo of `Method::Notify` received by this Pod. Broadcast on
/// receipt so subscribers can render the external input as a log
/// element. The same `message` is independently pushed into the
/// notification buffer for LLM injection (with prompt-pack
/// wrapping); this echo carries the raw payload and does not
/// imply any turn-boundary semantics.
Notify {
message: String,
},
/// Echo of `Method::PodEvent` received by this Pod. Same rationale
/// as `Notify`: subscribers render the event as a log element,
/// while a rendered summary is independently injected into the LLM
/// context via the notification buffer.
PodEvent(PodEvent),
TurnStart {
turn: usize,
},
@@ -930,6 +944,43 @@ mod tests {
assert_eq!(parsed["data"]["code"], "already_running");
}
#[test]
fn event_notify_roundtrip() {
let event = Event::Notify {
message: "child-pod finished".into(),
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "notify");
assert_eq!(parsed["data"]["message"], "child-pod finished");
let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded {
Event::Notify { message } => assert_eq!(message, "child-pod finished"),
other => panic!("expected Notify, got {other:?}"),
}
}
#[test]
fn event_pod_event_roundtrip() {
let event = Event::PodEvent(PodEvent::TurnEnded {
pod_name: "child".into(),
});
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "pod_event");
assert_eq!(parsed["data"]["kind"], "turn_ended");
assert_eq!(parsed["data"]["pod_name"], "child");
let decoded: Event = serde_json::from_str(&json).unwrap();
match decoded {
Event::PodEvent(PodEvent::TurnEnded { pod_name }) => {
assert_eq!(pod_name, "child");
}
other => panic!("expected PodEvent::TurnEnded, got {other:?}"),
}
}
#[test]
fn event_user_message_roundtrip() {
let event = Event::UserMessage {
+8
View File
@@ -312,6 +312,14 @@ impl App {
self.blocks.push(Block::UserMessage { segments });
self.assistant_streaming = false;
}
Event::Notify { message } => {
self.blocks.push(Block::Notify { message });
self.assistant_streaming = false;
}
Event::PodEvent(event) => {
self.blocks.push(Block::PodEvent { event });
self.assistant_streaming = false;
}
Event::TurnStart { .. } => {
self.running = true;
self.paused = false;
+12 -1
View File
@@ -9,7 +9,7 @@
use std::time::Instant;
use protocol::{AlertLevel, AlertSource, Greeting, Segment};
use protocol::{AlertLevel, AlertSource, Greeting, PodEvent, Segment};
pub enum Block {
Greeting(Greeting),
@@ -19,6 +19,17 @@ pub enum Block {
UserMessage {
segments: Vec<Segment>,
},
/// Echo of `Method::Notify` received by this Pod, surfaced as a log
/// element so subscribers see the external input that drove any
/// following auto-kicked turn.
Notify {
message: String,
},
/// Echo of `Method::PodEvent` received by this Pod. Same role as
/// `Notify` — an input log element, not a turn-control signal.
PodEvent {
event: PodEvent,
},
AssistantText {
text: String,
},
+43 -1
View File
@@ -22,7 +22,7 @@ use ratatui::widgets::{
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use protocol::{AlertLevel, CompletionEntry, Greeting, Segment};
use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment};
use crate::app::{App, CompletionState, alert_source_label, fmt_tokens};
use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
@@ -361,6 +361,20 @@ fn render_block_into(lines: &mut Vec<Line<'static>>, block: &Block, width: u16,
)));
}
Block::UserMessage { segments } => render_user_message(lines, segments, width, mode),
Block::Notify { message } => {
let text = format!("[notify] {message}");
match mode {
Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""),
_ => push_padded_lines(lines, &text, MessageKind::Notify),
}
}
Block::PodEvent { event } => {
let text = format_pod_event(event);
match mode {
Mode::Overview => push_overview_line(lines, &text, width, MessageKind::Notify, ""),
_ => push_padded_lines(lines, &text, MessageKind::Notify),
}
}
Block::AssistantText { text } => match mode {
Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""),
_ => push_padded_lines(lines, text, MessageKind::Assistant),
@@ -913,6 +927,10 @@ fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
pub enum MessageKind {
TurnHeader,
User,
/// External-input echoes (`Method::Notify` / `Method::PodEvent`).
/// Visually distinct from User / Assistant / Notice so it's clear
/// the line came from another Pod or operator, not the local user.
Notify,
Assistant,
Thinking,
TurnStats,
@@ -924,6 +942,7 @@ pub fn kind_style(kind: MessageKind) -> Style {
match kind {
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
MessageKind::User => Style::default().fg(Color::Green),
MessageKind::Notify => Style::default().fg(Color::Yellow),
MessageKind::Assistant => Style::default().fg(Color::White),
MessageKind::Thinking => Style::default()
.fg(Color::Magenta)
@@ -939,3 +958,26 @@ pub fn kind_style(kind: MessageKind) -> Style {
.add_modifier(Modifier::BOLD),
}
}
/// One-line summary of a `PodEvent` for display in the activity log.
/// Independent from the LLM-injection wrapper (`crate::ipc::event::render_event`
/// in the pod crate) — that path applies prompt-pack wrapping, while
/// this is the human-facing rendering of the raw structured event.
fn format_pod_event(event: &PodEvent) -> String {
match event {
PodEvent::TurnEnded { pod_name } => {
format!("[pod_event] {pod_name} → turn_ended")
}
PodEvent::Errored { pod_name, message } => {
format!("[pod_event] {pod_name} → errored: {message}")
}
PodEvent::ShutDown { pod_name } => {
format!("[pod_event] {pod_name} → shut_down")
}
PodEvent::ScopeSubDelegated {
parent_pod, sub_pod, ..
} => {
format!("[pod_event] {parent_pod} → scope_sub_delegated: {sub_pod}")
}
}
}