Skip to content

Commit 6383f26

Browse files
authored
fix(acp): goal-unavailable notice gating on resume (#503)
## Summary Goal-notice fixes for nori-client MCP goal resume, split out from the original combined cleanup PR (the browser-CDP fixes now live in #505 targeting main). - Surface the goal-unavailable notice for **all** in-play goals on resume, not just the first. - Stop recording the goal-unavailable notice to the transcript (it is a transient UI notice, not conversation history). - Document the goal-notice gating and non-recording-on-resume behavior. - Share a panic-safe `EnvGuard` across the goal resume tests. Targets `feat/nori-client-mcp-cleanup`. ## Test plan - [x] `cargo test -p nori-acp` (574 pass, with the feat-branch mock agent built) - [x] `cargo clippy -p nori-acp --tests` (clean) - [x] `cargo fmt --check`
1 parent 0393c99 commit 6383f26

6 files changed

Lines changed: 203 additions & 67 deletions

File tree

nori-rs/acp/docs.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,13 @@ The model-facing tool contract is intentionally narrower than the user-facing `/
127127

128128
Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `<goal_context>` block when a goal exists. Hook context is still applied before goal context, and compact summaries remain the outermost framing instruction, so resumed/compacted turns retain their existing prompt-ordering invariant while still carrying goal state to the agent. The prompt goal context and hidden continuation prompt use the same compact elapsed-time and token-count formatting as the visible TUI goal summary.
129129

130-
Agents that are not advertised the local `nori-client` server do not receive goal context through prompt transformation and do not receive hidden goal-continuation prompts. Backend `ThreadGoal*` operations from user-facing paths emit an unavailable notice instead of mutating goal state, because those agents cannot call `update_goal` to close the loop. If transcript replay restores an active goal into a non-MCP session, resume emits the same unavailable notice after the replayed goal snapshot rather than silently leaving automation inert. The local MCP server is the required automation path for active goals, while transcript replay, usage accounting, and ordinary prompts continue without it. The intended degradation path is a concise first-prompt `<context>` block with Nori CLI context, the open source repo URL, and a note that MCP-backed Nori affordances are unavailable, not repeated prompt-prefix workarounds on every turn.
130+
Agents that are not advertised the local `nori-client` server do not receive goal context through prompt transformation and do not receive hidden goal-continuation prompts. Backend `ThreadGoal*` operations from user-facing paths emit an unavailable notice instead of mutating goal state, because those agents cannot call `update_goal` to close the loop. That op-time notice is emitted directly to the client channel and deliberately **not** recorded to the transcript -- like resume notices it is a transient affordance derived from session state, so recording it would replay and accumulate a duplicate notice on every `/goal` op. If transcript replay restores any in-play goal (active, paused, blocked, or usage-limited) into a non-MCP session, resume emits the same unavailable notice after the replayed goal snapshot rather than the `/goal resume` affordance, which would mislead since `/goal` is disabled for non-MCP agents. The local MCP server is the required automation path for active goals, while transcript replay, usage accounting, and ordinary prompts continue without it. The intended degradation path is a concise first-prompt `<context>` block with Nori CLI context, the open source repo URL, and a note that MCP-backed Nori affordances are unavailable, not repeated prompt-prefix workarounds on every turn.
131131

132132
After an active goal mutation or a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit a hidden goal-continuation prompt to the same ACP session. `thread_goal.rs` owns the continuation prompt text so it is derived from the current backend goal snapshot, not from TUI state or transcript text. The driver only starts a continuation when the goal is active, the reducer has returned to idle, and no queued user work remains. Chaining from one hidden `GoalContinuation` into another is gated on `goal_mcp_connected`, an `@/nori-rs/acp/src/backend/mod.rs` session flag that `NoriClientService::initialize` (the rmcp `ServerHandler::initialize` hook) flips only after the local MCP server receives an `initialize` request. The first successful initialize also re-emits `SessionCapabilitiesChanged` with `nori_client.initialized = true`. This is a safety invariant: until the agent has actually connected to the `nori-client` endpoint it has no way to mark the goal complete, so unbounded continuation-to-continuation chaining is not allowed. Agents without a connected goal MCP endpoint receive at most one hidden continuation per active goal mutation or visible user turn.
133133

134134
Goal state is also part of the replay contract. `transcript.rs` passes Nori-owned goal update and clear events through replay, and `session.rs` seeds `ThreadGoalState` from those transcript-derived events before ACP session setup advertises local MCP tools. Server-side `session/load` can also emit ACP replay notifications while loading; those normalized client events are deferred until backend setup completes, then combined with the transcript replay events before rebuilding `ThreadGoalState`. This ordering matters because ACP agents replay their own session history, but they do not replay Nori-owned `ThreadGoalUpdated` events, so the transcript remains authoritative for goal state even when the agent emits load replay notifications.
135135

136-
When a resumed goal is paused, blocked, or usage-limited, `thread_goal.rs` derives a one-time `SessionUpdateInfo` notice from the rehydrated snapshot so the TUI can show why goal automation will not continue until the user resumes, edits, clears, or resolves the blocker. That notice is emitted directly by `session.rs` after deferred replay events and is not written back into the transcript, so each future resume still derives its notice from goal state instead of accumulating duplicate history entries. ACP usage updates still normalize to `SessionUpdateInfo`, but `session_runtime_driver.rs` observes those events and asks the goal state to refresh `tokens_used`; when a goal exists, the backend emits a follow-up `ThreadGoalUpdated` snapshot so the TUI and transcript stay synchronized with usage accounting.
136+
When a resumed goal is paused, blocked, or usage-limited **and goal automation is available**, `thread_goal.rs` derives a one-time `SessionUpdateInfo` resume notice from the rehydrated snapshot so the TUI can show why goal automation will not continue until the user resumes, edits, clears, or resolves the blocker. `ThreadGoalState::resume_notice_for` centralizes this decision: with automation available it returns the status-specific resume notice (suggesting `/goal resume`); without automation it returns the unavailable notice for any in-play goal instead, since the resume affordance would be misleading. Either notice is emitted directly by `session.rs` after deferred replay events and is not written back into the transcript, so each future resume still derives its notice from goal state instead of accumulating duplicate history entries. ACP usage updates still normalize to `SessionUpdateInfo`, but `session_runtime_driver.rs` observes those events and asks the goal state to refresh `tokens_used`; when a goal exists, the backend emits a follow-up `ThreadGoalUpdated` snapshot so the TUI and transcript stay synchronized with usage accounting.
137137

138138
**Browser Session** (`backend/browser_session.rs`):
139139

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

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -485,15 +485,7 @@ impl AcpBackend {
485485
let resume_goal_notice = {
486486
let goals = backend.thread_goal_state.lock().await;
487487
let now = thread_goal::now_seconds();
488-
if !goal_automation_available
489-
&& goals.snapshot(now).is_some_and(|goal| {
490-
goal.status == codex_protocol::protocol::ThreadGoalStatus::Active
491-
})
492-
{
493-
Some(thread_goal::unavailable_notice())
494-
} else {
495-
goals.resume_notice(now)
496-
}
488+
goals.resume_notice_for(now, goal_automation_available)
497489
};
498490

499491
if !deferred_replay_client_events.is_empty() || resume_goal_notice.is_some() {

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,45 @@
11
use super::*;
22
use serial_test::serial;
33

4+
/// RAII guard that sets or removes a process env var and restores the previous
5+
/// value on drop, including on panic/unwind. `#[serial]` tests share process
6+
/// env, so a non-restored mutation would leak into later tests.
7+
struct EnvGuard {
8+
name: &'static str,
9+
previous: Option<String>,
10+
}
11+
12+
impl EnvGuard {
13+
fn set(name: &'static str, value: &str) -> Self {
14+
let previous = std::env::var(name).ok();
15+
unsafe {
16+
std::env::set_var(name, value);
17+
}
18+
Self { name, previous }
19+
}
20+
21+
fn remove(name: &'static str) -> Self {
22+
let previous = std::env::var(name).ok();
23+
unsafe {
24+
std::env::remove_var(name);
25+
}
26+
Self { name, previous }
27+
}
28+
}
29+
30+
impl Drop for EnvGuard {
31+
fn drop(&mut self) {
32+
match &self.previous {
33+
Some(value) => unsafe {
34+
std::env::set_var(self.name, value);
35+
},
36+
None => unsafe {
37+
std::env::remove_var(self.name);
38+
},
39+
}
40+
}
41+
}
42+
443
async fn recv_backend_control(
544
rx: &mut mpsc::Receiver<BackendEvent>,
645
timeout: std::time::Duration,

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

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1618,8 +1618,10 @@ async fn test_resume_session_uses_server_side_when_load_session_succeeds() {
16181618
}
16191619
}
16201620

1621-
/// When transcript replay restores a paused goal, the resumed session should
1622-
/// surface a direct notice with the next available goal commands.
1621+
/// When transcript replay restores a paused goal into an MCP-capable session,
1622+
/// the resumed session should surface a direct notice with the next available
1623+
/// goal commands. The `/goal resume` affordance is only valid when goal
1624+
/// automation is available, so the agent must advertise HTTP MCP support.
16231625
#[tokio::test]
16241626
#[serial]
16251627
async fn test_resume_session_notifies_about_paused_goal() {
@@ -1635,6 +1637,8 @@ async fn test_resume_session_notifies_about_paused_goal() {
16351637
return;
16361638
}
16371639

1640+
let _mcp_guard = EnvGuard::set("MOCK_AGENT_MCP_HTTP", "1");
1641+
16381642
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
16391643
let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64);
16401644
let config = build_test_config(temp_dir.path());
@@ -1713,16 +1717,7 @@ async fn test_resume_session_non_mcp_active_goal_reports_unavailable() {
17131717
return;
17141718
}
17151719

1716-
let previous_mcp_http = std::env::var("MOCK_AGENT_MCP_HTTP").ok();
1717-
unsafe {
1718-
std::env::remove_var("MOCK_AGENT_MCP_HTTP");
1719-
}
1720-
let restore_mcp_http = || unsafe {
1721-
match &previous_mcp_http {
1722-
Some(value) => std::env::set_var("MOCK_AGENT_MCP_HTTP", value),
1723-
None => std::env::remove_var("MOCK_AGENT_MCP_HTTP"),
1724-
}
1725-
};
1720+
let _mcp_guard = EnvGuard::remove("MOCK_AGENT_MCP_HTTP");
17261721

17271722
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
17281723
let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64);
@@ -1775,14 +1770,12 @@ async fn test_resume_session_non_mcp_active_goal_reports_unavailable() {
17751770
saw_replayed_goal,
17761771
"unavailable notice should follow the replayed goal snapshot"
17771772
);
1778-
restore_mcp_http();
17791773
return;
17801774
}
17811775
Some(_) => continue,
17821776
None => continue,
17831777
}
17841778
}
17851779

