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

This commit is contained in:
Keisuke Hirata 2026-05-03 12:45:05 +09:00
parent 69a6f63023
commit cae18a4339
7 changed files with 177 additions and 8 deletions

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

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"

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 {

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;

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,
},

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}")
}
}
}

View File

@ -1,20 +1,29 @@
# TUI で auto-kick 由来のターンが表示されない
# TUI で Pod への外部入力 (Notify / PodEvent) が描画されない
## 背景
Pod が `Method::PodEvent::TurnEnded` などを socket 経由で受信すると、controller は notification を notify buffer に積み、Idle なら `pod.run_for_notification()` で新しいターンを起動する(`crates/pod/src/controller.rs:611-687`)。このターンの assistant 出力 (`Event::TurnStart` / `TextDelta` / `TurnEnd` 等) は通常通り broadcast Event として全クライアントTUI 含む)に配信されるはず
Pod が `Method::Notify` / `Method::PodEvent` を socket 経由で受信すると、controller は内容を notify buffer に積み、Idle なら `pod.run_for_notification()` で新しいターンを起動する(`crates/pod/src/controller.rs`。auto-kick されたターンの assistant 出力 (`Event::TurnStart` / `TextDelta` / `TurnEnd` 等) は通常通り broadcast Event として全クライアントTUI 含む)に配信される。
## 問題
socat で稼働中の codex-oauth pod の socket に `Method::PodEvent::TurnEnded` を 1 行流したところ、socat 側の subscribe には turn が完全に流れてきたthinking_delta / text_done / turn_end 取得済み)が、同じ pod を起動している TUI 画面には新ターンが描画されなかった。
`Method::Run` 経由の通常ターンは TUI に表示されるので、broadcast 配信そのものは生きている。auto-kick 由来のターンuser_message を伴わない turnに固有の表示パスで落ちている可能性が高い
`Method::Run` 経由の通常ターンは TUI に表示されるので、broadcast 配信そのものは生きている。原因は **Pod が受信した外部入力 (`Method::Notify` / `Method::PodEvent`) が broadcast event として echo されておらず**、TUI からは「何も入力がない状態で突然ターンが始まる」ように見えていることにある。auto-kick ターンが描画されない件はこの下流症状の一つ
## 要件
- auto-kick で起動したターンuser 入力を伴わないターンも、user 由来ターンと同様に TUI 履歴に表示される。
- turn header 等の見た目で「通知由来である」ことを示す表記を入れるかは別議論。
- Pod が socket で受信した外部入力のうち、活動ログとして残すべきもの (`Method::Notify` / `Method::PodEvent`) を broadcast event として全 subscriber に echo する。
- TUI はその event を user message / assistant text と並列のログ要素として描画する。
- auto-kick 由来ターン (`TurnStart` 以降) は既存経路で従来通り表示される。Notify / PodEvent 受信が表示されるようになれば、ターン境界の出所はログ上で自然に区別できる。
## 範囲外 / 非目標
- LLM 注入テキスト (`notify_wrapper` 適用後の wrapped string) を UI に見せるかは別判断。本チケットでは **raw メッセージをそのまま echo** する形で着地する。UI 側で wrapper を適用したくなったら、別途 catalog を引く形で対応する。
- `starts_turn` 等の「auto-kick フラグ」を新 event に持たせない。ターン境界制御は `TurnStart` の責務であり、入力 echo event はあくまで入力ログ要素のみを表す。
- protocol に追加する Event variant は **入力 echo の責務だけ**を持ち、UI 通知 (toast / OS 通知) を兼ねない。
## 完了条件
- 親 pod が PodEvent を受信して auto-kick した際、TUI 上で thinking / assistant text / turn_end が user 由来ターンと同様に表示される。
- socket に `Method::Notify { message }` を流すと、全 subscriberTUI 含む)のログにその通知本文が user / assistant と並列の要素として表示される。
- socket に `Method::PodEvent::TurnEnded` 等を流すと、その受信を示すログ要素 + 後続 auto-kick ターンの thinking / assistant text / turn_end が、user 由来ターンと同様に TUI に表示される。
- 追加した broadcast event は `Method::Notify` / `Method::PodEvent` の payload と一対一対応し、`starts_turn` のような派生フラグを持たない。