From 702ed795172eeacdda99f594b1281860b3465611 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 3 May 2026 12:45:05 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20TUI=E3=81=AB=E4=BB=96Pod=E3=81=8B?= =?UTF-8?q?=E3=82=89=E3=81=AE=E9=80=9A=E7=9F=A5=E3=82=92=E8=A1=A8=E7=A4=BA?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/pod/src/controller.rs | 11 +++++++ crates/pod/tests/controller_test.rs | 37 +++++++++++++++++++++ crates/protocol/src/lib.rs | 51 +++++++++++++++++++++++++++++ crates/tui/src/app.rs | 8 +++++ crates/tui/src/block.rs | 13 +++++++- crates/tui/src/ui.rs | 44 ++++++++++++++++++++++++- tickets/tui-pod-event-render.md | 21 ++++++++---- 7 files changed, 177 insertions(+), 8 deletions(-) diff --git a/crates/pod/src/controller.rs b/crates/pod/src/controller.rs index c0f6bb42..4056c260 100644 --- a/crates/pod/src/controller.rs +++ b/crates/pod/src/controller.rs @@ -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 diff --git a/crates/pod/tests/controller_test.rs b/crates/pod/tests/controller_test.rs index 8ea34269..4603bb16 100644 --- a/crates/pod/tests/controller_test.rs +++ b/crates/pod/tests/controller_test.rs @@ -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::() => { 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" diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 49f8812e..160e55d4 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -214,6 +214,20 @@ pub enum Event { UserMessage { segments: Vec, }, + /// 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 { diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 38becf98..d6324f34 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -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; diff --git a/crates/tui/src/block.rs b/crates/tui/src/block.rs index 957718e0..1692c0a4 100644 --- a/crates/tui/src/block.rs +++ b/crates/tui/src/block.rs @@ -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, }, + /// 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, }, diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 1275ee1c..e4497828 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -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>, 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> { 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}") + } + } +} diff --git a/tickets/tui-pod-event-render.md b/tickets/tui-pod-event-render.md index c598dd1f..50b55e0c 100644 --- a/tickets/tui-pod-event-render.md +++ b/tickets/tui-pod-event-render.md @@ -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 }` を流すと、全 subscriber(TUI 含む)のログにその通知本文が user / assistant と並列の要素として表示される。 +- socket に `Method::PodEvent::TurnEnded` 等を流すと、その受信を示すログ要素 + 後続 auto-kick ターンの thinking / assistant text / turn_end が、user 由来ターンと同様に TUI に表示される。 +- 追加した broadcast event は `Method::Notify` / `Method::PodEvent` の payload と一対一対応し、`starts_turn` のような派生フラグを持たない。