diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 2b70adce..653a756b 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1053,8 +1053,7 @@ impl Runtime { worker.status = worker_status_from_run_state(dispatch_result.run_state); let status = worker.status; #[cfg(feature = "ws-server")] - { - let payload = input_protocol_event(&input); + if let Some(payload) = input_protocol_event(&input) { state.push_worker_observation_event(worker_ref.clone(), payload); } state.publish_worker_upsert(worker_ref.worker_id)?; @@ -1464,7 +1463,9 @@ impl Runtime { ) -> Result<(), RuntimeError> { let mut state = self.lock()?; state.ensure_worker_ref(worker_ref)?; - state.push_worker_observation_event(worker_ref.clone(), input_protocol_event(&input)); + if let Some(payload) = input_protocol_event(&input) { + state.push_worker_observation_event(worker_ref.clone(), payload); + } Ok(()) } @@ -2646,30 +2647,28 @@ fn validate_worker_input(input: &WorkerInput) -> Result<(), RuntimeError> { } #[cfg(feature = "ws-server")] -fn input_protocol_event(input: &WorkerInput) -> protocol::Event { +fn input_protocol_event(input: &WorkerInput) -> Option { match input.kind { - WorkerInputKind::User => protocol::Event::UserMessage { + WorkerInputKind::User => Some(protocol::Event::UserMessage { segments: input.segments.clone().unwrap_or_else(|| { vec![protocol::Segment::Text { content: input.content.clone(), }] }), - }, - WorkerInputKind::Notify => protocol::Event::SystemItem { - item: serde_json::json!({ - "kind": "embedded_worker_notification", - "content": input.content.clone(), - }), - }, + }), + // The committed `SystemItem::Notification` is the sole agent-visible + // and Console-visible authority for Notify. A synthetic observation + // here would display the same notification twice. + WorkerInputKind::Notify => None, WorkerInputKind::Compact | WorkerInputKind::ListRewindTargets - | WorkerInputKind::RegisterPeer => protocol::Event::SystemItem { + | WorkerInputKind::RegisterPeer => Some(protocol::Event::SystemItem { item: serde_json::json!({ "kind": "embedded_worker_command_input", "command": input.kind, "content": input.content.clone(), }), - }, + }), } } @@ -3859,7 +3858,7 @@ mod tests { #[cfg(feature = "ws-server")] #[test] - fn send_input_records_protocol_observations() { + fn notify_input_does_not_duplicate_committed_notification_observation() { let runtime = Runtime::with_execution_backend( RuntimeOptions { ..RuntimeOptions::default() @@ -3880,15 +3879,40 @@ mod tests { let observations = runtime .read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero()) .unwrap(); - assert_eq!(observations.len(), 2); + assert_eq!(observations.len(), 1); assert!(matches!( observations[0].payload, protocol::Event::UserMessage { .. } )); - assert!(matches!( - observations[1].payload, - protocol::Event::SystemItem { .. } - )); + + runtime + .observe_worker_event( + &detail.worker_ref, + protocol::Event::SystemItem { + item: serde_json::json!({ + "kind": "notification", + "message": "note", + "body": "[Notification] note", + }), + }, + ) + .unwrap(); + + let observations = runtime + .read_worker_observation_events(&detail.worker_ref, WorkerObservationCursor::zero()) + .unwrap(); + assert_eq!(observations.len(), 2); + let protocol::Event::SystemItem { item } = &observations[1].payload else { + panic!("committed notification observation must be a system item"); + }; + assert_eq!(item["kind"], "notification"); + assert!(observations.iter().all(|observation| { + !matches!( + &observation.payload, + protocol::Event::SystemItem { item } + if item["kind"] == "embedded_worker_notification" + ) + })); } #[test] diff --git a/docs/report/2026-08-06-web-console-notify-double-projection.md b/docs/report/2026-08-06-web-console-notify-double-projection.md new file mode 100644 index 00000000..8aae3d60 --- /dev/null +++ b/docs/report/2026-08-06-web-console-notify-double-projection.md @@ -0,0 +1,39 @@ +# Web Console renders one Worker notification twice + +Date: 2026-08-06 + +## Symptom + +A single Workspace Orchestrator attention notification appears in Web Console as two adjacent system lines with identical content: + +- `System · embedded worker notification` +- `System · notification` + +The first label is also misleading for Workers running on a non-embedded Runtime. + +## Confirmed cause + +`worker-runtime` projects one accepted `WorkerInputKind::Notify` through two independent protocol-event paths. + +1. `Runtime::interact_worker` calls `input_protocol_event(&input)` after the execution backend acknowledges the input and pushes the result to the Worker observation bus. For `Notify`, `input_protocol_event` constructs a synthetic `protocol::Event::SystemItem` with kind `embedded_worker_notification`. +2. The real Worker drains `NotifyBuffer`, commits `SystemItem::Notification` to Worker history, and the Runtime execution bridge republishes the committed log entry as another `protocol::Event::SystemItem` with kind `notification`. + +The Workspace subscription forwards both events. `web/workspace/src/lib/workspace/console/model.ts` renders every `system_item` independently and has no semantic deduplication between these two different kinds/event identities. + +The authoritative history item is the second event. The synthetic input-observation item is not the durable Worker-history authority, so the LLM should receive the notification once even though Web Console displays it twice. + +## Relevant code + +- `crates/worker-runtime/src/runtime.rs` + - `input_protocol_event(WorkerInputKind::Notify)` created `embedded_worker_notification`. + - `Runtime::interact_worker` unconditionally published that synthetic event after accepted input. +- `crates/worker-runtime/src/worker_backend.rs` + - the controller bridge republishes committed `SegmentLogSink` entries through `live_log_entry_event`. +- `web/workspace/src/lib/workspace/console/model.ts` + - both system-item variants are rendered as separate lines. + +## Resolution + +`worker-runtime` no longer converts accepted `Notify` input into a synthetic protocol event. `input_protocol_event` returns no event for Notify, while the normal interaction acknowledgement remains unchanged. The committed `SystemItem::Notification` is therefore the single Console-visible projection. + +A caller-boundary regression test drives accepted user and Notify inputs, verifies that Notify adds no synthetic observation, then publishes the committed notification event and asserts that exactly one notification system item is present.