feat: TUIに他Podからの通知を表示する
This commit is contained in:
parent
69a6f63023
commit
cae18a4339
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` のような派生フラグを持たない。
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user