Skip to content

Commit c39887e

Browse files
nori-sessions[bot]CSResselnori-agent
authored
fix(acp): de-duplicate post-cancel orphan-update warnings (#457)
## Summary 🤖 Generated with [Nori](https://noriagentic.com/) - After a cancel, in-flight `session/update` events still hit the reducer with no active request and currently produce one "Received request-owned content update while no request is active" yellow warning cell per event — flooding the history. - The reducer now tracks an `orphan_update_warning_emitted` flag on `SessionRuntime`. The warning fires only on the first orphan update in a burst; subsequent orphans in the same idle window skip the warning. The normalizer still runs on every update, so well-formed `session/update` events continue to render as tool / message history cells. - The flag resets when a new prompt or load begins (`start_prompt`, `reduce_load_submit`), so a fresh burst on a future request will warn again. ## Test Plan - [x] `cargo test -p nori-acp` (494 tests pass) - [x] New unit tests in `nori-rs/acp/src/backend/session_reducer/tests.rs`: - `orphan_warning_is_emitted_only_once_per_burst` — three orphan `ToolCallUpdate`s produce exactly one warning and three `ToolSnapshot`s. - `orphan_warning_resets_when_a_new_prompt_starts` — burst → new prompt → new burst produces two warnings total. - [x] Existing `test_out_of_phase_tool_call_update_still_emits_normalized_tool_snapshot` still passes (single update still gets the warning + snapshot). - [x] `just fmt` and `just fix -p nori-acp -p nori-protocol` clean. Share Nori with your team: https://www.npmjs.com/package/nori-skillsets Co-authored-by: Cliff <clifford@tilework.tech> Co-authored-by: Nori <contact@tilework.tech>
1 parent 46092c4 commit c39887e

4 files changed

Lines changed: 147 additions & 5 deletions

File tree

nori-rs/acp/src/backend/session_reducer.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ fn start_prompt(runtime: &mut SessionRuntime, prompt: QueuedPrompt, out: &mut Re
177177
request_id.clone(),
178178
prompt.clone(),
179179
));
180+
runtime.orphan_update_warning_emitted = false;
180181