1786-
restore_mcp_http();
17871780
panic!("Timed out waiting for active-goal unavailable notice");
17881781
}

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

Lines changed: 75 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
use super::*;
22

3-
struct EnvGuard {
4-
name: &'static str,
5-
previous: Option<String>,
6-
}
7-
83
struct RegistryGuard;
94

105
impl RegistryGuard {
@@ -20,37 +15,6 @@ impl Drop for RegistryGuard {
2015
}
2116
}
2217

23-
impl EnvGuard {
24-
fn set(name: &'static str, value: &str) -> Self {
25-
let previous = std::env::var(name).ok();
26-
unsafe {
27-
std::env::set_var(name, value);
28-
}
29-
Self { name, previous }
30-
}
31-
32-
fn remove(name: &'static str) -> Self {
33-
let previous = std::env::var(name).ok();
34-
unsafe {
35-
std::env::remove_var(name);
36-
}
37-
Self { name, previous }
38-
}
39-
}
40-
41-
impl Drop for EnvGuard {
42-
fn drop(&mut self) {
43-
match &self.previous {
44-
Some(value) => unsafe {
45-
std::env::set_var(self.name, value);
46-
},
47-
None => unsafe {
48-
std::env::remove_var(self.name);
49-
},
50-
}
51-
}
52-
}
53-
5418
async fn assert_no_prompt_completed(
5519
backend_event_rx: &mut mpsc::Receiver<BackendEvent>,
5620
window: std::time::Duration,
@@ -621,6 +585,81 @@ async fn non_mcp_replayed_active_goal_does_not_drive_prompt_context_or_continuat
621585
);
622586
}
623587

