From 76a2e9a8bba5a8a34c644bd38e8e994cd02bb771 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 6 Jun 2026 12:55:22 -0700 Subject: [PATCH] operator: echo the loop/observation trigger into the panel, drop dead suppression The operator panel showed the operator's response to each ambient/observation turn ("noted", "Worked", a finding) but not the INPUT that triggered it -- the observation is a Message event, never rendered to the panel's PTY. So a user watching the panel saw context-free "noted / Worked / noted ..." and couldn't tell what was being noted. Now, before the response streams, the operator echoes the OBSERVATION trigger into the panel (dim), with the monitor's instruction boilerplate stripped so only the substance shows -- an ambient monitor finding or a fleet event. The panel reads question -> answer. Also removes the dead ambient-turn PTY/message suppression: it keyed on the stale "ambient operator loop tick" string (the loop's observation became "ambient fleet monitor flagged" in #376), so it never fired -- and the intent is now to SHOW responses with their trigger, not hide them. Leaving it was a trap (fixing its string would have hidden the wanted responses). The typewriter monolog (#379) is unaffected -- it consumes the response Message stream; the echo is PTY-only. - interactive.rs: observation_panel_echo() + emit it before the user Message; removed ambient_turn detection + both suppression blocks. - Test: observation_panel_echo strips the marker/boilerplate, keeps substance, returns None for real user input. 38 interactive tests pass. Spec 0024. --- crates/adapter-zarvis/src/interactive.rs | 62 ++++++++++++++++--- ...perator-panel-shows-observation-trigger.md | 24 +++++++ 2 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 specs/0024-operator-panel-shows-observation-trigger.md diff --git a/crates/adapter-zarvis/src/interactive.rs b/crates/adapter-zarvis/src/interactive.rs index 04ac182e..30a2d94e 100644 --- a/crates/adapter-zarvis/src/interactive.rs +++ b/crates/adapter-zarvis/src/interactive.rs @@ -1352,6 +1352,27 @@ mod tests { assert_eq!(default_monitor_spec("gemini", "gemini-pro"), None); } + #[test] + fn observation_panel_echo_shows_trigger_without_boilerplate() { + // Real user input echoes itself — no synthetic echo. + assert!(observation_panel_echo("hello operator").is_none()); + // Fleet event: shown (dim), with the internal marker stripped. + let s = String::from_utf8( + observation_panel_echo("OBSERVATION: session ab12 entered errored (boom)").unwrap(), + ) + .unwrap(); + assert!(s.contains("session ab12 entered errored (boom)"), "{s}"); + assert!(!s.contains("OBSERVATION:"), "marker not stripped:\n{s}"); + // Ambient monitor: boilerplate stripped, findings kept under a label. + let obs = "OBSERVATION: ambient fleet monitor flagged the following. Decide whether to \ + surface it to the user or update an Operator widget; reply exactly `noted` if \ + it's not worth it.\ns277 \"x\": blocked at prompt"; + let s = String::from_utf8(observation_panel_echo(obs).unwrap()).unwrap(); + assert!(s.contains("ambient monitor flagged:"), "{s}"); + assert!(s.contains("s277 \"x\": blocked at prompt"), "{s}"); + assert!(!s.contains("Decide whether to surface"), "boilerplate kept:\n{s}"); + } + #[test] fn parse_triage_finding_filters_nothing() { assert!(parse_triage_finding("nothing").is_none()); @@ -2485,7 +2506,13 @@ pub async fn run( }, } ); - let ambient_turn = user_text.starts_with("OBSERVATION: ambient operator loop tick"); + // Echo an OBSERVATION trigger into the panel (dim) before the response + // streams, so the user sees what a reply like `noted` is reacting to — + // an ambient monitor finding or a fleet event — instead of a bare reply + // with no context. Real user input echoes itself, so it's skipped. + if let Some(echo) = observation_panel_echo(&user_text) { + emit.emit(SessionEvent::pty(&echo)); + } emit.emit(SessionEvent::Message { role: agentd_protocol::MessageRole::User, text: user_text, @@ -2563,10 +2590,6 @@ pub async fn run( } let _pruned = context::prune_to_budget(&mut messages, budget); let mut sink = PtySink::new(&emit, pty_width, turn_started_at_ms); - if ambient_turn { - sink.emit_pty = false; - sink.emit_messages = false; - } // Wrap the provider call so user typing during the // stream is fed to the editor and pressed-Enter lines // join the pending-input queue instead of vanishing @@ -2627,10 +2650,6 @@ pub async fn run( )), }); let mut sink2 = PtySink::new(&emit, pty_width, turn_started_at_ms); - if ambient_turn { - sink2.emit_pty = false; - sink2.emit_messages = false; - } let drive2 = drive_with_input_silent( &mut inbox, &mut editor, @@ -3075,6 +3094,31 @@ fn operator_ambient_loop_interval() -> Duration { /// Bare ambient-tick prompt used when the daemon snapshot is unavailable. const AMBIENT_TICK_FALLBACK: &str = "OBSERVATION: ambient operator loop tick. Quietly inspect only if useful; update Operator widgets for helpful ambient status; reply exactly `noted` if nothing needs surfacing."; +/// Dim PTY echo of an `OBSERVATION:` trigger so the operator panel shows what a +/// response (e.g. a bare `noted`) is reacting to — an ambient monitor finding +/// or a fleet event — instead of an answer with no visible question. Returns +/// `None` for non-observation turns (real user input echoes itself). The +/// monitor's instruction boilerplate is stripped so only the substance shows. +fn observation_panel_echo(user_text: &str) -> Option> { + let body = user_text.strip_prefix("OBSERVATION: ")?; + let display = match body.split_once('\n') { + Some((first, rest)) if first.starts_with("ambient fleet monitor flagged") => { + format!("ambient monitor flagged:\n{}", rest.trim_end()) + } + _ => body.trim_end().to_string(), + }; + if display.trim().is_empty() { + return None; + } + let mut out = String::from("\r\n"); + for line in display.lines() { + out.push_str("\x1b[2m\u{2502} "); + out.push_str(line); + out.push_str("\x1b[0m\r\n"); + } + Some(out.into_bytes()) +} + /// Minutes a `Running` PTY session may go without output before the snapshot /// treats it as idle (likely waiting for input, or stuck). Interactive /// claude/codex/shell sessions never emit `AwaitingInput`, so PTY quiescence diff --git a/specs/0024-operator-panel-shows-observation-trigger.md b/specs/0024-operator-panel-shows-observation-trigger.md new file mode 100644 index 00000000..59b8e724 --- /dev/null +++ b/specs/0024-operator-panel-shows-observation-trigger.md @@ -0,0 +1,24 @@ +# 0024-operator-panel-shows-observation-trigger + +Status: accepted +Date: 2026-06-06 +Area: tui +Scope: What the operator panel shows for loop/observation-driven turns. + +## Decision + +When an `OBSERVATION:` trigger (an ambient monitor finding or a fleet event) drives an operator turn, the operator now echoes that trigger into its panel — dim, above the response — so a reply like `noted` has its question visible. Real user input is not echoed (it echoes itself). The monitor's instruction boilerplate is stripped so only the substance (the finding / event) shows. + +The dead ambient-turn PTY/message suppression (keyed on the stale `"ambient operator loop tick"` string from before [0022](0022-operator-ambient-loop-runs-as-monitor-subagent.md)) is removed: the operator's responses are meant to be visible, not hidden. + +## Reason + +After 0022 the loop's observation string changed, so the suppression never fired and every tick's response + `Worked` status leaked into the panel — but the *input* (a `Message` event, not PTY) was never rendered there. The result was a stream of context-free `noted / Worked / noted …`. The user wants to *see and reason about* what triggered each response, so showing the trigger (not hiding the response) is the fix. Leaving the stale suppression in place was also a trap: "fixing" its string would have silently hidden the responses the user wants. + +## Consequences + +The panel reads as question → answer: the trigger, then the operator's `noted`/finding/reply. Findings still also surface via the typewriter monolog ([0023](0023-operator-monolog-typewriter.md)) and widgets. The echo fires for fleet-event observations too (consistent context for those replies). The monolog is unaffected — it consumes the response `Message` stream; the echo is PTY-only. + +## Non-Goals + +Not a new event type (the echo is dim PTY, like any other panel output); does not change rate limiting, the monitor, or the monolog; does not reinstate any ambient-turn suppression.