181182
// Add user message to transcript.
182183
if let Some(display_text) = &prompt.display_text
@@ -372,6 +373,7 @@ fn reduce_load_submit(runtime: &mut SessionRuntime, request_id: String, out: &mu
372373
request_id,
373374
ActiveRequestKind::Loading,
374375
));
376+
runtime.orphan_update_warning_emitted = false;
375377
out.events
376378
.push(ClientEvent::SessionPhaseChanged(runtime.phase_view()));
377379
}
@@ -422,9 +424,13 @@ fn reduce_notification(
422424

423425
// Request-owned content requires an active request.
424426
if runtime.active.is_none() {
425-
out.events.push(ClientEvent::Warning(WarningInfo {
426-
message: "Received request-owned content update while no request is active".to_string(),
427-
}));
427+
if !runtime.orphan_update_warning_emitted {
428+
out.events.push(ClientEvent::Warning(WarningInfo {
429+
message: "Received request-owned content update while no request is active"
430+
.to_string(),
431+
}));
432+
runtime.orphan_update_warning_emitted = true;
433+
}
428434
let client_events = normalizer.push_session_update(&update);
429435
out.events.extend(client_events);
430436
return;

nori-rs/acp/src/backend/session_reducer/tests.rs

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -803,3 +803,129 @@ fn usage_update_is_accepted_while_idle_and_emits_info_event() {
803803
if info.kind == nori_protocol::SessionUpdateKind::Usage
804804
)));
805805
}
806+
807+
// =========================================================================
808+
// Orphan-update warning de-duplication
809+
// =========================================================================
810+
811+
fn orphan_tool_update(call_id: &'static str) -> acp::SessionUpdate {
812+
acp::SessionUpdate::ToolCallUpdate(acp::ToolCallUpdate::new(
813+
call_id,
814+
acp::ToolCallUpdateFields::new()
815+
.title("Read Cargo.toml")
816+
.kind(acp::ToolKind::Read)
817+
.status(acp::ToolCallStatus::Completed)
818+
.raw_input(serde_json::json!({ "path": "Cargo.toml" })),
819+
))
820+
}
821+
822+
fn count_orphan_warnings(events: &[ClientEvent]) -> usize {
823+
events
824+
.iter()
825+
.filter(|e| match e {
826+
ClientEvent::Warning(w) => w
827+
.message
828+
.contains("Received request-owned content update while no request is active"),
829+
_ => false,
830+
})
831+
.count()
832+
}
833+
834+
fn count_tool_snapshots(events: &[ClientEvent]) -> usize {
835+
events
836+
.iter()
837+
.filter(|e| matches!(e, ClientEvent::ToolSnapshot(_)))
838+
.count()
839+
}
840+
841+
#[test]
842+
fn orphan_warning_is_emitted_only_once_per_burst() {
843+
let mut rt = new_runtime();
844+
let mut norm = new_normalizer();
845+
846+
let first = reduce(
847+
&mut rt,
848+
notification(orphan_tool_update("call-1")),
849+
&mut norm,
850+
);
851+
let second = reduce(
852+
&mut rt,
853+
notification(orphan_tool_update("call-2")),
854+
&mut norm,
855+
);
856+
let third = reduce(
857+
&mut rt,
858+
notification(orphan_tool_update("call-3")),
859+
&mut norm,
860+
);
861+
862+
let warnings = count_orphan_warnings(&first.events)
863+
+ count_orphan_warnings(&second.events)
864+
+ count_orphan_warnings(&third.events);
865+
assert_eq!(
866+
warnings, 1,
867+
"expected exactly one orphan warning across the burst"
868+
);
869+
870+
let snapshots = count_tool_snapshots(&first.events)
871+
+ count_tool_snapshots(&second.events)
872+
+ count_tool_snapshots(&third.events);
873+
assert_eq!(
874+
snapshots, 3,
875+
"every orphan update should still produce a tool snapshot"
876+
);
877+
}
878+
879+
#[test]
880+
fn orphan_warning_resets_when_a_new_prompt_starts() {
881+
let mut rt = new_runtime();
882+
let mut norm = new_normalizer();
883+
884+
// First burst while idle.
885+
let first = reduce(
886+
&mut rt,
887+
notification(orphan_tool_update("call-1")),
888+
&mut norm,
889+
);
890+
let second = reduce(
891+
&mut rt,
892+
notification(orphan_tool_update("call-2")),
893+
&mut norm,
894+
);
895+
assert_eq!(
896+
count_orphan_warnings(&first.events) + count_orphan_warnings(&second.events),
897+
1
898+
);
899+
900+
// Start a new prompt and finalize it back to idle.
901+
reduce(
902+
&mut rt,
903+
InboundEvent::PromptSubmit(simple_prompt()),
904+
&mut norm,
905+
);
906+
reduce(
907+
&mut rt,
908+
InboundEvent::PromptResponse {
909+
stop_reason: acp::StopReason::EndTurn,
910+
},
911+
&mut norm,
912+
);
913+
assert_eq!(rt.phase_view(), SessionPhaseView::Idle);
914+
915+
// Second burst after the new request — warning should fire again.
916+
let third = reduce(
917+
&mut rt,
918+
notification(orphan_tool_update("call-3")),
919+
&mut norm,
920+
);
921+
let fourth = reduce(
922+
&mut rt,
923+
notification(orphan_tool_update("call-4")),
924+
&mut norm,
925+
);
926+
assert_eq!(
927+
count_orphan_warnings(&third.events) + count_orphan_warnings(&fourth.events),
928+
1,
929+
"a new prompt should reset the burst window so the warning fires again"
930+
);
931+
}

nori-rs/nori-protocol/src/session_runtime.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,11 @@ pub struct SessionRuntime {
220220
pub persisted: PersistedSessionState,
221221
pub active: Option<ActiveRequestState>,
222222
pub queue: VecDeque<QueuedPrompt>,
223+
/// Tracks whether we have already emitted the "request-owned content
224+
/// update while no request is active" warning since the last time a
225+
/// request became active. Reset on each new prompt/load start so that a
226+
/// fresh post-cancel burst on a subsequent request can warn again.
227+
pub orphan_update_warning_emitted: bool,
223228
}
224229

225230
impl SessionRuntime {
@@ -229,6 +234,7 @@ impl SessionRuntime {
229234
persisted: PersistedSessionState::default(),
230235
active: None,
231236
queue: VecDeque::new(),
237+
orphan_update_warning_emitted: false,
232238
}
233239
}
234240

spec/agent-turn-state-spec.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,14 +268,18 @@ handle that path anyway, even if only to log and drop it.
268268
The observable behavior should be:
269269

270270
- if a request-owned content update arrives with `active.is_none()`, emit
271-
`UiEvent::Warning`
271+
`UiEvent::Warning` once per burstonly the first such update since the
272+
last active request emits the warning, subsequent updates in the same
273+
idle window do not. The flag resets when a new prompt or load begins.
272274
- forward the well-formed content to the TUI as standalone between-turn output
275+
(every update, regardless of whether the warning fired)
273276
- do not attribute that content to a prior or future request
274277
- do not reopen `active`
275278
- if the update is malformed or unrecognizable, log a warning and drop it
276279

277280
This keeps the protocol handling honest without adding attribution heuristics to
278-
the core reducer.
281+
the core reducer, and prevents post-cancel update bursts from spamming the
282+
history with identical warning cells.
279283

280284
### 4. Attributed tool updates
281285

0 commit comments

Comments
 (0)