588+
#[tokio::test]
589+
#[serial]
590+
async fn non_mcp_goal_op_notice_is_not_recorded_to_transcript() {
591+
use std::time::Duration;
592+
593+
let mock_config =
594+
crate::registry::get_agent_config("mock-model").expect("mock-model should be registered");
595+
if !std::path::Path::new(&mock_config.command).exists() {
596+
eprintln!(
597+
"Skipping test: mock_acp_agent not found at {}",
598+
mock_config.command
599+
);
600+
return;
601+
}
602+
603+
let _mcp_guard = EnvGuard::remove("MOCK_AGENT_MCP_HTTP");
604+
605+
let temp_dir = tempfile::tempdir().expect("Failed to create temp dir");
606+
let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64);
607+
let config = build_test_config(temp_dir.path());
608+
609+
let backend = AcpBackend::spawn(&config, backend_event_tx)
610+
.await
611+
.expect("Failed to spawn ACP backend");
612+
613+
let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5))
614+
.await
615+
.expect("Should receive SessionConfigured event");
616+
617+
let recorder = backend
618+
.transcript_recorder
619+
.clone()
620+
.expect("transcript recorder should be initialized");
621+
622+
// Repeated /goal ops in a non-MCP session each surface the unavailable
623+
// notice to the user.
624+
for _ in 0..2 {
625+
backend
626+
.submit(Op::ThreadGoalGet)
627+
.await
628+
.expect("Failed to submit /goal get");
629+
630+
let start = std::time::Instant::now();
631+
let mut saw_unavailable = false;
632+
while start.elapsed() < Duration::from_secs(5) {
633+
match recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await {
634+
Some(nori_protocol::ClientEvent::SessionUpdateInfo(update))
635+
if update.message.contains("/goal is unavailable") =>
636+
{
637+
saw_unavailable = true;
638+
break;
639+
}
640+
Some(_) => {}
641+
None => {}
642+
}
643+
}
644+
assert!(
645+
saw_unavailable,
646+
"expected unavailable notice emitted to the client"
647+
);
648+
}
649+
650+
// The notice is a transient affordance (like resume notices), so it must
651+
// never be persisted to the transcript, where it would replay and
652+
// accumulate on every resume.
653+
recorder.flush().await.expect("flush transcript");
654+
let recorded = tokio::fs::read_to_string(recorder.transcript_path())
655+
.await
656+
.expect("read transcript");
657+
assert!(
658+
!recorded.contains("/goal is unavailable"),
659+
"goal-unavailable notice must not be recorded to the transcript, got:\n{recorded}"
660+
);
661+
}
662+
624663
#[tokio::test]
625664
#[serial]
626665
async fn setting_active_goal_starts_hidden_continuation_without_extra_prompt() {

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

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,27 @@ Before deciding that the goal is achieved, treat completion as unproven and veri
179179
}
180180
}
181181

182+
/// Resume-time notice that respects whether goal automation is available.
183+
/// Without HTTP MCP support `/goal` is disabled, so the `resume_notice`
184+
/// affordances (which suggest `/goal resume`) would mislead; surface the
185+
/// `unavailable_notice` for any goal that is still in play instead.
186+
pub(crate) fn resume_notice_for(
187+
&self,
188+
now: i64,
189+
goal_automation_available: bool,
190+
) -> Option<SessionUpdateInfo> {
191+
if goal_automation_available {
192+
return self.resume_notice(now);
193+
}
194+
self.snapshot(now).and_then(|goal| match goal.status {
195+
ThreadGoalStatus::Active
196+
| ThreadGoalStatus::Paused
197+
| ThreadGoalStatus::Blocked
198+
| ThreadGoalStatus::UsageLimited => Some(unavailable_notice()),
199+
ThreadGoalStatus::BudgetLimited | ThreadGoalStatus::Complete => None,
200+
})
201+
}
202+
182203
pub(crate) fn set_objective(
183204
&mut self,
184205
objective: String,
@@ -454,12 +475,15 @@ impl AcpBackend {
454475
}
455476

456477
async fn emit_goal_unavailable(&self) {
457-
emit_client_event(
458-
&self.backend_event_tx,
459-
self.transcript_recorder.as_ref(),
460-
ClientEvent::SessionUpdateInfo(unavailable_notice()),
461-
)
462-
.await;
478+
// Deliberately not recorded to the transcript: like resume notices this
479+
// is a transient affordance derived from session state. Recording it
480+
// would replay and accumulate a duplicate notice on every /goal op.
481+
let _ = self
482+
.backend_event_tx
483+
.send(BackendEvent::Client(ClientEvent::SessionUpdateInfo(
484+
unavailable_notice(),
485+
)))
486+
.await;
463487
}
464488

465489
async fn emit_thread_goal_updated(&self, goal: ThreadGoalSnapshot) {
@@ -695,6 +719,55 @@ mod tests {
695719
assert_eq!(goals.resume_notice(55), None);
696720
}
697721

722+
#[test]
723+
fn resume_notice_for_non_mcp_surfaces_unavailable_for_in_play_goals() {
724+
let mut goals = ThreadGoalState::default();
725+
// No goal: nothing to surface, regardless of automation availability.
726+
assert_eq!(goals.resume_notice_for(10, true), None);
727+
assert_eq!(goals.resume_notice_for(10, false), None);
728+
729+
goals
730+
.set_objective("Keep going".to_string(), Some(ThreadGoalStatus::Active), 10)
731+
.expect("valid objective");
732+
// Active goal with automation available has no resume affordance...
733+
assert_eq!(goals.resume_notice_for(15, true), None);
734+
// ...but without automation we surface that /goal is unavailable.
735+
assert_eq!(
736+
goals.resume_notice_for(15, false),
737+
Some(unavailable_notice())
738+
);
739+
740+
// Every resumable status surfaces the unavailable notice without
741+
// automation, and never the misleading /goal resume affordance.
742+
for status in [
743+
ThreadGoalStatus::Paused,
744+
ThreadGoalStatus::Blocked,
745+
ThreadGoalStatus::UsageLimited,
746+
] {
747+
goals.set_status(status, 20).expect("existing goal");
748+
let available = goals
749+
.resume_notice_for(25, true)
750+
.expect("resumable goal notice");
751+
assert!(
752+
available
753+
.hint
754+
.as_deref()
755+
.is_some_and(|hint| hint.contains("/goal resume"))
756+
);
757+
assert_eq!(
758+
goals.resume_notice_for(25, false),
759+
Some(unavailable_notice())
760+
);
761+
}
762+
763+
// Terminal statuses surface nothing in either mode.
764+
goals
765+
.set_status(ThreadGoalStatus::Complete, 30)
766+
.expect("existing goal");
767+
assert_eq!(goals.resume_notice_for(35, true), None);
768+
assert_eq!(goals.resume_notice_for(35, false), None);
769+
}
770+
698771
#[test]
699772
fn usage_updates_count_tokens_since_goal_started() {
700773
let mut goals = ThreadGoalState::default();

0 commit comments

Comments
 (0)