From fdc66c583e424ee1820bbe894cc857c0810fa7d0 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:05:28 -0400 Subject: [PATCH 01/29] feat(protocol): add thread goal operations --- CURRENT-PROGRESS.md | 49 ++++++++++++++++++++++++++ nori-rs/protocol/src/protocol/mod.rs | 44 +++++++++++++++++++++++ nori-rs/protocol/src/protocol/tests.rs | 32 +++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 CURRENT-PROGRESS.md diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md new file mode 100644 index 000000000..7f663ce97 --- /dev/null +++ b/CURRENT-PROGRESS.md @@ -0,0 +1,49 @@ +Cuts: + +1. Remove the old upstream onboarding stack in onboarding_screen.rs (/home/clifford/ + Documents/source/nori/cli/codex-rs/tui/src/onboarding/onboarding_screen.rs). It is + explicitly “kept for reference,” and lib.rs (/home/clifford/Documents/source/nori/cli/ + codex-rs/tui/src/lib.rs) only runs Nori onboarding now. +2. Trim the compatibility-only login_status and auth_manager fields from nori/onboarding/ + onboarding_screen.rs (/home/clifford/Documents/source/nori/cli/codex-rs/tui/src/nori/ + onboarding/onboarding_screen.rs). The file itself says they are unused and only kept for + API compatibility. +3. Delete the unused upstream update UI in update_prompt.rs (/home/clifford/Documents/ + source/nori/cli/codex-rs/tui/src/update_prompt.rs) and its paired update_action.rs (/ + home/clifford/Documents/source/nori/cli/codex-rs/tui/src/update_action.rs). lib.rs (/ + home/clifford/Documents/source/nori/cli/codex-rs/tui/src/lib.rs) re-exports the Nori + versions instead. +4. Remove the model migration prompt subsystem in model_migration.rs (/home/clifford/ + Documents/source/nori/cli/codex-rs/tui/src/model_migration.rs) and its hooks in app/ + mod.rs (/home/clifford/Documents/source/nori/cli/codex-rs/tui/src/app/mod.rs). This is + legacy Codex/GPT migration baggage, not ACP. +5. Remove legacy transcript parsers for Claude/Codex/Gemini in session_parser.rs (/home/ + clifford/Documents/source/nori/cli/codex-rs/acp/src/session_parser.rs). They are another + non-ACP compatibility layer. +6. Drop legacy transcript entry variants ToolCall, ToolResult, and PatchApply from + transcript/types.rs (/home/clifford/Documents/source/nori/cli/codex-rs/acp/src/ + transcript/types.rs). acp/docs.md (/home/clifford/Documents/source/nori/cli/codex-rs/acp/ + docs.md) says they remain only for legacy read compatibility. +7. Drop transcript attachments from transcript/types.rs (/home/clifford/Documents/source/ + nori/cli/codex-rs/acp/src/transcript/types.rs) and transcript/recorder.rs (/home/ + clifford/Documents/source/nori/cli/codex-rs/acp/src/transcript/recorder.rs). backend/ + user_input.rs (/home/clifford/Documents/source/nori/cli/codex-rs/acp/src/backend/ + user_input.rs) records every user message with vec![]. +8. Remove the debug-only /rollout surface in slash_command.rs (/home/clifford/Documents/ + source/nori/cli/codex-rs/tui/src/slash_command.rs) and chatwidget/key_handling.rs (/ + home/clifford/Documents/source/nori/cli/codex-rs/tui/src/chatwidget/key_handling.rs). It + exposes an old Codex concept, not ACP behavior. +9. Remove the debug-only /test-approval path in slash_command.rs (/home/clifford/Documents/ + source/nori/cli/codex-rs/tui/src/slash_command.rs) and chatwidget/key_handling.rs (/home/ + clifford/Documents/source/nori/cli/codex-rs/tui/src/chatwidget/key_handling.rs). Good + cleanup, zero protocol cost. + +Goal command progress: + +10. Started `/goal` implementation from the current `feat/goal` branch, which was still at + `main` with no committed goal work. Chosen architecture: keep the goal state in the + ACP backend because it already owns the long-lived ACP session, and have the TUI send + typed goal ops instead of forwarding `/goal` text to arbitrary agents. +11. Added the first shared protocol contract for thread goals: statuses, typed get/set/clear + ops, and objective validation. Token budgets remain intentionally out of scope for the + first Nori implementation, matching the goal-context instruction. diff --git a/nori-rs/protocol/src/protocol/mod.rs b/nori-rs/protocol/src/protocol/mod.rs index 8c1821938..005c7e955 100644 --- a/nori-rs/protocol/src/protocol/mod.rs +++ b/nori-rs/protocol/src/protocol/mod.rs @@ -64,6 +64,34 @@ pub struct Submission { pub op: Op, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub enum ThreadGoalStatus { + Active, + Paused, + Blocked, + UsageLimited, + BudgetLimited, + Complete, +} + +pub const MAX_THREAD_GOAL_OBJECTIVE_CHARS: i64 = 4_000; + +pub fn validate_thread_goal_objective(value: &str) -> Result<(), String> { + if value.is_empty() { + return Err("goal objective must not be empty".to_string()); + } + + let char_count = value.chars().fold(0_i64, |count, _| count + 1); + if char_count > MAX_THREAD_GOAL_OBJECTIVE_CHARS { + return Err(format!( + "goal objective must be at most {MAX_THREAD_GOAL_OBJECTIVE_CHARS} characters" + )); + } + + Ok(()) +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] @@ -80,6 +108,22 @@ pub enum Op { items: Vec, }, + /// Get the persistent goal for the current thread, if any. + ThreadGoalGet, + + /// Create or update the persistent goal for the current thread. + ThreadGoalSet { + /// New objective. When omitted, only the status is updated. + #[serde(skip_serializing_if = "Option::is_none")] + objective: Option, + /// New status. When omitted with an objective, defaults to active. + #[serde(skip_serializing_if = "Option::is_none")] + status: Option, + }, + + /// Clear the persistent goal for the current thread. + ThreadGoalClear, + /// Similar to [`Op::UserInput`], but contains additional context required /// for a model turn. UserTurn { diff --git a/nori-rs/protocol/src/protocol/tests.rs b/nori-rs/protocol/src/protocol/tests.rs index d4f1c67a8..b29ee6b4b 100644 --- a/nori-rs/protocol/src/protocol/tests.rs +++ b/nori-rs/protocol/src/protocol/tests.rs @@ -119,6 +119,38 @@ fn serialize_mcp_startup_update_event() -> Result<()> { Ok(()) } +#[test] +fn thread_goal_objective_validation_accepts_non_empty_objective() { + assert_eq!(Ok(()), validate_thread_goal_objective("ship the goal command")); +} + +#[test] +fn thread_goal_objective_validation_accepts_max_length_objective() { + let objective: String = (0..MAX_THREAD_GOAL_OBJECTIVE_CHARS).map(|_| 'x').collect(); + + assert_eq!(Ok(()), validate_thread_goal_objective(&objective)); +} + +#[test] +fn thread_goal_objective_validation_rejects_empty_objective() { + assert_eq!( + Err("goal objective must not be empty".to_string()), + validate_thread_goal_objective("") + ); +} + +#[test] +fn thread_goal_objective_validation_rejects_overlong_objective() { + let objective: String = (0..=MAX_THREAD_GOAL_OBJECTIVE_CHARS).map(|_| 'x').collect(); + + assert_eq!( + Err(format!( + "goal objective must be at most {MAX_THREAD_GOAL_OBJECTIVE_CHARS} characters" + )), + validate_thread_goal_objective(&objective) + ); +} + #[test] fn serialize_mcp_startup_complete_event() -> Result<()> { let event = Event { From a74d9d631ef2cc6cea72736823defc54be2dc11d Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:06:52 -0400 Subject: [PATCH 02/29] feat(protocol): add goal client events --- CURRENT-PROGRESS.md | 3 ++ nori-rs/nori-protocol/src/lib.rs | 64 ++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 7f663ce97..28f9cd538 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -47,3 +47,6 @@ Goal command progress: 11. Added the first shared protocol contract for thread goals: statuses, typed get/set/clear ops, and objective validation. Token budgets remain intentionally out of scope for the first Nori implementation, matching the goal-context instruction. +12. Added normalized ACP-client events for goal updated/cleared notifications in + `nori-protocol`. This keeps the ACP backend as the goal-state owner while giving the + TUI an agent-independent event shape to render. diff --git a/nori-rs/nori-protocol/src/lib.rs b/nori-rs/nori-protocol/src/lib.rs index 684265155..aee1b18fa 100644 --- a/nori-rs/nori-protocol/src/lib.rs +++ b/nori-rs/nori-protocol/src/lib.rs @@ -26,6 +26,8 @@ pub enum ClientEvent { SessionUpdateInfo(SessionUpdateInfo), SessionConfigUpdate(SessionConfigUpdate), SessionModeChanged(SessionModeChanged), + ThreadGoalUpdated(ThreadGoalUpdated), + ThreadGoalCleared, Warning(WarningInfo), } @@ -98,6 +100,34 @@ pub struct SessionModeChanged { pub current_mode_id: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ThreadGoalStatus { + Active, + Paused, + Blocked, + UsageLimited, + BudgetLimited, + Complete, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ThreadGoal { + pub objective: String, + pub status: ThreadGoalStatus, + pub tokens_used: i64, + pub time_used_seconds: i64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ThreadGoalUpdated { + pub goal: ThreadGoal, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum SessionUpdateKind { @@ -1739,6 +1769,40 @@ mod tests { assert_eq!(parsed, event); } + #[test] + fn thread_goal_update_event_round_trips_through_serde() { + let event = ClientEvent::ThreadGoalUpdated(ThreadGoalUpdated { + goal: ThreadGoal { + objective: "Keep implementing /goal".to_string(), + status: ThreadGoalStatus::Active, + tokens_used: 123, + time_used_seconds: 45, + created_at: 1000, + updated_at: 1045, + }, + }); + + let json = serde_json::to_string(&event).unwrap(); + assert_eq!( + json, + r#"{"event_type":"thread_goal_updated","goal":{"objective":"Keep implementing /goal","status":"active","tokens_used":123,"time_used_seconds":45,"created_at":1000,"updated_at":1045}}"# + ); + let parsed: ClientEvent = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed, event); + } + + #[test] + fn thread_goal_cleared_event_round_trips_through_serde() { + let event = ClientEvent::ThreadGoalCleared; + + let json = serde_json::to_string(&event).unwrap(); + assert_eq!(json, r#"{"event_type":"thread_goal_cleared"}"#); + let parsed: ClientEvent = serde_json::from_str(&json).unwrap(); + + assert_eq!(parsed, event); + } + #[test] fn normalizer_extracts_delete_file_operation() { let mut normalizer = ClientEventNormalizer::default(); From 09aeaa6423871ca226aaabeeef5230737fe57db3 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:10:11 -0400 Subject: [PATCH 03/29] feat(acp): maintain session goal state --- CURRENT-PROGRESS.md | 4 + nori-rs/acp/src/backend/helpers.rs | 3 + nori-rs/acp/src/backend/mod.rs | 3 + nori-rs/acp/src/backend/session.rs | 1 + .../acp/src/backend/session_runtime_driver.rs | 2 + nori-rs/acp/src/backend/spawn_and_relay.rs | 1 + nori-rs/acp/src/backend/submit_and_ops.rs | 9 + nori-rs/acp/src/backend/thread_goal.rs | 306 ++++++++++++++++++ nori-rs/acp/src/backend/transcript.rs | 2 + 9 files changed, 331 insertions(+) create mode 100644 nori-rs/acp/src/backend/thread_goal.rs diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 28f9cd538..8fc5694a3 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -50,3 +50,7 @@ Goal command progress: 12. Added normalized ACP-client events for goal updated/cleared notifications in `nori-protocol`. This keeps the ACP backend as the goal-state owner while giving the TUI an agent-independent event shape to render. +13. Added an in-memory ACP-session goal state machine and wired `ThreadGoalGet`, + `ThreadGoalSet`, and `ThreadGoalClear` through `AcpBackend::submit`. Goal time is + accumulated only while the status is active; paused/blocked/limited/complete states do + not accrue elapsed time until resumed. diff --git a/nori-rs/acp/src/backend/helpers.rs b/nori-rs/acp/src/backend/helpers.rs index 9ca843e1b..1fd57c33c 100644 --- a/nori-rs/acp/src/backend/helpers.rs +++ b/nori-rs/acp/src/backend/helpers.rs @@ -5,6 +5,9 @@ pub(crate) fn get_op_name(op: &Op) -> &'static str { match op { Op::Interrupt => "Interrupt", Op::UserInput { .. } => "UserInput", + Op::ThreadGoalGet => "ThreadGoalGet", + Op::ThreadGoalSet { .. } => "ThreadGoalSet", + Op::ThreadGoalClear => "ThreadGoalClear", Op::UserTurn { .. } => "UserTurn", Op::OverrideTurnContext { .. } => "OverrideTurnContext", Op::ExecApproval { .. } => "ExecApproval", diff --git a/nori-rs/acp/src/backend/mod.rs b/nori-rs/acp/src/backend/mod.rs index e296c0e2b..c3537ad19 100644 --- a/nori-rs/acp/src/backend/mod.rs +++ b/nori-rs/acp/src/backend/mod.rs @@ -286,6 +286,8 @@ pub struct AcpBackend { approval_policy_tx: watch::Sender, /// Stored summary from last /compact operation, to be prepended to next prompt pending_compact_summary: Arc>>, + /// Persistent goal for this ACP session. + thread_goal_state: Arc>, /// Accumulated context from hook `::context::` lines, prepended to next prompt pending_hook_context: Arc>>, /// Transcript recorder for session persistence @@ -342,6 +344,7 @@ pub(crate) mod session_reducer; mod session_runtime_driver; mod spawn_and_relay; mod submit_and_ops; +mod thread_goal; mod user_input; mod user_shell; use helpers::get_op_name; diff --git a/nori-rs/acp/src/backend/session.rs b/nori-rs/acp/src/backend/session.rs index 19e016485..1d92d9460 100644 --- a/nori-rs/acp/src/backend/session.rs +++ b/nori-rs/acp/src/backend/session.rs @@ -305,6 +305,7 @@ impl AcpBackend { conversation_id, approval_policy_tx, pending_compact_summary: Arc::new(Mutex::new(pending_summary)), + thread_goal_state: Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), pending_hook_context: Arc::new(Mutex::new(config.session_context.clone())), transcript_recorder, session_event_tx: session_event_tx.clone(), diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index afd0e1617..ba952c446 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -53,6 +53,8 @@ fn client_event_kind(event: &ClientEvent) -> &'static str { ClientEvent::ContextCompacted(_) => "context_compacted", ClientEvent::Warning(_) => "warning", ClientEvent::ReplayEntry(_) => "replay_entry", + ClientEvent::ThreadGoalUpdated(_) => "thread_goal_updated", + ClientEvent::ThreadGoalCleared => "thread_goal_cleared", } } diff --git a/nori-rs/acp/src/backend/spawn_and_relay.rs b/nori-rs/acp/src/backend/spawn_and_relay.rs index fd4b4b480..d48e23d4b 100644 --- a/nori-rs/acp/src/backend/spawn_and_relay.rs +++ b/nori-rs/acp/src/backend/spawn_and_relay.rs @@ -166,6 +166,7 @@ impl AcpBackend { conversation_id, approval_policy_tx, pending_compact_summary: Arc::new(Mutex::new(config.initial_context.clone())), + thread_goal_state: Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), pending_hook_context: Arc::new(Mutex::new(config.session_context.clone())), transcript_recorder, session_event_tx: session_event_tx.clone(), diff --git a/nori-rs/acp/src/backend/submit_and_ops.rs b/nori-rs/acp/src/backend/submit_and_ops.rs index 18ffda755..b7d90cd78 100644 --- a/nori-rs/acp/src/backend/submit_and_ops.rs +++ b/nori-rs/acp/src/backend/submit_and_ops.rs @@ -20,6 +20,15 @@ impl AcpBackend { Op::UserInput { items } => { self.handle_user_input(items, &id).await?; } + Op::ThreadGoalGet => { + self.handle_thread_goal_get().await; + } + Op::ThreadGoalSet { objective, status } => { + self.handle_thread_goal_set(objective, status).await; + } + Op::ThreadGoalClear => { + self.handle_thread_goal_clear().await; + } Op::Interrupt => { let _ = self .session_event_tx diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs new file mode 100644 index 000000000..7c55a1d79 --- /dev/null +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -0,0 +1,306 @@ +use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::validate_thread_goal_objective; +use nori_protocol::ClientEvent; +use nori_protocol::SessionUpdateInfo; +use nori_protocol::SessionUpdateKind; +use nori_protocol::ThreadGoalUpdated; + +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ThreadGoalSnapshot { + pub(crate) objective: String, + pub(crate) status: ThreadGoalStatus, + pub(crate) tokens_used: i64, + pub(crate) time_used_seconds: i64, + pub(crate) created_at: i64, + pub(crate) updated_at: i64, +} + +impl ThreadGoalSnapshot { + pub(crate) fn into_client_goal(self) -> nori_protocol::ThreadGoal { + nori_protocol::ThreadGoal { + objective: self.objective, + status: client_status(self.status), + tokens_used: self.tokens_used, + time_used_seconds: self.time_used_seconds, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct StoredThreadGoal { + objective: String, + status: ThreadGoalStatus, + tokens_used: i64, + accumulated_active_seconds: i64, + active_started_at: Option, + created_at: i64, + updated_at: i64, +} + +#[derive(Debug, Default, Clone)] +pub(crate) struct ThreadGoalState { + goal: Option, +} + +impl ThreadGoalState { + pub(crate) fn snapshot(&self, now: i64) -> Option { + self.goal.as_ref().map(|goal| goal.snapshot(now)) + } + + pub(crate) fn set_objective( + &mut self, + objective: String, + status: Option, + now: i64, + ) -> Result { + validate_thread_goal_objective(&objective)?; + let status = status.unwrap_or(ThreadGoalStatus::Active); + self.goal = Some(StoredThreadGoal { + objective, + status, + tokens_used: 0, + accumulated_active_seconds: 0, + active_started_at: active_started_at(status, now), + created_at: now, + updated_at: now, + }); + Ok(self.snapshot(now).expect("goal was just set")) + } + + pub(crate) fn set_status( + &mut self, + status: ThreadGoalStatus, + now: i64, + ) -> Result { + let Some(goal) = self.goal.as_mut() else { + return Err("cannot update goal: no goal exists".to_string()); + }; + goal.apply_status(status, now); + Ok(goal.snapshot(now)) + } + + pub(crate) fn clear(&mut self) -> bool { + self.goal.take().is_some() + } +} + +impl StoredThreadGoal { + fn snapshot(&self, now: i64) -> ThreadGoalSnapshot { + ThreadGoalSnapshot { + objective: self.objective.clone(), + status: self.status, + tokens_used: self.tokens_used, + time_used_seconds: self.active_seconds(now), + created_at: self.created_at, + updated_at: self.updated_at, + } + } + + fn apply_status(&mut self, status: ThreadGoalStatus, now: i64) { + self.accumulated_active_seconds = self.active_seconds(now); + self.status = status; + self.active_started_at = active_started_at(status, now); + self.updated_at = now; + } + + fn active_seconds(&self, now: i64) -> i64 { + let current_active_seconds = self + .active_started_at + .map(|started_at| now.saturating_sub(started_at)) + .unwrap_or(0); + self.accumulated_active_seconds + current_active_seconds + } +} + +fn active_started_at(status: ThreadGoalStatus, now: i64) -> Option { + match status { + ThreadGoalStatus::Active => Some(now), + ThreadGoalStatus::Paused + | ThreadGoalStatus::Blocked + | ThreadGoalStatus::UsageLimited + | ThreadGoalStatus::BudgetLimited + | ThreadGoalStatus::Complete => None, + } +} + +fn client_status(status: ThreadGoalStatus) -> nori_protocol::ThreadGoalStatus { + match status { + ThreadGoalStatus::Active => nori_protocol::ThreadGoalStatus::Active, + ThreadGoalStatus::Paused => nori_protocol::ThreadGoalStatus::Paused, + ThreadGoalStatus::Blocked => nori_protocol::ThreadGoalStatus::Blocked, + ThreadGoalStatus::UsageLimited => nori_protocol::ThreadGoalStatus::UsageLimited, + ThreadGoalStatus::BudgetLimited => nori_protocol::ThreadGoalStatus::BudgetLimited, + ThreadGoalStatus::Complete => nori_protocol::ThreadGoalStatus::Complete, + } +} + +pub(super) fn now_seconds() -> i64 { + let now = std::time::SystemTime::now(); + now.duration_since(std::time::UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX)) + .unwrap_or(0) +} + +impl AcpBackend { + pub(super) async fn handle_thread_goal_get(&self) { + let now = now_seconds(); + let goal = self.thread_goal_state.lock().await.snapshot(now); + match goal { + Some(goal) => { + self.emit_thread_goal_updated(goal).await; + } + None => { + emit_client_event( + &self.backend_event_tx, + self.transcript_recorder.as_ref(), + ClientEvent::SessionUpdateInfo(SessionUpdateInfo { + kind: SessionUpdateKind::SessionInfo, + message: "Usage: /goal ".to_string(), + hint: Some("No goal is currently set.".to_string()), + usage: None, + }), + ) + .await; + } + } + } + + pub(super) async fn handle_thread_goal_set( + &self, + objective: Option, + status: Option, + ) { + let now = now_seconds(); + let result = { + let mut state = self.thread_goal_state.lock().await; + match objective { + Some(objective) => state.set_objective(objective, status, now), + None => match status { + Some(status) => state.set_status(status, now), + None => Err("goal update must include an objective or status".to_string()), + }, + } + }; + + match result { + Ok(goal) => self.emit_thread_goal_updated(goal).await, + Err(message) => self.send_error(&message).await, + } + } + + pub(super) async fn handle_thread_goal_clear(&self) { + let cleared = self.thread_goal_state.lock().await.clear(); + if cleared { + emit_client_event( + &self.backend_event_tx, + self.transcript_recorder.as_ref(), + ClientEvent::ThreadGoalCleared, + ) + .await; + } else { + emit_client_event( + &self.backend_event_tx, + self.transcript_recorder.as_ref(), + ClientEvent::SessionUpdateInfo(SessionUpdateInfo { + kind: SessionUpdateKind::SessionInfo, + message: "No goal to clear".to_string(), + hint: Some("This session does not currently have a goal.".to_string()), + usage: None, + }), + ) + .await; + } + } + + async fn emit_thread_goal_updated(&self, goal: ThreadGoalSnapshot) { + emit_client_event( + &self.backend_event_tx, + self.transcript_recorder.as_ref(), + ClientEvent::ThreadGoalUpdated(ThreadGoalUpdated { + goal: goal.into_client_goal(), + }), + ) + .await; + } +} + +#[cfg(test)] +mod tests { + use codex_protocol::protocol::ThreadGoalStatus; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn setting_objective_creates_active_goal() { + let mut goals = ThreadGoalState::default(); + + let goal = goals + .set_objective( + "Ship the ACP goal command".to_string(), + Some(ThreadGoalStatus::Active), + 10, + ) + .expect("valid objective"); + + assert_eq!(goal.objective, "Ship the ACP goal command"); + assert_eq!(goal.status, ThreadGoalStatus::Active); + assert_eq!(goal.tokens_used, 0); + assert_eq!(goal.time_used_seconds, 0); + assert_eq!(goal.created_at, 10); + assert_eq!(goal.updated_at, 10); + } + + #[test] + fn updating_status_preserves_objective_and_accumulates_active_time() { + let mut goals = ThreadGoalState::default(); + goals + .set_objective("Keep going".to_string(), None, 10) + .expect("valid objective"); + + let goal = goals + .set_status(ThreadGoalStatus::Paused, 25) + .expect("existing goal"); + + assert_eq!(goal.objective, "Keep going"); + assert_eq!(goal.status, ThreadGoalStatus::Paused); + assert_eq!(goal.time_used_seconds, 15); + assert_eq!(goal.created_at, 10); + assert_eq!(goal.updated_at, 25); + } + + #[test] + fn paused_time_does_not_accumulate_until_resumed() { + let mut goals = ThreadGoalState::default(); + goals + .set_objective("Keep going".to_string(), None, 10) + .expect("valid objective"); + goals + .set_status(ThreadGoalStatus::Paused, 25) + .expect("existing goal"); + goals + .set_status(ThreadGoalStatus::Active, 100) + .expect("existing goal"); + + let goal = goals.snapshot(130).expect("goal exists"); + + assert_eq!(goal.status, ThreadGoalStatus::Active); + assert_eq!(goal.time_used_seconds, 45); + } + + #[test] + fn clearing_goal_reports_whether_goal_existed() { + let mut goals = ThreadGoalState::default(); + assert_eq!(goals.clear(), false); + goals + .set_objective("Keep going".to_string(), None, 10) + .expect("valid objective"); + + assert_eq!(goals.clear(), true); + assert_eq!(goals.snapshot(20), None); + } +} diff --git a/nori-rs/acp/src/backend/transcript.rs b/nori-rs/acp/src/backend/transcript.rs index 982c890b6..56bc79217 100644 --- a/nori-rs/acp/src/backend/transcript.rs +++ b/nori-rs/acp/src/backend/transcript.rs @@ -212,6 +212,8 @@ fn replay_entry_from_client_event( | nori_protocol::ClientEvent::SessionUpdateInfo(_) | nori_protocol::ClientEvent::SessionConfigUpdate(_) | nori_protocol::ClientEvent::SessionModeChanged(_) + | nori_protocol::ClientEvent::ThreadGoalUpdated(_) + | nori_protocol::ClientEvent::ThreadGoalCleared | nori_protocol::ClientEvent::Warning(_) => None, } } From 81519ff11723818c7f20a90a4e8d1c9e16ee7d3d Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:14:30 -0400 Subject: [PATCH 04/29] feat(tui): dispatch goal slash command --- CURRENT-PROGRESS.md | 4 + nori-rs/tui/src/chatwidget/constructors.rs | 4 + nori-rs/tui/src/chatwidget/event_handlers.rs | 6 + nori-rs/tui/src/chatwidget/goal.rs | 125 +++++++++++++++++++ nori-rs/tui/src/chatwidget/key_handling.rs | 3 + nori-rs/tui/src/chatwidget/mod.rs | 5 + nori-rs/tui/src/chatwidget/tests/mod.rs | 2 + nori-rs/tui/src/chatwidget/tests/part2.rs | 108 ++++++++++++++++ nori-rs/tui/src/chatwidget/user_input.rs | 4 + nori-rs/tui/src/slash_command.rs | 3 + nori-rs/tui/src/viewonly_transcript.rs | 2 + 11 files changed, 266 insertions(+) create mode 100644 nori-rs/tui/src/chatwidget/goal.rs diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 8fc5694a3..402ee8b77 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -54,3 +54,7 @@ Goal command progress: `ThreadGoalSet`, and `ThreadGoalClear` through `AcpBackend::submit`. Goal time is accumulated only while the status is active; paused/blocked/limited/complete states do not accrue elapsed time until resumed. +14. Wired the TUI `/goal` command to typed ACP goal ops. Bare `/goal` requests the current + goal, `/goal ` creates/replaces the objective as active, `/goal pause`, + `/goal resume`, and `/goal clear` map to direct mutations, and `/goal edit` preloads + the current objective into the composer when the TUI has a goal snapshot. diff --git a/nori-rs/tui/src/chatwidget/constructors.rs b/nori-rs/tui/src/chatwidget/constructors.rs index 251ce1d77..7afc3f0f5 100644 --- a/nori-rs/tui/src/chatwidget/constructors.rs +++ b/nori-rs/tui/src/chatwidget/constructors.rs @@ -108,6 +108,8 @@ impl ChatWidget { login_handler: None, active_resume_picker_generation: None, first_prompt_text, + current_goal: None, + pending_goal_edit: false, loop_remaining: None, loop_total: None, #[cfg(feature = "nori-config")] @@ -231,6 +233,8 @@ impl ChatWidget { login_handler: None, active_resume_picker_generation: None, first_prompt_text, + current_goal: None, + pending_goal_edit: false, loop_remaining: None, loop_total: None, #[cfg(feature = "nori-config")] diff --git a/nori-rs/tui/src/chatwidget/event_handlers.rs b/nori-rs/tui/src/chatwidget/event_handlers.rs index 32d564b56..f22c19146 100644 --- a/nori-rs/tui/src/chatwidget/event_handlers.rs +++ b/nori-rs/tui/src/chatwidget/event_handlers.rs @@ -1174,6 +1174,12 @@ impl ChatWidget { nori_protocol::ClientEvent::SessionModeChanged(update) => { self.handle_acp_session_mode_changed(&update.current_mode_id); } + nori_protocol::ClientEvent::ThreadGoalUpdated(update) => { + self.handle_thread_goal_updated(update.goal); + } + nori_protocol::ClientEvent::ThreadGoalCleared => { + self.handle_thread_goal_cleared(); + } nori_protocol::ClientEvent::Warning(warning) => { self.on_warning(warning.message); } diff --git a/nori-rs/tui/src/chatwidget/goal.rs b/nori-rs/tui/src/chatwidget/goal.rs new file mode 100644 index 000000000..529b98d61 --- /dev/null +++ b/nori-rs/tui/src/chatwidget/goal.rs @@ -0,0 +1,125 @@ +use super::*; + +impl ChatWidget { + pub(super) fn handle_goal_user_message(&mut self, text: &str) -> bool { + let Some(rest) = text.strip_prefix("/goal") else { + return false; + }; + if !rest.is_empty() && !rest.starts_with(' ') { + return false; + } + + let rest = rest.trim(); + if rest.is_empty() { + self.submit_op(Op::ThreadGoalGet); + return true; + } + + let lower = rest.to_ascii_lowercase(); + match lower.as_str() { + "pause" => { + self.submit_op(Op::ThreadGoalSet { + objective: None, + status: Some(codex_core::protocol::ThreadGoalStatus::Paused), + }); + } + "resume" => { + self.submit_op(Op::ThreadGoalSet { + objective: None, + status: Some(codex_core::protocol::ThreadGoalStatus::Active), + }); + } + "clear" => { + self.submit_op(Op::ThreadGoalClear); + } + "edit" => { + self.open_goal_editor_or_request_snapshot(); + } + _ => { + if let Err(message) = codex_core::protocol::validate_thread_goal_objective(rest) { + self.add_error_message(message); + return true; + } + self.submit_op(Op::ThreadGoalSet { + objective: Some(rest.to_string()), + status: Some(codex_core::protocol::ThreadGoalStatus::Active), + }); + } + } + true + } + + pub(super) fn handle_thread_goal_updated(&mut self, goal: nori_protocol::ThreadGoal) { + self.current_goal = Some(goal.clone()); + if self.pending_goal_edit { + self.pending_goal_edit = false; + self.open_goal_editor(goal); + } else { + self.show_goal_summary(&goal); + } + self.request_redraw(); + } + + pub(super) fn handle_thread_goal_cleared(&mut self) { + self.current_goal = None; + self.pending_goal_edit = false; + self.add_info_message("Goal cleared".to_string(), None); + self.request_redraw(); + } + + fn open_goal_editor_or_request_snapshot(&mut self) { + if let Some(goal) = self.current_goal.clone() { + self.open_goal_editor(goal); + } else { + self.pending_goal_edit = true; + self.submit_op(Op::ThreadGoalGet); + } + } + + fn open_goal_editor(&mut self, goal: nori_protocol::ThreadGoal) { + self.bottom_pane + .set_composer_text(format!("/goal {}", goal.objective)); + } + + fn show_goal_summary(&mut self, goal: &nori_protocol::ThreadGoal) { + self.add_plain_history_lines(vec![ + Line::from("Goal".bold()), + Line::from(vec!["Status: ".dim(), goal_status_label(goal.status).into()]), + Line::from(vec!["Objective: ".dim(), goal.objective.clone().into()]), + Line::from(vec![ + "Time used: ".dim(), + format!("{}s", goal.time_used_seconds).into(), + ]), + Line::from(vec![ + "Tokens used: ".dim(), + goal.tokens_used.to_string().into(), + ]), + Line::default(), + Line::from(goal_command_hint(goal.status).dim()), + ]); + } +} + +fn goal_status_label(status: nori_protocol::ThreadGoalStatus) -> &'static str { + match status { + nori_protocol::ThreadGoalStatus::Active => "active", + nori_protocol::ThreadGoalStatus::Paused => "paused", + nori_protocol::ThreadGoalStatus::Blocked => "blocked", + nori_protocol::ThreadGoalStatus::UsageLimited => "usage limited", + nori_protocol::ThreadGoalStatus::BudgetLimited => "limited by budget", + nori_protocol::ThreadGoalStatus::Complete => "complete", + } +} + +fn goal_command_hint(status: nori_protocol::ThreadGoalStatus) -> &'static str { + match status { + nori_protocol::ThreadGoalStatus::Active => "Commands: /goal edit, /goal pause, /goal clear", + nori_protocol::ThreadGoalStatus::Paused + | nori_protocol::ThreadGoalStatus::Blocked + | nori_protocol::ThreadGoalStatus::UsageLimited => { + "Commands: /goal edit, /goal resume, /goal clear" + } + nori_protocol::ThreadGoalStatus::BudgetLimited + | nori_protocol::ThreadGoalStatus::Complete => "Commands: /goal edit, /goal clear", + } +} diff --git a/nori-rs/tui/src/chatwidget/key_handling.rs b/nori-rs/tui/src/chatwidget/key_handling.rs index 575005750..561374157 100644 --- a/nori-rs/tui/src/chatwidget/key_handling.rs +++ b/nori-rs/tui/src/chatwidget/key_handling.rs @@ -153,6 +153,9 @@ impl ChatWidget { None, ); } + SlashCommand::Goal => { + self.submit_op(Op::ThreadGoalGet); + } SlashCommand::Quit | SlashCommand::Exit => { self.submit_op(Op::Shutdown); } diff --git a/nori-rs/tui/src/chatwidget/mod.rs b/nori-rs/tui/src/chatwidget/mod.rs index 8968a0a5a..6851fda40 100644 --- a/nori-rs/tui/src/chatwidget/mod.rs +++ b/nori-rs/tui/src/chatwidget/mod.rs @@ -126,6 +126,7 @@ mod session_header; mod approvals; mod constructors; mod event_handlers; +mod goal; mod helpers; mod key_handling; mod login; @@ -416,6 +417,10 @@ pub(crate) struct ChatWidget { active_resume_picker_generation: Option, // The first user prompt text, preserved for /first-prompt command first_prompt_text: Option, + // Latest ACP-owned goal snapshot for this session. + current_goal: Option, + // Whether `/goal edit` is waiting for the backend to return a goal snapshot. + pending_goal_edit: bool, // Loop mode state: remaining iterations (None = not looping) loop_remaining: Option, // Loop mode state: total iterations configured diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index 1cb5c408a..f8faf5e30 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -316,6 +316,8 @@ pub(crate) fn make_chatwidget_manual() -> ( login_handler: None, active_resume_picker_generation: None, first_prompt_text: None, + current_goal: None, + pending_goal_edit: false, loop_remaining: None, loop_total: None, #[cfg(feature = "nori-config")] diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index eab5cf1c0..ef1285057 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -1,6 +1,8 @@ use super::*; use pretty_assertions::assert_eq; +use codex_core::protocol::ThreadGoalStatus; + #[test] fn slash_quit_sends_shutdown() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); @@ -31,6 +33,112 @@ fn slash_undo_sends_op() { } } +#[test] +fn slash_goal_requests_current_goal() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + + chat.dispatch_command(SlashCommand::Goal); + + assert_matches!(op_rx.try_recv(), Ok(Op::ThreadGoalGet)); +} + +#[test] +fn goal_objective_submits_thread_goal_set() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + + chat.submit_user_message("/goal Ship the ACP goal command".to_string().into()); + + assert_eq!( + op_rx.try_recv(), + Ok(Op::ThreadGoalSet { + objective: Some("Ship the ACP goal command".to_string()), + status: Some(ThreadGoalStatus::Active), + }) + ); +} + +#[test] +fn goal_status_commands_submit_goal_mutations() { + let cases = [ + ( + "/goal pause", + Op::ThreadGoalSet { + objective: None, + status: Some(ThreadGoalStatus::Paused), + }, + ), + ( + "/goal resume", + Op::ThreadGoalSet { + objective: None, + status: Some(ThreadGoalStatus::Active), + }, + ), + ("/goal clear", Op::ThreadGoalClear), + ]; + + for (input, expected) in cases { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + + chat.submit_user_message(input.to_string().into()); + + assert_eq!(op_rx.try_recv(), Ok(expected)); + } +} + +#[test] +fn goal_update_event_renders_summary() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Active), + }, + )); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Goal") + && rendered.contains("Status: active") + && rendered.contains("Objective: Keep going") + && rendered.contains("Commands: /goal edit, /goal pause, /goal clear"), + "expected goal summary, got: {rendered}" + ); +} + +#[test] +fn goal_edit_prefills_current_goal_objective() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + chat.current_goal = Some(test_thread_goal( + "Keep improving the ACP goal command", + nori_protocol::ThreadGoalStatus::Paused, + )); + + chat.submit_user_message("/goal edit".to_string().into()); + + assert_eq!( + chat.bottom_pane.composer_text(), + "/goal Keep improving the ACP goal command" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +fn test_thread_goal( + objective: &str, + status: nori_protocol::ThreadGoalStatus, +) -> nori_protocol::ThreadGoal { + nori_protocol::ThreadGoal { + objective: objective.to_string(), + status, + tokens_used: 0, + time_used_seconds: 0, + created_at: 10, + updated_at: 10, + } +} + #[test] fn slash_first_prompt_shows_initial_prompt() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); diff --git a/nori-rs/tui/src/chatwidget/user_input.rs b/nori-rs/tui/src/chatwidget/user_input.rs index f9257f072..5d5964e95 100644 --- a/nori-rs/tui/src/chatwidget/user_input.rs +++ b/nori-rs/tui/src/chatwidget/user_input.rs @@ -50,6 +50,10 @@ impl ChatWidget { return; } + if image_paths.is_empty() && self.handle_goal_user_message(&text) { + return; + } + // Special-case: "/login " triggers login for a specific agent // This intercepts before the message is sent to the agent if let Some(agent_name) = text.strip_prefix("/login ").map(str::trim) diff --git a/nori-rs/tui/src/slash_command.rs b/nori-rs/tui/src/slash_command.rs index ad9e22bad..d95175580 100644 --- a/nori-rs/tui/src/slash_command.rs +++ b/nori-rs/tui/src/slash_command.rs @@ -17,6 +17,7 @@ pub enum SlashCommand { Config, Approvals, Settings, + Goal, New, Resume, ResumeViewonly, @@ -60,6 +61,7 @@ impl SlashCommand { SlashCommand::Config => "configure ACP agent settings (if exposed by the agent)", SlashCommand::Approvals => "choose what Nori can do without approval", SlashCommand::Settings => "configure Nori CLI settings (theme, hotkeys, layout, …)", + SlashCommand::Goal => "set or view the goal for a long-running task", SlashCommand::Mcp => "manage MCP server connections", SlashCommand::Login => "log in to the current agent", SlashCommand::Logout => "show logout instructions", @@ -99,6 +101,7 @@ impl SlashCommand { | SlashCommand::Status | SlashCommand::Memory | SlashCommand::FirstPrompt + | SlashCommand::Goal | SlashCommand::Quit | SlashCommand::Exit => true, } diff --git a/nori-rs/tui/src/viewonly_transcript.rs b/nori-rs/tui/src/viewonly_transcript.rs index abadd1a07..6f359b150 100644 --- a/nori-rs/tui/src/viewonly_transcript.rs +++ b/nori-rs/tui/src/viewonly_transcript.rs @@ -179,6 +179,8 @@ fn format_client_event(event: &nori_protocol::ClientEvent) -> Option { | nori_protocol::ClientEvent::AgentCommandsUpdate(_) | nori_protocol::ClientEvent::SessionConfigUpdate(_) | nori_protocol::ClientEvent::SessionModeChanged(_) + | nori_protocol::ClientEvent::ThreadGoalUpdated(_) + | nori_protocol::ClientEvent::ThreadGoalCleared | nori_protocol::ClientEvent::Warning(_) => None, } } From c5e3e2d98c2c46931125d196d7003a5cac56c86f Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:15:04 -0400 Subject: [PATCH 05/29] style: format goal code --- nori-rs/protocol/src/protocol/tests.rs | 5 ++++- nori-rs/tui/src/chatwidget/goal.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/nori-rs/protocol/src/protocol/tests.rs b/nori-rs/protocol/src/protocol/tests.rs index b29ee6b4b..c535d531a 100644 --- a/nori-rs/protocol/src/protocol/tests.rs +++ b/nori-rs/protocol/src/protocol/tests.rs @@ -121,7 +121,10 @@ fn serialize_mcp_startup_update_event() -> Result<()> { #[test] fn thread_goal_objective_validation_accepts_non_empty_objective() { - assert_eq!(Ok(()), validate_thread_goal_objective("ship the goal command")); + assert_eq!( + Ok(()), + validate_thread_goal_objective("ship the goal command") + ); } #[test] diff --git a/nori-rs/tui/src/chatwidget/goal.rs b/nori-rs/tui/src/chatwidget/goal.rs index 529b98d61..30a359af5 100644 --- a/nori-rs/tui/src/chatwidget/goal.rs +++ b/nori-rs/tui/src/chatwidget/goal.rs @@ -84,7 +84,10 @@ impl ChatWidget { fn show_goal_summary(&mut self, goal: &nori_protocol::ThreadGoal) { self.add_plain_history_lines(vec![ Line::from("Goal".bold()), - Line::from(vec!["Status: ".dim(), goal_status_label(goal.status).into()]), + Line::from(vec![ + "Status: ".dim(), + goal_status_label(goal.status).into(), + ]), Line::from(vec!["Objective: ".dim(), goal.objective.clone().into()]), Line::from(vec![ "Time used: ".dim(), From d2dcbcd8465f80e9cdbbe1e0c97893773da97381 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:16:53 -0400 Subject: [PATCH 06/29] feat(acp): replay goal client events --- CURRENT-PROGRESS.md | 4 ++++ nori-rs/acp/src/backend/transcript.rs | 34 ++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 402ee8b77..852ccb85a 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -58,3 +58,7 @@ Goal command progress: goal, `/goal ` creates/replaces the objective as active, `/goal pause`, `/goal resume`, and `/goal clear` map to direct mutations, and `/goal edit` preloads the current objective into the composer when the TUI has a goal snapshot. +15. Preserved goal update/clear client events through transcript replay conversion. This + restores the latest TUI-visible goal snapshot on resumed sessions, but the ACP backend + still needs a stronger state rehydration path before automatic continuation can rely on + a replayed goal without a fresh mutation. diff --git a/nori-rs/acp/src/backend/transcript.rs b/nori-rs/acp/src/backend/transcript.rs index 56bc79217..f561ed3b3 100644 --- a/nori-rs/acp/src/backend/transcript.rs +++ b/nori-rs/acp/src/backend/transcript.rs @@ -219,7 +219,12 @@ fn replay_entry_from_client_event( } fn should_pass_through_replay_client_event(event: &nori_protocol::ClientEvent) -> bool { - matches!(event, nori_protocol::ClientEvent::SessionUpdateInfo(_)) + matches!( + event, + nori_protocol::ClientEvent::SessionUpdateInfo(_) + | nori_protocol::ClientEvent::ThreadGoalUpdated(_) + | nori_protocol::ClientEvent::ThreadGoalCleared + ) } #[cfg(test)] @@ -387,6 +392,33 @@ mod tests { ); } + #[test] + fn client_events_to_replay_client_events_preserves_goal_updates() { + let goal_event = nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Keep the north star".to_string(), + status: nori_protocol::ThreadGoalStatus::Active, + tokens_used: 42, + time_used_seconds: 7, + created_at: 100, + updated_at: 107, + }, + }, + ); + let replay = client_events_to_replay_client_events(vec![goal_event.clone()]); + + assert_eq!(replay, vec![goal_event]); + } + + #[test] + fn client_events_to_replay_client_events_preserves_goal_clears() { + let replay = + client_events_to_replay_client_events(vec![nori_protocol::ClientEvent::ThreadGoalCleared]); + + assert_eq!(replay, vec![nori_protocol::ClientEvent::ThreadGoalCleared]); + } + #[test] fn client_events_to_replay_client_events_preserves_mixed_message_delta_order() { let replay = client_events_to_replay_client_events(vec![ From c71d2836f260dfbdf0a6d68aa86186cd133e1185 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:18:41 -0400 Subject: [PATCH 07/29] feat(acp): rehydrate goal state on resume --- CURRENT-PROGRESS.md | 3 + nori-rs/acp/src/backend/session.rs | 4 +- nori-rs/acp/src/backend/thread_goal.rs | 97 ++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 1 deletion(-) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 852ccb85a..1b645be4d 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -62,3 +62,6 @@ Goal command progress: restores the latest TUI-visible goal snapshot on resumed sessions, but the ACP backend still needs a stronger state rehydration path before automatic continuation can rely on a replayed goal without a fresh mutation. +16. Rehydrated ACP backend goal state from replayed goal events during resume setup. Active + goals resume elapsed-time accounting from their last `updated_at` timestamp; a later + replayed clear removes the backend goal state. diff --git a/nori-rs/acp/src/backend/session.rs b/nori-rs/acp/src/backend/session.rs index 1d92d9460..16be05517 100644 --- a/nori-rs/acp/src/backend/session.rs +++ b/nori-rs/acp/src/backend/session.rs @@ -289,6 +289,8 @@ impl AcpBackend { .as_ref() .and_then(|recorder| ConversationId::from_string(recorder.session_id()).ok()) .unwrap_or_default(); + let thread_goal_state = + thread_goal::ThreadGoalState::from_replay_events(&deferred_replay_client_events); let backend = Self { connection, @@ -305,7 +307,7 @@ impl AcpBackend { conversation_id, approval_policy_tx, pending_compact_summary: Arc::new(Mutex::new(pending_summary)), - thread_goal_state: Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), + thread_goal_state: Arc::new(Mutex::new(thread_goal_state)), pending_hook_context: Arc::new(Mutex::new(config.session_context.clone())), transcript_recorder, session_event_tx: session_event_tx.clone(), diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index 7c55a1d79..0972522da 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -47,6 +47,36 @@ pub(crate) struct ThreadGoalState { } impl ThreadGoalState { + pub(crate) fn from_replay_events(events: &[ClientEvent]) -> Self { + let mut state = Self::default(); + for event in events { + match event { + ClientEvent::ThreadGoalUpdated(update) => { + state.goal = Some(StoredThreadGoal::from_client_goal(&update.goal)); + } + ClientEvent::ThreadGoalCleared => { + state.goal = None; + } + ClientEvent::ToolSnapshot(_) + | ClientEvent::ApprovalRequest(_) + | ClientEvent::MessageDelta(_) + | ClientEvent::PlanSnapshot(_) + | ClientEvent::SessionPhaseChanged(_) + | ClientEvent::PromptCompleted(_) + | ClientEvent::LoadCompleted + | ClientEvent::QueueChanged(_) + | ClientEvent::ContextCompacted(_) + | ClientEvent::ReplayEntry(_) + | ClientEvent::AgentCommandsUpdate(_) + | ClientEvent::SessionUpdateInfo(_) + | ClientEvent::SessionConfigUpdate(_) + | ClientEvent::SessionModeChanged(_) + | ClientEvent::Warning(_) => {} + } + } + state + } + pub(crate) fn snapshot(&self, now: i64) -> Option { self.goal.as_ref().map(|goal| goal.snapshot(now)) } @@ -89,6 +119,19 @@ impl ThreadGoalState { } impl StoredThreadGoal { + fn from_client_goal(goal: &nori_protocol::ThreadGoal) -> Self { + let status = status_from_client(goal.status); + Self { + objective: goal.objective.clone(), + status, + tokens_used: goal.tokens_used, + accumulated_active_seconds: goal.time_used_seconds, + active_started_at: active_started_at(status, goal.updated_at), + created_at: goal.created_at, + updated_at: goal.updated_at, + } + } + fn snapshot(&self, now: i64) -> ThreadGoalSnapshot { ThreadGoalSnapshot { objective: self.objective.clone(), @@ -138,6 +181,17 @@ fn client_status(status: ThreadGoalStatus) -> nori_protocol::ThreadGoalStatus { } } +fn status_from_client(status: nori_protocol::ThreadGoalStatus) -> ThreadGoalStatus { + match status { + nori_protocol::ThreadGoalStatus::Active => ThreadGoalStatus::Active, + nori_protocol::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + nori_protocol::ThreadGoalStatus::Blocked => ThreadGoalStatus::Blocked, + nori_protocol::ThreadGoalStatus::UsageLimited => ThreadGoalStatus::UsageLimited, + nori_protocol::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + nori_protocol::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} + pub(super) fn now_seconds() -> i64 { let now = std::time::SystemTime::now(); now.duration_since(std::time::UNIX_EPOCH) @@ -303,4 +357,47 @@ mod tests { assert_eq!(goals.clear(), true); assert_eq!(goals.snapshot(20), None); } + + #[test] + fn rehydrates_latest_goal_from_replay_events() { + let goals = ThreadGoalState::from_replay_events(&[nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Keep going".to_string(), + status: nori_protocol::ThreadGoalStatus::Active, + tokens_used: 42, + time_used_seconds: 15, + created_at: 10, + updated_at: 25, + }, + }, + )]); + + let goal = goals.snapshot(30).expect("goal should be rehydrated"); + assert_eq!(goal.objective, "Keep going"); + assert_eq!(goal.status, ThreadGoalStatus::Active); + assert_eq!(goal.tokens_used, 42); + assert_eq!(goal.time_used_seconds, 20); + assert_eq!(goal.created_at, 10); + assert_eq!(goal.updated_at, 25); + } + + #[test] + fn rehydration_respects_latest_clear_event() { + let goals = ThreadGoalState::from_replay_events(&[ + nori_protocol::ClientEvent::ThreadGoalUpdated(nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Keep going".to_string(), + status: nori_protocol::ThreadGoalStatus::Paused, + tokens_used: 42, + time_used_seconds: 15, + created_at: 10, + updated_at: 25, + }, + }), + nori_protocol::ClientEvent::ThreadGoalCleared, + ]); + + assert_eq!(goals.snapshot(30), None); + } } From 6c31a15d224f18c43bd897c93904731a9573dc1a Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:20:29 -0400 Subject: [PATCH 08/29] test(tui): cover goal slash arguments --- .../bottom_pane/chat_composer/tests/part6.rs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part6.rs b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part6.rs index 4310739a7..4c1fa8084 100644 --- a/nori-rs/tui/src/bottom_pane/chat_composer/tests/part6.rs +++ b/nori-rs/tui/src/bottom_pane/chat_composer/tests/part6.rs @@ -544,6 +544,32 @@ fn slash_mode_prompt_stays_active_after_command_arguments() { ); } +#[test] +fn builtin_goal_command_with_arguments_submits_literal_text() { + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + true, + sender, + false, + "Ask Nori to do anything".to_string(), + false, + ); + + type_chars_humanlike( + &mut composer, + &[ + '/', 'g', 'o', 'a', 'l', ' ', 'S', 'h', 'i', 'p', ' ', 'i', 't', + ], + ); + + let (result, _needs_redraw) = + composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + + assert_eq!(InputResult::Submitted("/goal Ship it".to_string()), result); + assert!(composer.textarea.is_empty(), "composer should be cleared"); +} + #[test] fn shell_mode_prompt_uses_red_bang_without_duplicate_prefix() { let (tx, _rx) = unbounded_channel::(); From 473d128c528e0f9072af3e4b8fd6137e43641142 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:24:56 -0400 Subject: [PATCH 09/29] feat(acp): prepend goal context to prompts --- CURRENT-PROGRESS.md | 4 ++ nori-rs/acp/src/backend/tests/part5.rs | 81 ++++++++++++++++++++++++++ nori-rs/acp/src/backend/thread_goal.rs | 71 ++++++++++++++++++---- nori-rs/acp/src/backend/transcript.rs | 12 ++-- nori-rs/acp/src/backend/user_input.rs | 7 ++- 5 files changed, 156 insertions(+), 19 deletions(-) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 1b645be4d..83fca5339 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -65,3 +65,7 @@ Goal command progress: 16. Rehydrated ACP backend goal state from replayed goal events during resume setup. Active goals resume elapsed-time accounting from their last `updated_at` timestamp; a later replayed clear removes the backend goal state. +17. Added ACP prompt-context injection for active session goals. Before user input is sent to + the ACP agent, the backend now prepends a structured `` block with the goal + status, objective, elapsed active time, and token count, while preserving compact-summary + ordering when a summary is pending. diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/acp/src/backend/tests/part5.rs index c6a99f4c5..4c3df69b8 100644 --- a/nori-rs/acp/src/backend/tests/part5.rs +++ b/nori-rs/acp/src/backend/tests/part5.rs @@ -220,6 +220,87 @@ async fn test_session_context_prepended_to_first_prompt() { } } +/// When a session goal is active, the next user prompt sent to the ACP agent +/// includes a structured goal context block. +#[tokio::test] +#[serial] +async fn test_goal_context_prepended_to_user_prompt() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + // Configure mock agent to echo back the full prompt text. + // SAFETY: Test-scoped environment variable for mock agent behavior. + unsafe { + std::env::set_var("MOCK_AGENT_ECHO_PROMPT", "1"); + } + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + backend + .submit(Op::ThreadGoalSet { + objective: Some("Keep the north star visible".to_string()), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), + }) + .await + .expect("Failed to set goal"); + + backend + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "hello agent".to_string(), + }], + }) + .await + .expect("Failed to submit user input"); + + let mut agent_text = String::new(); + let timeout = Duration::from_secs(10); + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + panic!("Timed out waiting for PromptCompleted"); + } + match recv_backend_client(&mut backend_event_rx, Duration::from_secs(5)).await { + Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { + agent_text.push_str(&delta.delta); + } + Some(nori_protocol::ClientEvent::PromptCompleted(_)) => break, + Some(_) => continue, + None => panic!("Backend event channel closed unexpectedly"), + } + } + + assert!( + agent_text.contains("") + && agent_text.contains("Objective: Keep the north star visible") + && agent_text.contains("hello agent"), + "Expected goal context and user prompt in agent echo, got: {agent_text}" + ); + + unsafe { + std::env::remove_var("MOCK_AGENT_ECHO_PROMPT"); + } +} + /// Test that session_context is consumed after the first prompt (not repeated). #[tokio::test] #[serial] diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index 0972522da..4a0889407 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -81,6 +81,26 @@ impl ThreadGoalState { self.goal.as_ref().map(|goal| goal.snapshot(now)) } + pub(crate) fn prompt_context(&self, now: i64) -> Option { + self.snapshot(now).map(|goal| { + let status = match goal.status { + ThreadGoalStatus::Active => "active", + ThreadGoalStatus::Paused => "paused", + ThreadGoalStatus::Blocked => "blocked", + ThreadGoalStatus::UsageLimited => "usage limited", + ThreadGoalStatus::BudgetLimited => "limited by budget", + ThreadGoalStatus::Complete => "complete", + }; + format!( + "\nStatus: {}\nObjective: {}\nTime used: {}s\nTokens used: {}\n", + status, + goal.objective, + goal.time_used_seconds, + goal.tokens_used + ) + }) + } + pub(crate) fn set_objective( &mut self, objective: String, @@ -200,6 +220,18 @@ pub(super) fn now_seconds() -> i64 { } impl AcpBackend { + pub(super) async fn prepend_goal_context_to_prompt(&self, prompt: String) -> String { + let goal_context = self + .thread_goal_state + .lock() + .await + .prompt_context(now_seconds()); + match goal_context { + Some(goal_context) => format!("{goal_context}\n\n{prompt}"), + None => prompt, + } + } + pub(super) async fn handle_thread_goal_get(&self) { let now = now_seconds(); let goal = self.thread_goal_state.lock().await.snapshot(now); @@ -360,18 +392,19 @@ mod tests { #[test] fn rehydrates_latest_goal_from_replay_events() { - let goals = ThreadGoalState::from_replay_events(&[nori_protocol::ClientEvent::ThreadGoalUpdated( - nori_protocol::ThreadGoalUpdated { - goal: nori_protocol::ThreadGoal { - objective: "Keep going".to_string(), - status: nori_protocol::ThreadGoalStatus::Active, - tokens_used: 42, - time_used_seconds: 15, - created_at: 10, - updated_at: 25, + let goals = + ThreadGoalState::from_replay_events(&[nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Keep going".to_string(), + status: nori_protocol::ThreadGoalStatus::Active, + tokens_used: 42, + time_used_seconds: 15, + created_at: 10, + updated_at: 25, + }, }, - }, - )]); + )]); let goal = goals.snapshot(30).expect("goal should be rehydrated"); assert_eq!(goal.objective, "Keep going"); @@ -400,4 +433,20 @@ mod tests { assert_eq!(goals.snapshot(30), None); } + + #[test] + fn prompt_context_includes_current_goal_snapshot() { + let mut goals = ThreadGoalState::default(); + goals + .set_objective("Keep going".to_string(), None, 10) + .expect("valid objective"); + + assert_eq!( + goals.prompt_context(25), + Some( + "\nStatus: active\nObjective: Keep going\nTime used: 15s\nTokens used: 0\n" + .to_string() + ) + ); + } } diff --git a/nori-rs/acp/src/backend/transcript.rs b/nori-rs/acp/src/backend/transcript.rs index f561ed3b3..d3b214bd6 100644 --- a/nori-rs/acp/src/backend/transcript.rs +++ b/nori-rs/acp/src/backend/transcript.rs @@ -394,8 +394,8 @@ mod tests { #[test] fn client_events_to_replay_client_events_preserves_goal_updates() { - let goal_event = nori_protocol::ClientEvent::ThreadGoalUpdated( - nori_protocol::ThreadGoalUpdated { + let goal_event = + nori_protocol::ClientEvent::ThreadGoalUpdated(nori_protocol::ThreadGoalUpdated { goal: nori_protocol::ThreadGoal { objective: "Keep the north star".to_string(), status: nori_protocol::ThreadGoalStatus::Active, @@ -404,8 +404,7 @@ mod tests { created_at: 100, updated_at: 107, }, - }, - ); + }); let replay = client_events_to_replay_client_events(vec![goal_event.clone()]); assert_eq!(replay, vec![goal_event]); @@ -413,8 +412,9 @@ mod tests { #[test] fn client_events_to_replay_client_events_preserves_goal_clears() { - let replay = - client_events_to_replay_client_events(vec![nori_protocol::ClientEvent::ThreadGoalCleared]); + let replay = client_events_to_replay_client_events(vec![ + nori_protocol::ClientEvent::ThreadGoalCleared, + ]); assert_eq!(replay, vec![nori_protocol::ClientEvent::ThreadGoalCleared]); } diff --git a/nori-rs/acp/src/backend/user_input.rs b/nori-rs/acp/src/backend/user_input.rs index 7f7f5d0f3..4ad863017 100644 --- a/nori-rs/acp/src/backend/user_input.rs +++ b/nori-rs/acp/src/backend/user_input.rs @@ -156,14 +156,17 @@ impl AcpBackend { } else { prompt_text }; + let prompt_with_goal_context = self + .prepend_goal_context_to_prompt(prompt_with_context) + .await; // Check if we have a pending compact summary to prepend let pending_summary = self.pending_compact_summary.lock().await.take(); let final_prompt_text = if let Some(summary) = pending_summary { use codex_core::compact::SUMMARY_PREFIX; - format!("{SUMMARY_PREFIX}\n{summary}\n\n{prompt_with_context}") + format!("{SUMMARY_PREFIX}\n{summary}\n\n{prompt_with_goal_context}") } else { - prompt_with_context + prompt_with_goal_context }; let (phase_before_submit, active_request_id_before_submit, queue_len_before_submit) = { From 079f0ad3b43e8f991ea68b654e70aa761a982e18 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:31:35 -0400 Subject: [PATCH 10/29] feat(acp): track goal token usage --- CURRENT-PROGRESS.md | 4 + .../acp/src/backend/session_runtime_driver.rs | 11 ++ nori-rs/acp/src/backend/tests/part5.rs | 64 ++++++++ nori-rs/acp/src/backend/thread_goal.rs | 140 ++++++++++++++++-- nori-rs/protocol/src/protocol/tests.rs | 8 - nori-rs/tui/src/chatwidget/tests/part2.rs | 18 +-- ...sts__part2__goal_update_event_summary.snap | 11 ++ 7 files changed, 223 insertions(+), 33 deletions(-) create mode 100644 nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 83fca5339..14c228a6d 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -69,3 +69,7 @@ Goal command progress: the ACP agent, the backend now prepends a structured `` block with the goal status, objective, elapsed active time, and token count, while preserving compact-summary ordering when a summary is pending. +18. Added goal token accounting from ACP usage updates. The backend keeps a usage baseline + when a goal is created, updates goal token totals as `UsageUpdate` client events arrive, + emits refreshed goal snapshots, and rebuilds the baseline from replayed usage/goal events + when resuming a session. diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index ba952c446..8dc1b90b1 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -344,12 +344,23 @@ impl AcpBackend { ); } } + let goal_event = self + .thread_goal_update_from_client_event(&client_event) + .await; emit_client_event( &self.backend_event_tx, self.transcript_recorder.as_ref(), client_event, ) .await; + if let Some(goal_event) = goal_event { + emit_client_event( + &self.backend_event_tx, + self.transcript_recorder.as_ref(), + goal_event, + ) + .await; + } } async fn handle_completed_turn(&self, completed_turn: &CompletedTurn) { diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/acp/src/backend/tests/part5.rs index 4c3df69b8..72923519d 100644 --- a/nori-rs/acp/src/backend/tests/part5.rs +++ b/nori-rs/acp/src/backend/tests/part5.rs @@ -301,6 +301,70 @@ async fn test_goal_context_prepended_to_user_prompt() { } } +#[tokio::test] +#[serial] +async fn usage_updates_refresh_goal_token_count() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + backend + .apply_session_event(session_reducer::InboundEvent::Notification(Box::new( + acp::SessionUpdate::UsageUpdate(acp::UsageUpdate::new(100, 4096)), + ))) + .await; + + backend + .submit(Op::ThreadGoalSet { + objective: Some("Track token budget".to_string()), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), + }) + .await + .expect("Failed to set goal"); + + backend + .apply_session_event(session_reducer::InboundEvent::Notification(Box::new( + acp::SessionUpdate::UsageUpdate(acp::UsageUpdate::new(175, 4096)), + ))) + .await; + + let mut saw_goal_update_with_tokens = false; + for _ in 0..4 { + match recv_backend_client(&mut backend_event_rx, Duration::from_secs(5)).await { + Some(nori_protocol::ClientEvent::ThreadGoalUpdated(update)) + if update.goal.tokens_used == 75 => + { + saw_goal_update_with_tokens = true; + break; + } + Some(_) => {} + None => panic!("Backend event channel closed unexpectedly"), + } + } + + assert!(saw_goal_update_with_tokens); +} + /// Test that session_context is consumed after the first prompt (not repeated). #[tokio::test] #[serial] diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index 4a0889407..1df89f677 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -35,6 +35,7 @@ struct StoredThreadGoal { objective: String, status: ThreadGoalStatus, tokens_used: i64, + token_usage_baseline: Option, accumulated_active_seconds: i64, active_started_at: Option, created_at: i64, @@ -44,6 +45,7 @@ struct StoredThreadGoal { #[derive(Debug, Default, Clone)] pub(crate) struct ThreadGoalState { goal: Option, + last_session_used_tokens: Option, } impl ThreadGoalState { @@ -52,11 +54,21 @@ impl ThreadGoalState { for event in events { match event { ClientEvent::ThreadGoalUpdated(update) => { - state.goal = Some(StoredThreadGoal::from_client_goal(&update.goal)); + state.goal = Some(StoredThreadGoal::from_client_goal( + &update.goal, + state.last_session_used_tokens, + )); } ClientEvent::ThreadGoalCleared => { state.goal = None; } + ClientEvent::SessionUpdateInfo(update) => { + if let Some(usage) = &update.usage { + let updated_at = + state.goal.as_ref().map(|goal| goal.updated_at).unwrap_or(0); + state.update_session_tokens(usage.used_tokens, updated_at); + } + } ClientEvent::ToolSnapshot(_) | ClientEvent::ApprovalRequest(_) | ClientEvent::MessageDelta(_) @@ -68,7 +80,6 @@ impl ThreadGoalState { | ClientEvent::ContextCompacted(_) | ClientEvent::ReplayEntry(_) | ClientEvent::AgentCommandsUpdate(_) - | ClientEvent::SessionUpdateInfo(_) | ClientEvent::SessionConfigUpdate(_) | ClientEvent::SessionModeChanged(_) | ClientEvent::Warning(_) => {} @@ -113,6 +124,7 @@ impl ThreadGoalState { objective, status, tokens_used: 0, + token_usage_baseline: Some(self.last_session_used_tokens.unwrap_or(0)), accumulated_active_seconds: 0, active_started_at: active_started_at(status, now), created_at: now, @@ -136,15 +148,37 @@ impl ThreadGoalState { pub(crate) fn clear(&mut self) -> bool { self.goal.take().is_some() } + + pub(crate) fn update_session_tokens( + &mut self, + used_tokens: i64, + now: i64, + ) -> Option { + self.last_session_used_tokens = Some(used_tokens); + let goal = self.goal.as_mut()?; + let baseline = goal.token_usage_baseline.unwrap_or_else(|| { + let baseline = used_tokens.saturating_sub(goal.tokens_used); + goal.token_usage_baseline = Some(baseline); + baseline + }); + goal.tokens_used = used_tokens.saturating_sub(baseline); + goal.updated_at = now; + Some(goal.snapshot(now)) + } } impl StoredThreadGoal { - fn from_client_goal(goal: &nori_protocol::ThreadGoal) -> Self { + fn from_client_goal( + goal: &nori_protocol::ThreadGoal, + session_used_tokens: Option, + ) -> Self { let status = status_from_client(goal.status); Self { objective: goal.objective.clone(), status, tokens_used: goal.tokens_used, + token_usage_baseline: session_used_tokens + .map(|used_tokens| used_tokens.saturating_sub(goal.tokens_used)), accumulated_active_seconds: goal.time_used_seconds, active_started_at: active_started_at(status, goal.updated_at), created_at: goal.created_at, @@ -220,6 +254,24 @@ pub(super) fn now_seconds() -> i64 { } impl AcpBackend { + pub(super) async fn thread_goal_update_from_client_event( + &self, + client_event: &ClientEvent, + ) -> Option { + let ClientEvent::SessionUpdateInfo(update) = client_event else { + return None; + }; + let usage = update.usage.as_ref()?; + let goal = self + .thread_goal_state + .lock() + .await + .update_session_tokens(usage.used_tokens, now_seconds())?; + Some(ClientEvent::ThreadGoalUpdated(ThreadGoalUpdated { + goal: goal.into_client_goal(), + })) + } + pub(super) async fn prepend_goal_context_to_prompt(&self, prompt: String) -> String { let goal_context = self .thread_goal_state @@ -392,19 +444,28 @@ mod tests { #[test] fn rehydrates_latest_goal_from_replay_events() { - let goals = - ThreadGoalState::from_replay_events(&[nori_protocol::ClientEvent::ThreadGoalUpdated( - nori_protocol::ThreadGoalUpdated { - goal: nori_protocol::ThreadGoal { - objective: "Keep going".to_string(), - status: nori_protocol::ThreadGoalStatus::Active, - tokens_used: 42, - time_used_seconds: 15, - created_at: 10, - updated_at: 25, - }, + let goals = ThreadGoalState::from_replay_events(&[ + nori_protocol::ClientEvent::ThreadGoalUpdated(nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Earlier goal".to_string(), + status: nori_protocol::ThreadGoalStatus::Paused, + tokens_used: 12, + time_used_seconds: 5, + created_at: 1, + updated_at: 8, + }, + }), + nori_protocol::ClientEvent::ThreadGoalUpdated(nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Keep going".to_string(), + status: nori_protocol::ThreadGoalStatus::Active, + tokens_used: 42, + time_used_seconds: 15, + created_at: 10, + updated_at: 25, }, - )]); + }), + ]); let goal = goals.snapshot(30).expect("goal should be rehydrated"); assert_eq!(goal.objective, "Keep going"); @@ -449,4 +510,53 @@ mod tests { ) ); } + + #[test] + fn usage_updates_count_tokens_since_goal_started() { + let mut goals = ThreadGoalState::default(); + assert_eq!(goals.update_session_tokens(100, 5), None); + goals + .set_objective("Keep going".to_string(), None, 10) + .expect("valid objective"); + + let goal = goals + .update_session_tokens(175, 15) + .expect("goal should be updated"); + + assert_eq!(goal.tokens_used, 75); + assert_eq!(goal.updated_at, 15); + } + + #[test] + fn rehydrated_goal_usage_baseline_survives_future_usage_updates() { + let goals = ThreadGoalState::from_replay_events(&[ + nori_protocol::ClientEvent::SessionUpdateInfo(nori_protocol::SessionUpdateInfo { + kind: nori_protocol::SessionUpdateKind::Usage, + message: "Session usage: 200 / 4096 tokens".to_string(), + hint: None, + usage: Some(nori_protocol::session_runtime::SessionUsageState { + used_tokens: 200, + total_tokens: 4096, + cost_display: None, + }), + }), + nori_protocol::ClientEvent::ThreadGoalUpdated(nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Keep going".to_string(), + status: nori_protocol::ThreadGoalStatus::Active, + tokens_used: 42, + time_used_seconds: 15, + created_at: 10, + updated_at: 25, + }, + }), + ]); + let mut goals = goals; + + let goal = goals + .update_session_tokens(220, 30) + .expect("goal should be updated"); + + assert_eq!(goal.tokens_used, 62); + } } diff --git a/nori-rs/protocol/src/protocol/tests.rs b/nori-rs/protocol/src/protocol/tests.rs index c535d531a..6997f709d 100644 --- a/nori-rs/protocol/src/protocol/tests.rs +++ b/nori-rs/protocol/src/protocol/tests.rs @@ -119,14 +119,6 @@ fn serialize_mcp_startup_update_event() -> Result<()> { Ok(()) } -#[test] -fn thread_goal_objective_validation_accepts_non_empty_objective() { - assert_eq!( - Ok(()), - validate_thread_goal_objective("ship the goal command") - ); -} - #[test] fn thread_goal_objective_validation_accepts_max_length_objective() { let objective: String = (0..MAX_THREAD_GOAL_OBJECTIVE_CHARS).map(|_| 'x').collect(); diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index ef1285057..736bd2b80 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -99,21 +99,19 @@ fn goal_update_event_renders_summary() { let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); let rendered = lines_to_single_string(&cells[0]); - assert!( - rendered.contains("Goal") - && rendered.contains("Status: active") - && rendered.contains("Objective: Keep going") - && rendered.contains("Commands: /goal edit, /goal pause, /goal clear"), - "expected goal summary, got: {rendered}" - ); + assert_snapshot!("goal_update_event_summary", rendered); } #[test] fn goal_edit_prefills_current_goal_objective() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); - chat.current_goal = Some(test_thread_goal( - "Keep improving the ACP goal command", - nori_protocol::ThreadGoalStatus::Paused, + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal( + "Keep improving the ACP goal command", + nori_protocol::ThreadGoalStatus::Paused, + ), + }, )); chat.submit_user_message("/goal edit".to_string().into()); diff --git a/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap new file mode 100644 index 000000000..f5b88b9a3 --- /dev/null +++ b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/part2.rs +expression: rendered +--- +Goal +Status: active +Objective: Keep going +Time used: 0s +Tokens used: 0 + +Commands: /goal edit, /goal pause, /goal clear From 99f2457c8f430c966afd0748d055b3757852a12a Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:37:52 -0400 Subject: [PATCH 11/29] docs: document ACP goal flow --- nori-rs/acp/docs.md | 24 +++++++++- nori-rs/nori-protocol/docs.md | 11 +++-- nori-rs/protocol/docs.md | 83 +++++++++-------------------------- nori-rs/tui/docs.md | 9 ++++ 4 files changed, 59 insertions(+), 68 deletions(-) diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index 5f8598fd2..d805c209f 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -4,7 +4,9 @@ Path: @/nori-rs/acp ### Overview -The ACP crate implements the Agent Client Protocol integration for Nori. It manages spawning ACP-compliant agent subprocesses (like Claude Code, Codex, or Gemini), communicating with them over JSON-RPC, and normalizing ACP session-domain data into `nori_protocol::ClientEvent` for the TUI and transcript layers. `codex_protocol::EventMsg` remains only for narrow control-plane concerns that are not ACP session semantics. +- The ACP crate implements the Agent Client Protocol integration for Nori. It manages spawning ACP-compliant agent subprocesses, communicating with them over JSON-RPC, and normalizing ACP session-domain data into `nori_protocol::ClientEvent` for the TUI and transcript layers. +- It owns ACP backend session state that is not provided by agents, including per-session thread goals used by the `/goal` TUI command and prompt-context injection. +- `codex_protocol::EventMsg` remains only for narrow control-plane concerns that are not ACP session semantics. ### How it fits into the larger codebase @@ -24,12 +26,14 @@ The ACP crate serves as a bridge between: - `nori-protocol`, which is the canonical ACP session event vocabulary used by live rendering and transcript recording - The shared `codex-protocol` event stream, which is still used for control-plane signals such as warnings, hook output, prompt summaries, shutdown, and other app-level notifications - `SessionRuntime` in `@/nori-rs/nori-protocol/`, which is now the ACP backend's single source of truth for prompt state, load state, queued prompts, permission ownership, and final assistant-message assembly +- Thread-goal operations from `@/nori-rs/protocol` and normalized goal events from `@/nori-rs/nori-protocol`, with backend storage and prompt transformation in `@/nori-rs/acp/src/backend/thread_goal.rs` Key files: - `registry.rs` - Agent configuration and npm package detection - `connection/` - SACP v11-based subprocess spawning and JSON-RPC communication - `translator.rs` - User input to ACP `ContentBlock` conversion and related parsing helpers - `backend/mod.rs` - Implements `ConversationClient` trait from codex-core and emits normalized ACP session events +- `backend/thread_goal.rs` - Owns per-session `/goal` state, prompt goal-context formatting, transcript rehydration, and usage-baseline updates - `transcript_discovery.rs` - Discovers transcript files for external agents - `auto_worktree.rs` - Orchestrates automatic git worktree creation, eligibility checking, and summary-based renaming @@ -91,6 +95,24 @@ Metadata notifications that ACP permits while idle are treated as session-owned `session/load` replay also preserves more session context than before. User-side `MessageDelta { stream: User, .. }` values are reassembled into `ReplayEntry::UserMessage`, while `SessionUpdateInfo` notes pass through unchanged. Message replay preserves chronological stream-kind boundaries: an answer -> reasoning -> answer sequence becomes three replay entries, while adjacent deltas of the same stream are still coalesced. For usage updates, that replay path now restores the structured footer context state without needing to re-render the verbose message in history. +**Thread Goal State** (`backend/thread_goal.rs`, `backend/submit_and_ops.rs`, `backend/user_input.rs`, `backend/transcript.rs`): + +The ACP backend owns the `/goal` feature as per-session state instead of delegating it to the ACP agent. The TUI sends typed `codex_protocol::protocol::Op::ThreadGoalGet`, `ThreadGoalSet`, and `ThreadGoalClear` operations; `submit_and_ops.rs` routes those operations directly to the backend goal handler; and successful mutations are emitted as `nori_protocol::ClientEvent::ThreadGoalUpdated` or `ThreadGoalCleared`. + +``` +@/nori-rs/tui/src/chatwidget/goal.rs + -> @/nori-rs/protocol/src/protocol/mod.rs (typed Op) + -> @/nori-rs/acp/src/backend/thread_goal.rs + -> @/nori-rs/nori-protocol/src/lib.rs (ClientEvent) + -> @/nori-rs/tui/src/chatwidget/event_handlers.rs +``` + +`ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, token usage, and the session-token baseline used to compute goal-local `tokens_used`. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules. + +Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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. + +Goal state is also part of the replay contract. `transcript.rs` passes goal update and clear events through replay, and `session.rs` rehydrates `ThreadGoalState` from those replay events before the live backend starts. 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. + **Custom Agent TOML Schema** (`config/types/mod.rs`): Custom agents are defined under `[[agents]]` in `config.toml`. Each entry is deserialized as `AgentConfigToml`: diff --git a/nori-rs/nori-protocol/docs.md b/nori-rs/nori-protocol/docs.md index eef9ce6bf..26e0dd812 100644 --- a/nori-rs/nori-protocol/docs.md +++ b/nori-rs/nori-protocol/docs.md @@ -4,10 +4,9 @@ Path: @/nori-rs/nori-protocol ### Overview -- Defines the normalized `ClientEvent` protocol that sits between raw ACP session updates (from `agent-client-protocol-schema`) and the TUI rendering layer. All ACP tool calls, messages, plans, approvals, and replay entries are transformed into this crate's types before reaching the TUI. -- The `ClientEventNormalizer` is the stateful entry point: it accepts `acp::SessionUpdate` and `acp::RequestPermissionRequest` values and emits `Vec`. -- Session-scoped ACP metadata is normalized into compact client events: simple mode/session/usage notes use `ClientEvent::SessionUpdateInfo`, while ACP session config snapshots use `ClientEvent::SessionConfigUpdate` so the TUI can diff option values without parsing display text. -- Single-file crate (`lib.rs`) with no submodules. +- Defines the normalized `ClientEvent` protocol between raw ACP session updates and the TUI rendering layer. `ClientEventNormalizer` converts provider messages, plans, tools, approvals, and session metadata into stable client events. +- Exposes backend-owned session state, including thread goals, through normalized client events so `@/nori-rs/tui` does not know ACP backend storage details. +- `@/nori-rs/nori-protocol/src/lib.rs` defines the client event vocabulary and normalization path, while `@/nori-rs/nori-protocol/src/session_runtime.rs` defines reducer-owned ACP runtime state shared with `@/nori-rs/acp`. ### How it fits into the larger codebase @@ -21,6 +20,7 @@ agent_client_protocol_schema::SessionUpdate - **Downstream consumer:** `nori-tui` (`@/nori-rs/tui/`) is the primary consumer. The TUI renders `ToolSnapshot`, `MessageDelta`, `PlanSnapshot`, `ApprovalRequest`, reducer-owned `SessionPhaseChanged` / `PromptCompleted` / `QueueChanged` events, `ReplayEntry`, and `AgentCommandsUpdate` from this crate. - `nori-acp` uses the same normalized events for both live updates and `session/load` replay, so this crate now has to preserve enough structure for replayable user-message chunks and pass-through session metadata notes. - The `nori-acp` backend (`@/nori-rs/acp/`) now wraps the normalizer inside a serialized `SessionRuntime` driver. ACP prompt responses, `session/load`, `session/update`, cancellations, and permission requests are reduced in order before the backend forwards the resulting `ClientEvent` items to the TUI via `BackendEvent::Client`. +- Thread-goal events are produced by `@/nori-rs/acp/src/backend/thread_goal.rs`, recorded by `@/nori-rs/acp/src/backend/transcript.rs`, and consumed by `@/nori-rs/tui/src/chatwidget/goal.rs`. This crate defines the shared client-facing shape so live sessions, replay, and resume all speak the same event vocabulary. - This crate intentionally has no TUI, rendering, or terminal dependencies. It is a pure data transformation layer. ### Core Implementation @@ -34,6 +34,7 @@ agent_client_protocol_schema::SessionUpdate - `SessionInfoUpdate` becomes a lightweight `SessionUpdateInfo` summary. - `UsageUpdate` also becomes `SessionUpdateInfo`, but the usage variant additionally carries `SessionUsageState` so the TUI can update footer context without reparsing the display string. - **Persisted session metadata** now includes `session_info` and `session_usage` alongside available commands, current mode, and config options. `nori-acp` owns persistence, but these structs live here so the reducer and replay pipeline share one runtime model. +- **Thread-goal client events** carry the current goal snapshot (`objective`, lifecycle status, token usage, active time, and timestamps) or a clear notification. They are not derived from ACP provider messages; they are backend session-state projections emitted through the same `ClientEvent` stream as normalized ACP data. - **`is_generic_tool_call()`** gates initial `ToolCall` emission: tool calls with no `raw_input`, no `locations`, empty `content`, and no `/` in the title are suppressed (return empty `Vec`). The normalizer still records them internally so that later attributed `ToolCallUpdate` messages can refine the existing call without forcing the TUI to render a placeholder cell first. - **Invocation priority cascade** in `invocation_from_tool_call()` resolves what the tool is doing, in priority order: @@ -52,6 +53,8 @@ agent_client_protocol_schema::SessionUpdate - The `is_generic_tool_call()` filter means the normalizer is not 1:1 with incoming events. Initial `ToolCall` messages that are sufficiently sparse are silently dropped, but later `ToolCallUpdate` messages still become visible `ToolSnapshot`s even if no initial `ToolCall` ever arrived. - `SessionUpdateInfo` stays intentionally lightweight, but it is no longer fully lossy: the `Usage` variant also carries structured `SessionUsageState` so replay and live footer updates can share the same path. +- `ThreadGoalUpdated` is a full replacement snapshot for the client's current goal, while `ThreadGoalCleared` removes that state. The TUI should not infer a goal lifecycle by replaying command text; it should consume these events directly. +- Usage events and goal events intentionally remain separate: ACP `UsageUpdate` normalizes to `SessionUpdateInfo`, and the backend may follow it with a refreshed `ThreadGoalUpdated` when a goal exists so `tokens_used` stays tied to the backend's session-token baseline. - The location fallback (tier 4) only handles `Read` and `Search` kinds. Edit/Delete/Move with locations but no `raw_input` return `None` from the normalizer and fall through to the TUI's location-path display fallback, avoiding creation of empty-diff `FileOperations` that would route to `PatchHistoryCell`. - `sanitize_title()` is a two-pass operation: first strips the `[current working directory ...]` bracket, then strips trailing `(description)` parenthetical. The parenthetical strip only fires after a cwd bracket was found, because Gemini appends descriptions after the cwd metadata. - Shell wrapper detection (`is_shell_wrapper()`) recognizes `bash`, `sh`, `zsh`, `fish`, `pwsh`, and `powershell` with `-c` or `-lc` flags. When a 3-element command array matches this pattern, only the script portion is extracted as the command string. diff --git a/nori-rs/protocol/docs.md b/nori-rs/protocol/docs.md index 683cfb2f0..410a80574 100644 --- a/nori-rs/protocol/docs.md +++ b/nori-rs/protocol/docs.md @@ -4,75 +4,24 @@ Path: @/nori-rs/protocol ### Overview -The protocol crate defines the internal message types used between Nori components. It specifies operations (`Op`), events (`EventMsg`), and approval-related types that flow between the TUI, core, and backend layers. +- Defines the internal message types used between Nori components. It specifies operations (`Op`), events (`EventMsg`), and approval-related types that flow between the TUI, core, and backend layers. +- Owns shared command contracts that must stay backend-agnostic, such as typed thread-goal operations and validation helpers used by both `@/nori-rs/tui` and `@/nori-rs/acp`. ### How it fits into the larger codebase -This crate provides the contract between: -- `@/nori-rs/tui/` - consumes events, sends operations -- `@/nori-rs/core/` - processes operations, emits events -- `@/nori-rs/acp/` - translates ACP protocol to/from these types - -The crate is a pure type definition library with serde serialization support. +- `@/nori-rs/tui` consumes shared protocol types when turning user actions into backend operations. +- `@/nori-rs/acp` implements ACP-specific behavior behind the same `Op` surface, including thread-goal handling in `@/nori-rs/acp/src/backend/thread_goal.rs`. +- `@/nori-rs/core` still provides the legacy Codex backend path and shared app/control-plane types. +- `@/nori-rs/nori-protocol` carries normalized ACP client events back toward the TUI; thread-goal commands start here as `Op` values and return there as normalized goal events. +- The crate is a pure type definition library with serde and schema support; ownership of runtime state belongs to backend crates, not this crate. ### Core Implementation -**Core Types:** - -```rust -// Operation sent to conversation -pub enum Op { - UserTurn { items, cwd, approval_policy, ... }, - Interrupt, - Shutdown, - // ... -} - -// Event received from conversation -pub struct Event { - pub id: String, - pub msg: EventMsg, -} - -pub enum EventMsg { - SessionConfigured { ... }, - TurnStart { ... }, - Delta { ... }, - TurnComplete { ... }, - Error { ... }, - ShutdownComplete, - // ... -} -``` - -**Operations** (`protocol/mod.rs`): Commands sent from TUI to core: - -| Op | Purpose | -|----|---------| -| `Configure` | Set session configuration | -| `UserTurn` | Send user message | -| `ApproveTool` / `RejectTool` | Handle approval requests | -| `CancelTurn` | Cancel current generation | -| `Undo` | Undo the most recent turn (sequential pop from snapshot stack) | -| `UndoList` | Request the list of available undo snapshots | -| `UndoTo { index }` | Restore to a specific snapshot by display index (0 = most recent) | -| `SearchHistoryRequest { max_results }` | Request all history entries for client-side search; response via `SearchHistoryResponse` | - -**Events** (`events.rs`): Messages from core to TUI: - -| Event | Purpose | -|-------|---------| -| `TaskStarted` | Turn began processing | -| `AgentMessage` | Streaming AI response content | -| `ToolCall` / `ToolResult` | Tool invocation lifecycle | -| `ApprovalRequired` | User approval needed | -| `TaskComplete` | Turn finished | -| `ContextCompacted` | Conversation history was compacted; carries optional summary text for TUI session boundary rendering | -| `UndoCompleted` | Result of an undo operation (success/failure with message) | -| `UndoListResult` | Response to `UndoList` containing available `SnapshotInfo` entries | -| `PromptSummary` | Short summary of the first user prompt for display in the footer | -| `HookOutput` | Output from a hook script, routed by level (Info/Warn/Error) for TUI display | -| `SearchHistoryResponse` | Response to `SearchHistoryRequest` with deduplicated history entries (newest first). Not persisted to rollout files. | +**Core Types:** `@/nori-rs/protocol/src/protocol/mod.rs` defines `Submission`, `Op`, `Event`, and `EventMsg`, which form the shared SQ/EQ contract between the UI and whichever backend owns the active session. + +**Operations** (`@/nori-rs/protocol/src/protocol/mod.rs`) group backend commands into user-input, lifecycle, approval, history, undo, custom-prompt, and session-control surfaces. The `/goal` feature belongs to that typed command surface through `ThreadGoalGet`, `ThreadGoalSet`, and `ThreadGoalClear`, rather than being smuggled through a normal user prompt. + +**Events** (`@/nori-rs/protocol/src/protocol/mod.rs`) carry shared control-plane updates back to TUI-facing code. Examples include turn lifecycle events, approval prompts, compact-summary notifications, undo results, prompt summaries, hook output, and history lookup results. ACP session-domain rendering uses `@/nori-rs/nori-protocol` instead. **Approval Types** (`approvals.rs`): Defines `ExecApprovalRequestEvent` for shell commands and `ApplyPatchApprovalRequestEvent` for file edits. The `ReviewDecision` enum captures user responses. @@ -88,6 +37,8 @@ pub enum EventMsg { `CustomPromptKind::Script` carries an `interpreter` string (e.g. `"bash"`, `"python3"`, `"node"`) that determines how the script file is executed. `CustomPromptKind` defaults to `Markdown` and is serde-tagged as `"type"` for JSON serialization. +**Thread Goal Types** (`protocol/mod.rs`): The `/goal` feature uses typed operations rather than encoding commands as regular prompt text. `Op::ThreadGoalGet`, `Op::ThreadGoalSet`, and `Op::ThreadGoalClear` define the backend-facing command surface; `ThreadGoalStatus` defines the shared lifecycle labels; `validate_thread_goal_objective()` defines the cross-crate validation invariant for objective text before the TUI or backend accepts it. + ### Things to Know **Module Structure:** The `protocol` module uses a directory layout (`protocol/mod.rs` + submodules) instead of a single `protocol.rs` file. Submodules include `display.rs` (Display impls), `history.rs` (conversation history types), `legacy_events.rs` (legacy event types), `sandbox.rs` (sandbox config types), `token_usage.rs` (token tracking types), and `tests.rs`. @@ -129,6 +80,12 @@ pub enum EventMsg { |------|---------| | `ContextCompactedEvent` | Carries an optional `summary: Option` field. When emitted by the ACP backend (`@/nori-rs/acp/`), the summary contains the compact summary text so the TUI can render a session boundary and reprint it. When emitted by the core backend (`@/nori-rs/core/`), the summary is `None` and the TUI shows only an info message. | +**Thread Goal Invariants:** + +- Goal objectives are validated in `@/nori-rs/protocol/src/protocol/mod.rs` so the same empty and maximum-length rules apply before `@/nori-rs/tui/src/chatwidget/goal.rs` submits a goal and before `@/nori-rs/acp/src/backend/thread_goal.rs` persists one. +- `ThreadGoalSet` accepts either a new objective, a status update for an existing goal, or both. The backend owns how that becomes session state and emits normalized `ThreadGoalUpdated` / `ThreadGoalCleared` events through `@/nori-rs/nori-protocol`. +- These operations are ACP-backend commands, not agent prompt text. The ACP backend may use the stored goal to transform later prompts, but the protocol operation itself never goes to the agent subprocess. + **Approval Policy:** `AskForApproval` enum controls when user confirmation is required: diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 872aa78fe..5bd8045c7 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -20,6 +20,7 @@ The TUI acts as the frontend layer. It: - Uses `nori-acp` for ACP agent communication (see `@/nori-rs/acp/`) - Uses `codex-core` for configuration loading and authentication (see `@/nori-rs/core/`) - Consumes `nori-protocol` for ACP session-domain rendering (messages, plans, tool snapshots, approvals, replay, lifecycle) +- Maps user-facing session controls such as `/goal` into typed `codex-protocol` operations, leaving ACP backend state ownership in `@/nori-rs/acp` - Displays approval requests from the ACP layer and forwards user decisions back - Renders streaming AI responses with markdown and syntax highlighting @@ -62,6 +63,14 @@ The chat interface is managed by the `chatwidget/` module (`chatwidget/mod.rs` + For replayed ACP conversations, user-authored message chunks are reconstructed upstream into `ReplayEntry::UserMessage` before they reach the widget. Live `MessageStream::User` deltas are therefore ignored by `ChatWidget` itself; the widget only needs to render the replay entry path, not duplicate the local composer state. +**Thread Goal UI** (`chatwidget/goal.rs`, `chatwidget/event_handlers.rs`, `slash_command.rs`): + +The `/goal` command is a TUI command surface for ACP backend-owned goal state. `@/nori-rs/tui/src/slash_command.rs` advertises the command, while `@/nori-rs/tui/src/chatwidget/goal.rs` maps the command family (viewing, setting, status changes, clearing, and editing) into typed `codex_protocol::protocol::Op::ThreadGoal*` operations. Those operations are handled by `@/nori-rs/acp/src/backend/thread_goal.rs`; the TUI does not persist or derive goal state from prompt text. + +`ClientEvent::ThreadGoalUpdated` is treated as the source of truth for the visible current goal. `ChatWidget` stores that snapshot in `current_goal`, renders a compact history summary, and uses it to seed `/goal edit` back into the composer. `ClientEvent::ThreadGoalCleared` clears the cached snapshot and writes a short info message. Goal updates are omitted from view-only transcript rendering in `@/nori-rs/tui/src/viewonly_transcript.rs` because they are state synchronization events rather than conversation messages. + +The TUI validates goal objective text through `@/nori-rs/protocol/src/protocol/mod.rs` before submitting a set operation, matching the backend's validation path. This keeps the UI responsive while preserving the backend as the authority for state transitions, resume rehydration, token accounting, and prompt `` injection. + The transcript pager overlay uses each history cell's transcript view rather than the live summary view. To keep reopened transcripts readable, the overlay caps non-patch cells at 20 lines and appends an omission marker, while patch cells keep their full diff output for review. In ACP sessions, `ClientToolCell` provides differentiated `transcript_lines()` for Execute tools (shell-style `$ command` format via `render_execute_transcript_lines()`) while exploring and edit cells reuse their `display_lines()` rendering for transcripts. **Approval Request Routing** (`chatwidget/event_handlers.rs`, `bottom_pane/approval_overlay.rs`): ACP approval requests arrive as `ClientEvent::ApprovalRequest` containing a `nori_protocol::ToolSnapshot`. The `approval_request_from_client_event()` function performs two-way routing: Execute tools with `Invocation::Command` map to `ApprovalRequest::Exec` (bash-highlighted overlay), and everything else (including Edit/Delete/Move) maps to `ApprovalRequest::AcpTool`. The `AcpTool` variant carries a boxed `ToolSnapshot`, a `cwd: PathBuf` (threaded from `self.config.cwd` in the chat widget), and dispatches decisions via `Op::ExecApproval`, which gives users the "always approve" option that `ApplyPatch` did not have. The `From` impl in `approval_overlay.rs` applies `relativize_paths_in_text` to the title before building the overlay prompt and `DiffSummary`, so users see relative paths instead of absolute ones. The fullscreen approval preview in `app/event_handling.rs` also uses the real `cwd` from the request for `DiffSummary` construction. `ApprovalRequest::ApplyPatch` is now only used by the legacy non-ACP codex backend. History cells for AcpTool decisions are produced by `history_cell::new_acp_approval_decision_cell()`, using `format_tool_kind()` for the kind label. From dd7c5f4f2b7cf3c4f33f590e51650949dfa7ba10 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:39:44 -0400 Subject: [PATCH 12/29] fix(acp): avoid expect in goal setup --- nori-rs/acp/src/backend/thread_goal.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index 1df89f677..efc7023b3 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -120,7 +120,7 @@ impl ThreadGoalState { ) -> Result { validate_thread_goal_objective(&objective)?; let status = status.unwrap_or(ThreadGoalStatus::Active); - self.goal = Some(StoredThreadGoal { + let goal = StoredThreadGoal { objective, status, tokens_used: 0, @@ -129,8 +129,10 @@ impl ThreadGoalState { active_started_at: active_started_at(status, now), created_at: now, updated_at: now, - }); - Ok(self.snapshot(now).expect("goal was just set")) + }; + let snapshot = goal.snapshot(now); + self.goal = Some(goal); + Ok(snapshot) } pub(crate) fn set_status( From 21b52f496d537130e1ab4f6c96f8d6f66801c7ca Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:44:54 -0400 Subject: [PATCH 13/29] docs(progress): record goal parity gaps --- CURRENT-PROGRESS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 14c228a6d..adef7b320 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -73,3 +73,10 @@ Goal command progress: when a goal is created, updates goal token totals as `UsageUpdate` client events arrive, emits refreshed goal snapshots, and rebuilds the baseline from replayed usage/goal events when resuming a session. +19. Compared the current ACP goal implementation against upstream Codex goal behavior after + verification. Remaining parity gaps are intentional blockers for the next design slice: + Codex confirms before replacing an unfinished goal, prompts on resume for paused/blocked + goals, exposes model-facing `create_goal`/`update_goal` tools, and can automatically + continue active goals when the runtime goes idle. The current Nori slice stores, + rehydrates, renders, and injects goal context, but does not yet auto-submit continuation + turns or expose structured goal tools to ACP agents. From 7c570b337bb260092ec635df546d76c738ad2280 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:52:16 -0400 Subject: [PATCH 14/29] feat(acp): auto-submit goal continuation prompts --- CURRENT-PROGRESS.md | 7 + .../acp/src/backend/session_reducer/tests.rs | 58 ++++++++ .../acp/src/backend/session_runtime_driver.rs | 131 ++++++++++++------ nori-rs/acp/src/backend/tests/part5.rs | 92 ++++++++++++ nori-rs/acp/src/backend/thread_goal.rs | 46 ++++++ nori-rs/nori-protocol/src/session_runtime.rs | 1 + 6 files changed, 295 insertions(+), 40 deletions(-) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index adef7b320..4aa580e36 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -80,3 +80,10 @@ Goal command progress: continue active goals when the runtime goes idle. The current Nori slice stores, rehydrates, renders, and injects goal context, but does not yet auto-submit continuation turns or expose structured goal tools to ACP agents. +20. Added the first ACP-native automatic continuation slice. After a visible user prompt + completes with an active goal and the ACP runtime is idle with no queued user work, the + backend now submits one hidden `GoalContinuation` prompt to the same ACP session. The + hidden prompt is omitted from visible queue text and user transcript entries, while the + agent response still renders and records like normal assistant work. This intentionally + does not recurse after continuation turns; deeper Codex-style autonomous loops and + structured agent goal tools remain follow-on parity work from note 19. diff --git a/nori-rs/acp/src/backend/session_reducer/tests.rs b/nori-rs/acp/src/backend/session_reducer/tests.rs index 585ba7a3b..7a986cc97 100644 --- a/nori-rs/acp/src/backend/session_reducer/tests.rs +++ b/nori-rs/acp/src/backend/session_reducer/tests.rs @@ -127,6 +127,64 @@ fn prompt_response_transitions_to_idle() { ))); } +#[test] +fn queued_goal_continuation_is_hidden_from_user_queue_and_transcript() { + let mut rt = new_runtime(); + let mut norm = new_normalizer(); + + reduce( + &mut rt, + InboundEvent::PromptSubmit(simple_prompt()), + &mut norm, + ); + + let hidden_prompt = QueuedPrompt { + event_id: "goal-continuation-1".to_string(), + kind: QueuedPromptKind::GoalContinuation, + text: "Continue working toward the active thread goal.".to_string(), + display_text: None, + images: Vec::new(), + }; + let out = reduce( + &mut rt, + InboundEvent::PromptSubmit(hidden_prompt), + &mut norm, + ); + + let queue_texts = out.events.iter().find_map(|event| match event { + ClientEvent::QueueChanged(update) => Some(update.prompts.clone()), + _ => None, + }); + assert_eq!(queue_texts, Some(Vec::new())); + + reduce( + &mut rt, + InboundEvent::PromptResponse { + stop_reason: acp::StopReason::EndTurn, + }, + &mut norm, + ); + + assert_eq!( + rt.persisted + .transcript + .iter() + .map(|message| (message.role, message.content.as_str())) + .collect::>(), + vec![( + nori_protocol::session_runtime::TranscriptRole::User, + "hello" + )] + ); + assert!(matches!( + rt.active.as_ref().and_then(|active| active.prompt.as_ref()), + Some(QueuedPrompt { + kind: QueuedPromptKind::GoalContinuation, + .. + }) + )); +} + #[test] fn inbound_event_kind_labels_prompt_response() { assert_eq!( diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index 8dc1b90b1..cb628c2db 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -5,6 +5,7 @@ use nori_protocol::ClientEvent; use nori_protocol::ClientEventNormalizer; use nori_protocol::session_runtime::QueuedPrompt; use nori_protocol::session_runtime::QueuedPromptKind; +use nori_protocol::session_runtime::SessionPhase; use nori_protocol::session_runtime::SessionRuntime; use super::session_reducer::InboundEvent; @@ -19,6 +20,7 @@ pub(crate) struct SessionDriver { pub(crate) struct CompletedTurn { pub prompt: QueuedPrompt, + pub stop_reason: agent_client_protocol_schema::StopReason, pub last_agent_message: Option, } @@ -84,6 +86,7 @@ impl SessionDriver { out.events.iter().find_map(|event| match event { ClientEvent::PromptCompleted(completed) => Some(CompletedTurn { prompt: prompt.clone(), + stop_reason: completed.stop_reason, last_agent_message: completed.last_agent_message.clone(), }), _ => None, @@ -365,7 +368,7 @@ impl AcpBackend { async fn handle_completed_turn(&self, completed_turn: &CompletedTurn) { match completed_turn.prompt.kind { - QueuedPromptKind::User => { + QueuedPromptKind::User | QueuedPromptKind::GoalContinuation => { if let Some(last_agent_message) = &completed_turn.last_agent_message && let Some(ref recorder) = self.transcript_recorder { @@ -430,46 +433,50 @@ impl AcpBackend { ); } - if let Some(display_text) = &completed_turn.prompt.display_text - && !self.post_user_prompt_hooks.is_empty() - { - let env_vars = HashMap::from([ - ( - "NORI_HOOK_EVENT".to_string(), - "post_user_prompt".to_string(), - ), - ("NORI_HOOK_PROMPT_TEXT".to_string(), display_text.clone()), - ]); - let results = crate::hooks::execute_hooks_with_env( - &self.post_user_prompt_hooks, - self.script_timeout, - &env_vars, - ) - .await; - route_hook_results( - &results, - &self.event_tx, - &completed_turn.prompt.event_id, - Some(&self.pending_hook_context), - ) - .await; - } + if completed_turn.prompt.kind == QueuedPromptKind::User { + if let Some(display_text) = &completed_turn.prompt.display_text + && !self.post_user_prompt_hooks.is_empty() + { + let env_vars = HashMap::from([ + ( + "NORI_HOOK_EVENT".to_string(), + "post_user_prompt".to_string(), + ), + ("NORI_HOOK_PROMPT_TEXT".to_string(), display_text.clone()), + ]); + let results = crate::hooks::execute_hooks_with_env( + &self.post_user_prompt_hooks, + self.script_timeout, + &env_vars, + ) + .await; + route_hook_results( + &results, + &self.event_tx, + &completed_turn.prompt.event_id, + Some(&self.pending_hook_context), + ) + .await; + } - if let Some(display_text) = &completed_turn.prompt.display_text - && !self.async_post_user_prompt_hooks.is_empty() - { - let env_vars = HashMap::from([ - ( - "NORI_HOOK_EVENT".to_string(), - "post_user_prompt".to_string(), - ), - ("NORI_HOOK_PROMPT_TEXT".to_string(), display_text.clone()), - ]); - let _ = crate::hooks::execute_hooks_fire_and_forget( - self.async_post_user_prompt_hooks.clone(), - self.script_timeout, - env_vars, - ); + if let Some(display_text) = &completed_turn.prompt.display_text + && !self.async_post_user_prompt_hooks.is_empty() + { + let env_vars = HashMap::from([ + ( + "NORI_HOOK_EVENT".to_string(), + "post_user_prompt".to_string(), + ), + ("NORI_HOOK_PROMPT_TEXT".to_string(), display_text.clone()), + ]); + let _ = crate::hooks::execute_hooks_fire_and_forget( + self.async_post_user_prompt_hooks.clone(), + self.script_timeout, + env_vars, + ); + } + + self.maybe_submit_goal_continuation(completed_turn).await; } } QueuedPromptKind::Compact => { @@ -513,6 +520,49 @@ impl AcpBackend { } } + async fn maybe_submit_goal_continuation(&self, completed_turn: &CompletedTurn) { + if completed_turn.stop_reason != agent_client_protocol_schema::StopReason::EndTurn { + return; + } + + let prompt_text = { + self.thread_goal_state + .lock() + .await + .continuation_prompt(thread_goal::now_seconds()) + }; + let Some(prompt_text) = prompt_text else { + return; + }; + + let can_start_continuation = { + let driver = self.session_driver.lock().await; + matches!(driver.runtime.phase, SessionPhase::Idle) && driver.runtime.queue.is_empty() + }; + if !can_start_continuation { + debug!( + target: "acp_event_flow", + "Skipping goal continuation because the ACP runtime is not idle" + ); + return; + } + + let _ = self + .session_event_tx + .send(SessionRuntimeInput::Reducer( + session_reducer::InboundEvent::PromptSubmit( + nori_protocol::session_runtime::QueuedPrompt { + event_id: format!("goal-continuation-{}", uuid::Uuid::new_v4()), + kind: nori_protocol::session_runtime::QueuedPromptKind::GoalContinuation, + text: prompt_text, + display_text: None, + images: Vec::new(), + }, + ), + )) + .await; + } + async fn execute_side_effect(&self, side_effect: SideEffect) { match side_effect { SideEffect::SendPrompt { request_id, prompt } => { @@ -621,6 +671,7 @@ impl AcpBackend { async fn send_prompt_error(&self, prompt_kind: QueuedPromptKind, err: &anyhow::Error) { let message = match prompt_kind { QueuedPromptKind::Compact => format!("Compact failed: {err}"), + QueuedPromptKind::GoalContinuation => format!("Goal continuation failed: {err}"), QueuedPromptKind::User => { let error_string = format!("{err:?}"); let category = categorize_acp_error(&error_string); diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/acp/src/backend/tests/part5.rs index 72923519d..475ea86d6 100644 --- a/nori-rs/acp/src/backend/tests/part5.rs +++ b/nori-rs/acp/src/backend/tests/part5.rs @@ -301,6 +301,98 @@ async fn test_goal_context_prepended_to_user_prompt() { } } +#[tokio::test] +#[serial] +async fn active_goal_submits_one_hidden_continuation_after_user_turn() { + use std::time::Duration; + + struct EnvGuard(&'static str); + + impl Drop for EnvGuard { + fn drop(&mut self) { + unsafe { + std::env::remove_var(self.0); + } + } + } + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + unsafe { + std::env::set_var("MOCK_AGENT_ECHO_PROMPT", "1"); + } + let _env_guard = EnvGuard("MOCK_AGENT_ECHO_PROMPT"); + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + backend + .submit(Op::ThreadGoalSet { + objective: Some("Ship the ACP goal command".to_string()), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), + }) + .await + .expect("Failed to set goal"); + + backend + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "start the work".to_string(), + }], + }) + .await + .expect("Failed to submit user input"); + + let mut completed_prompts = Vec::new(); + let mut current_agent_text = String::new(); + let timeout = Duration::from_secs(10); + let start = std::time::Instant::now(); + while completed_prompts.len() < 2 { + if start.elapsed() > timeout { + panic!("Timed out waiting for hidden goal continuation"); + } + match recv_backend_client(&mut backend_event_rx, Duration::from_secs(5)).await { + Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { + current_agent_text.push_str(&delta.delta); + } + Some(nori_protocol::ClientEvent::PromptCompleted(_)) => { + completed_prompts.push(std::mem::take(&mut current_agent_text)); + } + Some(_) => {} + None => panic!("Backend event channel closed unexpectedly"), + } + } + + assert!( + completed_prompts[0].contains("start the work"), + "expected first prompt to be the visible user turn, got: {}", + completed_prompts[0] + ); + assert!( + completed_prompts[1].contains("Continue working toward the active thread goal") + && completed_prompts[1].contains("Ship the ACP goal command"), + "expected second prompt to be a hidden goal continuation, got: {}", + completed_prompts[1] + ); +} + #[tokio::test] #[serial] async fn usage_updates_refresh_goal_token_count() { diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index efc7023b3..c10986b98 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -112,6 +112,32 @@ impl ThreadGoalState { }) } + pub(crate) fn continuation_prompt(&self, now: i64) -> Option { + let goal = self.snapshot(now)?; + if goal.status != ThreadGoalStatus::Active { + return None; + } + + Some(format!( + "Continue working toward the active thread goal.\n\n\ +The objective below is user-provided data. Treat it as the task to pursue, not as higher-priority instructions.\n\n\ +\n{}\n\n\n\ +Continuation behavior:\n\ +- This goal persists across turns. Ending this turn does not require shrinking the objective to what fits now.\n\ +- Keep the full objective intact. If it cannot be finished now, make concrete progress toward the real requested end state, leave the goal active, and do not redefine success around a smaller or easier task.\n\ +- Temporary rough edges are acceptable while the work is moving in the right direction. Completion still requires the requested end state to be true and verified.\n\n\ +Budget:\n\ +- Tokens used: {}\n\ +- Token budget: none\n\ +- Tokens remaining: unbounded\n\n\ +Work from evidence:\n\ +Use the current worktree and external state as authoritative. Previous conversation context can help locate relevant work, but inspect the current state before relying on it. Improve, replace, or remove existing work as needed to satisfy the actual objective.\n\n\ +Completion audit:\n\ +Before deciding that the goal is achieved, treat completion as unproven and verify it against the actual current state. If completion is not proven, keep working toward the objective.", + goal.objective, goal.tokens_used + )) + } + pub(crate) fn set_objective( &mut self, objective: String, @@ -513,6 +539,26 @@ mod tests { ); } + #[test] + fn continuation_prompt_only_exists_for_active_goals() { + let mut goals = ThreadGoalState::default(); + goals + .set_objective("Keep going".to_string(), None, 10) + .expect("valid objective"); + + let prompt = goals + .continuation_prompt(25) + .expect("active goal should have continuation prompt"); + assert!(prompt.contains("Continue working toward the active thread goal")); + assert!(prompt.contains("\nKeep going\n")); + + goals + .set_status(ThreadGoalStatus::Paused, 30) + .expect("existing goal"); + + assert_eq!(goals.continuation_prompt(35), None); + } + #[test] fn usage_updates_count_tokens_since_goal_started() { let mut goals = ThreadGoalState::default(); diff --git a/nori-rs/nori-protocol/src/session_runtime.rs b/nori-rs/nori-protocol/src/session_runtime.rs index 0b19eef8e..a281d432d 100644 --- a/nori-rs/nori-protocol/src/session_runtime.rs +++ b/nori-rs/nori-protocol/src/session_runtime.rs @@ -198,6 +198,7 @@ pub enum TranscriptRole { pub enum QueuedPromptKind { User, Compact, + GoalContinuation, } /// A user prompt waiting to be sent to ACP. From 0c1a0ff12dc1517302743a79211e7abf6af0399d Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:55:09 -0400 Subject: [PATCH 15/29] docs: document goal continuations --- nori-rs/acp/docs.md | 6 +++++- nori-rs/nori-protocol/docs.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index d805c209f..e39fdb370 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -82,7 +82,7 @@ ACP session-domain state now flows through a single serialized reducer. `Session `SessionRuntime` is the authoritative model for: - whether the ACP session is idle, loading, or in a prompt turn -- queued user prompts and compact prompts waiting behind an active request +- queued user, compact, and hidden goal-continuation prompts waiting behind an active request - request-local message assembly for user/assistant/reasoning streams, including flushing the prior open text buffer when the ACP session update type changes - tool snapshot ownership via `owner_request_id` - pending permission request ownership and cancellation cleanup @@ -95,6 +95,8 @@ Metadata notifications that ACP permits while idle are treated as session-owned `session/load` replay also preserves more session context than before. User-side `MessageDelta { stream: User, .. }` values are reassembled into `ReplayEntry::UserMessage`, while `SessionUpdateInfo` notes pass through unchanged. Message replay preserves chronological stream-kind boundaries: an answer -> reasoning -> answer sequence becomes three replay entries, while adjacent deltas of the same stream are still coalesced. For usage updates, that replay path now restores the structured footer context state without needing to re-render the verbose message in history. +The runtime differentiates visible user work from backend-internal continuation work through `QueuedPromptKind` in `@/nori-rs/nori-protocol/src/session_runtime.rs`. Goal continuations are sent through the same reducer and ACP side-effect path as user prompts, so assistant deltas, tool activity, hooks, transcript assistant messages, usage updates, and completion events remain normal. Their prompt text is hidden from visible queue updates and from persisted user transcript entries, which keeps the user's transcript anchored to explicit user input while still letting the ACP session continue the active goal. + **Thread Goal State** (`backend/thread_goal.rs`, `backend/submit_and_ops.rs`, `backend/user_input.rs`, `backend/transcript.rs`): The ACP backend owns the `/goal` feature as per-session state instead of delegating it to the ACP agent. The TUI sends typed `codex_protocol::protocol::Op::ThreadGoalGet`, `ThreadGoalSet`, and `ThreadGoalClear` operations; `submit_and_ops.rs` routes those operations directly to the backend goal handler; and successful mutations are emitted as `nori_protocol::ClientEvent::ThreadGoalUpdated` or `ThreadGoalCleared`. @@ -111,6 +113,8 @@ The ACP backend owns the `/goal` feature as per-session state instead of delegat Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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. +After a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit one 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 this continuation when the goal is active, the reducer has returned to idle, and no queued user work remains; it does not chain continuations after a `GoalContinuation` turn. This gives long-lived goals forward progress across user turns without recursively taking over the session or obscuring pending user input. + Goal state is also part of the replay contract. `transcript.rs` passes goal update and clear events through replay, and `session.rs` rehydrates `ThreadGoalState` from those replay events before the live backend starts. 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. **Custom Agent TOML Schema** (`config/types/mod.rs`): diff --git a/nori-rs/nori-protocol/docs.md b/nori-rs/nori-protocol/docs.md index 26e0dd812..ac87e9efb 100644 --- a/nori-rs/nori-protocol/docs.md +++ b/nori-rs/nori-protocol/docs.md @@ -26,7 +26,7 @@ agent_client_protocol_schema::SessionUpdate ### Core Implementation - **`ClientEventNormalizer`** maintains a `HashMap` keyed by `call_id`. `ToolCallUpdate` messages always upsert into that map: if the ACP agent never sent an initial `ToolCall`, the normalizer synthesizes a placeholder `ToolCall`, applies the update fields, and still emits a visible `ToolSnapshot`. -- **`SessionRuntime` support types** in `session_runtime.rs` define the reducer-owned ACP runtime model used by `nori-acp`: `SessionPhase`, `PersistedSessionState`, `ActiveRequestState`, `OpenMessage`, and `QueuedPrompt`. These types let the backend treat prompt turns, `session/load`, queued prompts, and ownership of tool/approval updates as one ordered state machine instead of reconstructing turn state from racing tasks. `ActiveRequestState` keeps the last flushed assistant text so `PromptCompleted { last_agent_message, .. }` remains correct even when a later reasoning chunk closes the assistant buffer before the turn ends. +- **`SessionRuntime` support types** in `session_runtime.rs` define the reducer-owned ACP runtime model used by `nori-acp`: `SessionPhase`, `PersistedSessionState`, `ActiveRequestState`, `OpenMessage`, and `QueuedPrompt`. These types let the backend treat prompt turns, `session/load`, queued prompts, and ownership of tool/approval updates as one ordered state machine instead of reconstructing turn state from racing tasks. `QueuedPromptKind` distinguishes visible user prompts, compaction prompts, and hidden goal continuations so `@/nori-rs/acp/src/backend/session_reducer.rs` can preserve the right queue, transcript, and completion behavior for each path. `ActiveRequestState` keeps the last flushed assistant text so `PromptCompleted { last_agent_message, .. }` remains correct even when a later reasoning chunk closes the assistant buffer before the turn ends. - **Session update normalization** keeps the first pass intentionally small: - `UserMessageChunk` becomes `MessageDelta { stream: User, .. }`, which lets replay paths reconstruct visible user history during `session/load`. - `CurrentModeUpdate` becomes `ClientEvent::SessionModeChanged { current_mode_id }`; the TUI resolves the id to a human label using its cached mode list. @@ -55,6 +55,7 @@ agent_client_protocol_schema::SessionUpdate - `SessionUpdateInfo` stays intentionally lightweight, but it is no longer fully lossy: the `Usage` variant also carries structured `SessionUsageState` so replay and live footer updates can share the same path. - `ThreadGoalUpdated` is a full replacement snapshot for the client's current goal, while `ThreadGoalCleared` removes that state. The TUI should not infer a goal lifecycle by replaying command text; it should consume these events directly. - Usage events and goal events intentionally remain separate: ACP `UsageUpdate` normalizes to `SessionUpdateInfo`, and the backend may follow it with a refreshed `ThreadGoalUpdated` when a goal exists so `tokens_used` stays tied to the backend's session-token baseline. +- Hidden goal continuations are protocol-visible as `QueuedPromptKind::GoalContinuation`, but they are not user-visible prompt text. Reducer consumers should treat their assistant output like any other assistant turn while excluding their prompt text from visible `QueueChanged` entries and user transcript messages. - The location fallback (tier 4) only handles `Read` and `Search` kinds. Edit/Delete/Move with locations but no `raw_input` return `None` from the normalizer and fall through to the TUI's location-path display fallback, avoiding creation of empty-diff `FileOperations` that would route to `PatchHistoryCell`. - `sanitize_title()` is a two-pass operation: first strips the `[current working directory ...]` bracket, then strips trailing `(description)` parenthetical. The parenthetical strip only fires after a cwd bracket was found, because Gemini appends descriptions after the cwd metadata. - Shell wrapper detection (`is_shell_wrapper()`) recognizes `bash`, `sh`, `zsh`, `fish`, `pwsh`, and `powershell` with `-c` or `-lc` flags. When a 3-element command array matches this pattern, only the script portion is extracted as the command string. From cb16dea49da3ac0a23bcfd0cf97ebc2531f7987a Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 13:59:43 -0400 Subject: [PATCH 16/29] feat(tui): confirm goal replacement --- CURRENT-PROGRESS.md | 4 ++ nori-rs/tui/src/chatwidget/goal.rs | 51 ++++++++++++++ nori-rs/tui/src/chatwidget/tests/part2.rs | 68 +++++++++++++++++++ ...art2__goal_replace_confirmation_popup.snap | 11 +++ 4 files changed, 134 insertions(+) create mode 100644 nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_replace_confirmation_popup.snap diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 4aa580e36..830cc44cd 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -87,3 +87,7 @@ Goal command progress: agent response still renders and records like normal assistant work. This intentionally does not recurse after continuation turns; deeper Codex-style autonomous loops and structured agent goal tools remain follow-on parity work from note 19. +21. Matched another Codex `/goal` UX affordance in the Nori TUI: submitting a new objective + while an unfinished goal is cached now opens a replacement confirmation picker instead + of immediately overwriting the active thread goal. Completed goals remain terminal and + can be replaced directly without confirmation. diff --git a/nori-rs/tui/src/chatwidget/goal.rs b/nori-rs/tui/src/chatwidget/goal.rs index 30a359af5..47c5c6070 100644 --- a/nori-rs/tui/src/chatwidget/goal.rs +++ b/nori-rs/tui/src/chatwidget/goal.rs @@ -40,6 +40,10 @@ impl ChatWidget { self.add_error_message(message); return true; } + if self.should_confirm_before_replacing_goal() { + self.show_replace_goal_confirmation(rest.to_string()); + return true; + } self.submit_op(Op::ThreadGoalSet { objective: Some(rest.to_string()), status: Some(codex_core::protocol::ThreadGoalStatus::Active), @@ -81,6 +85,53 @@ impl ChatWidget { .set_composer_text(format!("/goal {}", goal.objective)); } + fn should_confirm_before_replacing_goal(&self) -> bool { + let Some(goal) = &self.current_goal else { + return false; + }; + + match goal.status { + nori_protocol::ThreadGoalStatus::Complete => false, + nori_protocol::ThreadGoalStatus::Active + | nori_protocol::ThreadGoalStatus::Paused + | nori_protocol::ThreadGoalStatus::Blocked + | nori_protocol::ThreadGoalStatus::UsageLimited + | nori_protocol::ThreadGoalStatus::BudgetLimited => true, + } + } + + fn show_replace_goal_confirmation(&mut self, objective: String) { + let replacement = objective.clone(); + let items = vec![ + SelectionItem { + name: "Replace current goal".to_string(), + description: Some("Set the new objective and start it now".to_string()), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(Op::ThreadGoalSet { + objective: Some(replacement.clone()), + status: Some(codex_core::protocol::ThreadGoalStatus::Active), + })); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current goal".to_string(), + description: Some("Leave the current objective unchanged".to_string()), + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.show_selection_view(SelectionViewParams { + title: Some("Replace goal?".to_string()), + subtitle: Some(format!("New objective: {objective}")), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + fn show_goal_summary(&mut self, goal: &nori_protocol::ThreadGoal) { self.add_plain_history_lines(vec![ Line::from("Goal".bold()), diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index 736bd2b80..a52ee6bda 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -57,6 +57,74 @@ fn goal_objective_submits_thread_goal_set() { ); } +#[test] +fn goal_objective_confirms_before_replacing_unfinished_goal() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Existing goal", nori_protocol::ThreadGoalStatus::Active), + }, + )); + + chat.submit_user_message("/goal Replacement goal".to_string().into()); + + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + let popup = render_bottom_popup(&chat, 80); + assert_snapshot!("goal_replace_confirmation_popup", popup); +} + +#[test] +fn goal_replace_confirmation_submits_new_objective() { + use crossterm::event::KeyCode; + use crossterm::event::KeyEvent; + + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Existing goal", nori_protocol::ThreadGoalStatus::Paused), + }, + )); + let _ = drain_insert_history(&mut rx); + + chat.submit_user_message("/goal Replacement goal".to_string().into()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + loop { + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::ThreadGoalSet { + objective: Some(objective), + status: Some(ThreadGoalStatus::Active), + })) => { + assert_eq!(objective, "Replacement goal"); + break; + } + Ok(_) => {} + other => panic!("expected replacement ThreadGoalSet event, got {other:?}"), + } + } + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[test] +fn goal_objective_replaces_completed_goal_without_confirmation() { + let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Finished goal", nori_protocol::ThreadGoalStatus::Complete), + }, + )); + + chat.submit_user_message("/goal Next goal".to_string().into()); + + assert_eq!( + op_rx.try_recv(), + Ok(Op::ThreadGoalSet { + objective: Some("Next goal".to_string()), + status: Some(ThreadGoalStatus::Active), + }) + ); +} + #[test] fn goal_status_commands_submit_goal_mutations() { let cases = [ diff --git a/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_replace_confirmation_popup.snap b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_replace_confirmation_popup.snap new file mode 100644 index 000000000..b5a8b61bc --- /dev/null +++ b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_replace_confirmation_popup.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/part2.rs +expression: popup +--- + Replace goal? + New objective: Replacement goal + +› 1. Replace current goal Set the new objective and start it now + 2. Keep current goal Leave the current objective unchanged + + ↑/k ↓/j to navigate, enter to confirm, esc to go back From d5a445911dba03f38b7330af57d65e89324f5c74 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 14:01:54 -0400 Subject: [PATCH 17/29] docs(tui): document goal replacement confirmation --- nori-rs/tui/docs.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 5bd8045c7..a7c26749c 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -71,6 +71,8 @@ The `/goal` command is a TUI command surface for ACP backend-owned goal state. ` The TUI validates goal objective text through `@/nori-rs/protocol/src/protocol/mod.rs` before submitting a set operation, matching the backend's validation path. This keeps the UI responsive while preserving the backend as the authority for state transitions, resume rehydration, token accounting, and prompt `` injection. +When `/goal ` is used while `current_goal` contains an unfinished ACP goal, the TUI opens a `SelectionView` confirmation instead of immediately sending the mutation. Choosing "Replace current goal" forwards `AppEvent::CodexOp(Op::ThreadGoalSet)` with the replacement objective and `Active` status; choosing "Keep current goal" dismisses the popup without changing backend state. Completed goals are replaced directly because they no longer protect an in-progress objective. This mirrors the Codex goal replacement flow while preserving the invariant that only explicit user confirmation can overwrite an unfinished goal snapshot cached from `ClientEvent::ThreadGoalUpdated`. + The transcript pager overlay uses each history cell's transcript view rather than the live summary view. To keep reopened transcripts readable, the overlay caps non-patch cells at 20 lines and appends an omission marker, while patch cells keep their full diff output for review. In ACP sessions, `ClientToolCell` provides differentiated `transcript_lines()` for Execute tools (shell-style `$ command` format via `render_execute_transcript_lines()`) while exploring and edit cells reuse their `display_lines()` rendering for transcripts. **Approval Request Routing** (`chatwidget/event_handlers.rs`, `bottom_pane/approval_overlay.rs`): ACP approval requests arrive as `ClientEvent::ApprovalRequest` containing a `nori_protocol::ToolSnapshot`. The `approval_request_from_client_event()` function performs two-way routing: Execute tools with `Invocation::Command` map to `ApprovalRequest::Exec` (bash-highlighted overlay), and everything else (including Edit/Delete/Move) maps to `ApprovalRequest::AcpTool`. The `AcpTool` variant carries a boxed `ToolSnapshot`, a `cwd: PathBuf` (threaded from `self.config.cwd` in the chat widget), and dispatches decisions via `Op::ExecApproval`, which gives users the "always approve" option that `ApplyPatch` did not have. The `From` impl in `approval_overlay.rs` applies `relativize_paths_in_text` to the title before building the overlay prompt and `DiffSummary`, so users see relative paths instead of absolute ones. The fullscreen approval preview in `app/event_handling.rs` also uses the real `cwd` from the request for `DiffSummary` construction. `ApprovalRequest::ApplyPatch` is now only used by the legacy non-ACP codex backend. History cells for AcpTool decisions are produced by `history_cell::new_acp_approval_decision_cell()`, using `format_tool_kind()` for the kind label. From 3a85a4296f681eb4db309f2f27e62a0219883e2c Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 14:09:23 -0400 Subject: [PATCH 18/29] fix(tui): clear stale goal edit requests --- CURRENT-PROGRESS.md | 4 +++ nori-rs/tui/docs.md | 2 ++ nori-rs/tui/src/chatwidget/event_handlers.rs | 1 + nori-rs/tui/src/chatwidget/goal.rs | 12 +++++++ nori-rs/tui/src/chatwidget/tests/part2.rs | 36 ++++++++++++++++++++ 5 files changed, 55 insertions(+) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 830cc44cd..8edf68d67 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -91,3 +91,7 @@ Goal command progress: while an unfinished goal is cached now opens a replacement confirmation picker instead of immediately overwriting the active thread goal. Completed goals remain terminal and can be replaced directly without confirmation. +22. Fixed a stale pending-edit edge case in `/goal edit`. When the TUI requests a backend goal + snapshot for editing and the backend replies that no goal exists, the pending edit request + is now cleared, so a later unrelated `ThreadGoalUpdated` event does not unexpectedly + replace the user's composer contents with `/goal `. diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index a7c26749c..7efeb8eec 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -71,6 +71,8 @@ The `/goal` command is a TUI command surface for ACP backend-owned goal state. ` The TUI validates goal objective text through `@/nori-rs/protocol/src/protocol/mod.rs` before submitting a set operation, matching the backend's validation path. This keeps the UI responsive while preserving the backend as the authority for state transitions, resume rehydration, token accounting, and prompt `` injection. +`/goal edit` uses the cached goal immediately when available. If no snapshot is cached, it requests one from the ACP backend and marks the edit as pending until the backend replies. A no-goal response clears that pending flag before rendering the usage hint, preventing a later unrelated goal update from unexpectedly replacing the user's composer contents. + When `/goal ` is used while `current_goal` contains an unfinished ACP goal, the TUI opens a `SelectionView` confirmation instead of immediately sending the mutation. Choosing "Replace current goal" forwards `AppEvent::CodexOp(Op::ThreadGoalSet)` with the replacement objective and `Active` status; choosing "Keep current goal" dismisses the popup without changing backend state. Completed goals are replaced directly because they no longer protect an in-progress objective. This mirrors the Codex goal replacement flow while preserving the invariant that only explicit user confirmation can overwrite an unfinished goal snapshot cached from `ClientEvent::ThreadGoalUpdated`. The transcript pager overlay uses each history cell's transcript view rather than the live summary view. To keep reopened transcripts readable, the overlay caps non-patch cells at 20 lines and appends an omission marker, while patch cells keep their full diff output for review. In ACP sessions, `ClientToolCell` provides differentiated `transcript_lines()` for Execute tools (shell-style `$ command` format via `render_execute_transcript_lines()`) while exploring and edit cells reuse their `display_lines()` rendering for transcripts. diff --git a/nori-rs/tui/src/chatwidget/event_handlers.rs b/nori-rs/tui/src/chatwidget/event_handlers.rs index f22c19146..43f7f39dd 100644 --- a/nori-rs/tui/src/chatwidget/event_handlers.rs +++ b/nori-rs/tui/src/chatwidget/event_handlers.rs @@ -1162,6 +1162,7 @@ impl ChatWidget { self.request_redraw(); return; } + self.clear_pending_goal_edit_if_no_goal(&update); if update.kind == nori_protocol::SessionUpdateKind::Usage && let Some(usage) = update.usage { diff --git a/nori-rs/tui/src/chatwidget/goal.rs b/nori-rs/tui/src/chatwidget/goal.rs index 47c5c6070..e3dd4032c 100644 --- a/nori-rs/tui/src/chatwidget/goal.rs +++ b/nori-rs/tui/src/chatwidget/goal.rs @@ -71,6 +71,18 @@ impl ChatWidget { self.request_redraw(); } + pub(super) fn clear_pending_goal_edit_if_no_goal( + &mut self, + update: &nori_protocol::SessionUpdateInfo, + ) { + if self.pending_goal_edit + && update.kind == nori_protocol::SessionUpdateKind::SessionInfo + && update.hint.as_deref() == Some("No goal is currently set.") + { + self.pending_goal_edit = false; + } + } + fn open_goal_editor_or_request_snapshot(&mut self) { if let Some(goal) = self.current_goal.clone() { self.open_goal_editor(goal); diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index a52ee6bda..0309a9e03 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -191,6 +191,42 @@ fn goal_edit_prefills_current_goal_objective() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[test] +fn goal_edit_without_goal_does_not_open_editor_on_later_goal_update() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + + chat.submit_user_message("/goal edit".to_string().into()); + assert_matches!(op_rx.try_recv(), Ok(Op::ThreadGoalGet)); + + chat.handle_client_event(nori_protocol::ClientEvent::SessionUpdateInfo( + nori_protocol::SessionUpdateInfo { + kind: nori_protocol::SessionUpdateKind::SessionInfo, + message: "Usage: /goal ".to_string(), + hint: Some("No goal is currently set.".to_string()), + usage: None, + }, + )); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|cell| lines_to_single_string(cell)) + .collect::>() + .join("\n"); + assert!( + rendered.contains("No goal is currently set."), + "expected no-goal hint, got: {rendered}" + ); + + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Later goal", nori_protocol::ThreadGoalStatus::Active), + }, + )); + + assert_ne!(chat.bottom_pane.composer_text(), "/goal Later goal"); +} + fn test_thread_goal( objective: &str, status: nori_protocol::ThreadGoalStatus, From d8a409682bee49ff565efc83ada9823a13c9d0cb Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 14:28:28 -0400 Subject: [PATCH 19/29] feat(acp): show resumed paused goal notices --- CURRENT-PROGRESS.md | 4 ++ nori-rs/acp/docs.md | 4 +- nori-rs/acp/src/backend/session.rs | 13 ++++- nori-rs/acp/src/backend/tests/part4.rs | 78 ++++++++++++++++++++++++++ nori-rs/acp/src/backend/thread_goal.rs | 63 +++++++++++++++++++++ 5 files changed, 159 insertions(+), 3 deletions(-) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 8edf68d67..cbfa08f06 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -95,3 +95,7 @@ Goal command progress: snapshot for editing and the backend replies that no goal exists, the pending edit request is now cleared, so a later unrelated `ThreadGoalUpdated` event does not unexpectedly replace the user's composer contents with `/goal `. +23. Added resume notices for replayed paused and blocked goals. After ACP resume sends deferred + replay events, the backend now appends a non-persisted session info notice when the restored + goal is paused or blocked, pointing the user at `/goal resume`, `/goal edit`, and `/goal + clear` without recording duplicate resume-only messages into future transcripts. diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index e39fdb370..c64db337f 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -115,7 +115,7 @@ Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends t After a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit one 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 this continuation when the goal is active, the reducer has returned to idle, and no queued user work remains; it does not chain continuations after a `GoalContinuation` turn. This gives long-lived goals forward progress across user turns without recursively taking over the session or obscuring pending user input. -Goal state is also part of the replay contract. `transcript.rs` passes goal update and clear events through replay, and `session.rs` rehydrates `ThreadGoalState` from those replay events before the live backend starts. 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. +Goal state is also part of the replay contract. `transcript.rs` passes goal update and clear events through replay, and `session.rs` rehydrates `ThreadGoalState` from those replay events before the live backend starts. When a resumed goal is paused or blocked, `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. **Custom Agent TOML Schema** (`config/types/mod.rs`): @@ -996,7 +996,7 @@ SacpConnection::spawn() -> check capabilities().load_session SessionConfigured event sent to TUI | v -Deferred replay relay spawned (sends buffered events to backend_event_tx) +Deferred replay relay spawned (sends buffered events, then optional goal resume notice) ``` **Server-side path:** A collect task runs concurrently during `load_session()`, taking ownership of the ordered `ConnectionEvent` receiver and buffering the normalized `ClientEvent` stream into a `Vec`. `SacpConnection::load_session()` reuses that same ordered inbox for the agent's replay notifications, so the collector can observe session updates in source order without a special side channel. On `#[cfg(feature = "unstable")]` builds, model state is also extracted from the `LoadSessionResponse` if available. The buffered events are returned as `deferred_replay_events` and a relay task is spawned only *after* all setup events (`SessionConfigured`, `Warning`, etc.) have been sent to the outbound backend-event channel. This deferred-relay pattern prevents a deadlock: the outbound channel is bounded, and the TUI consumer only starts after `resume_session()` returns, so sending replay events before setup events would fill the channel and block `resume_session()` from making progress. If `load_session()` fails at runtime (e.g., the agent advertises the capability but the call itself errors), the collect task is aborted and the method falls back to a fresh session. A `WarningEvent` is emitted to inform the user that the restored session will not have server-side replay. diff --git a/nori-rs/acp/src/backend/session.rs b/nori-rs/acp/src/backend/session.rs index 16be05517..61ce265c6 100644 --- a/nori-rs/acp/src/backend/session.rs +++ b/nori-rs/acp/src/backend/session.rs @@ -414,7 +414,13 @@ impl AcpBackend { approval_policy_rx, )); - if !deferred_replay_client_events.is_empty() { + let resume_goal_notice = backend + .thread_goal_state + .lock() + .await + .resume_notice(thread_goal::now_seconds()); + + if !deferred_replay_client_events.is_empty() || resume_goal_notice.is_some() { let backend_event_tx = backend.backend_event_tx.clone(); tokio::spawn(async move { for client_event in deferred_replay_client_events { @@ -422,6 +428,11 @@ impl AcpBackend { .send(BackendEvent::Client(client_event)) .await; } + if let Some(update) = resume_goal_notice { + let _ = backend_event_tx + .send(BackendEvent::Client(ClientEvent::SessionUpdateInfo(update))) + .await; + } }); } diff --git a/nori-rs/acp/src/backend/tests/part4.rs b/nori-rs/acp/src/backend/tests/part4.rs index dd64a2216..f3d58311a 100644 --- a/nori-rs/acp/src/backend/tests/part4.rs +++ b/nori-rs/acp/src/backend/tests/part4.rs @@ -1530,3 +1530,81 @@ async fn test_resume_session_uses_server_side_when_load_session_succeeds() { ), } } + +/// When transcript replay restores a paused goal, the resumed session should +/// surface a direct notice with the next available goal commands. +#[tokio::test] +#[serial] +async fn test_resume_session_notifies_about_paused_goal() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + let mut transcript = build_test_transcript(); + transcript + .entries + .push(crate::transcript::TranscriptLine::new( + crate::transcript::TranscriptEntry::ClientEvent(crate::transcript::ClientEventEntry { + event: nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Finish the resume notice".to_string(), + status: nori_protocol::ThreadGoalStatus::Paused, + tokens_used: 12, + time_used_seconds: 34, + created_at: 100, + updated_at: 200, + }, + }, + ), + }), + )); + + let _backend = AcpBackend::resume_session(&config, None, Some(&transcript), backend_event_tx) + .await + .expect("resume_session should succeed"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + let start = std::time::Instant::now(); + let mut saw_replayed_goal = false; + while start.elapsed() < Duration::from_secs(5) { + match recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await { + Some(nori_protocol::ClientEvent::ThreadGoalUpdated(update)) => { + if update.goal.objective == "Finish the resume notice" { + saw_replayed_goal = true; + } + } + Some(nori_protocol::ClientEvent::SessionUpdateInfo(update)) + if update.message.contains("Goal is paused") + && update + .hint + .as_deref() + .is_some_and(|hint| hint.contains("/goal resume")) => + { + assert!( + saw_replayed_goal, + "resume notice should follow the replayed goal snapshot" + ); + return; + } + Some(_) => continue, + None => continue, + } + } + + panic!("Timed out waiting for paused goal resume notice"); +} diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index c10986b98..b048b3e50 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -138,6 +138,28 @@ Before deciding that the goal is achieved, treat completion as unproven and veri )) } + pub(crate) fn resume_notice(&self, now: i64) -> Option { + let goal = self.snapshot(now)?; + match goal.status { + ThreadGoalStatus::Paused => Some(SessionUpdateInfo { + kind: SessionUpdateKind::SessionInfo, + message: format!("Goal is paused: {}", goal.objective), + hint: Some("Use /goal resume to continue, /goal edit to change it, or /goal clear to remove it.".to_string()), + usage: None, + }), + ThreadGoalStatus::Blocked => Some(SessionUpdateInfo { + kind: SessionUpdateKind::SessionInfo, + message: format!("Goal is blocked: {}", goal.objective), + hint: Some("Resolve the blocker, then use /goal resume to continue; /goal edit and /goal clear are also available.".to_string()), + usage: None, + }), + ThreadGoalStatus::Active + | ThreadGoalStatus::UsageLimited + | ThreadGoalStatus::BudgetLimited + | ThreadGoalStatus::Complete => None, + } + } + pub(crate) fn set_objective( &mut self, objective: String, @@ -559,6 +581,47 @@ mod tests { assert_eq!(goals.continuation_prompt(35), None); } + #[test] + fn resume_notice_only_exists_for_paused_and_blocked_goals() { + let mut goals = ThreadGoalState::default(); + assert_eq!(goals.resume_notice(10), None); + + goals + .set_objective("Keep going".to_string(), Some(ThreadGoalStatus::Active), 10) + .expect("valid objective"); + assert_eq!(goals.resume_notice(15), None); + + goals + .set_status(ThreadGoalStatus::Paused, 20) + .expect("existing goal"); + let paused_notice = goals.resume_notice(25).expect("paused goal notice"); + assert_eq!(paused_notice.kind, SessionUpdateKind::SessionInfo); + assert_eq!(paused_notice.message, "Goal is paused: Keep going"); + assert_eq!( + paused_notice.hint.as_deref(), + Some( + "Use /goal resume to continue, /goal edit to change it, or /goal clear to remove it." + ) + ); + + goals + .set_status(ThreadGoalStatus::Blocked, 30) + .expect("existing goal"); + let blocked_notice = goals.resume_notice(35).expect("blocked goal notice"); + assert_eq!(blocked_notice.message, "Goal is blocked: Keep going"); + assert!( + blocked_notice + .hint + .as_deref() + .is_some_and(|hint| hint.contains("/goal resume")) + ); + + goals + .set_status(ThreadGoalStatus::Complete, 40) + .expect("existing goal"); + assert_eq!(goals.resume_notice(45), None); + } + #[test] fn usage_updates_count_tokens_since_goal_started() { let mut goals = ThreadGoalState::default(); From 69388e23556f25bf8ff6d364d0941183278bb06c Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 14:36:02 -0400 Subject: [PATCH 20/29] fix(acp): prompt usage-limited goals on resume --- CURRENT-PROGRESS.md | 4 ++-- nori-rs/acp/docs.md | 2 +- nori-rs/acp/src/backend/thread_goal.rs | 29 +++++++++++++++++++------- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index cbfa08f06..9c1fd46b7 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -95,7 +95,7 @@ Goal command progress: snapshot for editing and the backend replies that no goal exists, the pending edit request is now cleared, so a later unrelated `ThreadGoalUpdated` event does not unexpectedly replace the user's composer contents with `/goal `. -23. Added resume notices for replayed paused and blocked goals. After ACP resume sends deferred +23. Added resume notices for replayed paused, blocked, and usage-limited goals. After ACP resume sends deferred replay events, the backend now appends a non-persisted session info notice when the restored - goal is paused or blocked, pointing the user at `/goal resume`, `/goal edit`, and `/goal + goal is stopped but resumable, pointing the user at `/goal resume`, `/goal edit`, and `/goal clear` without recording duplicate resume-only messages into future transcripts. diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index c64db337f..25abdf813 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -115,7 +115,7 @@ Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends t After a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit one 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 this continuation when the goal is active, the reducer has returned to idle, and no queued user work remains; it does not chain continuations after a `GoalContinuation` turn. This gives long-lived goals forward progress across user turns without recursively taking over the session or obscuring pending user input. -Goal state is also part of the replay contract. `transcript.rs` passes goal update and clear events through replay, and `session.rs` rehydrates `ThreadGoalState` from those replay events before the live backend starts. When a resumed goal is paused or blocked, `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. +Goal state is also part of the replay contract. `transcript.rs` passes goal update and clear events through replay, and `session.rs` rehydrates `ThreadGoalState` from those replay events before the live backend starts. 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. **Custom Agent TOML Schema** (`config/types/mod.rs`): diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index b048b3e50..ffe0029d8 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -153,10 +153,13 @@ Before deciding that the goal is achieved, treat completion as unproven and veri hint: Some("Resolve the blocker, then use /goal resume to continue; /goal edit and /goal clear are also available.".to_string()), usage: None, }), - ThreadGoalStatus::Active - | ThreadGoalStatus::UsageLimited - | ThreadGoalStatus::BudgetLimited - | ThreadGoalStatus::Complete => None, + ThreadGoalStatus::UsageLimited => Some(SessionUpdateInfo { + kind: SessionUpdateKind::SessionInfo, + message: format!("Goal is usage limited: {}", goal.objective), + hint: Some("Use /goal resume after usage is available again, /goal edit to change it, or /goal clear to remove it.".to_string()), + usage: None, + }), + ThreadGoalStatus::Active | ThreadGoalStatus::BudgetLimited | ThreadGoalStatus::Complete => None, } } @@ -582,7 +585,7 @@ mod tests { } #[test] - fn resume_notice_only_exists_for_paused_and_blocked_goals() { + fn resume_notice_exists_for_resumable_stopped_goals() { let mut goals = ThreadGoalState::default(); assert_eq!(goals.resume_notice(10), None); @@ -617,9 +620,21 @@ mod tests { ); goals - .set_status(ThreadGoalStatus::Complete, 40) + .set_status(ThreadGoalStatus::UsageLimited, 40) + .expect("existing goal"); + let usage_notice = goals.resume_notice(45).expect("usage-limited goal notice"); + assert_eq!(usage_notice.message, "Goal is usage limited: Keep going"); + assert!( + usage_notice + .hint + .as_deref() + .is_some_and(|hint| hint.contains("/goal resume")) + ); + + goals + .set_status(ThreadGoalStatus::Complete, 50) .expect("existing goal"); - assert_eq!(goals.resume_notice(45), None); + assert_eq!(goals.resume_notice(55), None); } #[test] From 3c80efc4d362727dad27498670fa708add163777 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 15:24:14 -0400 Subject: [PATCH 21/29] feat(acp): expose thread goal MCP tools --- CURRENT-PROGRESS.md | 11 + nori-rs/acp/docs.md | 32 +- nori-rs/acp/src/backend/mod.rs | 4 + nori-rs/acp/src/backend/session.rs | 53 +- .../acp/src/backend/session_runtime_driver.rs | 11 +- nori-rs/acp/src/backend/spawn_and_relay.rs | 16 +- nori-rs/acp/src/backend/tests/part4.rs | 85 +++ nori-rs/acp/src/backend/tests/part5.rs | 142 +++++ nori-rs/acp/src/backend/thread_goal_mcp.rs | 546 ++++++++++++++++++ nori-rs/acp/src/connection/local_mcp.rs | 359 ++++++++++++ nori-rs/acp/src/connection/mod.rs | 1 + nori-rs/acp/src/connection/sacp_connection.rs | 42 +- .../src/connection/sacp_connection_tests.rs | 79 +++ nori-rs/mock-acp-agent/docs.md | 1 + nori-rs/mock-acp-agent/src/main.rs | 17 +- 15 files changed, 1376 insertions(+), 23 deletions(-) create mode 100644 nori-rs/acp/src/backend/thread_goal_mcp.rs create mode 100644 nori-rs/acp/src/connection/local_mcp.rs diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 9c1fd46b7..7eaae70d8 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -99,3 +99,14 @@ Goal command progress: replay events, the backend now appends a non-persisted session info notice when the restored goal is stopped but resumable, pointing the user at `/goal resume`, `/goal edit`, and `/goal clear` without recording duplicate resume-only messages into future transcripts. +24. Added backend-owned goal MCP tools for ACP agents that advertise HTTP MCP support. Nori now + registers an in-process `nori-goal` MCP server over ACP MCP-over-ACP during new, resumed, and + compaction-created sessions; agents can call `get_goal`, `create_goal`, and `update_goal` + while the backend remains the goal-state authority and continues emitting transcript-backed + `ThreadGoalUpdated` snapshots. Agents without HTTP MCP support still receive the existing + prompt goal context and hidden continuation behavior without the structured tools. +25. Addressed review feedback on the goal MCP slice. Server-side ACP resume now rebuilds goal + state from transcript-owned goal replay plus any agent load replay so non-goal ACP + notifications cannot erase a restored goal. The local MCP bridge now has direct + `_mcp/connect`/`_mcp/message` routing coverage and retains dynamic handler registrations only + for the current advertised local MCP endpoint instead of leaking stale endpoints indefinitely. diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index 25abdf813..fa28d6df7 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -27,6 +27,7 @@ The ACP crate serves as a bridge between: - The shared `codex-protocol` event stream, which is still used for control-plane signals such as warnings, hook output, prompt summaries, shutdown, and other app-level notifications - `SessionRuntime` in `@/nori-rs/nori-protocol/`, which is now the ACP backend's single source of truth for prompt state, load state, queued prompts, permission ownership, and final assistant-message assembly - Thread-goal operations from `@/nori-rs/protocol` and normalized goal events from `@/nori-rs/nori-protocol`, with backend storage and prompt transformation in `@/nori-rs/acp/src/backend/thread_goal.rs` +- Backend-owned local MCP tools that expose the same goal state to ACP agents through `@/nori-rs/acp/src/backend/thread_goal_mcp.rs` and the connection bridge in `@/nori-rs/acp/src/connection/local_mcp.rs` Key files: - `registry.rs` - Agent configuration and npm package detection @@ -34,6 +35,8 @@ Key files: - `translator.rs` - User input to ACP `ContentBlock` conversion and related parsing helpers - `backend/mod.rs` - Implements `ConversationClient` trait from codex-core and emits normalized ACP session events - `backend/thread_goal.rs` - Owns per-session `/goal` state, prompt goal-context formatting, transcript rehydration, and usage-baseline updates +- `backend/thread_goal_mcp.rs` - Adapts backend-owned goal state into MCP tools for agents that support HTTP MCP +- `connection/local_mcp.rs` - Bridges in-process MCP servers into ACP's MCP-over-ACP dynamic handler flow - `transcript_discovery.rs` - Discovers transcript files for external agents - `auto_worktree.rs` - Orchestrates automatic git worktree creation, eligibility checking, and summary-based renaming @@ -97,25 +100,34 @@ Metadata notifications that ACP permits while idle are treated as session-owned The runtime differentiates visible user work from backend-internal continuation work through `QueuedPromptKind` in `@/nori-rs/nori-protocol/src/session_runtime.rs`. Goal continuations are sent through the same reducer and ACP side-effect path as user prompts, so assistant deltas, tool activity, hooks, transcript assistant messages, usage updates, and completion events remain normal. Their prompt text is hidden from visible queue updates and from persisted user transcript entries, which keeps the user's transcript anchored to explicit user input while still letting the ACP session continue the active goal. -**Thread Goal State** (`backend/thread_goal.rs`, `backend/submit_and_ops.rs`, `backend/user_input.rs`, `backend/transcript.rs`): +**Thread Goal State** (`backend/thread_goal.rs`, `backend/thread_goal_mcp.rs`, `backend/submit_and_ops.rs`, `backend/user_input.rs`, `backend/transcript.rs`): -The ACP backend owns the `/goal` feature as per-session state instead of delegating it to the ACP agent. The TUI sends typed `codex_protocol::protocol::Op::ThreadGoalGet`, `ThreadGoalSet`, and `ThreadGoalClear` operations; `submit_and_ops.rs` routes those operations directly to the backend goal handler; and successful mutations are emitted as `nori_protocol::ClientEvent::ThreadGoalUpdated` or `ThreadGoalCleared`. +The ACP backend owns the `/goal` feature as per-session state instead of delegating it to the ACP agent. The TUI sends typed `codex_protocol::protocol::Op::ThreadGoalGet`, `ThreadGoalSet`, and `ThreadGoalClear` operations; `submit_and_ops.rs` routes those operations directly to the backend goal handler; and successful mutations are emitted as `nori_protocol::ClientEvent::ThreadGoalUpdated` or `ThreadGoalCleared`. ACP agents that advertise HTTP MCP can also interact with the same state through the backend-owned `nori-goal` local MCP server, which exposes `get_goal`, `create_goal`, and `update_goal`. ``` @/nori-rs/tui/src/chatwidget/goal.rs -> @/nori-rs/protocol/src/protocol/mod.rs (typed Op) -> @/nori-rs/acp/src/backend/thread_goal.rs + -> @/nori-rs/acp/src/backend/thread_goal_mcp.rs (optional model-facing MCP) -> @/nori-rs/nori-protocol/src/lib.rs (ClientEvent) -> @/nori-rs/tui/src/chatwidget/event_handlers.rs ``` `ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, token usage, and the session-token baseline used to compute goal-local `tokens_used`. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules. +`thread_goal_mcp.rs` is a bridge, not a second store. Its tools lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/acp/src/backend/transcript.rs` when a transcript recorder is available; session setup stores the recorder behind a shared cell because local MCP handlers are registered before all resume/create paths know the final transcript session id. + +The model-facing MCP contract is intentionally narrower than the user-facing `/goal` command surface. `create_goal` creates a new active goal only when no goal exists, rejects token budgets for now, and delegates objective validation to `ThreadGoalState`. `update_goal` only lets an agent mark the existing goal `complete` or `blocked`; pause, resume, usage-limited, and budget-limited transitions remain controlled by the user or the backend system path. Errors are returned as MCP tool errors instead of changing state. + Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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. +Agents without `mcp_capabilities.http` still receive goal context through prompt transformation and hidden goal-continuation prompts. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or continuation behavior. + After a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit one 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 this continuation when the goal is active, the reducer has returned to idle, and no queued user work remains; it does not chain continuations after a `GoalContinuation` turn. This gives long-lived goals forward progress across user turns without recursively taking over the session or obscuring pending user input. -Goal state is also part of the replay contract. `transcript.rs` passes goal update and clear events through replay, and `session.rs` rehydrates `ThreadGoalState` from those replay events before the live backend starts. 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. +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. + +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. **Custom Agent TOML Schema** (`config/types/mod.rs`): @@ -638,7 +650,7 @@ The ACP connection layer uses SACP v11 (`sacp` crate) to communicate with agent **Approval flow:** The `RequestPermissionRequest` handler translates the request to a Codex `ApprovalRequest`, sends it through the ordered inbox, and uses the SACP responder plus `ConnectionTo` to send the eventual review decision back without blocking the dispatch loop while the UI collects user input. -**MCP Server Forwarding** (`connection/mcp.rs`): +**MCP Server Forwarding and Local MCP Bridges** (`connection/mcp.rs`, `connection/local_mcp.rs`): CLI-configured MCP servers (from `config.toml`) are converted to ACP schema types and passed to the agent via `NewSessionRequest.mcp_servers` at session creation time. The `to_sacp_mcp_servers()` function in `connection/mcp.rs` bridges `codex_core::config::types::McpServerConfig` to ACP `McpServer` values inside the transport adapter: @@ -656,11 +668,13 @@ Disabled servers (`enabled == false`) are filtered out before conversion. Enviro This means `to_sacp_mcp_servers()` has side effects (reads from keyring/file system) rather than being a pure config transformation. The `acp` crate depends on `codex-rmcp-client`'s `load_oauth_tokens` for this purpose. -`create_session()` accepts a `mcp_servers: Vec` parameter that is populated by calling `to_sacp_mcp_servers()` at each session creation site: -- `spawn_and_relay.rs` -- initial session creation during backend spawn -- `session.rs` -- both the server-side `load_session` fallback and client-side replay paths during session resume -- `submit_and_ops.rs` -- fresh session creation after context compaction -- `hooks.rs` -- passes an empty vec (hook sessions do not need MCP servers) +`SacpConnection::register_local_mcp_server()` is the connection-layer escape hatch for backend-owned MCP servers that are not user config. It creates an `acp:` URL, registers a dynamic `LocalMcpSession` handler on the live SACP connection, and appends an HTTP-shaped ACP MCP server entry to the same `mcp_servers` list used for configured servers. The connection retains the dynamic registration guard for the currently advertised backend-owned endpoint and drops the prior guard when a new local MCP server is registered, so load-session fallback and compaction-created sessions cannot leave stale `acp:` endpoints attached to the connection. + +`connection/local_mcp.rs` handles the MCP-over-ACP lifecycle for each advertised local endpoint: ACP `_mcp/connect` opens a per-connection channel, `_mcp/message` requests and notifications are routed to the in-process server for that connection id, and disconnect notifications remove the route. The local server itself runs in process and speaks normal MCP roles through SACP channels, while the ACP agent only sees an HTTP-shaped MCP server whose URL uses the `acp:` scheme. + +ACP session setup paths build the MCP server list in two phases: first convert configured MCP servers with `to_sacp_mcp_servers()`, then let backend-owned features append local MCP servers when the agent's initialize response advertises `mcp_capabilities.http`. This applies to resumed, fresh, fallback, and compaction-created sessions. Hook-only ACP sessions pass an empty list because hooks do not need user-configured or backend-owned MCP servers. + +The local goal MCP server is intentionally additive. User-configured MCP servers are still forwarded normally, and unsupported agents simply do not receive the `nori-goal` `acp:` endpoint. ### Transcript Persistence diff --git a/nori-rs/acp/src/backend/mod.rs b/nori-rs/acp/src/backend/mod.rs index c3537ad19..96b1d5b40 100644 --- a/nori-rs/acp/src/backend/mod.rs +++ b/nori-rs/acp/src/backend/mod.rs @@ -288,6 +288,9 @@ pub struct AcpBackend { pending_compact_summary: Arc>>, /// Persistent goal for this ACP session. thread_goal_state: Arc>, + /// Transcript recorder cell used by local MCP tools created before the + /// recorder's session ID is known. + transcript_recorder_cell: Arc>>>, /// Accumulated context from hook `::context::` lines, prepended to next prompt pending_hook_context: Arc>>, /// Transcript recorder for session persistence @@ -345,6 +348,7 @@ mod session_runtime_driver; mod spawn_and_relay; mod submit_and_ops; mod thread_goal; +mod thread_goal_mcp; mod user_input; mod user_shell; use helpers::get_op_name; diff --git a/nori-rs/acp/src/backend/session.rs b/nori-rs/acp/src/backend/session.rs index 61ce265c6..427b231b5 100644 --- a/nori-rs/acp/src/backend/session.rs +++ b/nori-rs/acp/src/backend/session.rs @@ -42,6 +42,13 @@ impl AcpBackend { })?; let supports_load_session = connection.capabilities().load_session; + let initial_goal_replay_events = transcript + .map(transcript_to_replay_client_events) + .unwrap_or_default(); + let thread_goal_state = Arc::new(Mutex::new( + thread_goal::ThreadGoalState::from_replay_events(&initial_goal_replay_events), + )); + let transcript_recorder_cell = Arc::new(Mutex::new(None)); // Either load the session server-side or create a fresh session for // client-side replay. @@ -125,7 +132,19 @@ impl AcpBackend { (session_driver, event_rx, buffered_events) }); - match connection.load_session(sid, &cwd).await { + let mut mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( + &config.mcp_servers, + config.mcp_oauth_credentials_store_mode, + ); + thread_goal_mcp::register_for_session( + &connection, + &mut mcp_servers, + Arc::clone(&thread_goal_state), + backend_event_tx.clone(), + Arc::clone(&transcript_recorder_cell), + )?; + + match connection.load_session(sid, &cwd, mcp_servers).await { Ok(session_id) => { // Signal the collector that load is done, then collect results. let _ = load_done_tx.send(()); @@ -159,10 +178,17 @@ impl AcpBackend { anyhow::anyhow!("load session collector task panicked: {err}") })?; - let mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( + let mut mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( &config.mcp_servers, config.mcp_oauth_credentials_store_mode, ); + thread_goal_mcp::register_for_session( + &connection, + &mut mcp_servers, + Arc::clone(&thread_goal_state), + backend_event_tx.clone(), + Arc::clone(&transcript_recorder_cell), + )?; let session_id = connection .create_session(&cwd, mcp_servers) @@ -208,10 +234,17 @@ impl AcpBackend { } else { debug!("Agent does not support session/load — using client-side replay"); - let mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( + let mut mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( &config.mcp_servers, config.mcp_oauth_credentials_store_mode, ); + thread_goal_mcp::register_for_session( + &connection, + &mut mcp_servers, + Arc::clone(&thread_goal_state), + backend_event_tx.clone(), + Arc::clone(&transcript_recorder_cell), + )?; let session_id = connection .create_session(&cwd, mcp_servers) .await @@ -254,6 +287,13 @@ impl AcpBackend { ) }; + if !deferred_replay_client_events.is_empty() { + let mut replay_events_for_goal_state = initial_goal_replay_events; + replay_events_for_goal_state.extend(deferred_replay_client_events.iter().cloned()); + *thread_goal_state.lock().await = + thread_goal::ThreadGoalState::from_replay_events(&replay_events_for_goal_state); + } + let connection = Arc::new(connection); let pending_approvals = Arc::new(Mutex::new(Vec::new())); let session_driver = Arc::new(Mutex::new(session_driver_state)); @@ -285,13 +325,11 @@ impl AcpBackend { None } }; + *transcript_recorder_cell.lock().await = transcript_recorder.clone(); let conversation_id = transcript_recorder .as_ref() .and_then(|recorder| ConversationId::from_string(recorder.session_id()).ok()) .unwrap_or_default(); - let thread_goal_state = - thread_goal::ThreadGoalState::from_replay_events(&deferred_replay_client_events); - let backend = Self { connection, session_id: Arc::new(RwLock::new(session_id)), @@ -307,7 +345,8 @@ impl AcpBackend { conversation_id, approval_policy_tx, pending_compact_summary: Arc::new(Mutex::new(pending_summary)), - thread_goal_state: Arc::new(Mutex::new(thread_goal_state)), + thread_goal_state, + transcript_recorder_cell, pending_hook_context: Arc::new(Mutex::new(config.session_context.clone())), transcript_recorder, session_event_tx: session_event_tx.clone(), diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index cb628c2db..9a966e1b5 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -486,10 +486,19 @@ impl AcpBackend { *self.pending_compact_summary.lock().await = Some(summary.clone()); let cwd = self.cwd.clone(); - let mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( + let mut mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( &self.mcp_servers, self.mcp_oauth_credentials_store_mode, ); + if let Err(err) = thread_goal_mcp::register_for_session( + &self.connection, + &mut mcp_servers, + Arc::clone(&self.thread_goal_state), + self.backend_event_tx.clone(), + Arc::clone(&self.transcript_recorder_cell), + ) { + warn!("Failed to register goal MCP server after compact: {err}"); + } match self.connection.create_session(&cwd, mcp_servers).await { Ok(new_session_id) => { debug!("Created new session after compact: {:?}", new_session_id); diff --git a/nori-rs/acp/src/backend/spawn_and_relay.rs b/nori-rs/acp/src/backend/spawn_and_relay.rs index d48e23d4b..136115ece 100644 --- a/nori-rs/acp/src/backend/spawn_and_relay.rs +++ b/nori-rs/acp/src/backend/spawn_and_relay.rs @@ -54,11 +54,21 @@ impl AcpBackend { } }; + let thread_goal_state = Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())); + let transcript_recorder_cell = Arc::new(Mutex::new(None)); + // Create a session with enhanced error handling, forwarding CLI MCP servers. - let mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( + let mut mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( &config.mcp_servers, config.mcp_oauth_credentials_store_mode, ); + thread_goal_mcp::register_for_session( + &connection, + &mut mcp_servers, + Arc::clone(&thread_goal_state), + backend_event_tx.clone(), + Arc::clone(&transcript_recorder_cell), + )?; let session_result = connection.create_session(&cwd, mcp_servers).await; let session_id = match session_result { Ok(id) => id, @@ -146,6 +156,7 @@ impl AcpBackend { None } }; + *transcript_recorder_cell.lock().await = transcript_recorder.clone(); let conversation_id = transcript_recorder .as_ref() .and_then(|recorder| ConversationId::from_string(recorder.session_id()).ok()) @@ -166,7 +177,8 @@ impl AcpBackend { conversation_id, approval_policy_tx, pending_compact_summary: Arc::new(Mutex::new(config.initial_context.clone())), - thread_goal_state: Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), + thread_goal_state, + transcript_recorder_cell, pending_hook_context: Arc::new(Mutex::new(config.session_context.clone())), transcript_recorder, session_event_tx: session_event_tx.clone(), diff --git a/nori-rs/acp/src/backend/tests/part4.rs b/nori-rs/acp/src/backend/tests/part4.rs index f3d58311a..23424362e 100644 --- a/nori-rs/acp/src/backend/tests/part4.rs +++ b/nori-rs/acp/src/backend/tests/part4.rs @@ -1463,6 +1463,91 @@ async fn test_resume_session_does_not_deadlock_with_many_notifications() { ); } +/// When server-side load_session sends replay notifications, transcript-owned +/// backend goal state must survive because ACP agents do not replay Nori's +/// ThreadGoalUpdated events. +#[tokio::test] +#[serial] +async fn test_resume_session_preserves_transcript_goal_after_server_side_replay() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + unsafe { + std::env::set_var("MOCK_AGENT_SUPPORT_LOAD_SESSION", "1"); + std::env::set_var("MOCK_AGENT_LOAD_SESSION_NOTIFICATION_COUNT", "2"); + } + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + let mut transcript = build_test_transcript(); + transcript + .entries + .push(crate::transcript::TranscriptLine::new( + crate::transcript::TranscriptEntry::ClientEvent(crate::transcript::ClientEventEntry { + event: nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + objective: "Keep the resumed goal".to_string(), + status: nori_protocol::ThreadGoalStatus::Active, + tokens_used: 7, + time_used_seconds: 11, + created_at: 100, + updated_at: 200, + }, + }, + ), + }), + )); + + let backend = AcpBackend::resume_session( + &config, + Some("acp-session-42"), + Some(&transcript), + backend_event_tx, + ) + .await + .expect("resume_session should succeed"); + + unsafe { + std::env::remove_var("MOCK_AGENT_SUPPORT_LOAD_SESSION"); + std::env::remove_var("MOCK_AGENT_LOAD_SESSION_NOTIFICATION_COUNT"); + } + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + backend + .submit(Op::ThreadGoalGet) + .await + .expect("ThreadGoalGet should submit"); + + let start = std::time::Instant::now(); + while start.elapsed() < Duration::from_secs(5) { + match recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await { + Some(nori_protocol::ClientEvent::ThreadGoalUpdated(update)) + if update.goal.objective == "Keep the resumed goal" => + { + return; + } + Some(_) => {} + None => {} + } + } + + panic!("Timed out waiting for resumed thread goal snapshot"); +} + /// When load_session succeeds, resume_session should use the server-side /// path and NOT produce initial_messages. #[tokio::test] diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/acp/src/backend/tests/part5.rs index 475ea86d6..9a46ef4da 100644 --- a/nori-rs/acp/src/backend/tests/part5.rs +++ b/nori-rs/acp/src/backend/tests/part5.rs @@ -457,6 +457,148 @@ async fn usage_updates_refresh_goal_token_count() { assert!(saw_goal_update_with_tokens); } +#[tokio::test] +#[serial] +async fn goal_mcp_server_is_advertised_to_http_mcp_agents() { + let Some(new_session) = logged_new_session_for_mock_agent(true).await else { + return; + }; + + assert!( + new_session.mcp_servers.iter().any(|server| { + matches!( + server, + acp::McpServer::Http(http) + if http.name == "nori-goal" && http.url.starts_with("acp:") + ) + }), + "expected session/new to advertise the local nori-goal MCP server, got: {:?}", + new_session.mcp_servers + ); +} + +#[tokio::test] +#[serial] +async fn goal_mcp_server_is_not_advertised_without_http_mcp_capability() { + let Some(new_session) = logged_new_session_for_mock_agent(false).await else { + return; + }; + + assert!( + new_session.mcp_servers.iter().all(|server| { + !matches!( + server, + acp::McpServer::Http(http) if http.name == "nori-goal" + ) + }), + "expected session/new to omit nori-goal without HTTP MCP capability, got: {:?}", + new_session.mcp_servers + ); +} + +async fn logged_new_session_for_mock_agent( + advertise_http_mcp: bool, +) -> Option { + use std::time::Duration; + + struct EnvGuard { + name: &'static str, + previous: Option, + } + + impl EnvGuard { + fn set(name: &'static str, value: &str) -> Self { + let previous = std::env::var(name).ok(); + unsafe { + std::env::set_var(name, value); + } + Self { name, previous } + } + + fn remove(name: &'static str) -> Self { + let previous = std::env::var(name).ok(); + unsafe { + std::env::remove_var(name); + } + Self { name, previous } + } + } + + impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => unsafe { + std::env::set_var(self.name, value); + }, + None => unsafe { + std::env::remove_var(self.name); + }, + } + } + } + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return None; + } + + let _env_guard = if advertise_http_mcp { + EnvGuard::set("MOCK_AGENT_MCP_HTTP", "1") + } else { + EnvGuard::remove("MOCK_AGENT_MCP_HTTP") + }; + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let wire_log_dir = temp_dir.path().join("acp-wire"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let mut config = build_test_config(temp_dir.path()); + config.acp_proxy = crate::config::AcpProxyConfig { + enabled: true, + log_dir: wire_log_dir.clone(), + }; + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let configured = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + assert!( + matches!(configured.msg, EventMsg::SessionConfigured(_)), + "expected SessionConfigured event, got: {configured:?}" + ); + + backend + .submit(Op::Shutdown) + .await + .expect("Failed to shut down ACP backend"); + + let log_path = std::fs::read_dir(&wire_log_dir) + .expect("wire log dir exists") + .map(|entry| entry.expect("wire log entry").path()) + .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .expect("wire log should be written"); + let log_content = std::fs::read_to_string(log_path).expect("wire log should be readable"); + let new_session = log_content + .lines() + .map(|line| serde_json::from_str::(line).expect("json wire log line")) + .filter(|record| { + record["direction"] == "client_to_agent" && record["message"]["method"] == "session/new" + }) + .next_back() + .expect("session/new should be logged"); + Some( + serde_json::from_value(new_session["message"]["params"].clone()) + .expect("session/new params should match ACP schema"), + ) +} + /// Test that session_context is consumed after the first prompt (not repeated). #[tokio::test] #[serial] diff --git a/nori-rs/acp/src/backend/thread_goal_mcp.rs b/nori-rs/acp/src/backend/thread_goal_mcp.rs new file mode 100644 index 000000000..5b604f80d --- /dev/null +++ b/nori-rs/acp/src/backend/thread_goal_mcp.rs @@ -0,0 +1,546 @@ +use serde_json::Value; +use tokio::sync::Mutex; + +use super::*; +use crate::connection::local_mcp::LocalMcpServer; +use codex_protocol::protocol::ThreadGoalStatus; +use nori_protocol::ThreadGoalUpdated; +use sacp::Agent; +use sacp::ConnectTo; +use sacp::ConnectionTo; +use sacp::Dispatch; +use sacp::DynConnectTo; +use sacp::UntypedMessage; +use sacp::role; + +const GET_GOAL_TOOL_NAME: &str = "get_goal"; +const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; +const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; + +const DUPLICATE_CREATE_GOAL_ERROR: &str = "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete"; +const UPDATE_GOAL_STATUS_ERROR: &str = "update_goal can only mark the existing goal complete or blocked; pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system"; + +#[derive(Clone)] +pub(crate) struct ThreadGoalMcpBridge { + thread_goal_state: Arc>, + backend_event_tx: mpsc::Sender, + transcript_recorder: Arc>>>, +} + +impl ThreadGoalMcpBridge { + pub(crate) fn new( + thread_goal_state: Arc>, + backend_event_tx: mpsc::Sender, + transcript_recorder: Arc>>>, + ) -> Self { + Self { + thread_goal_state, + backend_event_tx, + transcript_recorder, + } + } + + pub(crate) async fn handle_mcp_request(&self, method: &str, params: Value) -> Value { + match method { + "initialize" => serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "nori-goal", "version": env!("CARGO_PKG_VERSION") } + }), + "tools/list" => serde_json::json!({ "tools": tools() }), + "tools/call" => self.handle_tool_call(params).await, + _ => tool_error(format!("unsupported goal MCP request: {method}")), + } + } + + async fn handle_tool_call(&self, params: Value) -> Value { + let name = params + .get("name") + .and_then(Value::as_str) + .unwrap_or_default(); + let arguments = params + .get("arguments") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + + match name { + GET_GOAL_TOOL_NAME => self.get_goal().await, + CREATE_GOAL_TOOL_NAME => self.create_goal(arguments).await, + UPDATE_GOAL_TOOL_NAME => self.update_goal(arguments).await, + "" => tool_error("tools/call requires a tool name"), + other => tool_error(format!("unknown goal tool: {other}")), + } + } + + async fn get_goal(&self) -> Value { + let state = self.thread_goal_state.lock().await; + let goal = state.snapshot(thread_goal::now_seconds()).map(goal_json); + tool_success(serde_json::json!({ "goal": goal })) + } + + async fn create_goal(&self, arguments: Value) -> Value { + if arguments.get("token_budget").is_some() { + return tool_error("token budgets are not supported by Nori ACP goals yet"); + } + + let Some(objective) = arguments.get("objective").and_then(Value::as_str) else { + return tool_error("create_goal requires objective"); + }; + let objective = objective.trim().to_string(); + let result = { + let now = thread_goal::now_seconds(); + let mut state = self.thread_goal_state.lock().await; + if state.snapshot(now).is_some() { + return tool_error(DUPLICATE_CREATE_GOAL_ERROR); + } + state.set_objective(objective, Some(ThreadGoalStatus::Active), now) + }; + + match result { + Ok(goal) => { + self.emit_goal_updated(goal.clone()).await; + tool_success(serde_json::json!({ "goal": goal_json(goal) })) + } + Err(err) => tool_error(err), + } + } + + async fn update_goal(&self, arguments: Value) -> Value { + let Some(status) = arguments.get("status").and_then(Value::as_str) else { + return tool_error("update_goal requires status"); + }; + let status = match status { + "complete" => ThreadGoalStatus::Complete, + "blocked" => ThreadGoalStatus::Blocked, + "active" | "paused" | "usage_limited" | "budget_limited" => { + return tool_error(UPDATE_GOAL_STATUS_ERROR); + } + other => return tool_error(format!("unsupported goal status: {other}")), + }; + + let result = { + let now = thread_goal::now_seconds(); + let mut state = self.thread_goal_state.lock().await; + state.set_status(status, now) + }; + + match result { + Ok(goal) => { + self.emit_goal_updated(goal.clone()).await; + tool_success(serde_json::json!({ "goal": goal_json(goal) })) + } + Err(err) => tool_error(err), + } + } + + async fn emit_goal_updated(&self, goal: thread_goal::ThreadGoalSnapshot) { + let recorder = self.transcript_recorder.lock().await.clone(); + emit_client_event( + &self.backend_event_tx, + recorder.as_ref(), + ClientEvent::ThreadGoalUpdated(ThreadGoalUpdated { + goal: goal.into_client_goal(), + }), + ) + .await; + } +} + +impl LocalMcpServer for ThreadGoalMcpBridge { + fn name(&self) -> String { + "nori-goal".to_string() + } + + fn connect( + &self, + _acp_url: String, + _connection: ConnectionTo, + ) -> DynConnectTo { + DynConnectTo::new(ThreadGoalMcpComponent { + bridge: self.clone(), + }) + } +} + +struct ThreadGoalMcpComponent { + bridge: ThreadGoalMcpBridge, +} + +impl ConnectTo for ThreadGoalMcpComponent { + async fn connect_to( + self, + client: impl ConnectTo, + ) -> Result<(), sacp::Error> { + let bridge = self.bridge; + role::mcp::Server + .builder() + .on_receive_dispatch( + async move |message: Dispatch, _connection| { + match message { + Dispatch::Request(request, responder) => { + let UntypedMessage { method, params } = request; + responder.respond(bridge.handle_mcp_request(&method, params).await)?; + } + Dispatch::Notification(_) | Dispatch::Response(_, _) => {} + } + Ok(()) + }, + sacp::on_receive_dispatch!(), + ) + .connect_to(client) + .await + } +} + +pub(super) fn register_for_session( + connection: &SacpConnection, + mcp_servers: &mut Vec, + thread_goal_state: Arc>, + backend_event_tx: mpsc::Sender, + transcript_recorder: Arc>>>, +) -> Result<()> { + if !connection.capabilities().mcp_capabilities.http { + return Ok(()); + } + + connection.register_local_mcp_server( + mcp_servers, + ThreadGoalMcpBridge::new(thread_goal_state, backend_event_tx, transcript_recorder), + ) +} + +fn tools() -> Vec { + vec![ + serde_json::json!({ + "name": GET_GOAL_TOOL_NAME, + "description": "Get the current goal for this thread, including status, token and elapsed-time usage.", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false + } + }), + serde_json::json!({ + "name": CREATE_GOAL_TOOL_NAME, + "description": format!( + "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status." + ), + "inputSchema": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." + } + }, + "required": ["objective"], + "additionalProperties": false + } + }), + serde_json::json!({ + "name": UPDATE_GOAL_TOOL_NAME, + "description": "Update the existing goal. Use this tool only to mark the goal achieved or genuinely blocked.", + "inputSchema": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["complete", "blocked"], + "description": "Required. Set to complete only when the objective is achieved and no required work remains. Set to blocked only after the same blocking condition has repeated and the agent is at an impasse." + } + }, + "required": ["status"], + "additionalProperties": false + } + }), + ] +} + +fn tool_success(body: Value) -> Value { + tool_response(body, false) +} + +fn tool_error(message: impl Into) -> Value { + tool_response(Value::String(message.into()), true) +} + +fn tool_response(body: Value, is_error: bool) -> Value { + let text = match body { + Value::String(text) => text, + other => serde_json::to_string(&other) + .unwrap_or_else(|err| format!("failed to serialize goal MCP response: {err}")), + }; + serde_json::json!({ + "content": [{ "type": "text", "text": text }], + "isError": is_error + }) +} + +fn goal_json(goal: thread_goal::ThreadGoalSnapshot) -> Value { + serde_json::json!({ + "objective": goal.objective, + "status": status_label(goal.status), + "tokens_used": goal.tokens_used, + "token_budget": null, + "tokens_remaining": null, + "time_used_seconds": goal.time_used_seconds, + "created_at": goal.created_at, + "updated_at": goal.updated_at, + }) +} + +fn status_label(status: ThreadGoalStatus) -> &'static str { + match status { + ThreadGoalStatus::Active => "active", + ThreadGoalStatus::Paused => "paused", + ThreadGoalStatus::Blocked => "blocked", + ThreadGoalStatus::UsageLimited => "usage_limited", + ThreadGoalStatus::BudgetLimited => "budget_limited", + ThreadGoalStatus::Complete => "complete", + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + fn bridge() -> ThreadGoalMcpBridge { + let (backend_event_tx, _backend_event_rx) = mpsc::channel(8); + ThreadGoalMcpBridge::new( + Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), + backend_event_tx, + Arc::new(Mutex::new(None)), + ) + } + + fn tool_text(response: &Value) -> &str { + response["content"][0]["text"] + .as_str() + .expect("tool response should contain text content") + } + + fn is_error(response: &Value) -> bool { + response["isError"].as_bool().unwrap_or(false) + } + + fn parsed_tool_text(response: &Value) -> Value { + serde_json::from_str(tool_text(response)).expect("tool text should be json") + } + + fn tool_by_name<'a>(response: &'a Value, name: &str) -> &'a Value { + response["tools"] + .as_array() + .expect("tools/list should return tools") + .iter() + .find(|tool| tool["name"] == name) + .expect("expected tool to be listed") + } + + #[tokio::test] + async fn goal_mcp_lists_codex_compatible_goal_tools() { + let response = bridge().handle_mcp_request("tools/list", json!({})).await; + + let get_goal = tool_by_name(&response, "get_goal"); + assert_eq!( + get_goal["inputSchema"]["additionalProperties"], + json!(false) + ); + assert_eq!( + tool_by_name(&response, "create_goal")["inputSchema"]["required"], + json!(["objective"]) + ); + assert_eq!( + tool_by_name(&response, "update_goal")["inputSchema"]["properties"]["status"]["enum"], + json!(["complete", "blocked"]) + ); + } + + #[tokio::test] + async fn get_goal_tool_returns_null_without_goal() { + let response = bridge() + .handle_mcp_request("tools/call", json!({ "name": "get_goal" })) + .await; + + assert!(!is_error(&response)); + assert_eq!(parsed_tool_text(&response), json!({ "goal": null })); + } + + #[tokio::test] + async fn create_goal_tool_creates_active_goal_and_get_goal_reads_it() { + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(8); + let bridge = ThreadGoalMcpBridge::new( + Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), + backend_event_tx, + Arc::new(Mutex::new(None)), + ); + + let create_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "create_goal", + "arguments": { "objective": "Ship the ACP goal bridge" } + }), + ) + .await; + + assert!(!is_error(&create_response)); + assert!(tool_text(&create_response).contains("Ship the ACP goal bridge")); + let emitted_event = tokio::time::timeout( + std::time::Duration::from_millis(200), + backend_event_rx.recv(), + ) + .await + .expect("create_goal should emit a client event before timeout") + .expect("create_goal should emit a client event"); + match emitted_event { + BackendEvent::Client(ClientEvent::ThreadGoalUpdated(update)) => { + assert_eq!(update.goal.objective, "Ship the ACP goal bridge"); + } + other => panic!("expected thread goal update, got {other:?}"), + } + + let get_response = bridge + .handle_mcp_request("tools/call", json!({ "name": "get_goal" })) + .await; + assert!(!is_error(&get_response)); + let goal = &parsed_tool_text(&get_response)["goal"]; + assert_eq!(goal["status"], "active"); + assert_eq!(goal["objective"], "Ship the ACP goal bridge"); + } + + #[tokio::test] + async fn create_goal_tool_rejects_existing_goal() { + let bridge = bridge(); + let first_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "create_goal", + "arguments": { "objective": "First goal" } + }), + ) + .await; + assert!(!is_error(&first_response)); + + let second_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "create_goal", + "arguments": { "objective": "Second goal" } + }), + ) + .await; + + assert!(is_error(&second_response)); + assert!(tool_text(&second_response).contains("already has a goal")); + + let get_response = bridge + .handle_mcp_request("tools/call", json!({ "name": "get_goal" })) + .await; + assert_eq!( + parsed_tool_text(&get_response)["goal"]["objective"], + "First goal" + ); + } + + #[tokio::test] + async fn update_goal_tool_only_allows_complete_or_blocked() { + let bridge = bridge(); + let create_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "create_goal", + "arguments": { "objective": "Finish carefully" } + }), + ) + .await; + assert!(!is_error(&create_response)); + + let paused_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "update_goal", + "arguments": { "status": "paused" } + }), + ) + .await; + assert!(is_error(&paused_response)); + assert!( + tool_text(&paused_response).contains("only mark the existing goal complete or blocked") + ); + + let get_response = bridge + .handle_mcp_request("tools/call", json!({ "name": "get_goal" })) + .await; + assert_eq!(parsed_tool_text(&get_response)["goal"]["status"], "active"); + + let blocked_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "update_goal", + "arguments": { "status": "blocked" } + }), + ) + .await; + assert!(!is_error(&blocked_response)); + assert_eq!( + parsed_tool_text(&blocked_response)["goal"]["status"], + "blocked" + ); + } + + #[tokio::test] + async fn update_goal_tool_marks_goal_complete() { + let bridge = bridge(); + let create_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "create_goal", + "arguments": { "objective": "Finish completely" } + }), + ) + .await; + assert!(!is_error(&create_response)); + + let complete_response = bridge + .handle_mcp_request( + "tools/call", + json!({ + "name": "update_goal", + "arguments": { "status": "complete" } + }), + ) + .await; + assert!(!is_error(&complete_response)); + assert_eq!( + parsed_tool_text(&complete_response)["goal"]["status"], + "complete" + ); + } + + #[tokio::test] + async fn update_goal_tool_reports_missing_goal() { + let response = bridge() + .handle_mcp_request( + "tools/call", + json!({ + "name": "update_goal", + "arguments": { "status": "complete" } + }), + ) + .await; + + assert!(is_error(&response)); + assert!(tool_text(&response).contains("no goal exists")); + } +} diff --git a/nori-rs/acp/src/connection/local_mcp.rs b/nori-rs/acp/src/connection/local_mcp.rs new file mode 100644 index 000000000..faceca90b --- /dev/null +++ b/nori-rs/acp/src/connection/local_mcp.rs @@ -0,0 +1,359 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use futures::SinkExt; +use futures::StreamExt; +use futures::channel::mpsc; +use sacp::Agent; +use sacp::Channel; +use sacp::ConnectTo; +use sacp::ConnectionTo; +use sacp::Dispatch; +use sacp::DynConnectTo; +use sacp::HandleDispatchFrom; +use sacp::Handled; +use sacp::Responder; +use sacp::Role; +use sacp::UntypedMessage; +use sacp::role; +use sacp::role::HasPeer; +use sacp::schema::McpConnectRequest; +use sacp::schema::McpConnectResponse; +use sacp::schema::McpDisconnectNotification; +use sacp::schema::McpOverAcpMessage; +use sacp::util::MatchDispatchFrom; + +pub(crate) trait LocalMcpServer: Send + Sync + 'static { + fn name(&self) -> String; + + fn connect( + &self, + acp_url: String, + connection: ConnectionTo, + ) -> DynConnectTo; +} + +pub(super) struct LocalMcpSession { + acp_url: String, + mcp_connect: Arc>, + connections: HashMap>, +} + +impl LocalMcpSession +where + Counterpart: HasPeer, +{ + pub(super) fn new(acp_url: String, mcp_connect: Arc>) -> Self { + Self { + acp_url, + mcp_connect, + connections: HashMap::new(), + } + } + + async fn handle_connect_request( + &mut self, + request: McpConnectRequest, + responder: Responder, + acp_connection: &ConnectionTo, + ) -> Result)>, sacp::Error> { + if request.acp_url != self.acp_url { + return Ok(Handled::No { + message: (request, responder), + retry: false, + }); + } + + let connection_id = format!("mcp-over-acp-connection:{}", uuid::Uuid::new_v4()); + let (mcp_server_tx, mut mcp_server_rx) = mpsc::channel(128); + self.connections + .insert(connection_id.clone(), mcp_server_tx); + + let (client_channel, server_channel) = Channel::duplex(); + let client_component = { + let connection_id = connection_id.clone(); + let acp_connection = acp_connection.clone(); + + role::mcp::Client + .builder() + .on_receive_dispatch( + async move |message: Dispatch, _mcp_connection| { + let wrapped = message.map( + |request, responder| { + ( + McpOverAcpMessage { + connection_id: connection_id.clone(), + message: request, + meta: None, + }, + responder, + ) + }, + |notification| McpOverAcpMessage { + connection_id: connection_id.clone(), + message: notification, + meta: None, + }, + ); + acp_connection.send_proxied_message_to(Agent, wrapped) + }, + sacp::on_receive_dispatch!(), + ) + .with_spawned(move |mcp_connection| async move { + while let Some(msg) = mcp_server_rx.next().await { + mcp_connection.send_proxied_message_to(role::mcp::Server, msg)?; + } + Ok(()) + }) + }; + + let spawned_server = self + .mcp_connect + .connect(request.acp_url, acp_connection.clone()); + + let spawn_results = acp_connection + .spawn(async move { client_component.connect_to(client_channel).await }) + .and_then(|()| { + acp_connection.spawn(async move { spawned_server.connect_to(server_channel).await }) + }); + + match spawn_results { + Ok(()) => { + responder.respond(McpConnectResponse { + connection_id, + meta: None, + })?; + Ok(Handled::Yes) + } + Err(err) => { + responder.respond_with_error(err)?; + Ok(Handled::Yes) + } + } + } + + async fn handle_mcp_over_acp_request( + &mut self, + request: McpOverAcpMessage, + responder: Responder, + ) -> Result< + Handled<( + McpOverAcpMessage, + Responder, + )>, + sacp::Error, + > { + let Some(mcp_server_tx) = self.connections.get_mut(&request.connection_id) else { + return Ok(Handled::No { + message: (request, responder), + retry: false, + }); + }; + + mcp_server_tx + .send(Dispatch::Request(request.message, responder)) + .await + .map_err(sacp::Error::into_internal_error)?; + + Ok(Handled::Yes) + } + + async fn handle_mcp_over_acp_notification( + &mut self, + notification: McpOverAcpMessage, + ) -> Result>, sacp::Error> { + let Some(mcp_server_tx) = self.connections.get_mut(¬ification.connection_id) else { + return Ok(Handled::No { + message: notification, + retry: false, + }); + }; + + mcp_server_tx + .send(Dispatch::Notification(notification.message)) + .await + .map_err(sacp::Error::into_internal_error)?; + + Ok(Handled::Yes) + } + + async fn handle_mcp_disconnect_notification( + &mut self, + notification: McpDisconnectNotification, + ) -> Result, sacp::Error> { + if self + .connections + .remove(¬ification.connection_id) + .is_some() + { + Ok(Handled::Yes) + } else { + Ok(Handled::No { + message: notification, + retry: false, + }) + } + } +} + +impl HandleDispatchFrom for LocalMcpSession +where + Counterpart: HasPeer, +{ + async fn handle_dispatch_from( + &mut self, + message: Dispatch, + connection: ConnectionTo, + ) -> Result, sacp::Error> { + MatchDispatchFrom::new(message, &connection) + .if_request_from(Agent, async |request, responder| { + self.handle_connect_request(request, responder, &connection) + .await + }) + .await + .if_request_from(Agent, async |request, responder| { + self.handle_mcp_over_acp_request(request, responder).await + }) + .await + .if_notification_from(Agent, async |notification| { + self.handle_mcp_over_acp_notification(notification).await + }) + .await + .if_notification_from(Agent, async |notification| { + self.handle_mcp_disconnect_notification(notification).await + }) + .await + .done() + } + + fn describe_chain(&self) -> impl std::fmt::Debug { + format!("LocalMcpSession({})", self.mcp_connect.name()) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use sacp::Client; + use sacp::JsonRpcResponse; + use sacp::SentRequest; + use serde_json::json; + use tokio::sync::oneshot; + + use super::*; + + async fn recv(response: SentRequest) -> Result { + let (tx, rx) = oneshot::channel(); + response.on_receiving_result(async move |result| { + tx.send(result).map_err(|_| sacp::Error::internal_error()) + })?; + rx.await.map_err(|_| sacp::Error::internal_error())? + } + + struct EchoMcpServer; + + impl LocalMcpServer for EchoMcpServer { + fn name(&self) -> String { + "echo".to_string() + } + + fn connect( + &self, + _acp_url: String, + _connection: ConnectionTo, + ) -> DynConnectTo { + DynConnectTo::new(EchoMcpComponent) + } + } + + struct EchoMcpComponent; + + impl ConnectTo for EchoMcpComponent { + async fn connect_to( + self, + client: impl ConnectTo, + ) -> Result<(), sacp::Error> { + role::mcp::Server + .builder() + .on_receive_dispatch( + async move |message: Dispatch, _connection| { + if let Dispatch::Request(request, responder) = message { + responder.respond(json!({ + "method": request.method, + "params": request.params, + }))?; + } + Ok(()) + }, + sacp::on_receive_dispatch!(), + ) + .connect_to(client) + .await + } + } + + #[tokio::test] + async fn local_mcp_session_routes_connect_and_message_requests() { + let acp_url = "acp:test-local-mcp".to_string(); + let (client_transport, agent_transport) = Channel::duplex(); + let (ready_tx, ready_rx) = oneshot::channel(); + let client_acp_url = acp_url.clone(); + + let client_task = tokio::spawn(async move { + Client + .builder() + .connect_with(client_transport, async move |connection| { + let registration = connection.add_dynamic_handler(LocalMcpSession::new( + client_acp_url, + Arc::new(EchoMcpServer), + ))?; + ready_tx + .send(()) + .map_err(|_| sacp::Error::internal_error())?; + let _registration = registration; + futures::future::pending::>().await + }) + .await + }); + + let agent_result = Agent + .builder() + .connect_with(agent_transport, async move |connection| { + ready_rx.await.map_err(|_| sacp::Error::internal_error())?; + let connect_response = recv(connection.send_request_to( + Client, + McpConnectRequest { + acp_url, + meta: None, + }, + )) + .await?; + let response = recv(connection.send_request_to( + Client, + McpOverAcpMessage { + connection_id: connect_response.connection_id.clone(), + message: UntypedMessage::new("tools/list", json!({ "cursor": null }))?, + meta: None, + }, + )) + .await?; + + assert_eq!(response["method"], "tools/list"); + assert_eq!(response["params"], json!({ "cursor": null })); + + connection.send_notification_to( + Client, + McpDisconnectNotification { + connection_id: connect_response.connection_id, + meta: None, + }, + )?; + + Ok(()) + }) + .await; + + client_task.abort(); + assert!(agent_result.is_ok(), "agent side failed: {agent_result:?}"); + } +} diff --git a/nori-rs/acp/src/connection/mod.rs b/nori-rs/acp/src/connection/mod.rs index 060af3292..e3ac8ae18 100644 --- a/nori-rs/acp/src/connection/mod.rs +++ b/nori-rs/acp/src/connection/mod.rs @@ -9,6 +9,7 @@ use codex_protocol::protocol::ReviewDecision; use sacp::schema as acp; use tokio::sync::oneshot; +pub(crate) mod local_mcp; pub mod mcp; pub mod sacp_connection; mod wire_log; diff --git a/nori-rs/acp/src/connection/sacp_connection.rs b/nori-rs/acp/src/connection/sacp_connection.rs index 9b16ea224..8f79e8c19 100644 --- a/nori-rs/acp/src/connection/sacp_connection.rs +++ b/nori-rs/acp/src/connection/sacp_connection.rs @@ -35,6 +35,8 @@ use super::AcpSessionConfigState; use super::ApprovalEventType; use super::ApprovalRequest; use super::ConnectionEvent; +use super::local_mcp::LocalMcpServer; +use super::local_mcp::LocalMcpSession; use super::wire_log::WireDirection; use super::wire_log::WireLogger; use crate::config::AcpProxyConfig; @@ -84,6 +86,10 @@ pub struct SacpConnection { /// Thread-safe session config state, updated from complete ACP snapshots. session_config_state: std::sync::Arc>, + /// Dynamic handler registrations for backend-owned MCP servers advertised + /// to the current ACP session. + local_mcp_registrations: std::sync::Arc>>>, + /// Handle to the background task driving the SACP connection. connection_task: tokio::task::JoinHandle<()>, @@ -543,6 +549,7 @@ impl SacpConnection { prompt_state, model_state: std::sync::Arc::new(std::sync::RwLock::new(AcpModelState::new())), session_config_state, + local_mcp_registrations: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), connection_task, child, stderr_task, @@ -586,15 +593,46 @@ impl SacpConnection { Ok(response.session_id) } + /// Register an in-process MCP server for a single ACP connection and append + /// its `acp:` endpoint to the session MCP server list. + pub(crate) fn register_local_mcp_server( + &self, + mcp_servers: &mut Vec, + mcp_connect: impl LocalMcpServer + 'static, + ) -> Result<()> { + let acp_url = format!("acp:{}", uuid::Uuid::new_v4()); + let name = mcp_connect.name(); + let handler = LocalMcpSession::new(acp_url.clone(), std::sync::Arc::new(mcp_connect)); + let registration = self + .cx + .add_dynamic_handler(handler) + .context("Failed to register local MCP-over-ACP server")?; + let mut registrations = self + .local_mcp_registrations + .lock() + .map_err(|_| anyhow::anyhow!("local MCP registration lock poisoned"))?; + registrations.clear(); + registrations.push(Box::new(registration)); + mcp_servers.push(acp::McpServer::Http(acp::McpServerHttp::new(name, acp_url))); + Ok(()) + } + /// Load (resume) an existing session. /// /// The agent replays previous session history. Updates flow through the /// ordered event inbox. The returned `SessionId` is the same as /// the input `session_id` (the LoadSessionResponse doesn't contain one). - pub async fn load_session(&self, session_id: &str, cwd: &Path) -> Result { + pub async fn load_session( + &self, + session_id: &str, + cwd: &Path, + mcp_servers: Vec, + ) -> Result { let response = self .cx - .send_request(acp::LoadSessionRequest::new(session_id.to_string(), cwd)) + .send_request( + acp::LoadSessionRequest::new(session_id.to_string(), cwd).mcp_servers(mcp_servers), + ) .block_task() .await .context("Failed to load ACP session")?; diff --git a/nori-rs/acp/src/connection/sacp_connection_tests.rs b/nori-rs/acp/src/connection/sacp_connection_tests.rs index 22a347435..264463871 100644 --- a/nori-rs/acp/src/connection/sacp_connection_tests.rs +++ b/nori-rs/acp/src/connection/sacp_connection_tests.rs @@ -5,6 +5,10 @@ use super::ConnectionEvent; use super::sacp_connection::SacpConnection; use agent_client_protocol_schema as acp; use pretty_assertions::assert_eq; +use sacp::ConnectTo; +use sacp::ConnectionTo; +use sacp::DynConnectTo; +use sacp::role; use serde_json::Value; use serial_test::serial; use tempfile::tempdir; @@ -58,6 +62,33 @@ async fn drive_logged_prompt( conn.shutdown().await; } +struct NoopMcpServer; + +impl super::local_mcp::LocalMcpServer for NoopMcpServer { + fn name(&self) -> String { + "nori-goal".to_string() + } + + fn connect( + &self, + _acp_url: String, + _connection: ConnectionTo, + ) -> DynConnectTo { + DynConnectTo::new(NoopMcpComponent) + } +} + +struct NoopMcpComponent; + +impl ConnectTo for NoopMcpComponent { + async fn connect_to( + self, + _client: impl ConnectTo, + ) -> Result<(), sacp::Error> { + futures::future::pending().await + } +} + fn read_wire_log(path: &std::path::Path) -> Vec { let content = std::fs::read_to_string(path).expect("read wire log"); content @@ -66,6 +97,54 @@ fn read_wire_log(path: &std::path::Path) -> Vec { .collect() } +#[tokio::test] +#[serial] +async fn test_register_local_mcp_server_advertises_acp_url_in_new_session() { + let Some(config) = mock_agent_config() else { + return; + }; + let temp_dir = tempdir().expect("temp dir"); + let proxy = crate::config::AcpProxyConfig { + enabled: true, + log_dir: temp_dir.path().join("acp-wire"), + }; + + let conn = SacpConnection::spawn(&config, temp_dir.path(), proxy.clone()) + .await + .expect("spawn connection"); + let mut mcp_servers = Vec::new(); + conn.register_local_mcp_server(&mut mcp_servers, NoopMcpServer) + .expect("register local mcp server"); + + conn.create_session(temp_dir.path(), mcp_servers) + .await + .expect("create session"); + conn.shutdown().await; + + let log_path = std::fs::read_dir(&proxy.log_dir) + .expect("wire log dir exists") + .map(|entry| entry.expect("wire log entry").path()) + .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .expect("wire log should be written"); + let records = read_wire_log(&log_path); + let new_session = records + .iter() + .find(|record| { + record["direction"] == "client_to_agent" && record["message"]["method"] == "session/new" + }) + .expect("session/new should be logged"); + let advertised_server = &new_session["message"]["params"]["mcpServers"][0]; + + assert_eq!(advertised_server["name"], "nori-goal"); + assert_eq!(advertised_server["type"], "http"); + assert!( + advertised_server["url"] + .as_str() + .expect("url should be string") + .starts_with("acp:") + ); +} + /// Test that SacpConnection can spawn a mock agent, perform the initialization /// handshake, and return a working connection. After spawn, the connection /// should be able to create a session (proving the transport is alive). diff --git a/nori-rs/mock-acp-agent/docs.md b/nori-rs/mock-acp-agent/docs.md index 1a6f7ed7b..a8606d973 100644 --- a/nori-rs/mock-acp-agent/docs.md +++ b/nori-rs/mock-acp-agent/docs.md @@ -23,6 +23,7 @@ Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock ag **Session Lifecycle Testing**: Several env vars control `session/load` behavior for testing the resume path in `@/nori-rs/acp/src/backend/session.rs`: - `MOCK_AGENT_SUPPORT_LOAD_SESSION` -- when set, the agent advertises `load_session: true` in its capabilities during `initialize()` +- `MOCK_AGENT_MCP_HTTP` -- when set, the agent advertises HTTP MCP capability so `@/nori-rs/acp/src/backend/thread_goal_mcp.rs` and `@/nori-rs/acp/src/connection/local_mcp.rs` can be tested through the normal `session/new` MCP server advertisement path - `MOCK_AGENT_LOAD_SESSION_FAIL` -- when set, the `load_session()` handler returns an error instead of succeeding, allowing tests to exercise the runtime-failure fallback path - `MOCK_AGENT_LOAD_SESSION_NOTIFICATION_COUNT` -- when set to an integer N, the `load_session()` handler sends N text-chunk notifications (via `send_text_chunk()`) before returning, simulating history replay with a configurable volume of events. Used to test the deferred-relay pattern in `resume_session()` that prevents deadlocks when the notification count exceeds the bounded `event_tx` channel capacity. diff --git a/nori-rs/mock-acp-agent/src/main.rs b/nori-rs/mock-acp-agent/src/main.rs index 52e72d066..fb9a9735f 100644 --- a/nori-rs/mock-acp-agent/src/main.rs +++ b/nori-rs/mock-acp-agent/src/main.rs @@ -295,10 +295,23 @@ impl acp::Agent for MockAgent { let mut response = acp::InitializeResponse::new(acp::ProtocolVersion::LATEST) .agent_info(acp::Implementation::new("mock-agent", "0.1.0").title("Mock Agent")); + let mut capabilities = acp::AgentCapabilities::new(); + let mut has_capabilities = false; + if std::env::var("MOCK_AGENT_SUPPORT_LOAD_SESSION").is_ok() { eprintln!("Mock agent: advertising load_session capability"); - response = - response.agent_capabilities(acp::AgentCapabilities::new().load_session(true)); + capabilities = capabilities.load_session(true); + has_capabilities = true; + } + + if std::env::var("MOCK_AGENT_MCP_HTTP").is_ok() { + eprintln!("Mock agent: advertising HTTP MCP capability"); + capabilities = capabilities.mcp_capabilities(acp::McpCapabilities::new().http(true)); + has_capabilities = true; + } + + if has_capabilities { + response = response.agent_capabilities(capabilities); } Ok(response) From 4c766d63aaab2a03698a43232f1c2d1fd5603e64 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 15:49:26 -0400 Subject: [PATCH 22/29] feat(acp): chain goal continuations for capable agents --- CURRENT-PROGRESS.md | 5 + nori-rs/acp/docs.md | 4 +- .../acp/src/backend/session_runtime_driver.rs | 11 +- nori-rs/acp/src/backend/tests/part5.rs | 302 ++++++++++++++---- 4 files changed, 249 insertions(+), 73 deletions(-) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 7eaae70d8..eb7b2861b 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -110,3 +110,8 @@ Goal command progress: notifications cannot erase a restored goal. The local MCP bridge now has direct `_mcp/connect`/`_mcp/message` routing coverage and retains dynamic handler registrations only for the current advertised local MCP endpoint instead of leaking stale endpoints indefinitely. +26. Added the deeper autonomous continuation slice for ACP agents that can use goal tools. When + an agent advertises HTTP MCP support, hidden `GoalContinuation` turns can now chain after + prior continuation turns while the active goal remains open and the runtime is idle; agents + without HTTP MCP support keep the previous single hidden continuation after a visible user + turn so unsupported agents are not put into an unbounded loop they cannot stop. diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index fa28d6df7..1aac19266 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -121,9 +121,9 @@ The model-facing MCP contract is intentionally narrower than the user-facing `/g Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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. -Agents without `mcp_capabilities.http` still receive goal context through prompt transformation and hidden goal-continuation prompts. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or continuation behavior. +Agents without `mcp_capabilities.http` still receive goal context through prompt transformation and a single hidden goal-continuation prompt after visible user turns. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or one-shot continuation behavior. -After a visible user prompt completes with `StopReason::EndTurn`, `session_runtime_driver.rs` may submit one 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 this continuation when the goal is active, the reducer has returned to idle, and no queued user work remains; it does not chain continuations after a `GoalContinuation` turn. This gives long-lived goals forward progress across user turns without recursively taking over the session or obscuring pending user input. +After 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. Agents without `mcp_capabilities.http` receive at most one hidden continuation after the visible user turn. Agents with HTTP MCP support can chain continuation-after-continuation because they receive the backend-owned goal tools and can stop the autonomous loop by calling `update_goal` with `complete` or `blocked`. 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. diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index 9a966e1b5..0cdbb3762 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -475,9 +475,9 @@ impl AcpBackend { env_vars, ); } - - self.maybe_submit_goal_continuation(completed_turn).await; } + + self.maybe_submit_goal_continuation(completed_turn).await; } QueuedPromptKind::Compact => { let Some(summary) = completed_turn.last_agent_message.clone() else { @@ -534,6 +534,13 @@ impl AcpBackend { return; } + let can_chain_continuation = self.connection.capabilities().mcp_capabilities.http; + match completed_turn.prompt.kind { + QueuedPromptKind::User => {} + QueuedPromptKind::GoalContinuation if can_chain_continuation => {} + QueuedPromptKind::GoalContinuation | QueuedPromptKind::Compact => return, + } + let prompt_text = { self.thread_goal_state .lock() diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/acp/src/backend/tests/part5.rs index 9a46ef4da..5aa1f7bf4 100644 --- a/nori-rs/acp/src/backend/tests/part5.rs +++ b/nori-rs/acp/src/backend/tests/part5.rs @@ -1,5 +1,59 @@ use super::*; +struct EnvGuard { + name: &'static str, + previous: Option, +} + +impl EnvGuard { + fn set(name: &'static str, value: &str) -> Self { + let previous = std::env::var(name).ok(); + unsafe { + std::env::set_var(name, value); + } + Self { name, previous } + } + + fn remove(name: &'static str) -> Self { + let previous = std::env::var(name).ok(); + unsafe { + std::env::remove_var(name); + } + Self { name, previous } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => unsafe { + std::env::set_var(self.name, value); + }, + None => unsafe { + std::env::remove_var(self.name); + }, + } + } +} + +async fn assert_no_prompt_completed( + backend_event_rx: &mut mpsc::Receiver, + window: std::time::Duration, +) { + let start = std::time::Instant::now(); + while start.elapsed() < window { + let remaining = window + .saturating_sub(start.elapsed()) + .min(std::time::Duration::from_millis(100)); + if let Some(event) = recv_backend_client(backend_event_rx, remaining).await { + assert!( + !matches!(event, nori_protocol::ClientEvent::PromptCompleted(_)), + "unexpected extra PromptCompleted event: {event:?}" + ); + } + } +} + #[tokio::test] #[serial] async fn user_shell_command_executes_locally_and_emits_exec_lifecycle() { @@ -154,11 +208,7 @@ async fn test_session_context_prepended_to_first_prompt() { return; } - // Configure mock agent to echo back the full prompt text. - // SAFETY: Test-scoped environment variable for mock agent behavior. - unsafe { - std::env::set_var("MOCK_AGENT_ECHO_PROMPT", "1"); - } + let _env_guard = EnvGuard::set("MOCK_AGENT_ECHO_PROMPT", "1"); let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); @@ -213,11 +263,6 @@ async fn test_session_context_prepended_to_first_prompt() { agent_text.contains("hello agent"), "Expected user prompt in agent's echoed prompt, got: {agent_text}" ); - - // Clean up env var - unsafe { - std::env::remove_var("MOCK_AGENT_ECHO_PROMPT"); - } } /// When a session goal is active, the next user prompt sent to the ACP agent @@ -237,11 +282,8 @@ async fn test_goal_context_prepended_to_user_prompt() { return; } - // Configure mock agent to echo back the full prompt text. - // SAFETY: Test-scoped environment variable for mock agent behavior. - unsafe { - std::env::set_var("MOCK_AGENT_ECHO_PROMPT", "1"); - } + let _env_guard = EnvGuard::set("MOCK_AGENT_ECHO_PROMPT", "1"); + let _mcp_guard = EnvGuard::remove("MOCK_AGENT_MCP_HTTP"); let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); @@ -295,10 +337,6 @@ async fn test_goal_context_prepended_to_user_prompt() { && agent_text.contains("hello agent"), "Expected goal context and user prompt in agent echo, got: {agent_text}" ); - - unsafe { - std::env::remove_var("MOCK_AGENT_ECHO_PROMPT"); - } } #[tokio::test] @@ -306,16 +344,6 @@ async fn test_goal_context_prepended_to_user_prompt() { async fn active_goal_submits_one_hidden_continuation_after_user_turn() { use std::time::Duration; - struct EnvGuard(&'static str); - - impl Drop for EnvGuard { - fn drop(&mut self) { - unsafe { - std::env::remove_var(self.0); - } - } - } - let mock_config = crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); if !std::path::Path::new(&mock_config.command).exists() { @@ -326,10 +354,8 @@ async fn active_goal_submits_one_hidden_continuation_after_user_turn() { return; } - unsafe { - std::env::set_var("MOCK_AGENT_ECHO_PROMPT", "1"); - } - let _env_guard = EnvGuard("MOCK_AGENT_ECHO_PROMPT"); + let _env_guard = EnvGuard::set("MOCK_AGENT_ECHO_PROMPT", "1"); + let _mcp_guard = EnvGuard::remove("MOCK_AGENT_MCP_HTTP"); let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); @@ -391,6 +417,180 @@ async fn active_goal_submits_one_hidden_continuation_after_user_turn() { "expected second prompt to be a hidden goal continuation, got: {}", completed_prompts[1] ); + + assert_no_prompt_completed(&mut backend_event_rx, Duration::from_millis(500)).await; +} + +#[tokio::test] +#[serial] +async fn http_mcp_agent_chains_hidden_goal_continuations() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + let _echo_guard = EnvGuard::set("MOCK_AGENT_ECHO_PROMPT", "1"); + let _mcp_guard = EnvGuard::set("MOCK_AGENT_MCP_HTTP", "1"); + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + backend + .submit(Op::ThreadGoalSet { + objective: Some("Keep going until the goal tool stops the loop".to_string()), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), + }) + .await + .expect("Failed to set goal"); + + backend + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "start the chain".to_string(), + }], + }) + .await + .expect("Failed to submit user input"); + + let mut completed_prompts = Vec::new(); + let mut current_agent_text = String::new(); + let timeout = Duration::from_secs(10); + let start = std::time::Instant::now(); + while completed_prompts.len() < 3 { + if start.elapsed() > timeout { + panic!("Timed out waiting for chained goal continuations"); + } + match recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await { + Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { + current_agent_text.push_str(&delta.delta); + } + Some(nori_protocol::ClientEvent::PromptCompleted(_)) => { + completed_prompts.push(std::mem::take(&mut current_agent_text)); + } + Some(_) => {} + None => {} + } + } + + backend + .submit(Op::Shutdown) + .await + .expect("Failed to shut down backend"); + + assert!( + completed_prompts[0].contains("start the chain"), + "expected first prompt to be the visible user turn, got: {}", + completed_prompts[0] + ); + assert!( + completed_prompts[1].contains("Continue working toward the active thread goal") + && completed_prompts[2].contains("Continue working toward the active thread goal"), + "expected chained hidden continuations, got: {completed_prompts:?}" + ); +} + +#[tokio::test] +#[serial] +async fn completed_goal_stops_chained_hidden_continuations() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + let _mcp_guard = EnvGuard::set("MOCK_AGENT_MCP_HTTP", "1"); + let _delay_guard = EnvGuard::set("MOCK_AGENT_DELAY_MS", "200"); + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + backend + .submit(Op::ThreadGoalSet { + objective: Some("Stop after the goal is complete".to_string()), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), + }) + .await + .expect("Failed to set goal"); + + backend + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "start then finish".to_string(), + }], + }) + .await + .expect("Failed to submit user input"); + + let timeout = Duration::from_secs(10); + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + panic!("Timed out waiting for first PromptCompleted"); + } + if let Some(nori_protocol::ClientEvent::PromptCompleted(_)) = + recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await + { + break; + } + } + + backend + .submit(Op::ThreadGoalSet { + objective: None, + status: Some(codex_protocol::protocol::ThreadGoalStatus::Complete), + }) + .await + .expect("Failed to complete goal"); + + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + panic!("Timed out waiting for already queued hidden goal continuation"); + } + if let Some(nori_protocol::ClientEvent::PromptCompleted(_)) = + recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await + { + break; + } + } + + assert_no_prompt_completed(&mut backend_event_rx, Duration::from_millis(500)).await; + + backend + .submit(Op::Shutdown) + .await + .expect("Failed to shut down backend"); } #[tokio::test] @@ -501,42 +701,6 @@ async fn logged_new_session_for_mock_agent( ) -> Option { use std::time::Duration; - struct EnvGuard { - name: &'static str, - previous: Option, - } - - impl EnvGuard { - fn set(name: &'static str, value: &str) -> Self { - let previous = std::env::var(name).ok(); - unsafe { - std::env::set_var(name, value); - } - Self { name, previous } - } - - fn remove(name: &'static str) -> Self { - let previous = std::env::var(name).ok(); - unsafe { - std::env::remove_var(name); - } - Self { name, previous } - } - } - - impl Drop for EnvGuard { - fn drop(&mut self) { - match &self.previous { - Some(value) => unsafe { - std::env::set_var(self.name, value); - }, - None => unsafe { - std::env::remove_var(self.name); - }, - } - } - } - let mock_config = crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); if !std::path::Path::new(&mock_config.command).exists() { From c77d6d5d9aa4ad75302253c94379fbe11755aeae Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 17:06:46 -0400 Subject: [PATCH 23/29] docs: Track pending fixes --- CURRENT-PROGRESS.md | 143 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index eb7b2861b..026d53dde 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -115,3 +115,146 @@ Goal command progress: prior continuation turns while the active goal remains open and the runtime is idle; agents without HTTP MCP support keep the previous single hidden continuation after a visible user turn so unsupported agents are not put into an unbounded loop they cannot stop. + +Follow-up bug investigations - 2026-05-28: + +### Bug 1: `nori-goal` MCP startup failure + +- Finding: The existing `nori-rs/closing-loop/nori-goal-mcp-over-acp-bug-report.md` + report is substantially correct. Nori advertises backend-owned goal tools as an + ACP local MCP server by sending an HTTP MCP server entry whose URL is `acp:`. + Codex ACP treats that entry as a normal streamable HTTP MCP server and Codex's + HTTP client rejects the `acp:` scheme before Nori's `_mcp/connect` bridge can run. +- Evidence: Goal MCP registration is guarded only by `mcp_capabilities.http` in + `nori-rs/acp/src/backend/thread_goal_mcp.rs`; registration appends + `McpServer::Http { url: "acp:" }` in + `nori-rs/acp/src/connection/sacp_connection.rs`; the intended local receiver is + `_mcp/connect` in `nori-rs/acp/src/connection/local_mcp.rs`. +- Correction to the prior report: the built-in Nori Codex agent still launches + `@zed-industries/codex-acp`, not `@agentclientprotocol/codex-acp`. Both appear to + have the same shape mismatch: they advertise HTTP MCP but forward/load the + `acp:` URL as a normal HTTP MCP config instead of using ACP `_mcp/connect`. +- Quickest estimate: 2-4 hours for a focused mitigation that disables local goal + MCP advertisement for Codex ACP and gates continuation chaining on goal MCP + being actually supported/registered, not raw HTTP MCP capability. A real + loopback HTTP MCP server is a more complete but larger 1-2 day fix. Upstream + Codex ACP support for ACP-local MCP is the cleanest architecture, but timing is + outside this repo. +- Risks/unknowns: Need confirm which non-Codex ACP agents actually support `acp:` + local MCP before changing behavior broadly. A Codex-specific deny path is safer + than treating all HTTP MCP agents as broken. + +### Bug 2: New `/goal ` does not begin work immediately + +- Finding: Confirmed. `/goal ` is a state mutation only. The TUI + intercepts the slash command, sends `Op::ThreadGoalSet`, and returns before any + normal ACP `session/prompt` is submitted. +- Root cause: Automatic hidden goal continuation is currently triggered only after + a completed visible user turn or prior continuation turn. Setting the goal does + not enqueue a hidden `GoalContinuation` prompt, so work starts only after the + user submits a second prompt. +- Evidence: `/goal` sends only `ThreadGoalSet` from + `nori-rs/tui/src/chatwidget/goal.rs`; `handle_thread_goal_set` stores state and + emits `ThreadGoalUpdated`; `maybe_submit_goal_continuation` is called from + completed-turn handling in `nori-rs/acp/src/backend/session_runtime_driver.rs`. +- Quickest estimate: small, 1-2 hours with tests. More like 2-3 hours if the same + change covers `/goal resume`, active-goal replacement while a request is in + flight, and HTTP-MCP chain semantics. +- Likely fix: after a successful active `ThreadGoalSet`, enqueue the existing + hidden `GoalContinuation` prompt when the runtime is idle and there is no queued + user work. Extract the enqueue portion of `maybe_submit_goal_continuation` so it + can be reused without requiring a `CompletedTurn`. + +### Bug 3: Goal status prints constantly into history + +- Finding: Confirmed. Nori emits a `ThreadGoalUpdated` event for every ACP usage + update, and the TUI renders every `ThreadGoalUpdated` as a full history summary. + That makes token/time refreshes look like repeated goal status messages. +- What Codex does: upstream Codex stores goal state and updates a compact footer + status indicator such as `Pursuing goal (...)`. It does not append every backend + goal update into chat history. History/info messages are mostly tied to explicit + `/goal` actions such as set, status, pause, resume, or clear. +- Evidence: ACP usage updates are converted into `ThreadGoalUpdated` in + `nori-rs/acp/src/backend/thread_goal.rs`; the TUI always calls + `show_goal_summary` from `handle_thread_goal_updated` in + `nori-rs/tui/src/chatwidget/goal.rs`. Codex's footer/status indicator path is in + `other-repos/codex/codex-rs/tui/src/chatwidget/goal_status.rs` and + `other-repos/codex/codex-rs/tui/src/bottom_pane/footer.rs`. +- Quickest estimate: small, 1-2 hours with focused TUI tests/snapshot updates. + Matching the upstream footer/status model more fully is closer to 0.5-1 day. +- Likely fix: keep updating cached `current_goal` on every event, but only append + a history summary when user-visible goal meaning changes or when the user + explicitly asks for `/goal` status. Minimal safe heuristic: suppress summaries + when previous and new goal have the same objective/status/created timestamp and + only accounting fields changed. +- Risks/unknowns: The protocol currently does not carry provenance that says + "this update is an explicit user-requested status response" versus "this update + is backend sync." Without adding provenance, the first fix will be heuristic. + +### Bug 4: `Tokens used` should pretty print + +- Finding: Display-only bug. `nori-rs/tui/src/chatwidget/goal.rs` renders + `Tokens used` with `goal.tokens_used.to_string()`. +- Existing helper: `codex_protocol::num_format::format_si_suffix` already exists + and is used elsewhere for compact token counts, so the cheapest fix does not + need a new formatter or dependency. +- Format ambiguity: `195043 -> 195K` matches the existing Nori helper and upstream + Codex compact formatting. The example `32492004 -> 32,492M` is ambiguous because + it reads as 32,492 million, while existing Nori/upstream compact formatting + would be around `32.5M`. +- Quickest estimate: tiny, 15-30 minutes including one TUI snapshot update. +- Likely fix: replace raw `to_string()` with `format_si_suffix(goal.tokens_used)`, + update the existing goal summary snapshot to cover a nonzero count, and decide + whether `32.5M` is acceptable or whether this needs a new project-specific + convention. + +### Bug 5: Goal token count mirrors latest usage instead of cumulative capacity + +- Finding: Confirmed. Goal token accounting treats ACP `UsageUpdate.used` as if it + were cumulative goal spend, but ACP usage is "tokens currently in context". Nori + subtracts a single baseline from a point-in-time context-window measurement. +- Evidence: `StoredThreadGoal` stores `tokens_used`, `token_usage_baseline`, and + `last_session_used_tokens`. On each usage update, + `nori-rs/acp/src/backend/thread_goal.rs` sets `goal.tokens_used = + used_tokens.saturating_sub(baseline)`. That exactly explains why the goal count + mirrors usage updates and can reset or drift across compaction, resume, subagent + sidechains, and new ACP session IDs. +- Likely correct model: keep goal-owned cumulative usage and per-source/segment + last-seen context values. For a same segment, add only positive deltas; when + context usage drops because of compaction/resume/session changes, start a new + segment instead of subtracting from an old baseline. Provider transcript totals + may be needed to include subagent usage accurately, with deduping by transcript + message/session identity. +- Quickest estimate: medium, 1-2 days for a focused segment-based approximation + with tests. More if robust provider transcript aggregation across Claude, Codex, + Gemini, and sidechain/subagent messages is required. +- Risks/unknowns: ACP does not appear to expose cumulative token spend directly. + Provider transcript schemas differ and may be stale during live sessions. Replay + and live `session/load` usage must be deduped to avoid double-counting. + +### Bug 6: Agent repeats "Verified again..." and cannot stop the goal loop + +- Finding: High-confidence root cause is continuation chaining based on advertised + HTTP MCP capability rather than observed working `nori-goal` MCP tools. For + Codex ACP, the tools fail to start because of Bug 1, but Nori still believes the + agent can stop itself via goal MCP and therefore keeps chaining hidden + continuations while the backend goal remains active. +- Evidence: `maybe_submit_goal_continuation` chains after a `GoalContinuation` + when `connection.capabilities().mcp_capabilities.http` is true. The actual stop + path requires the agent to call MCP `update_goal({"status":"complete"})` in + `nori-rs/acp/src/backend/thread_goal_mcp.rs`. Since Codex never successfully + initializes the `acp:` goal MCP server, the active goal never transitions to + complete through that path. +- Why those history cells appear: hidden `GoalContinuation` prompts are not shown + as user prompts, but assistant output is still recorded and finalized like normal + assistant history. The repeated "Verified again..." messages are normal + assistant completions from each chained hidden continuation, not duplicate TUI + rendering of the same cell. +- Quickest estimate: small for mitigation, roughly 1-2 hours to disable chained + continuation unless goal MCP is known usable. Medium, 0.5-1 day, to track an + observed `_mcp/connect` success from the local MCP bridge and gate chaining on + that runtime state. +- Likely fix: allow one post-user hidden continuation as the unsupported-agent + fallback, but do not allow `GoalContinuation -> GoalContinuation` chaining until + Nori has observed a working `nori-goal` MCP connection. A faster temporary + mitigation is to disable chained continuations entirely. From c25a271e84fa2adefd568bfb346721d52920b56d Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 17:52:15 -0400 Subject: [PATCH 24/29] fix(acp): serve goal MCP over loopback HTTP --- nori-rs/acp/docs.md | 28 +- nori-rs/acp/src/backend/mod.rs | 7 + nori-rs/acp/src/backend/session.rs | 19 +- .../acp/src/backend/session_runtime_driver.rs | 10 +- nori-rs/acp/src/backend/spawn_and_relay.rs | 9 +- nori-rs/acp/src/backend/tests/part5.rs | 280 ++++++++++++-- .../acp/src/backend/thread_goal_http_mcp.rs | 213 +++++++++++ nori-rs/acp/src/backend/thread_goal_mcp.rs | 112 +++--- nori-rs/acp/src/connection/local_mcp.rs | 359 ------------------ nori-rs/acp/src/connection/mod.rs | 1 - nori-rs/acp/src/connection/sacp_connection.rs | 31 -- .../src/connection/sacp_connection_tests.rs | 79 ---- nori-rs/mock-acp-agent/docs.md | 2 +- 13 files changed, 568 insertions(+), 582 deletions(-) create mode 100644 nori-rs/acp/src/backend/thread_goal_http_mcp.rs delete mode 100644 nori-rs/acp/src/connection/local_mcp.rs diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index 1aac19266..aec6fb472 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -27,7 +27,7 @@ The ACP crate serves as a bridge between: - The shared `codex-protocol` event stream, which is still used for control-plane signals such as warnings, hook output, prompt summaries, shutdown, and other app-level notifications - `SessionRuntime` in `@/nori-rs/nori-protocol/`, which is now the ACP backend's single source of truth for prompt state, load state, queued prompts, permission ownership, and final assistant-message assembly - Thread-goal operations from `@/nori-rs/protocol` and normalized goal events from `@/nori-rs/nori-protocol`, with backend storage and prompt transformation in `@/nori-rs/acp/src/backend/thread_goal.rs` -- Backend-owned local MCP tools that expose the same goal state to ACP agents through `@/nori-rs/acp/src/backend/thread_goal_mcp.rs` and the connection bridge in `@/nori-rs/acp/src/connection/local_mcp.rs` +- Backend-owned local MCP tools that expose the same goal state to ACP agents through `@/nori-rs/acp/src/backend/thread_goal_mcp.rs` and the loopback HTTP server in `@/nori-rs/acp/src/backend/thread_goal_http_mcp.rs` Key files: - `registry.rs` - Agent configuration and npm package detection @@ -35,8 +35,8 @@ Key files: - `translator.rs` - User input to ACP `ContentBlock` conversion and related parsing helpers - `backend/mod.rs` - Implements `ConversationClient` trait from codex-core and emits normalized ACP session events - `backend/thread_goal.rs` - Owns per-session `/goal` state, prompt goal-context formatting, transcript rehydration, and usage-baseline updates -- `backend/thread_goal_mcp.rs` - Adapts backend-owned goal state into MCP tools for agents that support HTTP MCP -- `connection/local_mcp.rs` - Bridges in-process MCP servers into ACP's MCP-over-ACP dynamic handler flow +- `backend/thread_goal_mcp.rs` - Adapts backend-owned goal state into MCP tools for agents that advertise HTTP MCP support +- `backend/thread_goal_http_mcp.rs` - Exposes those backend-owned tools over a loopback HTTP endpoint for ACP adapters - `transcript_discovery.rs` - Discovers transcript files for external agents - `auto_worktree.rs` - Orchestrates automatic git worktree creation, eligibility checking, and summary-based renaming @@ -100,9 +100,9 @@ Metadata notifications that ACP permits while idle are treated as session-owned The runtime differentiates visible user work from backend-internal continuation work through `QueuedPromptKind` in `@/nori-rs/nori-protocol/src/session_runtime.rs`. Goal continuations are sent through the same reducer and ACP side-effect path as user prompts, so assistant deltas, tool activity, hooks, transcript assistant messages, usage updates, and completion events remain normal. Their prompt text is hidden from visible queue updates and from persisted user transcript entries, which keeps the user's transcript anchored to explicit user input while still letting the ACP session continue the active goal. -**Thread Goal State** (`backend/thread_goal.rs`, `backend/thread_goal_mcp.rs`, `backend/submit_and_ops.rs`, `backend/user_input.rs`, `backend/transcript.rs`): +**Thread Goal State** (`backend/thread_goal.rs`, `backend/thread_goal_mcp.rs`, `backend/thread_goal_http_mcp.rs`, `backend/submit_and_ops.rs`, `backend/user_input.rs`, `backend/transcript.rs`): -The ACP backend owns the `/goal` feature as per-session state instead of delegating it to the ACP agent. The TUI sends typed `codex_protocol::protocol::Op::ThreadGoalGet`, `ThreadGoalSet`, and `ThreadGoalClear` operations; `submit_and_ops.rs` routes those operations directly to the backend goal handler; and successful mutations are emitted as `nori_protocol::ClientEvent::ThreadGoalUpdated` or `ThreadGoalCleared`. ACP agents that advertise HTTP MCP can also interact with the same state through the backend-owned `nori-goal` local MCP server, which exposes `get_goal`, `create_goal`, and `update_goal`. +The ACP backend owns the `/goal` feature as per-session state instead of delegating it to the ACP agent. The TUI sends typed `codex_protocol::protocol::Op::ThreadGoalGet`, `ThreadGoalSet`, and `ThreadGoalClear` operations; `submit_and_ops.rs` routes those operations directly to the backend goal handler; and successful mutations are emitted as `nori_protocol::ClientEvent::ThreadGoalUpdated` or `ThreadGoalCleared`. Eligible ACP agents can also interact with the same state through the backend-owned `nori-goal` local MCP server, which exposes `get_goal`, `create_goal`, and `update_goal`. ``` @/nori-rs/tui/src/chatwidget/goal.rs @@ -115,15 +115,17 @@ The ACP backend owns the `/goal` feature as per-session state instead of delegat `ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, token usage, and the session-token baseline used to compute goal-local `tokens_used`. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules. -`thread_goal_mcp.rs` is a bridge, not a second store. Its tools lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/acp/src/backend/transcript.rs` when a transcript recorder is available; session setup stores the recorder behind a shared cell because local MCP handlers are registered before all resume/create paths know the final transcript session id. +`thread_goal_mcp.rs` is a bridge, not a second store. Its tools lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/acp/src/backend/transcript.rs` when a transcript recorder is available; session setup stores the recorder behind a shared cell because the goal MCP bridge can be created before all resume/create paths know the final transcript session id. + +The local `nori-goal` server is only advertised when `@/nori-rs/acp/src/backend/thread_goal_mcp.rs` sees HTTP MCP support from `@/nori-rs/acp/src/connection/mod.rs`. Nori advertises a real `http://127.0.0.1:/mcp` endpoint rather than an ACP pseudo-URL, because Codex ACP and Claude ACP both forward ACP `mcpServers` to their underlying clients as ordinary HTTP MCP server config. The loopback server is owned by the ACP backend and talks directly to the same in-memory goal state as `/goal`. The model-facing MCP contract is intentionally narrower than the user-facing `/goal` command surface. `create_goal` creates a new active goal only when no goal exists, rejects token budgets for now, and delegates objective validation to `ThreadGoalState`. `update_goal` only lets an agent mark the existing goal `complete` or `blocked`; pause, resume, usage-limited, and budget-limited transitions remain controlled by the user or the backend system path. Errors are returned as MCP tool errors instead of changing state. Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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. -Agents without `mcp_capabilities.http` still receive goal context through prompt transformation and a single hidden goal-continuation prompt after visible user turns. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or one-shot continuation behavior. +Agents that are not advertised the local `nori-goal` server still receive goal context through prompt transformation and a single hidden goal-continuation prompt after visible user turns. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or one-shot continuation behavior. -After 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. Agents without `mcp_capabilities.http` receive at most one hidden continuation after the visible user turn. Agents with HTTP MCP support can chain continuation-after-continuation because they receive the backend-owned goal tools and can stop the autonomous loop by calling `update_goal` with `complete` or `blocked`. +After 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 `thread_goal_mcp.rs` flips only after the local HTTP MCP server receives an `initialize` request. Agents without a connected goal MCP endpoint receive at most the existing one hidden continuation after each visible user turn. 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. @@ -650,7 +652,7 @@ The ACP connection layer uses SACP v11 (`sacp` crate) to communicate with agent **Approval flow:** The `RequestPermissionRequest` handler translates the request to a Codex `ApprovalRequest`, sends it through the ordered inbox, and uses the SACP responder plus `ConnectionTo` to send the eventual review decision back without blocking the dispatch loop while the UI collects user input. -**MCP Server Forwarding and Local MCP Bridges** (`connection/mcp.rs`, `connection/local_mcp.rs`): +**MCP Server Forwarding and Backend-Owned Goal MCP** (`connection/mcp.rs`, `backend/thread_goal_http_mcp.rs`): CLI-configured MCP servers (from `config.toml`) are converted to ACP schema types and passed to the agent via `NewSessionRequest.mcp_servers` at session creation time. The `to_sacp_mcp_servers()` function in `connection/mcp.rs` bridges `codex_core::config::types::McpServerConfig` to ACP `McpServer` values inside the transport adapter: @@ -668,13 +670,13 @@ Disabled servers (`enabled == false`) are filtered out before conversion. Enviro This means `to_sacp_mcp_servers()` has side effects (reads from keyring/file system) rather than being a pure config transformation. The `acp` crate depends on `codex-rmcp-client`'s `load_oauth_tokens` for this purpose. -`SacpConnection::register_local_mcp_server()` is the connection-layer escape hatch for backend-owned MCP servers that are not user config. It creates an `acp:` URL, registers a dynamic `LocalMcpSession` handler on the live SACP connection, and appends an HTTP-shaped ACP MCP server entry to the same `mcp_servers` list used for configured servers. The connection retains the dynamic registration guard for the currently advertised backend-owned endpoint and drops the prior guard when a new local MCP server is registered, so load-session fallback and compaction-created sessions cannot leave stale `acp:` endpoints attached to the connection. +`thread_goal_http_mcp.rs` is the backend-owned MCP escape hatch for goal tools. It binds a loopback TCP listener, serves the minimal JSON-RPC-over-HTTP requests the ACP adapters send for MCP initialization, tool listing, and tool calls, and appends an HTTP ACP MCP server entry to the same `mcp_servers` list used for configured servers. The server handle is stored on `AcpBackend`, so it lives for the backend conversation and is dropped with the backend. -`connection/local_mcp.rs` handles the MCP-over-ACP lifecycle for each advertised local endpoint: ACP `_mcp/connect` opens a per-connection channel, `_mcp/message` requests and notifications are routed to the in-process server for that connection id, and disconnect notifications remove the route. The local server itself runs in process and speaks normal MCP roles through SACP channels, while the ACP agent only sees an HTTP-shaped MCP server whose URL uses the `acp:` scheme. +The goal server uses a normal `http://127.0.0.1:/mcp` URL because current Codex and Claude ACP adapters forward ACP MCP server entries into their underlying clients as ordinary HTTP MCP config. Avoiding `acp:` keeps startup compatible with those adapters while still keeping the tool implementation in process. -ACP session setup paths build the MCP server list in two phases: first convert configured MCP servers with `to_sacp_mcp_servers()`, then let backend-owned features append local MCP servers when the agent's initialize response advertises `mcp_capabilities.http`. This applies to resumed, fresh, fallback, and compaction-created sessions. Hook-only ACP sessions pass an empty list because hooks do not need user-configured or backend-owned MCP servers. +ACP session setup paths build the MCP server list in two phases: first convert configured MCP servers with `to_sacp_mcp_servers()`, then let backend-owned features append local MCP servers when their own eligibility checks pass. The goal feature requires HTTP MCP support. This setup applies to resumed, fresh, fallback, and compaction-created sessions. Hook-only ACP sessions pass an empty list because hooks do not need user-configured or backend-owned MCP servers. -The local goal MCP server is intentionally additive. User-configured MCP servers are still forwarded normally, and unsupported agents simply do not receive the `nori-goal` `acp:` endpoint. +The local goal MCP server is intentionally additive. User-configured MCP servers are still forwarded normally, and ineligible agents simply do not receive the `nori-goal` loopback endpoint. Continuation chaining depends on the local MCP server actually being initialized for the current advertised endpoint, not just on HTTP MCP support or endpoint advertisement. ### Transcript Persistence diff --git a/nori-rs/acp/src/backend/mod.rs b/nori-rs/acp/src/backend/mod.rs index 96b1d5b40..6bf3766e1 100644 --- a/nori-rs/acp/src/backend/mod.rs +++ b/nori-rs/acp/src/backend/mod.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; +use std::sync::atomic::AtomicBool; use agent_client_protocol_schema as acp; use anyhow::Result; @@ -288,6 +289,11 @@ pub struct AcpBackend { pending_compact_summary: Arc>>, /// Persistent goal for this ACP session. thread_goal_state: Arc>, + /// True after the active ACP agent has successfully opened the backend-owned + /// `nori-goal` MCP endpoint. + goal_mcp_connected: Arc, + /// Loopback HTTP server exposing the backend-owned `nori-goal` MCP tools. + goal_mcp_http_server: Arc>>, /// Transcript recorder cell used by local MCP tools created before the /// recorder's session ID is known. transcript_recorder_cell: Arc>>>, @@ -348,6 +354,7 @@ mod session_runtime_driver; mod spawn_and_relay; mod submit_and_ops; mod thread_goal; +mod thread_goal_http_mcp; mod thread_goal_mcp; mod user_input; mod user_shell; diff --git a/nori-rs/acp/src/backend/session.rs b/nori-rs/acp/src/backend/session.rs index 427b231b5..e03acca4e 100644 --- a/nori-rs/acp/src/backend/session.rs +++ b/nori-rs/acp/src/backend/session.rs @@ -49,6 +49,8 @@ impl AcpBackend { thread_goal::ThreadGoalState::from_replay_events(&initial_goal_replay_events), )); let transcript_recorder_cell = Arc::new(Mutex::new(None)); + let goal_mcp_connected = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let goal_mcp_http_server = Arc::new(Mutex::new(None)); // Either load the session server-side or create a fresh session for // client-side replay. @@ -142,7 +144,10 @@ impl AcpBackend { Arc::clone(&thread_goal_state), backend_event_tx.clone(), Arc::clone(&transcript_recorder_cell), - )?; + Arc::clone(&goal_mcp_connected), + Arc::clone(&goal_mcp_http_server), + ) + .await?; match connection.load_session(sid, &cwd, mcp_servers).await { Ok(session_id) => { @@ -188,7 +193,10 @@ impl AcpBackend { Arc::clone(&thread_goal_state), backend_event_tx.clone(), Arc::clone(&transcript_recorder_cell), - )?; + Arc::clone(&goal_mcp_connected), + Arc::clone(&goal_mcp_http_server), + ) + .await?; let session_id = connection .create_session(&cwd, mcp_servers) @@ -244,7 +252,10 @@ impl AcpBackend { Arc::clone(&thread_goal_state), backend_event_tx.clone(), Arc::clone(&transcript_recorder_cell), - )?; + Arc::clone(&goal_mcp_connected), + Arc::clone(&goal_mcp_http_server), + ) + .await?; let session_id = connection .create_session(&cwd, mcp_servers) .await @@ -346,6 +357,8 @@ impl AcpBackend { approval_policy_tx, pending_compact_summary: Arc::new(Mutex::new(pending_summary)), thread_goal_state, + goal_mcp_connected, + goal_mcp_http_server, transcript_recorder_cell, pending_hook_context: Arc::new(Mutex::new(config.session_context.clone())), transcript_recorder, diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index 0cdbb3762..760674fa2 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -496,7 +496,11 @@ impl AcpBackend { Arc::clone(&self.thread_goal_state), self.backend_event_tx.clone(), Arc::clone(&self.transcript_recorder_cell), - ) { + Arc::clone(&self.goal_mcp_connected), + Arc::clone(&self.goal_mcp_http_server), + ) + .await + { warn!("Failed to register goal MCP server after compact: {err}"); } match self.connection.create_session(&cwd, mcp_servers).await { @@ -534,7 +538,9 @@ impl AcpBackend { return; } - let can_chain_continuation = self.connection.capabilities().mcp_capabilities.http; + let can_chain_continuation = self + .goal_mcp_connected + .load(std::sync::atomic::Ordering::Relaxed); match completed_turn.prompt.kind { QueuedPromptKind::User => {} QueuedPromptKind::GoalContinuation if can_chain_continuation => {} diff --git a/nori-rs/acp/src/backend/spawn_and_relay.rs b/nori-rs/acp/src/backend/spawn_and_relay.rs index 136115ece..92b295691 100644 --- a/nori-rs/acp/src/backend/spawn_and_relay.rs +++ b/nori-rs/acp/src/backend/spawn_and_relay.rs @@ -56,6 +56,8 @@ impl AcpBackend { let thread_goal_state = Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())); let transcript_recorder_cell = Arc::new(Mutex::new(None)); + let goal_mcp_connected = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let goal_mcp_http_server = Arc::new(Mutex::new(None)); // Create a session with enhanced error handling, forwarding CLI MCP servers. let mut mcp_servers = crate::connection::mcp::to_sacp_mcp_servers( @@ -68,7 +70,10 @@ impl AcpBackend { Arc::clone(&thread_goal_state), backend_event_tx.clone(), Arc::clone(&transcript_recorder_cell), - )?; + Arc::clone(&goal_mcp_connected), + Arc::clone(&goal_mcp_http_server), + ) + .await?; let session_result = connection.create_session(&cwd, mcp_servers).await; let session_id = match session_result { Ok(id) => id, @@ -178,6 +183,8 @@ impl AcpBackend { approval_policy_tx, pending_compact_summary: Arc::new(Mutex::new(config.initial_context.clone())), thread_goal_state, + goal_mcp_connected, + goal_mcp_http_server, transcript_recorder_cell, pending_hook_context: Arc::new(Mutex::new(config.session_context.clone())), transcript_recorder, diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/acp/src/backend/tests/part5.rs index 5aa1f7bf4..b589ee928 100644 --- a/nori-rs/acp/src/backend/tests/part5.rs +++ b/nori-rs/acp/src/backend/tests/part5.rs @@ -5,6 +5,21 @@ struct EnvGuard { previous: Option, } +struct RegistryGuard; + +impl RegistryGuard { + fn with_agents(agents: Vec) -> Self { + crate::registry::initialize_registry(agents).expect("registry override should be valid"); + Self + } +} + +impl Drop for RegistryGuard { + fn drop(&mut self) { + crate::registry::initialize_registry(Vec::new()).expect("registry reset should be valid"); + } +} + impl EnvGuard { fn set(name: &'static str, value: &str) -> Self { let previous = std::env::var(name).ok(); @@ -418,12 +433,12 @@ async fn active_goal_submits_one_hidden_continuation_after_user_turn() { completed_prompts[1] ); - assert_no_prompt_completed(&mut backend_event_rx, Duration::from_millis(500)).await; + assert_no_prompt_completed(&mut backend_event_rx, Duration::from_secs(2)).await; } #[tokio::test] #[serial] -async fn http_mcp_agent_chains_hidden_goal_continuations() { +async fn http_mcp_agent_without_goal_mcp_connection_does_not_chain_hidden_continuations() { use std::time::Duration; let mock_config = @@ -440,8 +455,13 @@ async fn http_mcp_agent_chains_hidden_goal_continuations() { let _mcp_guard = EnvGuard::set("MOCK_AGENT_MCP_HTTP", "1"); let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let wire_log_dir = temp_dir.path().join("acp-wire"); let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); - let config = build_test_config(temp_dir.path()); + let mut config = build_test_config(temp_dir.path()); + config.acp_proxy = crate::config::AcpProxyConfig { + enabled: true, + log_dir: wire_log_dir.clone(), + }; let backend = AcpBackend::spawn(&config, backend_event_tx) .await @@ -472,9 +492,9 @@ async fn http_mcp_agent_chains_hidden_goal_continuations() { let mut current_agent_text = String::new(); let timeout = Duration::from_secs(10); let start = std::time::Instant::now(); - while completed_prompts.len() < 3 { + while completed_prompts.len() < 2 { if start.elapsed() > timeout { - panic!("Timed out waiting for chained goal continuations"); + panic!("Timed out waiting for goal continuation"); } match recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await { Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { @@ -488,21 +508,99 @@ async fn http_mcp_agent_chains_hidden_goal_continuations() { } } - backend - .submit(Op::Shutdown) - .await - .expect("Failed to shut down backend"); - assert!( completed_prompts[0].contains("start the chain"), "expected first prompt to be the visible user turn, got: {}", completed_prompts[0] ); assert!( - completed_prompts[1].contains("Continue working toward the active thread goal") - && completed_prompts[2].contains("Continue working toward the active thread goal"), - "expected chained hidden continuations, got: {completed_prompts:?}" + completed_prompts[1].contains("Continue working toward the active thread goal"), + "expected second prompt to be the one hidden goal continuation, got: {}", + completed_prompts[1] + ); + + tokio::time::sleep(Duration::from_secs(2)).await; + assert_eq!( + count_logged_requests(&wire_log_dir, "session/prompt"), + 2, + "expected no chained goal continuation before the goal MCP server is connected" ); + + backend + .submit(Op::Shutdown) + .await + .expect("Failed to shut down backend"); +} + +#[tokio::test] +#[serial] +async fn goal_mcp_initialize_allows_chained_hidden_continuations() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + let _echo_guard = EnvGuard::set("MOCK_AGENT_ECHO_PROMPT", "1"); + let _mcp_guard = EnvGuard::set("MOCK_AGENT_MCP_HTTP", "1"); + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let wire_log_dir = temp_dir.path().join("acp-wire"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let mut config = build_test_config(temp_dir.path()); + config.acp_proxy = crate::config::AcpProxyConfig { + enabled: true, + log_dir: wire_log_dir.clone(), + }; + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + let new_session = latest_logged_new_session(&wire_log_dir); + let goal_mcp_url = nori_goal_http_url(&new_session); + initialize_goal_mcp(&goal_mcp_url).await; + + backend + .submit(Op::ThreadGoalSet { + objective: Some("Keep going until a later tool call stops it".to_string()), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), + }) + .await + .expect("Failed to set goal"); + + backend + .submit(Op::UserInput { + items: vec![codex_protocol::user_input::UserInput::Text { + text: "start the initialized chain".to_string(), + }], + }) + .await + .expect("Failed to submit user input"); + + let start = std::time::Instant::now(); + while count_logged_requests(&wire_log_dir, "session/prompt") < 3 { + assert!( + start.elapsed() < Duration::from_secs(10), + "expected initialized goal MCP server to allow chaining beyond the first hidden continuation" + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } + + backend + .submit(Op::Shutdown) + .await + .expect("Failed to shut down backend"); } #[tokio::test] @@ -669,10 +767,10 @@ async fn goal_mcp_server_is_advertised_to_http_mcp_agents() { matches!( server, acp::McpServer::Http(http) - if http.name == "nori-goal" && http.url.starts_with("acp:") + if http.name == "nori-goal" && http.url.starts_with("http://127.0.0.1:") ) }), - "expected session/new to advertise the local nori-goal MCP server, got: {:?}", + "expected session/new to advertise the local loopback nori-goal MCP server, got: {:?}", new_session.mcp_servers ); } @@ -696,13 +794,64 @@ async fn goal_mcp_server_is_not_advertised_without_http_mcp_capability() { ); } +#[tokio::test] +#[serial] +async fn codex_agent_receives_loopback_http_goal_mcp_server() { + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + let _registry_guard = RegistryGuard::with_agents(vec![crate::config::AgentConfigToml { + name: "Codex".to_string(), + slug: "codex".to_string(), + distribution: crate::config::AgentDistributionToml { + local: Some(crate::config::LocalDistribution { + command: mock_config.command.clone(), + args: mock_config.args.clone(), + env: mock_config.env.clone(), + }), + ..Default::default() + }, + context_window_size: None, + auth_hint: None, + transcript_base_dir: None, + }]); + let Some(new_session) = logged_new_session_for_agent("codex", true).await else { + return; + }; + + assert!( + new_session.mcp_servers.iter().any(|server| { + matches!( + server, + acp::McpServer::Http(http) + if http.name == "nori-goal" && http.url.starts_with("http://127.0.0.1:") + ) + }), + "expected session/new to advertise a real loopback HTTP nori-goal server for Codex ACP, got: {:?}", + new_session.mcp_servers + ); +} + async fn logged_new_session_for_mock_agent( advertise_http_mcp: bool, +) -> Option { + logged_new_session_for_agent("mock-model", advertise_http_mcp).await +} + +async fn logged_new_session_for_agent( + agent: &str, + advertise_http_mcp: bool, ) -> Option { use std::time::Duration; - let mock_config = - crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + let mock_config = crate::registry::get_agent_config(agent).expect("agent should be registered"); if !std::path::Path::new(&mock_config.command).exists() { eprintln!( "Skipping test: mock_acp_agent not found at {}", @@ -721,6 +870,7 @@ async fn logged_new_session_for_mock_agent( let wire_log_dir = temp_dir.path().join("acp-wire"); let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); let mut config = build_test_config(temp_dir.path()); + config.agent = agent.to_string(); config.acp_proxy = crate::config::AcpProxyConfig { enabled: true, log_dir: wire_log_dir.clone(), @@ -743,12 +893,11 @@ async fn logged_new_session_for_mock_agent( .await .expect("Failed to shut down ACP backend"); - let log_path = std::fs::read_dir(&wire_log_dir) - .expect("wire log dir exists") - .map(|entry| entry.expect("wire log entry").path()) - .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) - .expect("wire log should be written"); - let log_content = std::fs::read_to_string(log_path).expect("wire log should be readable"); + Some(latest_logged_new_session(&wire_log_dir)) +} + +fn latest_logged_new_session(log_dir: &std::path::Path) -> acp::NewSessionRequest { + let log_content = read_wire_log(log_dir); let new_session = log_content .lines() .map(|line| serde_json::from_str::(line).expect("json wire log line")) @@ -757,10 +906,87 @@ async fn logged_new_session_for_mock_agent( }) .next_back() .expect("session/new should be logged"); - Some( - serde_json::from_value(new_session["message"]["params"].clone()) - .expect("session/new params should match ACP schema"), - ) + serde_json::from_value(new_session["message"]["params"].clone()) + .expect("session/new params should match ACP schema") +} + +fn read_wire_log(log_dir: &std::path::Path) -> String { + let log_path = std::fs::read_dir(log_dir) + .expect("wire log dir exists") + .map(|entry| entry.expect("wire log entry").path()) + .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) + .expect("wire log should be written"); + std::fs::read_to_string(log_path).expect("wire log should be readable") +} + +fn count_logged_requests(log_dir: &std::path::Path, method: &str) -> usize { + let log_content = read_wire_log(log_dir); + log_content + .lines() + .map(|line| serde_json::from_str::(line).expect("json wire log line")) + .filter(|record| { + record["direction"] == "client_to_agent" && record["message"]["method"] == method + }) + .count() +} + +fn nori_goal_http_url(new_session: &acp::NewSessionRequest) -> String { + new_session + .mcp_servers + .iter() + .find_map(|server| match server { + acp::McpServer::Http(http) if http.name == "nori-goal" => Some(http.url.clone()), + _ => None, + }) + .expect("session/new should advertise nori-goal HTTP MCP server") +} + +async fn initialize_goal_mcp(url: &str) { + use tokio::io::AsyncReadExt; + use tokio::io::AsyncWriteExt; + + let without_scheme = url + .strip_prefix("http://") + .expect("test URL should be plain HTTP"); + let (host_port, path) = without_scheme + .split_once('/') + .expect("test URL should include a path"); + let body = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "nori-test", "version": "0" } + } + }) + .to_string(); + let request = format!( + "POST /{path} HTTP/1.1\r\n\ +Host: {host_port}\r\n\ +Content-Type: application/json\r\n\ +Content-Length: {}\r\n\ +Connection: close\r\n\r\n\ +{body}", + body.len() + ); + let mut stream = tokio::net::TcpStream::connect(host_port) + .await + .expect("goal MCP HTTP server should accept initialize"); + stream + .write_all(request.as_bytes()) + .await + .expect("initialize request should write"); + let mut response = String::new(); + stream + .read_to_string(&mut response) + .await + .expect("initialize response should read"); + assert!( + response.contains("200 OK") && response.contains("\"serverInfo\""), + "expected successful goal MCP initialize response, got: {response}" + ); } /// Test that session_context is consumed after the first prompt (not repeated). diff --git a/nori-rs/acp/src/backend/thread_goal_http_mcp.rs b/nori-rs/acp/src/backend/thread_goal_http_mcp.rs new file mode 100644 index 000000000..a0d11b6f2 --- /dev/null +++ b/nori-rs/acp/src/backend/thread_goal_http_mcp.rs @@ -0,0 +1,213 @@ +use anyhow::Context; +use anyhow::Result; +use serde_json::Value; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::net::TcpStream; + +use super::thread_goal_mcp::ThreadGoalMcpBridge; + +pub(crate) struct GoalMcpHttpServer { + url: String, + abort_handle: tokio::task::AbortHandle, +} + +impl GoalMcpHttpServer { + pub(crate) async fn spawn(bridge: ThreadGoalMcpBridge) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .context("failed to bind local goal MCP HTTP server")?; + let url = format!("http://{}/mcp", listener.local_addr()?); + let task = tokio::spawn(async move { + loop { + let Ok((stream, _addr)) = listener.accept().await else { + break; + }; + let bridge = bridge.clone(); + tokio::spawn(async move { + if let Err(err) = handle_connection(stream, bridge).await { + tracing::debug!("local goal MCP HTTP request failed: {err}"); + } + }); + } + }); + + Ok(Self { + url, + abort_handle: task.abort_handle(), + }) + } + + pub(crate) fn url(&self) -> &str { + &self.url + } +} + +impl Drop for GoalMcpHttpServer { + fn drop(&mut self) { + self.abort_handle.abort(); + } +} + +async fn handle_connection(mut stream: TcpStream, bridge: ThreadGoalMcpBridge) -> Result<()> { + let request = read_http_request(&mut stream).await?; + let (status, body) = match request { + HttpRequest::Post { body } => match serde_json::from_slice::(&body) { + Ok(value) => handle_json_rpc(bridge, value).await, + Err(err) => ( + "400 Bad Request", + Some(json_rpc_error( + Value::Null, + -32700, + format!("parse error: {err}"), + )), + ), + }, + HttpRequest::Options => ("204 No Content", None), + HttpRequest::Other => ( + "405 Method Not Allowed", + Some(serde_json::json!({ "error": "method not allowed" })), + ), + }; + write_http_response(&mut stream, status, body.as_ref()).await +} + +enum HttpRequest { + Post { body: Vec }, + Options, + Other, +} + +async fn read_http_request(stream: &mut TcpStream) -> Result { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + let header_end = loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + anyhow::bail!("connection closed before HTTP headers"); + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(header_end) = find_header_end(&buffer) { + break header_end; + } + }; + + let headers = String::from_utf8_lossy(&buffer[..header_end]); + let request_line = headers.lines().next().unwrap_or_default(); + if request_line.starts_with("OPTIONS ") { + return Ok(HttpRequest::Options); + } + if !request_line.starts_with("POST ") { + return Ok(HttpRequest::Other); + } + + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + let body_start = header_end + 4; + while buffer.len().saturating_sub(body_start) < content_length { + let read = stream.read(&mut chunk).await?; + if read == 0 { + anyhow::bail!("connection closed before HTTP body"); + } + buffer.extend_from_slice(&chunk[..read]); + } + + Ok(HttpRequest::Post { + body: buffer[body_start..body_start + content_length].to_vec(), + }) +} + +fn find_header_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +async fn handle_json_rpc( + bridge: ThreadGoalMcpBridge, + value: Value, +) -> (&'static str, Option) { + if let Some(items) = value.as_array() { + let mut responses = Vec::new(); + for item in items { + if let Some(response) = handle_json_rpc_message(&bridge, item.clone()).await { + responses.push(response); + } + } + return if responses.is_empty() { + ("202 Accepted", None) + } else { + ("200 OK", Some(Value::Array(responses))) + }; + } + + match handle_json_rpc_message(&bridge, value).await { + Some(response) => ("200 OK", Some(response)), + None => ("202 Accepted", None), + } +} + +async fn handle_json_rpc_message(bridge: &ThreadGoalMcpBridge, message: Value) -> Option { + let id = message.get("id").cloned(); + let method = message + .get("method") + .and_then(Value::as_str) + .unwrap_or_default(); + let params = message + .get("params") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + + let id = id?; + let result = bridge.handle_mcp_request(method, params).await; + Some(serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "result": result, + })) +} + +fn json_rpc_error(id: Value, code: i64, message: String) -> Value { + serde_json::json!({ + "jsonrpc": "2.0", + "id": id, + "error": { + "code": code, + "message": message, + }, + }) +} + +async fn write_http_response( + stream: &mut TcpStream, + status: &str, + body: Option<&Value>, +) -> Result<()> { + let body = body.map(serde_json::to_vec).transpose()?; + let body_len = body.as_ref().map_or(0, Vec::len); + let content_type = if body.is_some() { + "application/json" + } else { + "text/plain" + }; + let headers = format!( + "HTTP/1.1 {status}\r\n\ +Access-Control-Allow-Origin: *\r\n\ +Access-Control-Allow-Headers: content-type, mcp-session-id, mcp-protocol-version\r\n\ +Access-Control-Allow-Methods: POST, OPTIONS\r\n\ +Content-Type: {content_type}\r\n\ +Content-Length: {body_len}\r\n\ +Connection: close\r\n\r\n" + ); + stream.write_all(headers.as_bytes()).await?; + if let Some(body) = body { + stream.write_all(&body).await?; + } + Ok(()) +} diff --git a/nori-rs/acp/src/backend/thread_goal_mcp.rs b/nori-rs/acp/src/backend/thread_goal_mcp.rs index 5b604f80d..8bdd1e460 100644 --- a/nori-rs/acp/src/backend/thread_goal_mcp.rs +++ b/nori-rs/acp/src/backend/thread_goal_mcp.rs @@ -1,17 +1,12 @@ use serde_json::Value; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; use tokio::sync::Mutex; use super::*; -use crate::connection::local_mcp::LocalMcpServer; +use crate::backend::thread_goal_http_mcp::GoalMcpHttpServer; use codex_protocol::protocol::ThreadGoalStatus; use nori_protocol::ThreadGoalUpdated; -use sacp::Agent; -use sacp::ConnectTo; -use sacp::ConnectionTo; -use sacp::Dispatch; -use sacp::DynConnectTo; -use sacp::UntypedMessage; -use sacp::role; const GET_GOAL_TOOL_NAME: &str = "get_goal"; const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; @@ -25,6 +20,7 @@ pub(crate) struct ThreadGoalMcpBridge { thread_goal_state: Arc>, backend_event_tx: mpsc::Sender, transcript_recorder: Arc>>>, + connected: Arc, } impl ThreadGoalMcpBridge { @@ -32,21 +28,26 @@ impl ThreadGoalMcpBridge { thread_goal_state: Arc>, backend_event_tx: mpsc::Sender, transcript_recorder: Arc>>>, + connected: Arc, ) -> Self { Self { thread_goal_state, backend_event_tx, transcript_recorder, + connected, } } pub(crate) async fn handle_mcp_request(&self, method: &str, params: Value) -> Value { match method { - "initialize" => serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": { "tools": {} }, - "serverInfo": { "name": "nori-goal", "version": env!("CARGO_PKG_VERSION") } - }), + "initialize" => { + self.connected.store(true, Ordering::Relaxed); + serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "nori-goal", "version": env!("CARGO_PKG_VERSION") } + }) + } "tools/list" => serde_json::json!({ "tools": tools() }), "tools/call" => self.handle_tool_call(params).await, _ => tool_error(format!("unsupported goal MCP request: {method}")), @@ -146,67 +147,46 @@ impl ThreadGoalMcpBridge { } } -impl LocalMcpServer for ThreadGoalMcpBridge { - fn name(&self) -> String { - "nori-goal".to_string() - } - - fn connect( - &self, - _acp_url: String, - _connection: ConnectionTo, - ) -> DynConnectTo { - DynConnectTo::new(ThreadGoalMcpComponent { - bridge: self.clone(), - }) - } -} - -struct ThreadGoalMcpComponent { - bridge: ThreadGoalMcpBridge, -} - -impl ConnectTo for ThreadGoalMcpComponent { - async fn connect_to( - self, - client: impl ConnectTo, - ) -> Result<(), sacp::Error> { - let bridge = self.bridge; - role::mcp::Server - .builder() - .on_receive_dispatch( - async move |message: Dispatch, _connection| { - match message { - Dispatch::Request(request, responder) => { - let UntypedMessage { method, params } = request; - responder.respond(bridge.handle_mcp_request(&method, params).await)?; - } - Dispatch::Notification(_) | Dispatch::Response(_, _) => {} - } - Ok(()) - }, - sacp::on_receive_dispatch!(), - ) - .connect_to(client) - .await - } -} - -pub(super) fn register_for_session( +pub(super) async fn register_for_session( connection: &SacpConnection, mcp_servers: &mut Vec, thread_goal_state: Arc>, backend_event_tx: mpsc::Sender, transcript_recorder: Arc>>>, + connected: Arc, + http_server: Arc>>, ) -> Result<()> { - if !connection.capabilities().mcp_capabilities.http { + connected.store(false, Ordering::Relaxed); + + if !supports_local_goal_mcp(connection) { return Ok(()); } - connection.register_local_mcp_server( - mcp_servers, - ThreadGoalMcpBridge::new(thread_goal_state, backend_event_tx, transcript_recorder), - ) + let mut server = http_server.lock().await; + if server.is_none() { + *server = Some( + GoalMcpHttpServer::spawn(ThreadGoalMcpBridge::new( + thread_goal_state, + backend_event_tx, + transcript_recorder, + Arc::clone(&connected), + )) + .await?, + ); + } + let Some(server) = server.as_ref() else { + return Ok(()); + }; + mcp_servers.push(acp::McpServer::Http(acp::McpServerHttp::new( + "nori-goal", + server.url().to_string(), + ))); + + Ok(()) +} + +fn supports_local_goal_mcp(connection: &SacpConnection) -> bool { + connection.capabilities().mcp_capabilities.http } fn tools() -> Vec { @@ -314,6 +294,7 @@ mod tests { Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), backend_event_tx, Arc::new(Mutex::new(None)), + Arc::new(AtomicBool::new(false)), ) } @@ -376,6 +357,7 @@ mod tests { Arc::new(Mutex::new(thread_goal::ThreadGoalState::default())), backend_event_tx, Arc::new(Mutex::new(None)), + Arc::new(AtomicBool::new(false)), ); let create_response = bridge diff --git a/nori-rs/acp/src/connection/local_mcp.rs b/nori-rs/acp/src/connection/local_mcp.rs deleted file mode 100644 index faceca90b..000000000 --- a/nori-rs/acp/src/connection/local_mcp.rs +++ /dev/null @@ -1,359 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use futures::SinkExt; -use futures::StreamExt; -use futures::channel::mpsc; -use sacp::Agent; -use sacp::Channel; -use sacp::ConnectTo; -use sacp::ConnectionTo; -use sacp::Dispatch; -use sacp::DynConnectTo; -use sacp::HandleDispatchFrom; -use sacp::Handled; -use sacp::Responder; -use sacp::Role; -use sacp::UntypedMessage; -use sacp::role; -use sacp::role::HasPeer; -use sacp::schema::McpConnectRequest; -use sacp::schema::McpConnectResponse; -use sacp::schema::McpDisconnectNotification; -use sacp::schema::McpOverAcpMessage; -use sacp::util::MatchDispatchFrom; - -pub(crate) trait LocalMcpServer: Send + Sync + 'static { - fn name(&self) -> String; - - fn connect( - &self, - acp_url: String, - connection: ConnectionTo, - ) -> DynConnectTo; -} - -pub(super) struct LocalMcpSession { - acp_url: String, - mcp_connect: Arc>, - connections: HashMap>, -} - -impl LocalMcpSession -where - Counterpart: HasPeer, -{ - pub(super) fn new(acp_url: String, mcp_connect: Arc>) -> Self { - Self { - acp_url, - mcp_connect, - connections: HashMap::new(), - } - } - - async fn handle_connect_request( - &mut self, - request: McpConnectRequest, - responder: Responder, - acp_connection: &ConnectionTo, - ) -> Result)>, sacp::Error> { - if request.acp_url != self.acp_url { - return Ok(Handled::No { - message: (request, responder), - retry: false, - }); - } - - let connection_id = format!("mcp-over-acp-connection:{}", uuid::Uuid::new_v4()); - let (mcp_server_tx, mut mcp_server_rx) = mpsc::channel(128); - self.connections - .insert(connection_id.clone(), mcp_server_tx); - - let (client_channel, server_channel) = Channel::duplex(); - let client_component = { - let connection_id = connection_id.clone(); - let acp_connection = acp_connection.clone(); - - role::mcp::Client - .builder() - .on_receive_dispatch( - async move |message: Dispatch, _mcp_connection| { - let wrapped = message.map( - |request, responder| { - ( - McpOverAcpMessage { - connection_id: connection_id.clone(), - message: request, - meta: None, - }, - responder, - ) - }, - |notification| McpOverAcpMessage { - connection_id: connection_id.clone(), - message: notification, - meta: None, - }, - ); - acp_connection.send_proxied_message_to(Agent, wrapped) - }, - sacp::on_receive_dispatch!(), - ) - .with_spawned(move |mcp_connection| async move { - while let Some(msg) = mcp_server_rx.next().await { - mcp_connection.send_proxied_message_to(role::mcp::Server, msg)?; - } - Ok(()) - }) - }; - - let spawned_server = self - .mcp_connect - .connect(request.acp_url, acp_connection.clone()); - - let spawn_results = acp_connection - .spawn(async move { client_component.connect_to(client_channel).await }) - .and_then(|()| { - acp_connection.spawn(async move { spawned_server.connect_to(server_channel).await }) - }); - - match spawn_results { - Ok(()) => { - responder.respond(McpConnectResponse { - connection_id, - meta: None, - })?; - Ok(Handled::Yes) - } - Err(err) => { - responder.respond_with_error(err)?; - Ok(Handled::Yes) - } - } - } - - async fn handle_mcp_over_acp_request( - &mut self, - request: McpOverAcpMessage, - responder: Responder, - ) -> Result< - Handled<( - McpOverAcpMessage, - Responder, - )>, - sacp::Error, - > { - let Some(mcp_server_tx) = self.connections.get_mut(&request.connection_id) else { - return Ok(Handled::No { - message: (request, responder), - retry: false, - }); - }; - - mcp_server_tx - .send(Dispatch::Request(request.message, responder)) - .await - .map_err(sacp::Error::into_internal_error)?; - - Ok(Handled::Yes) - } - - async fn handle_mcp_over_acp_notification( - &mut self, - notification: McpOverAcpMessage, - ) -> Result>, sacp::Error> { - let Some(mcp_server_tx) = self.connections.get_mut(¬ification.connection_id) else { - return Ok(Handled::No { - message: notification, - retry: false, - }); - }; - - mcp_server_tx - .send(Dispatch::Notification(notification.message)) - .await - .map_err(sacp::Error::into_internal_error)?; - - Ok(Handled::Yes) - } - - async fn handle_mcp_disconnect_notification( - &mut self, - notification: McpDisconnectNotification, - ) -> Result, sacp::Error> { - if self - .connections - .remove(¬ification.connection_id) - .is_some() - { - Ok(Handled::Yes) - } else { - Ok(Handled::No { - message: notification, - retry: false, - }) - } - } -} - -impl HandleDispatchFrom for LocalMcpSession -where - Counterpart: HasPeer, -{ - async fn handle_dispatch_from( - &mut self, - message: Dispatch, - connection: ConnectionTo, - ) -> Result, sacp::Error> { - MatchDispatchFrom::new(message, &connection) - .if_request_from(Agent, async |request, responder| { - self.handle_connect_request(request, responder, &connection) - .await - }) - .await - .if_request_from(Agent, async |request, responder| { - self.handle_mcp_over_acp_request(request, responder).await - }) - .await - .if_notification_from(Agent, async |notification| { - self.handle_mcp_over_acp_notification(notification).await - }) - .await - .if_notification_from(Agent, async |notification| { - self.handle_mcp_disconnect_notification(notification).await - }) - .await - .done() - } - - fn describe_chain(&self) -> impl std::fmt::Debug { - format!("LocalMcpSession({})", self.mcp_connect.name()) - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - use sacp::Client; - use sacp::JsonRpcResponse; - use sacp::SentRequest; - use serde_json::json; - use tokio::sync::oneshot; - - use super::*; - - async fn recv(response: SentRequest) -> Result { - let (tx, rx) = oneshot::channel(); - response.on_receiving_result(async move |result| { - tx.send(result).map_err(|_| sacp::Error::internal_error()) - })?; - rx.await.map_err(|_| sacp::Error::internal_error())? - } - - struct EchoMcpServer; - - impl LocalMcpServer for EchoMcpServer { - fn name(&self) -> String { - "echo".to_string() - } - - fn connect( - &self, - _acp_url: String, - _connection: ConnectionTo, - ) -> DynConnectTo { - DynConnectTo::new(EchoMcpComponent) - } - } - - struct EchoMcpComponent; - - impl ConnectTo for EchoMcpComponent { - async fn connect_to( - self, - client: impl ConnectTo, - ) -> Result<(), sacp::Error> { - role::mcp::Server - .builder() - .on_receive_dispatch( - async move |message: Dispatch, _connection| { - if let Dispatch::Request(request, responder) = message { - responder.respond(json!({ - "method": request.method, - "params": request.params, - }))?; - } - Ok(()) - }, - sacp::on_receive_dispatch!(), - ) - .connect_to(client) - .await - } - } - - #[tokio::test] - async fn local_mcp_session_routes_connect_and_message_requests() { - let acp_url = "acp:test-local-mcp".to_string(); - let (client_transport, agent_transport) = Channel::duplex(); - let (ready_tx, ready_rx) = oneshot::channel(); - let client_acp_url = acp_url.clone(); - - let client_task = tokio::spawn(async move { - Client - .builder() - .connect_with(client_transport, async move |connection| { - let registration = connection.add_dynamic_handler(LocalMcpSession::new( - client_acp_url, - Arc::new(EchoMcpServer), - ))?; - ready_tx - .send(()) - .map_err(|_| sacp::Error::internal_error())?; - let _registration = registration; - futures::future::pending::>().await - }) - .await - }); - - let agent_result = Agent - .builder() - .connect_with(agent_transport, async move |connection| { - ready_rx.await.map_err(|_| sacp::Error::internal_error())?; - let connect_response = recv(connection.send_request_to( - Client, - McpConnectRequest { - acp_url, - meta: None, - }, - )) - .await?; - let response = recv(connection.send_request_to( - Client, - McpOverAcpMessage { - connection_id: connect_response.connection_id.clone(), - message: UntypedMessage::new("tools/list", json!({ "cursor": null }))?, - meta: None, - }, - )) - .await?; - - assert_eq!(response["method"], "tools/list"); - assert_eq!(response["params"], json!({ "cursor": null })); - - connection.send_notification_to( - Client, - McpDisconnectNotification { - connection_id: connect_response.connection_id, - meta: None, - }, - )?; - - Ok(()) - }) - .await; - - client_task.abort(); - assert!(agent_result.is_ok(), "agent side failed: {agent_result:?}"); - } -} diff --git a/nori-rs/acp/src/connection/mod.rs b/nori-rs/acp/src/connection/mod.rs index e3ac8ae18..060af3292 100644 --- a/nori-rs/acp/src/connection/mod.rs +++ b/nori-rs/acp/src/connection/mod.rs @@ -9,7 +9,6 @@ use codex_protocol::protocol::ReviewDecision; use sacp::schema as acp; use tokio::sync::oneshot; -pub(crate) mod local_mcp; pub mod mcp; pub mod sacp_connection; mod wire_log; diff --git a/nori-rs/acp/src/connection/sacp_connection.rs b/nori-rs/acp/src/connection/sacp_connection.rs index 8f79e8c19..6b8150241 100644 --- a/nori-rs/acp/src/connection/sacp_connection.rs +++ b/nori-rs/acp/src/connection/sacp_connection.rs @@ -35,8 +35,6 @@ use super::AcpSessionConfigState; use super::ApprovalEventType; use super::ApprovalRequest; use super::ConnectionEvent; -use super::local_mcp::LocalMcpServer; -use super::local_mcp::LocalMcpSession; use super::wire_log::WireDirection; use super::wire_log::WireLogger; use crate::config::AcpProxyConfig; @@ -86,10 +84,6 @@ pub struct SacpConnection { /// Thread-safe session config state, updated from complete ACP snapshots. session_config_state: std::sync::Arc>, - /// Dynamic handler registrations for backend-owned MCP servers advertised - /// to the current ACP session. - local_mcp_registrations: std::sync::Arc>>>, - /// Handle to the background task driving the SACP connection. connection_task: tokio::task::JoinHandle<()>, @@ -549,7 +543,6 @@ impl SacpConnection { prompt_state, model_state: std::sync::Arc::new(std::sync::RwLock::new(AcpModelState::new())), session_config_state, - local_mcp_registrations: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), connection_task, child, stderr_task, @@ -593,30 +586,6 @@ impl SacpConnection { Ok(response.session_id) } - /// Register an in-process MCP server for a single ACP connection and append - /// its `acp:` endpoint to the session MCP server list. - pub(crate) fn register_local_mcp_server( - &self, - mcp_servers: &mut Vec, - mcp_connect: impl LocalMcpServer + 'static, - ) -> Result<()> { - let acp_url = format!("acp:{}", uuid::Uuid::new_v4()); - let name = mcp_connect.name(); - let handler = LocalMcpSession::new(acp_url.clone(), std::sync::Arc::new(mcp_connect)); - let registration = self - .cx - .add_dynamic_handler(handler) - .context("Failed to register local MCP-over-ACP server")?; - let mut registrations = self - .local_mcp_registrations - .lock() - .map_err(|_| anyhow::anyhow!("local MCP registration lock poisoned"))?; - registrations.clear(); - registrations.push(Box::new(registration)); - mcp_servers.push(acp::McpServer::Http(acp::McpServerHttp::new(name, acp_url))); - Ok(()) - } - /// Load (resume) an existing session. /// /// The agent replays previous session history. Updates flow through the diff --git a/nori-rs/acp/src/connection/sacp_connection_tests.rs b/nori-rs/acp/src/connection/sacp_connection_tests.rs index 264463871..22a347435 100644 --- a/nori-rs/acp/src/connection/sacp_connection_tests.rs +++ b/nori-rs/acp/src/connection/sacp_connection_tests.rs @@ -5,10 +5,6 @@ use super::ConnectionEvent; use super::sacp_connection::SacpConnection; use agent_client_protocol_schema as acp; use pretty_assertions::assert_eq; -use sacp::ConnectTo; -use sacp::ConnectionTo; -use sacp::DynConnectTo; -use sacp::role; use serde_json::Value; use serial_test::serial; use tempfile::tempdir; @@ -62,33 +58,6 @@ async fn drive_logged_prompt( conn.shutdown().await; } -struct NoopMcpServer; - -impl super::local_mcp::LocalMcpServer for NoopMcpServer { - fn name(&self) -> String { - "nori-goal".to_string() - } - - fn connect( - &self, - _acp_url: String, - _connection: ConnectionTo, - ) -> DynConnectTo { - DynConnectTo::new(NoopMcpComponent) - } -} - -struct NoopMcpComponent; - -impl ConnectTo for NoopMcpComponent { - async fn connect_to( - self, - _client: impl ConnectTo, - ) -> Result<(), sacp::Error> { - futures::future::pending().await - } -} - fn read_wire_log(path: &std::path::Path) -> Vec { let content = std::fs::read_to_string(path).expect("read wire log"); content @@ -97,54 +66,6 @@ fn read_wire_log(path: &std::path::Path) -> Vec { .collect() } -#[tokio::test] -#[serial] -async fn test_register_local_mcp_server_advertises_acp_url_in_new_session() { - let Some(config) = mock_agent_config() else { - return; - }; - let temp_dir = tempdir().expect("temp dir"); - let proxy = crate::config::AcpProxyConfig { - enabled: true, - log_dir: temp_dir.path().join("acp-wire"), - }; - - let conn = SacpConnection::spawn(&config, temp_dir.path(), proxy.clone()) - .await - .expect("spawn connection"); - let mut mcp_servers = Vec::new(); - conn.register_local_mcp_server(&mut mcp_servers, NoopMcpServer) - .expect("register local mcp server"); - - conn.create_session(temp_dir.path(), mcp_servers) - .await - .expect("create session"); - conn.shutdown().await; - - let log_path = std::fs::read_dir(&proxy.log_dir) - .expect("wire log dir exists") - .map(|entry| entry.expect("wire log entry").path()) - .find(|path| path.extension().is_some_and(|ext| ext == "jsonl")) - .expect("wire log should be written"); - let records = read_wire_log(&log_path); - let new_session = records - .iter() - .find(|record| { - record["direction"] == "client_to_agent" && record["message"]["method"] == "session/new" - }) - .expect("session/new should be logged"); - let advertised_server = &new_session["message"]["params"]["mcpServers"][0]; - - assert_eq!(advertised_server["name"], "nori-goal"); - assert_eq!(advertised_server["type"], "http"); - assert!( - advertised_server["url"] - .as_str() - .expect("url should be string") - .starts_with("acp:") - ); -} - /// Test that SacpConnection can spawn a mock agent, perform the initialization /// handshake, and return a working connection. After spawn, the connection /// should be able to create a session (proving the transport is alive). diff --git a/nori-rs/mock-acp-agent/docs.md b/nori-rs/mock-acp-agent/docs.md index a8606d973..021d5edbb 100644 --- a/nori-rs/mock-acp-agent/docs.md +++ b/nori-rs/mock-acp-agent/docs.md @@ -23,7 +23,7 @@ Used by `@/nori-rs/tui-pty-e2e/` for end-to-end integration testing. The mock ag **Session Lifecycle Testing**: Several env vars control `session/load` behavior for testing the resume path in `@/nori-rs/acp/src/backend/session.rs`: - `MOCK_AGENT_SUPPORT_LOAD_SESSION` -- when set, the agent advertises `load_session: true` in its capabilities during `initialize()` -- `MOCK_AGENT_MCP_HTTP` -- when set, the agent advertises HTTP MCP capability so `@/nori-rs/acp/src/backend/thread_goal_mcp.rs` and `@/nori-rs/acp/src/connection/local_mcp.rs` can be tested through the normal `session/new` MCP server advertisement path +- `MOCK_AGENT_MCP_HTTP` -- when set, the agent advertises HTTP MCP capability so `@/nori-rs/acp/src/backend/thread_goal_mcp.rs` and `@/nori-rs/acp/src/backend/thread_goal_http_mcp.rs` can be tested through the normal `session/new` MCP server advertisement path - `MOCK_AGENT_LOAD_SESSION_FAIL` -- when set, the `load_session()` handler returns an error instead of succeeding, allowing tests to exercise the runtime-failure fallback path - `MOCK_AGENT_LOAD_SESSION_NOTIFICATION_COUNT` -- when set to an integer N, the `load_session()` handler sends N text-chunk notifications (via `send_text_chunk()`) before returning, simulating history replay with a configurable volume of events. Used to test the deferred-relay pattern in `resume_session()` that prevents deadlocks when the notification count exceeds the bounded `event_tx` channel capacity. From 09ee1b9ff40a679aa6d3ce1364aecc4eb4dc28e5 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 18:02:30 -0400 Subject: [PATCH 25/29] test(tui): stabilize vertical footer snapshot --- nori-rs/tui-pty-e2e/src/lib.rs | 32 +++++++++++++++++-- nori-rs/tui-pty-e2e/tests/nori_footer.rs | 11 +++++-- .../nori_footer__vertical_footer.snap | 2 -- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/nori-rs/tui-pty-e2e/src/lib.rs b/nori-rs/tui-pty-e2e/src/lib.rs index 8e8ad53aa..db578b79c 100644 --- a/nori-rs/tui-pty-e2e/src/lib.rs +++ b/nori-rs/tui-pty-e2e/src/lib.rs @@ -1098,8 +1098,12 @@ pub fn normalize_for_input_snapshot(contents: String) -> String { // Don't break yet - install line may follow } } - // Skip empty lines after the header block - while skip_until < lines.len() && lines[skip_until].trim().is_empty() { + // Skip empty lines and wrapped tail text after the header block. In + // narrow PTYs the install hint can wrap after "Nori AI", leaving a + // standalone "enhancements" line after the init command line. + while skip_until < lines.len() + && (lines[skip_until].trim().is_empty() || lines[skip_until].trim() == "enhancements") + { skip_until += 1; } if skip_until > 0 { @@ -1287,6 +1291,30 @@ mod tests { } } + #[test] + fn test_normalize_wrapped_skillset_init_header() { + let input = "\ +╭────────────────────────────╮ +│ Nori CLI v0.0.0 │ +╰────────────────────────────╯ + + Run 'npx nori-skillsets init' to set up Nori AI +enhancements + +› @ for file mentions + + ⎇ master + Approvals: Read Only +"; + let expected = "\ +› [DEFAULT_PROMPT] + + ⎇ master + Approvals: Read Only +"; + assert_eq!(normalize_for_input_snapshot(input.to_string()), expected); + } + #[test] fn test_strip_git_stats_from_footer() { // Stats in middle of footer (between segments) diff --git a/nori-rs/tui-pty-e2e/tests/nori_footer.rs b/nori-rs/tui-pty-e2e/tests/nori_footer.rs index 3552db0b0..7614f81b4 100644 --- a/nori-rs/tui-pty-e2e/tests/nori_footer.rs +++ b/nori-rs/tui-pty-e2e/tests/nori_footer.rs @@ -176,9 +176,14 @@ name = "Mock ACP provider for tests" vertical_footer = true "#; - let mut session = - TuiSession::spawn_with_config(24, 60, SessionConfig::new().with_config_toml(config_toml)) - .expect("Failed to spawn"); + let mut session = TuiSession::spawn_with_config( + 24, + 60, + SessionConfig::new() + .with_config_toml(config_toml) + .with_excluded_binary("nori-skillsets"), + ) + .expect("Failed to spawn"); session.wait_for_text("›", TIMEOUT).unwrap(); session.wait_for_text("Approvals", TIMEOUT).unwrap(); diff --git a/nori-rs/tui-pty-e2e/tests/snapshots/nori_footer__vertical_footer.snap b/nori-rs/tui-pty-e2e/tests/snapshots/nori_footer__vertical_footer.snap index 4d552eef2..5caef5676 100644 --- a/nori-rs/tui-pty-e2e/tests/snapshots/nori_footer__vertical_footer.snap +++ b/nori-rs/tui-pty-e2e/tests/snapshots/nori_footer__vertical_footer.snap @@ -2,8 +2,6 @@ source: tui-pty-e2e/tests/nori_footer.rs expression: normalize_for_input_snapshot(contents) --- -enhancements - › [DEFAULT_PROMPT] ⎇ master From 2028b2dd0487f3dee8cf87107ee10c2569e25db6 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 20:41:40 -0400 Subject: [PATCH 26/29] docs: visualize and document goal architecture --- goal-command-architecture-diagrams.md | 276 ++++++++++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 goal-command-architecture-diagrams.md diff --git a/goal-command-architecture-diagrams.md b/goal-command-architecture-diagrams.md new file mode 100644 index 000000000..d1c1b77ba --- /dev/null +++ b/goal-command-architecture-diagrams.md @@ -0,0 +1,276 @@ +# Goal Command Architecture Diagrams + +This note compares two implementations of the same user-facing idea: a long-lived +thread goal that keeps the agent aligned across turns until the model marks the +goal complete or blocked. + +- In the raw Codex harness, goals are native session/runtime state. +- In Nori CLI over ACP, goals are owned by the Nori ACP backend and projected + into an external ACP agent through prompt context plus a local `nori-goal` + MCP server. + +## Raw Codex Harness + +Codex keeps the goal loop inside the core harness. The app-server persists the +goal, the running `Session` observes goal lifecycle events, and the model marks +completion through the built-in `update_goal` tool. + +### Mermaid Sequence Diagram + +```mermaid +sequenceDiagram + autonumber + actor User + participant App as Codex app-server + participant DB as State DB + participant Session as Codex Session + participant Runtime as GoalRuntimeState + participant History as ContextManager + participant Model as Model turn + participant Tool as update_goal handler + + User->>App: thread/goal/set(objective) + App->>DB: create or update ThreadGoal(status=Active) + App-->>User: thread/goal/updated + App->>Session: ExternalSet(goal) + Session->>Runtime: GoalRuntimeEvent::ExternalSet + + User->>Session: user turn + Session->>Runtime: GoalRuntimeEvent::TurnStarted + Session->>History: record prompt and context + History-->>Model: prompt input with thread history + Model-->>Session: assistant output and tool activity + Session->>Runtime: GoalRuntimeEvent::TurnFinished + Session->>Runtime: GoalRuntimeEvent::MaybeContinueIfIdle + + alt goal is active and session is idle + Runtime->>DB: re-read current ThreadGoal + Runtime->>History: enqueue hidden GoalContext continuation + Runtime->>Session: start RegularTask with empty visible input + History-->>Model: history plus hidden continuation prompt + Model-->>Session: continues work toward objective + else user work, mailbox work, inactive goal, or plan mode + Runtime-->>Session: no automatic continuation + end + + alt model proves goal complete or blocked + Model->>Tool: update_goal(status="complete" or "blocked") + Tool->>Runtime: GoalRuntimeEvent::ToolCompletedGoal + Tool->>DB: set ThreadGoal.status + Tool-->>Model: updated goal and usage report + Runtime-->>Session: status is no longer Active, so continuation stops + end +``` + +### ASCII Overview + +```text +User/App + | + | thread/goal/set + v +Codex app-server + | + | validate + write ThreadGoal(status=Active) + v +State DB -------------------------------+ + | | + | ExternalSet | + v | +Codex Session + GoalRuntimeState | + | | + | normal user turn | + v | +Model sees session history | + | | + | turn finishes | + v | +MaybeContinueIfIdle | + | | + | re-read DB; if active goal + idle | + v | +Hidden GoalContext continuation ---------+ + | + | starts another RegularTask in same thread + v +Model keeps working + | + | when evidence proves done or blocked + v +update_goal(status="complete" | "blocked") + | + v +ThreadGoal.status changes; continuation loop stops +``` + +### Codex Source Notes + +- `thread/goal/set` writes the goal through the app-server and applies runtime + effects to a running thread: + `../other-repos/codex/codex-rs/app-server/src/request_processors/thread_goal_processor.rs`. +- Goal runtime events and continuation scheduling live in + `../other-repos/codex/codex-rs/core/src/goals.rs`. +- Hidden continuation text is wrapped as `GoalContext` with `` + markers in `../other-repos/codex/codex-rs/core/src/context/goal_context.rs`. +- Turns call `MaybeContinueIfIdle` after the active turn is cleared in + `../other-repos/codex/codex-rs/core/src/tasks/mod.rs`. +- The completion/blocking state transition is the built-in `update_goal` tool in + `../other-repos/codex/codex-rs/core/src/tools/handlers/goal/update_goal.rs`. + +## Nori CLI Over ACP + +Nori keeps the user-facing goal state in the ACP backend. During ACP session +setup, it advertises a local `nori-goal` MCP server when the agent connection +reports HTTP MCP support. Per turn, it sends goal context to the external ACP +agent as prompt text, and the external agent marks completion/blocking through +that local MCP server. + +### Mermaid Sequence Diagram + +```mermaid +sequenceDiagram + autonumber + actor User + participant TUI as Nori TUI + participant Backend as Nori ACP backend + participant GoalState as ThreadGoalState + participant Runtime as SessionRuntimeDriver + participant ACP as ACP connection + participant Agent as External ACP agent + participant MCP as local nori-goal MCP server + + Backend->>MCP: ensure local nori-goal server exists + Backend->>ACP: session/new or session/load with mcpServers[nori-goal] + ACP-->>Agent: advertise nori-goal HTTP MCP server + Agent->>MCP: connect and initialize goal tools + + User->>TUI: /goal or /goal resume + TUI->>Backend: goal command request + alt /goal + Backend->>GoalState: set objective and status=Active + else /goal resume + Backend->>GoalState: set status=Active + end + GoalState-->>Backend: ThreadGoalSnapshot + Backend-->>TUI: ThreadGoalUpdated + + User->>TUI: visible prompt + TUI->>Backend: submit prompt + Backend->>GoalState: render + Backend->>Runtime: enqueue user prompt with prepended goal context + Runtime->>ACP: send prompt + ACP->>Agent: user message plus goal context + Agent-->>ACP: response stream + ACP-->>Runtime: EndTurn + + Runtime->>GoalState: ask for continuation_prompt() + alt goal is active, runtime is idle, queue is empty, and chaining is allowed + GoalState-->>Runtime: hidden goal continuation text + Runtime->>Runtime: enqueue GoalContinuation prompt + Runtime->>ACP: send hidden continuation + ACP->>Agent: hidden continuation prompt + Agent-->>ACP: continues work + else inactive goal, pending work, or unconnected goal MCP after a hidden turn + Runtime-->>Backend: no hidden continuation + end + + alt agent proves goal complete or blocked + Agent->>MCP: update_goal(status="complete" or "blocked") + MCP->>GoalState: set_status(...) + GoalState-->>MCP: updated ThreadGoalSnapshot + MCP-->>Backend: emit ThreadGoalUpdated event + Backend-->>TUI: updated goal status + Runtime-->>Runtime: future continuation_prompt() returns none + end +``` + +### ASCII Overview + +```text +ACP session setup + | + | if connection supports HTTP MCP + v +Advertise local nori-goal HTTP MCP server + | + | external agent connects and initializes tools + v +External ACP agent has goal tools + +-- Goal command -- + +User / Nori TUI + | + | /goal or /goal resume + v +Nori ACP backend + | + | owns ThreadGoalState + | emits ThreadGoalUpdated + +-- Per-turn steering loop -- + +Visible user prompt + | + | Nori prepends + v +ACP prompt to agent + | + | agent responds, ACP reports EndTurn + v +SessionRuntimeDriver + | + | if goal active + idle + queue empty + | user turns may start a continuation; + | hidden turns only chain after goal MCP connects + v +Hidden GoalContinuation prompt + | + | sent over same ACP session + v +External ACP agent keeps working + | + | when evidence proves done or blocked + v +nori-goal.update_goal(status="complete" | "blocked") + | + v +ThreadGoalState status changes; continuation loop stops +``` + +### Nori Source Notes + +- `ThreadGoalState` renders visible `` and hidden continuation + text in `acp/src/backend/thread_goal.rs`. +- User prompts are augmented with goal context before submission in + `acp/src/backend/user_input.rs`. +- The ACP runtime schedules hidden continuations after `EndTurn` in + `acp/src/backend/session_runtime_driver.rs`. +- Nori registers the local goal MCP server during ACP session setup/load and + advertises it only when the connection reports HTTP MCP support: + `acp/src/backend/spawn_and_relay.rs`, `acp/src/backend/session.rs`, and + `acp/src/backend/thread_goal_mcp.rs`. +- The ACP connection forwards `mcpServers` to the external agent when creating a + session in `acp/src/connection/sacp_connection.rs`. +- The local `nori-goal` MCP server exposes `get_goal`, `create_goal`, and + `update_goal` in `acp/src/backend/thread_goal_mcp.rs`. + +## Comparison + +| Concern | Raw Codex harness | Nori CLI over ACP | +| --- | --- | --- | +| Goal state owner | Codex state DB plus core `Session` runtime | Nori ACP backend `ThreadGoalState` | +| Model-facing goal context | Hidden `GoalContext` response item | Prepended prompt text and hidden continuation prompt | +| Continuation scheduler | `GoalRuntimeState::MaybeContinueIfIdle` | `SessionRuntimeDriver::maybe_submit_goal_continuation` | +| Completion evaluator | The model self-audits against current evidence | The external ACP agent self-audits against current evidence | +| Completion actuator | Built-in Codex `update_goal` tool | Local `nori-goal` MCP `update_goal` tool | +| Context window | Same Codex thread/session history, compacted as needed | External ACP agent's session context, steered by Nori prompts | +| Subagents | Separate Codex threads only when explicitly spawned | Determined by the external ACP agent, not by Nori goal state | + +## Mental Model + +Both implementations are intentionally simple at the decision point: the model +decides whether the objective is complete or blocked, and a narrow tool changes +goal status. The harness/backend does not independently prove completion. Its +job is to keep the objective visible, continue work while the goal remains +active, persist status, and stop the loop once the status changes. From 846c27c13fc6dd11306908c382b3327bdec2c529 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 21:03:19 -0400 Subject: [PATCH 27/29] fix(goal): quiet status updates and accumulate usage --- nori-rs/acp/docs.md | 6 +- nori-rs/acp/src/backend/thread_goal.rs | 60 +++++++--- nori-rs/nori-protocol/docs.md | 2 +- nori-rs/tui/docs.md | 2 +- nori-rs/tui/src/chatwidget/constructors.rs | 2 + nori-rs/tui/src/chatwidget/goal.rs | 30 ++++- nori-rs/tui/src/chatwidget/key_handling.rs | 2 +- nori-rs/tui/src/chatwidget/mod.rs | 2 + nori-rs/tui/src/chatwidget/tests/mod.rs | 1 + nori-rs/tui/src/chatwidget/tests/part2.rs | 113 +++++++++++++++++- ...sts__part2__goal_update_event_summary.snap | 2 +- 11 files changed, 196 insertions(+), 26 deletions(-) diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index aec6fb472..e7bb4b046 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -34,7 +34,7 @@ Key files: - `connection/` - SACP v11-based subprocess spawning and JSON-RPC communication - `translator.rs` - User input to ACP `ContentBlock` conversion and related parsing helpers - `backend/mod.rs` - Implements `ConversationClient` trait from codex-core and emits normalized ACP session events -- `backend/thread_goal.rs` - Owns per-session `/goal` state, prompt goal-context formatting, transcript rehydration, and usage-baseline updates +- `backend/thread_goal.rs` - Owns per-session `/goal` state, prompt goal-context formatting, transcript rehydration, and usage checkpoint updates - `backend/thread_goal_mcp.rs` - Adapts backend-owned goal state into MCP tools for agents that advertise HTTP MCP support - `backend/thread_goal_http_mcp.rs` - Exposes those backend-owned tools over a loopback HTTP endpoint for ACP adapters - `transcript_discovery.rs` - Discovers transcript files for external agents @@ -113,7 +113,7 @@ The ACP backend owns the `/goal` feature as per-session state instead of delegat -> @/nori-rs/tui/src/chatwidget/event_handlers.rs ``` -`ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, token usage, and the session-token baseline used to compute goal-local `tokens_used`. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules. +`ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, accumulated goal token usage, and the latest ACP session-usage checkpoint. ACP usage updates add only positive deltas since the last checkpoint to goal-local `tokens_used`; if context-window usage drops after compaction or session reset, the already accumulated goal usage is preserved and the lower value becomes the next checkpoint. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules. `thread_goal_mcp.rs` is a bridge, not a second store. Its tools lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/acp/src/backend/transcript.rs` when a transcript recorder is available; session setup stores the recorder behind a shared cell because the goal MCP bridge can be created before all resume/create paths know the final transcript session id. @@ -121,7 +121,7 @@ The local `nori-goal` server is only advertised when `@/nori-rs/acp/src/backend/ The model-facing MCP contract is intentionally narrower than the user-facing `/goal` command surface. `create_goal` creates a new active goal only when no goal exists, rejects token budgets for now, and delegates objective validation to `ThreadGoalState`. `update_goal` only lets an agent mark the existing goal `complete` or `blocked`; pause, resume, usage-limited, and budget-limited transitions remain controlled by the user or the backend system path. Errors are returned as MCP tool errors instead of changing state. -Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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. +Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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 both label goal token usage as excluding subagent usage, matching the backend accounting source. Agents that are not advertised the local `nori-goal` server still receive goal context through prompt transformation and a single hidden goal-continuation prompt after visible user turns. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or one-shot continuation behavior. diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index ffe0029d8..046a0c40c 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -35,7 +35,7 @@ struct StoredThreadGoal { objective: String, status: ThreadGoalStatus, tokens_used: i64, - token_usage_baseline: Option, + token_usage_checkpoint: Option, accumulated_active_seconds: i64, active_started_at: Option, created_at: i64, @@ -103,7 +103,7 @@ impl ThreadGoalState { ThreadGoalStatus::Complete => "complete", }; format!( - "\nStatus: {}\nObjective: {}\nTime used: {}s\nTokens used: {}\n", + "\nStatus: {}\nObjective: {}\nTime used: {}s\nTokens used (subagents not counted): {}\n", status, goal.objective, goal.time_used_seconds, @@ -127,7 +127,7 @@ Continuation behavior:\n\ - Keep the full objective intact. If it cannot be finished now, make concrete progress toward the real requested end state, leave the goal active, and do not redefine success around a smaller or easier task.\n\ - Temporary rough edges are acceptable while the work is moving in the right direction. Completion still requires the requested end state to be true and verified.\n\n\ Budget:\n\ -- Tokens used: {}\n\ +- Tokens used (subagents not counted): {}\n\ - Token budget: none\n\ - Tokens remaining: unbounded\n\n\ Work from evidence:\n\ @@ -175,7 +175,7 @@ Before deciding that the goal is achieved, treat completion as unproven and veri objective, status, tokens_used: 0, - token_usage_baseline: Some(self.last_session_used_tokens.unwrap_or(0)), + token_usage_checkpoint: Some(self.last_session_used_tokens.unwrap_or(0)), accumulated_active_seconds: 0, active_started_at: active_started_at(status, now), created_at: now, @@ -209,12 +209,14 @@ Before deciding that the goal is achieved, treat completion as unproven and veri ) -> Option { self.last_session_used_tokens = Some(used_tokens); let goal = self.goal.as_mut()?; - let baseline = goal.token_usage_baseline.unwrap_or_else(|| { - let baseline = used_tokens.saturating_sub(goal.tokens_used); - goal.token_usage_baseline = Some(baseline); - baseline - }); - goal.tokens_used = used_tokens.saturating_sub(baseline); + if let Some(checkpoint) = goal.token_usage_checkpoint + && used_tokens >= checkpoint + { + goal.tokens_used = goal + .tokens_used + .saturating_add(used_tokens.saturating_sub(checkpoint)); + } + goal.token_usage_checkpoint = Some(used_tokens); goal.updated_at = now; Some(goal.snapshot(now)) } @@ -230,8 +232,7 @@ impl StoredThreadGoal { objective: goal.objective.clone(), status, tokens_used: goal.tokens_used, - token_usage_baseline: session_used_tokens - .map(|used_tokens| used_tokens.saturating_sub(goal.tokens_used)), + token_usage_checkpoint: session_used_tokens, accumulated_active_seconds: goal.time_used_seconds, active_started_at: active_started_at(status, goal.updated_at), created_at: goal.created_at, @@ -558,7 +559,7 @@ mod tests { assert_eq!( goals.prompt_context(25), Some( - "\nStatus: active\nObjective: Keep going\nTime used: 15s\nTokens used: 0\n" + "\nStatus: active\nObjective: Keep going\nTime used: 15s\nTokens used (subagents not counted): 0\n" .to_string() ) ); @@ -654,7 +655,38 @@ mod tests { } #[test] - fn rehydrated_goal_usage_baseline_survives_future_usage_updates() { + fn usage_updates_accumulate_across_context_window_resets() { + let mut goals = ThreadGoalState::default(); + assert_eq!(goals.update_session_tokens(100, 5), None); + goals + .set_objective("Keep going".to_string(), None, 10) + .expect("valid objective"); + + assert_eq!( + goals + .update_session_tokens(175, 15) + .expect("goal should be updated") + .tokens_used, + 75 + ); + assert_eq!( + goals + .update_session_tokens(80, 20) + .expect("goal should be updated") + .tokens_used, + 75 + ); + assert_eq!( + goals + .update_session_tokens(130, 25) + .expect("goal should be updated") + .tokens_used, + 125 + ); + } + + #[test] + fn rehydrated_goal_usage_checkpoint_survives_future_usage_updates() { let goals = ThreadGoalState::from_replay_events(&[ nori_protocol::ClientEvent::SessionUpdateInfo(nori_protocol::SessionUpdateInfo { kind: nori_protocol::SessionUpdateKind::Usage, diff --git a/nori-rs/nori-protocol/docs.md b/nori-rs/nori-protocol/docs.md index ac87e9efb..be79cf532 100644 --- a/nori-rs/nori-protocol/docs.md +++ b/nori-rs/nori-protocol/docs.md @@ -54,7 +54,7 @@ agent_client_protocol_schema::SessionUpdate - The `is_generic_tool_call()` filter means the normalizer is not 1:1 with incoming events. Initial `ToolCall` messages that are sufficiently sparse are silently dropped, but later `ToolCallUpdate` messages still become visible `ToolSnapshot`s even if no initial `ToolCall` ever arrived. - `SessionUpdateInfo` stays intentionally lightweight, but it is no longer fully lossy: the `Usage` variant also carries structured `SessionUsageState` so replay and live footer updates can share the same path. - `ThreadGoalUpdated` is a full replacement snapshot for the client's current goal, while `ThreadGoalCleared` removes that state. The TUI should not infer a goal lifecycle by replaying command text; it should consume these events directly. -- Usage events and goal events intentionally remain separate: ACP `UsageUpdate` normalizes to `SessionUpdateInfo`, and the backend may follow it with a refreshed `ThreadGoalUpdated` when a goal exists so `tokens_used` stays tied to the backend's session-token baseline. +- Usage events and goal events intentionally remain separate: ACP `UsageUpdate` normalizes to `SessionUpdateInfo`, and the backend may follow it with a refreshed `ThreadGoalUpdated` when a goal exists. Goal `tokens_used` is accumulated by `@/nori-rs/acp/src/backend/thread_goal.rs` from positive ACP session-usage deltas, with context-window drops treated as new checkpoints rather than subtracting previously counted work. - Hidden goal continuations are protocol-visible as `QueuedPromptKind::GoalContinuation`, but they are not user-visible prompt text. Reducer consumers should treat their assistant output like any other assistant turn while excluding their prompt text from visible `QueueChanged` entries and user transcript messages. - The location fallback (tier 4) only handles `Read` and `Search` kinds. Edit/Delete/Move with locations but no `raw_input` return `None` from the normalizer and fall through to the TUI's location-path display fallback, avoiding creation of empty-diff `FileOperations` that would route to `PatchHistoryCell`. - `sanitize_title()` is a two-pass operation: first strips the `[current working directory ...]` bracket, then strips trailing `(description)` parenthetical. The parenthetical strip only fires after a cwd bracket was found, because Gemini appends descriptions after the cwd metadata. diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 7efeb8eec..31c83d8f1 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -67,7 +67,7 @@ For replayed ACP conversations, user-authored message chunks are reconstructed u The `/goal` command is a TUI command surface for ACP backend-owned goal state. `@/nori-rs/tui/src/slash_command.rs` advertises the command, while `@/nori-rs/tui/src/chatwidget/goal.rs` maps the command family (viewing, setting, status changes, clearing, and editing) into typed `codex_protocol::protocol::Op::ThreadGoal*` operations. Those operations are handled by `@/nori-rs/acp/src/backend/thread_goal.rs`; the TUI does not persist or derive goal state from prompt text. -`ClientEvent::ThreadGoalUpdated` is treated as the source of truth for the visible current goal. `ChatWidget` stores that snapshot in `current_goal`, renders a compact history summary, and uses it to seed `/goal edit` back into the composer. `ClientEvent::ThreadGoalCleared` clears the cached snapshot and writes a short info message. Goal updates are omitted from view-only transcript rendering in `@/nori-rs/tui/src/viewonly_transcript.rs` because they are state synchronization events rather than conversation messages. +`ClientEvent::ThreadGoalUpdated` is treated as the source of truth for the visible current goal. `ChatWidget` stores that snapshot in `current_goal`, renders a compact history summary for new goals and objective/status changes, and uses it to seed `/goal edit` back into the composer. Accounting-only updates from backend usage refresh the cached snapshot without adding history cells. The summary formats token counts with the shared SI formatter and labels them as excluding subagent usage. `ClientEvent::ThreadGoalCleared` clears the cached snapshot and writes a short info message. Goal updates are omitted from view-only transcript rendering in `@/nori-rs/tui/src/viewonly_transcript.rs` because they are state synchronization events rather than conversation messages. The TUI validates goal objective text through `@/nori-rs/protocol/src/protocol/mod.rs` before submitting a set operation, matching the backend's validation path. This keeps the UI responsive while preserving the backend as the authority for state transitions, resume rehydration, token accounting, and prompt `` injection. diff --git a/nori-rs/tui/src/chatwidget/constructors.rs b/nori-rs/tui/src/chatwidget/constructors.rs index 7afc3f0f5..da45cc736 100644 --- a/nori-rs/tui/src/chatwidget/constructors.rs +++ b/nori-rs/tui/src/chatwidget/constructors.rs @@ -109,6 +109,7 @@ impl ChatWidget { active_resume_picker_generation: None, first_prompt_text, current_goal: None, + pending_goal_status: false, pending_goal_edit: false, loop_remaining: None, loop_total: None, @@ -234,6 +235,7 @@ impl ChatWidget { active_resume_picker_generation: None, first_prompt_text, current_goal: None, + pending_goal_status: false, pending_goal_edit: false, loop_remaining: None, loop_total: None, diff --git a/nori-rs/tui/src/chatwidget/goal.rs b/nori-rs/tui/src/chatwidget/goal.rs index e3dd4032c..5f3cddde1 100644 --- a/nori-rs/tui/src/chatwidget/goal.rs +++ b/nori-rs/tui/src/chatwidget/goal.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::num_format::format_si_suffix; impl ChatWidget { pub(super) fn handle_goal_user_message(&mut self, text: &str) -> bool { @@ -11,7 +12,7 @@ impl ChatWidget { let rest = rest.trim(); if rest.is_empty() { - self.submit_op(Op::ThreadGoalGet); + self.request_thread_goal_status(); return true; } @@ -53,12 +54,24 @@ impl ChatWidget { true } + pub(super) fn request_thread_goal_status(&mut self) { + self.pending_goal_status = true; + self.submit_op(Op::ThreadGoalGet); + } + pub(super) fn handle_thread_goal_updated(&mut self, goal: nori_protocol::ThreadGoal) { + let should_show_summary = self.current_goal.as_ref().is_none_or(|previous| { + previous.objective != goal.objective + || previous.status != goal.status + || previous.created_at != goal.created_at + }); self.current_goal = Some(goal.clone()); if self.pending_goal_edit { self.pending_goal_edit = false; + self.pending_goal_status = false; self.open_goal_editor(goal); - } else { + } else if self.pending_goal_status || should_show_summary { + self.pending_goal_status = false; self.show_goal_summary(&goal); } self.request_redraw(); @@ -66,6 +79,7 @@ impl ChatWidget { pub(super) fn handle_thread_goal_cleared(&mut self) { self.current_goal = None; + self.pending_goal_status = false; self.pending_goal_edit = false; self.add_info_message("Goal cleared".to_string(), None); self.request_redraw(); @@ -81,6 +95,12 @@ impl ChatWidget { { self.pending_goal_edit = false; } + if self.pending_goal_status + && update.kind == nori_protocol::SessionUpdateKind::SessionInfo + && update.hint.as_deref() == Some("No goal is currently set.") + { + self.pending_goal_status = false; + } } fn open_goal_editor_or_request_snapshot(&mut self) { @@ -158,7 +178,11 @@ impl ChatWidget { ]), Line::from(vec![ "Tokens used: ".dim(), - goal.tokens_used.to_string().into(), + format!( + "{} (subagents not counted)", + format_si_suffix(goal.tokens_used) + ) + .into(), ]), Line::default(), Line::from(goal_command_hint(goal.status).dim()), diff --git a/nori-rs/tui/src/chatwidget/key_handling.rs b/nori-rs/tui/src/chatwidget/key_handling.rs index 561374157..06c937e17 100644 --- a/nori-rs/tui/src/chatwidget/key_handling.rs +++ b/nori-rs/tui/src/chatwidget/key_handling.rs @@ -154,7 +154,7 @@ impl ChatWidget { ); } SlashCommand::Goal => { - self.submit_op(Op::ThreadGoalGet); + self.request_thread_goal_status(); } SlashCommand::Quit | SlashCommand::Exit => { self.submit_op(Op::Shutdown); diff --git a/nori-rs/tui/src/chatwidget/mod.rs b/nori-rs/tui/src/chatwidget/mod.rs index 6851fda40..ebb00a5ed 100644 --- a/nori-rs/tui/src/chatwidget/mod.rs +++ b/nori-rs/tui/src/chatwidget/mod.rs @@ -419,6 +419,8 @@ pub(crate) struct ChatWidget { first_prompt_text: Option, // Latest ACP-owned goal snapshot for this session. current_goal: Option, + // Whether `/goal` is waiting for the backend to return a goal snapshot. + pending_goal_status: bool, // Whether `/goal edit` is waiting for the backend to return a goal snapshot. pending_goal_edit: bool, // Loop mode state: remaining iterations (None = not looping) diff --git a/nori-rs/tui/src/chatwidget/tests/mod.rs b/nori-rs/tui/src/chatwidget/tests/mod.rs index f8faf5e30..6759f9db0 100644 --- a/nori-rs/tui/src/chatwidget/tests/mod.rs +++ b/nori-rs/tui/src/chatwidget/tests/mod.rs @@ -317,6 +317,7 @@ pub(crate) fn make_chatwidget_manual() -> ( active_resume_picker_generation: None, first_prompt_text: None, current_goal: None, + pending_goal_status: false, pending_goal_edit: false, loop_remaining: None, loop_total: None, diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index 0309a9e03..2f81c11f2 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -42,6 +42,30 @@ fn slash_goal_requests_current_goal() { assert_matches!(op_rx.try_recv(), Ok(Op::ThreadGoalGet)); } +#[test] +fn slash_picker_goal_renders_current_goal_summary() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let goal = test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Active); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { goal: goal.clone() }, + )); + let _ = drain_insert_history(&mut rx); + + chat.dispatch_command(SlashCommand::Goal); + assert_eq!(op_rx.try_recv(), Ok(Op::ThreadGoalGet)); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { goal }, + )); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Objective: Keep going"), + "expected slash picker goal summary, got: {rendered}" + ); +} + #[test] fn goal_objective_submits_thread_goal_set() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); @@ -160,7 +184,11 @@ fn goal_update_event_renders_summary() { chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( nori_protocol::ThreadGoalUpdated { - goal: test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Active), + goal: test_thread_goal_with_tokens( + "Keep going", + nori_protocol::ThreadGoalStatus::Active, + 195_043, + ), }, )); @@ -170,6 +198,79 @@ fn goal_update_event_renders_summary() { assert_snapshot!("goal_update_event_summary", rendered); } +#[test] +fn accounting_only_goal_update_does_not_render_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Active), + }, + )); + let _ = drain_insert_history(&mut rx); + + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: nori_protocol::ThreadGoal { + tokens_used: 195_043, + time_used_seconds: 15, + updated_at: 25, + ..test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Active) + }, + }, + )); + + assert_eq!(drain_insert_history(&mut rx).len(), 0); +} + +#[test] +fn explicit_goal_status_request_renders_current_goal_summary() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); + let goal = test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Active); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { goal: goal.clone() }, + )); + let _ = drain_insert_history(&mut rx); + + chat.submit_user_message("/goal".to_string().into()); + assert_eq!(op_rx.try_recv(), Ok(Op::ThreadGoalGet)); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { goal }, + )); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Objective: Keep going"), + "expected explicit goal status summary, got: {rendered}" + ); +} + +#[test] +fn status_goal_update_still_renders_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Active), + }, + )); + let _ = drain_insert_history(&mut rx); + + chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( + nori_protocol::ThreadGoalUpdated { + goal: test_thread_goal("Keep going", nori_protocol::ThreadGoalStatus::Paused), + }, + )); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Status: paused"), + "expected paused status summary, got: {rendered}" + ); +} + #[test] fn goal_edit_prefills_current_goal_objective() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(); @@ -230,11 +331,19 @@ fn goal_edit_without_goal_does_not_open_editor_on_later_goal_update() { fn test_thread_goal( objective: &str, status: nori_protocol::ThreadGoalStatus, +) -> nori_protocol::ThreadGoal { + test_thread_goal_with_tokens(objective, status, 0) +} + +fn test_thread_goal_with_tokens( + objective: &str, + status: nori_protocol::ThreadGoalStatus, + tokens_used: i64, ) -> nori_protocol::ThreadGoal { nori_protocol::ThreadGoal { objective: objective.to_string(), status, - tokens_used: 0, + tokens_used, time_used_seconds: 0, created_at: 10, updated_at: 10, diff --git a/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap index f5b88b9a3..21ae68513 100644 --- a/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap +++ b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap @@ -6,6 +6,6 @@ Goal Status: active Objective: Keep going Time used: 0s -Tokens used: 0 +Tokens used: 195K (subagents not counted) Commands: /goal edit, /goal pause, /goal clear From efffbd60b8b9971428aa1f96545891ce94301fdf Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Thu, 28 May 2026 21:59:16 -0400 Subject: [PATCH 28/29] docs(goal): update goal progress log --- CURRENT-PROGRESS.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/CURRENT-PROGRESS.md b/CURRENT-PROGRESS.md index 026d53dde..2fd6cd6c6 100644 --- a/CURRENT-PROGRESS.md +++ b/CURRENT-PROGRESS.md @@ -115,6 +115,24 @@ Goal command progress: prior continuation turns while the active goal remains open and the runtime is idle; agents without HTTP MCP support keep the previous single hidden continuation after a visible user turn so unsupported agents are not put into an unbounded loop they cannot stop. +27. Follow-up: disable or clearly mark `/goal` unavailable when the active ACP agent does not + support HTTP MCP servers. The current slash popup has description overrides but no disabled + row state, and pasted `/goal ...` is handled separately in `chatwidget/goal.rs`, so the + correct small fix is probably both UI affordance and a backend/TUI command guard. This + matters because prompt goal context can still work without MCP, but the main close-the-loop + path depends on the agent having the `nori-goal` MCP tools so it can mark goals complete or + blocked. +28. Fixed the quick goal visual/accounting issues from bugs 3, 4, and the scoped version of 5. + The TUI now suppresses history cells for accounting-only `ThreadGoalUpdated` refreshes while + still rendering explicit `/goal` status requests and objective/status changes. Goal summaries + use compact SI token formatting and label the count as excluding subagents. Backend goal token + usage now accumulates positive ACP usage deltas across context-window drops instead of + mirroring the latest session usage value. +29. Verified the bug 3/4/5 slice end-to-end and pushed it to PR #491 as commit `846c27c1`. + Local verification covered `cargo test -p nori-acp`, `cargo test -p nori-tui`, + `cargo build --bin nori && cargo test -p tui-pty-e2e`, `just fmt`, scoped `just fix`, + snapshot acceptance, and an isolated ElizACP TUI smoke test. GitHub checks for the PR passed + afterward: `Linux checks` and `cargo-deny`. Follow-up bug investigations - 2026-05-28: From eb7fd1708e258ebb0779b9e279d939492f6bd828 Mon Sep 17 00:00:00 2001 From: Clifford Ressel Date: Fri, 29 May 2026 00:17:59 -0400 Subject: [PATCH 29/29] fix(goal): start active goals immediately Clean up goal usage formatting and add close-loop captures for the slash command flow. --- nori-rs/acp/docs.md | 8 +- .../acp/src/backend/session_runtime_driver.rs | 4 + nori-rs/acp/src/backend/tests/part5.rs | 285 +++++++++++------- nori-rs/acp/src/backend/thread_goal.rs | 28 +- .../goal-bugs-verify/001-initial.txt | 40 +++ .../goal-bugs-verify/002-after-goal.txt | 40 +++ .../003-goal-after-one-minute.txt | 40 +++ nori-rs/protocol/docs.md | 2 + nori-rs/protocol/src/num_format.rs | 21 ++ nori-rs/tui/docs.md | 4 +- nori-rs/tui/src/chatwidget/goal.rs | 9 +- nori-rs/tui/src/chatwidget/tests/part2.rs | 13 +- ...sts__part2__goal_update_event_summary.snap | 4 +- 13 files changed, 370 insertions(+), 128 deletions(-) create mode 100644 nori-rs/closing-loop/goal-bugs-verify/001-initial.txt create mode 100644 nori-rs/closing-loop/goal-bugs-verify/002-after-goal.txt create mode 100644 nori-rs/closing-loop/goal-bugs-verify/003-goal-after-one-minute.txt diff --git a/nori-rs/acp/docs.md b/nori-rs/acp/docs.md index e7bb4b046..90fafde55 100644 --- a/nori-rs/acp/docs.md +++ b/nori-rs/acp/docs.md @@ -113,7 +113,7 @@ The ACP backend owns the `/goal` feature as per-session state instead of delegat -> @/nori-rs/tui/src/chatwidget/event_handlers.rs ``` -`ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, accumulated goal token usage, and the latest ACP session-usage checkpoint. ACP usage updates add only positive deltas since the last checkpoint to goal-local `tokens_used`; if context-window usage drops after compaction or session reset, the already accumulated goal usage is preserved and the lower value becomes the next checkpoint. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules. +`ThreadGoalState` tracks the current objective, lifecycle status, active elapsed time, accumulated goal token usage, and the latest ACP session-usage checkpoint. ACP usage updates add only positive deltas since the last checkpoint to goal-local `tokens_used`; if context-window usage drops after compaction or session reset, the already accumulated goal usage is preserved and the lower value becomes the next checkpoint. Only the `Active` status accrues active time; paused, blocked, usage-limited, budget-limited, and complete goals keep their accumulated time until they become active again. Objective validation is shared with `@/nori-rs/protocol/src/protocol/mod.rs` so the TUI and backend enforce the same acceptance rules, and goal status text uses the shared compact elapsed-time and SI-token formatters from `@/nori-rs/protocol/src/num_format.rs`. `thread_goal_mcp.rs` is a bridge, not a second store. Its tools lock the same `ThreadGoalState` used by TUI `/goal` operations, return JSON snapshots shaped for model consumption, and emit the same `ThreadGoalUpdated` client event after mutations. The bridge records those emitted events through `@/nori-rs/acp/src/backend/transcript.rs` when a transcript recorder is available; session setup stores the recorder behind a shared cell because the goal MCP bridge can be created before all resume/create paths know the final transcript session id. @@ -121,11 +121,11 @@ The local `nori-goal` server is only advertised when `@/nori-rs/acp/src/backend/ The model-facing MCP contract is intentionally narrower than the user-facing `/goal` command surface. `create_goal` creates a new active goal only when no goal exists, rejects token budgets for now, and delegates objective validation to `ThreadGoalState`. `update_goal` only lets an agent mark the existing goal `complete` or `blocked`; pause, resume, usage-limited, and budget-limited transitions remain controlled by the user or the backend system path. Errors are returned as MCP tool errors instead of changing state. -Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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 both label goal token usage as excluding subagent usage, matching the backend accounting source. +Before user prompts are submitted to the ACP runtime, `user_input.rs` prepends the current goal as a structured `` 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. -Agents that are not advertised the local `nori-goal` server still receive goal context through prompt transformation and a single hidden goal-continuation prompt after visible user turns. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or one-shot continuation behavior. +Agents that are not advertised the local `nori-goal` server still receive goal context through prompt transformation, an immediate hidden goal-continuation prompt when an active goal is set while the runtime is idle, and a single hidden goal-continuation prompt after visible user turns. The local MCP server is additive for capable agents so they can use structured goal tools; it is never required for goal context, transcript replay, usage accounting, or one-shot continuation behavior. -After 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 `thread_goal_mcp.rs` flips only after the local HTTP MCP server receives an `initialize` request. Agents without a connected goal MCP endpoint receive at most the existing one hidden continuation after each visible user turn. +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 `thread_goal_mcp.rs` flips only after the local HTTP MCP server receives an `initialize` request. Agents without a connected goal MCP endpoint receive at most one hidden continuation per active goal mutation or visible user turn. 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. diff --git a/nori-rs/acp/src/backend/session_runtime_driver.rs b/nori-rs/acp/src/backend/session_runtime_driver.rs index 760674fa2..1e776a4df 100644 --- a/nori-rs/acp/src/backend/session_runtime_driver.rs +++ b/nori-rs/acp/src/backend/session_runtime_driver.rs @@ -547,6 +547,10 @@ impl AcpBackend { QueuedPromptKind::GoalContinuation | QueuedPromptKind::Compact => return, } + self.submit_goal_continuation_if_idle().await; + } + + pub(super) async fn submit_goal_continuation_if_idle(&self) { let prompt_text = { self.thread_goal_state .lock() diff --git a/nori-rs/acp/src/backend/tests/part5.rs b/nori-rs/acp/src/backend/tests/part5.rs index b589ee928..784927d16 100644 --- a/nori-rs/acp/src/backend/tests/part5.rs +++ b/nori-rs/acp/src/backend/tests/part5.rs @@ -69,6 +69,28 @@ async fn assert_no_prompt_completed( } } +async fn collect_completed_prompt_text( + backend_event_rx: &mut mpsc::Receiver, + timeout: std::time::Duration, + timeout_message: &str, +) -> String { + let mut agent_text = String::new(); + let start = std::time::Instant::now(); + loop { + if start.elapsed() > timeout { + panic!("{timeout_message}"); + } + match recv_backend_client(backend_event_rx, std::time::Duration::from_millis(500)).await { + Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { + agent_text.push_str(&delta.delta); + } + Some(nori_protocol::ClientEvent::PromptCompleted(_)) => return agent_text, + Some(_) => {} + None => {} + } + } +} + #[tokio::test] #[serial] async fn user_shell_command_executes_locally_and_emits_exec_lifecycle() { @@ -320,6 +342,18 @@ async fn test_goal_context_prepended_to_user_prompt() { .await .expect("Failed to set goal"); + let initial_goal_work = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for initial goal continuation", + ) + .await; + assert!( + initial_goal_work.contains("Continue working toward the active thread goal") + && initial_goal_work.contains("Keep the north star visible"), + "expected /goal to start immediate goal work, got: {initial_goal_work}" + ); + backend .submit(Op::UserInput { items: vec![codex_protocol::user_input::UserInput::Text { @@ -329,22 +363,12 @@ async fn test_goal_context_prepended_to_user_prompt() { .await .expect("Failed to submit user input"); - let mut agent_text = String::new(); - let timeout = Duration::from_secs(10); - let start = std::time::Instant::now(); - loop { - if start.elapsed() > timeout { - panic!("Timed out waiting for PromptCompleted"); - } - match recv_backend_client(&mut backend_event_rx, Duration::from_secs(5)).await { - Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { - agent_text.push_str(&delta.delta); - } - Some(nori_protocol::ClientEvent::PromptCompleted(_)) => break, - Some(_) => continue, - None => panic!("Backend event channel closed unexpectedly"), - } - } + let agent_text = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for PromptCompleted", + ) + .await; assert!( agent_text.contains("") @@ -354,6 +378,60 @@ async fn test_goal_context_prepended_to_user_prompt() { ); } +#[tokio::test] +#[serial] +async fn setting_active_goal_starts_hidden_continuation_without_extra_prompt() { + use std::time::Duration; + + let mock_config = + crate::registry::get_agent_config("mock-model").expect("mock-model should be registered"); + if !std::path::Path::new(&mock_config.command).exists() { + eprintln!( + "Skipping test: mock_acp_agent not found at {}", + mock_config.command + ); + return; + } + + let _env_guard = EnvGuard::set("MOCK_AGENT_ECHO_PROMPT", "1"); + let _mcp_guard = EnvGuard::remove("MOCK_AGENT_MCP_HTTP"); + + let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); + let (backend_event_tx, mut backend_event_rx) = mpsc::channel(64); + let config = build_test_config(temp_dir.path()); + + let backend = AcpBackend::spawn(&config, backend_event_tx) + .await + .expect("Failed to spawn ACP backend"); + + let _ = recv_backend_control(&mut backend_event_rx, Duration::from_secs(5)) + .await + .expect("Should receive SessionConfigured event"); + + backend + .submit(Op::ThreadGoalSet { + objective: Some("Begin immediately from the goal command".to_string()), + status: Some(codex_protocol::protocol::ThreadGoalStatus::Active), + }) + .await + .expect("Failed to set goal"); + + let agent_text = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for hidden goal continuation", + ) + .await; + + assert!( + agent_text.contains("Continue working toward the active thread goal") + && agent_text.contains("Begin immediately from the goal command"), + "expected goal command to start a hidden continuation, got: {agent_text}" + ); + + assert_no_prompt_completed(&mut backend_event_rx, Duration::from_secs(2)).await; +} + #[tokio::test] #[serial] async fn active_goal_submits_one_hidden_continuation_after_user_turn() { @@ -392,6 +470,18 @@ async fn active_goal_submits_one_hidden_continuation_after_user_turn() { .await .expect("Failed to set goal"); + let initial_goal_work = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for initial goal continuation", + ) + .await; + assert!( + initial_goal_work.contains("Continue working toward the active thread goal") + && initial_goal_work.contains("Ship the ACP goal command"), + "expected /goal to start immediate goal work, got: {initial_goal_work}" + ); + backend .submit(Op::UserInput { items: vec![codex_protocol::user_input::UserInput::Text { @@ -401,36 +491,27 @@ async fn active_goal_submits_one_hidden_continuation_after_user_turn() { .await .expect("Failed to submit user input"); - let mut completed_prompts = Vec::new(); - let mut current_agent_text = String::new(); - let timeout = Duration::from_secs(10); - let start = std::time::Instant::now(); - while completed_prompts.len() < 2 { - if start.elapsed() > timeout { - panic!("Timed out waiting for hidden goal continuation"); - } - match recv_backend_client(&mut backend_event_rx, Duration::from_secs(5)).await { - Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { - current_agent_text.push_str(&delta.delta); - } - Some(nori_protocol::ClientEvent::PromptCompleted(_)) => { - completed_prompts.push(std::mem::take(&mut current_agent_text)); - } - Some(_) => {} - None => panic!("Backend event channel closed unexpectedly"), - } - } + let user_turn = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for visible user turn", + ) + .await; + let post_user_goal_work = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for hidden goal continuation", + ) + .await; assert!( - completed_prompts[0].contains("start the work"), - "expected first prompt to be the visible user turn, got: {}", - completed_prompts[0] + user_turn.contains("start the work"), + "expected visible user turn after initial goal work, got: {user_turn}" ); assert!( - completed_prompts[1].contains("Continue working toward the active thread goal") - && completed_prompts[1].contains("Ship the ACP goal command"), - "expected second prompt to be a hidden goal continuation, got: {}", - completed_prompts[1] + post_user_goal_work.contains("Continue working toward the active thread goal") + && post_user_goal_work.contains("Ship the ACP goal command"), + "expected hidden goal continuation after user turn, got: {post_user_goal_work}" ); assert_no_prompt_completed(&mut backend_event_rx, Duration::from_secs(2)).await; @@ -479,6 +560,17 @@ async fn http_mcp_agent_without_goal_mcp_connection_does_not_chain_hidden_contin .await .expect("Failed to set goal"); + let initial_goal_work = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for initial goal continuation", + ) + .await; + assert!( + initial_goal_work.contains("Continue working toward the active thread goal"), + "expected /goal to start immediate goal work, got: {initial_goal_work}" + ); + backend .submit(Op::UserInput { items: vec![codex_protocol::user_input::UserInput::Text { @@ -488,41 +580,32 @@ async fn http_mcp_agent_without_goal_mcp_connection_does_not_chain_hidden_contin .await .expect("Failed to submit user input"); - let mut completed_prompts = Vec::new(); - let mut current_agent_text = String::new(); - let timeout = Duration::from_secs(10); - let start = std::time::Instant::now(); - while completed_prompts.len() < 2 { - if start.elapsed() > timeout { - panic!("Timed out waiting for goal continuation"); - } - match recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await { - Some(nori_protocol::ClientEvent::MessageDelta(delta)) => { - current_agent_text.push_str(&delta.delta); - } - Some(nori_protocol::ClientEvent::PromptCompleted(_)) => { - completed_prompts.push(std::mem::take(&mut current_agent_text)); - } - Some(_) => {} - None => {} - } - } + let user_turn = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for visible user turn", + ) + .await; + let post_user_goal_work = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for goal continuation", + ) + .await; assert!( - completed_prompts[0].contains("start the chain"), - "expected first prompt to be the visible user turn, got: {}", - completed_prompts[0] + user_turn.contains("start the chain"), + "expected visible user turn after initial goal work, got: {user_turn}" ); assert!( - completed_prompts[1].contains("Continue working toward the active thread goal"), - "expected second prompt to be the one hidden goal continuation, got: {}", - completed_prompts[1] + post_user_goal_work.contains("Continue working toward the active thread goal"), + "expected post-user hidden goal continuation, got: {post_user_goal_work}" ); tokio::time::sleep(Duration::from_secs(2)).await; assert_eq!( count_logged_requests(&wire_log_dir, "session/prompt"), - 2, + 3, "expected no chained goal continuation before the goal MCP server is connected" ); @@ -579,20 +662,11 @@ async fn goal_mcp_initialize_allows_chained_hidden_continuations() { .await .expect("Failed to set goal"); - backend - .submit(Op::UserInput { - items: vec![codex_protocol::user_input::UserInput::Text { - text: "start the initialized chain".to_string(), - }], - }) - .await - .expect("Failed to submit user input"); - let start = std::time::Instant::now(); while count_logged_requests(&wire_log_dir, "session/prompt") < 3 { assert!( start.elapsed() < Duration::from_secs(10), - "expected initialized goal MCP server to allow chaining beyond the first hidden continuation" + "expected initialized goal MCP server to allow chaining beyond the initial goal continuation" ); tokio::time::sleep(Duration::from_millis(100)).await; } @@ -619,6 +693,7 @@ async fn completed_goal_stops_chained_hidden_continuations() { } let _mcp_guard = EnvGuard::set("MOCK_AGENT_MCP_HTTP", "1"); + let _echo_guard = EnvGuard::set("MOCK_AGENT_ECHO_PROMPT", "1"); let _delay_guard = EnvGuard::set("MOCK_AGENT_DELAY_MS", "200"); let temp_dir = tempfile::tempdir().expect("Failed to create temp dir"); @@ -641,6 +716,17 @@ async fn completed_goal_stops_chained_hidden_continuations() { .await .expect("Failed to set goal"); + let initial_goal_work = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for initial goal continuation", + ) + .await; + assert!( + initial_goal_work.contains("Continue working toward the active thread goal"), + "expected /goal to start immediate goal work, got: {initial_goal_work}" + ); + backend .submit(Op::UserInput { items: vec![codex_protocol::user_input::UserInput::Text { @@ -650,18 +736,16 @@ async fn completed_goal_stops_chained_hidden_continuations() { .await .expect("Failed to submit user input"); - let timeout = Duration::from_secs(10); - let start = std::time::Instant::now(); - loop { - if start.elapsed() > timeout { - panic!("Timed out waiting for first PromptCompleted"); - } - if let Some(nori_protocol::ClientEvent::PromptCompleted(_)) = - recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await - { - break; - } - } + let user_turn = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for visible user turn", + ) + .await; + assert!( + user_turn.contains("start then finish"), + "expected visible user turn after initial goal work, got: {user_turn}" + ); backend .submit(Op::ThreadGoalSet { @@ -671,17 +755,16 @@ async fn completed_goal_stops_chained_hidden_continuations() { .await .expect("Failed to complete goal"); - let start = std::time::Instant::now(); - loop { - if start.elapsed() > timeout { - panic!("Timed out waiting for already queued hidden goal continuation"); - } - if let Some(nori_protocol::ClientEvent::PromptCompleted(_)) = - recv_backend_client(&mut backend_event_rx, Duration::from_millis(500)).await - { - break; - } - } + let already_queued_goal_work = collect_completed_prompt_text( + &mut backend_event_rx, + Duration::from_secs(10), + "Timed out waiting for already queued hidden goal continuation", + ) + .await; + assert!( + already_queued_goal_work.contains("Continue working toward the active thread goal"), + "expected already queued goal continuation, got: {already_queued_goal_work}" + ); assert_no_prompt_completed(&mut backend_event_rx, Duration::from_millis(500)).await; diff --git a/nori-rs/acp/src/backend/thread_goal.rs b/nori-rs/acp/src/backend/thread_goal.rs index 046a0c40c..f350e7976 100644 --- a/nori-rs/acp/src/backend/thread_goal.rs +++ b/nori-rs/acp/src/backend/thread_goal.rs @@ -1,3 +1,5 @@ +use codex_protocol::num_format::format_elapsed_seconds; +use codex_protocol::num_format::format_si_suffix; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::validate_thread_goal_objective; use nori_protocol::ClientEvent; @@ -103,11 +105,11 @@ impl ThreadGoalState { ThreadGoalStatus::Complete => "complete", }; format!( - "\nStatus: {}\nObjective: {}\nTime used: {}s\nTokens used (subagents not counted): {}\n", + "\nStatus: {}\nObjective: {}\nTime used: {}\nTokens used: {}\n", status, goal.objective, - goal.time_used_seconds, - goal.tokens_used + format_elapsed_seconds(goal.time_used_seconds), + format_si_suffix(goal.tokens_used) ) }) } @@ -127,14 +129,15 @@ Continuation behavior:\n\ - Keep the full objective intact. If it cannot be finished now, make concrete progress toward the real requested end state, leave the goal active, and do not redefine success around a smaller or easier task.\n\ - Temporary rough edges are acceptable while the work is moving in the right direction. Completion still requires the requested end state to be true and verified.\n\n\ Budget:\n\ -- Tokens used (subagents not counted): {}\n\ +- Tokens used: {}\n\ - Token budget: none\n\ - Tokens remaining: unbounded\n\n\ Work from evidence:\n\ Use the current worktree and external state as authoritative. Previous conversation context can help locate relevant work, but inspect the current state before relying on it. Improve, replace, or remove existing work as needed to satisfy the actual objective.\n\n\ Completion audit:\n\ Before deciding that the goal is achieved, treat completion as unproven and verify it against the actual current state. If completion is not proven, keep working toward the objective.", - goal.objective, goal.tokens_used + goal.objective, + format_si_suffix(goal.tokens_used) )) } @@ -379,7 +382,13 @@ impl AcpBackend { }; match result { - Ok(goal) => self.emit_thread_goal_updated(goal).await, + Ok(goal) => { + let should_start = goal.status == ThreadGoalStatus::Active; + self.emit_thread_goal_updated(goal).await; + if should_start { + self.submit_goal_continuation_if_idle().await; + } + } Err(message) => self.send_error(&message).await, } } @@ -555,11 +564,14 @@ mod tests { goals .set_objective("Keep going".to_string(), None, 10) .expect("valid objective"); + goals + .update_session_tokens(1_060, 73) + .expect("goal should exist"); assert_eq!( - goals.prompt_context(25), + goals.prompt_context(73), Some( - "\nStatus: active\nObjective: Keep going\nTime used: 15s\nTokens used (subagents not counted): 0\n" + "\nStatus: active\nObjective: Keep going\nTime used: 1m 3s\nTokens used: 1.06K\n" .to_string() ) ); diff --git a/nori-rs/closing-loop/goal-bugs-verify/001-initial.txt b/nori-rs/closing-loop/goal-bugs-verify/001-initial.txt new file mode 100644 index 000000000..2c084d875 --- /dev/null +++ b/nori-rs/closing-loop/goal-bugs-verify/001-initial.txt @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +╭────────────────────────────────────────────────╮ +│ Nori CLI v0.0.0 │ +│ │ +│ directory: ~/Documents/source/nori/cli/nori-rs │ +│ agent: elizacp │ +│ skillset: clifford │ +│ │ +│ Instruction Files │ +╰────────────────────────────────────────────────╯ + + +› @ for file mentions + + ⎇ feat/goal · Approvals: Read Only diff --git a/nori-rs/closing-loop/goal-bugs-verify/002-after-goal.txt b/nori-rs/closing-loop/goal-bugs-verify/002-after-goal.txt new file mode 100644 index 000000000..687bd6c5f --- /dev/null +++ b/nori-rs/closing-loop/goal-bugs-verify/002-after-goal.txt @@ -0,0 +1,40 @@ + + + + + + + +╭────────────────────────────────────────────────╮ +│ Nori CLI v0.0.0 │ +│ │ +│ directory: ~/Documents/source/nori/cli/nori-rs │ +│ agent: elizacp │ +│ skillset: clifford │ +│ │ +│ Instruction Files │ +╰────────────────────────────────────────────────╯ + +Goal +Status: active +Objective: Verify immediate goal continuation from slash command +Time used: 0s +Tokens used: 0 + +Commands: /goal edit, /goal pause, /goal clear + +─ Worked for 0s ──────────────────────────────────────────────────────────────────────────────────────────────────────── + +• What do you think about it cannot be finished now make concrete progress toward the real requested end state leave the + goal active and do not redefine success around a smaller or easier task - temporary rough edges are acceptable while + the work is moving in the right direction completion still requires the requested end state to be true and verified + budget - tokens used 0 - token budget none - tokens remaining unbounded work from evidence use the current worktree + and external state as authoritative previous conversation context can help locate relevant work but inspect the + current state before relying on it improve replace or remove existing work as needed to satisfy the actual objective + completion audit before deciding that the goal is achieved treat completion as unproven and verify it against the + actual current state if completion is not proven keep working toward the objective? + + +› @ for file mentions + + ⎇ feat/goal · Approvals: Read Only diff --git a/nori-rs/closing-loop/goal-bugs-verify/003-goal-after-one-minute.txt b/nori-rs/closing-loop/goal-bugs-verify/003-goal-after-one-minute.txt new file mode 100644 index 000000000..cd06b5ac5 --- /dev/null +++ b/nori-rs/closing-loop/goal-bugs-verify/003-goal-after-one-minute.txt @@ -0,0 +1,40 @@ +│ Nori CLI v0.0.0 │ +│ │ +│ directory: ~/Documents/source/nori/cli/nori-rs │ +│ agent: elizacp │ +│ skillset: clifford │ +│ │ +│ Instruction Files │ +╰────────────────────────────────────────────────╯ + +Goal +Status: active +Objective: Verify immediate goal continuation from slash command +Time used: 0s +Tokens used: 0 + +Commands: /goal edit, /goal pause, /goal clear + +─ Worked for 0s ──────────────────────────────────────────────────────────────────────────────────────────────────────── + +• What do you think about it cannot be finished now make concrete progress toward the real requested end state leave the + goal active and do not redefine success around a smaller or easier task - temporary rough edges are acceptable while + the work is moving in the right direction completion still requires the requested end state to be true and verified + budget - tokens used 0 - token budget none - tokens remaining unbounded work from evidence use the current worktree + and external state as authoritative previous conversation context can help locate relevant work but inspect the + current state before relying on it improve replace or remove existing work as needed to satisfy the actual objective + completion audit before deciding that the goal is achieved treat completion as unproven and verify it against the + actual current state if completion is not proven keep working toward the objective? + +Goal +Status: active +Objective: Verify immediate goal continuation from slash command +Time used: 1m 58s +Tokens used: 0 + +Commands: /goal edit, /goal pause, /goal clear + + +› @ for file mentions + + ⎇ feat/goal · Approvals: Read Only diff --git a/nori-rs/protocol/docs.md b/nori-rs/protocol/docs.md index 410a80574..2ca901155 100644 --- a/nori-rs/protocol/docs.md +++ b/nori-rs/protocol/docs.md @@ -39,6 +39,8 @@ Path: @/nori-rs/protocol **Thread Goal Types** (`protocol/mod.rs`): The `/goal` feature uses typed operations rather than encoding commands as regular prompt text. `Op::ThreadGoalGet`, `Op::ThreadGoalSet`, and `Op::ThreadGoalClear` define the backend-facing command surface; `ThreadGoalStatus` defines the shared lifecycle labels; `validate_thread_goal_objective()` defines the cross-crate validation invariant for objective text before the TUI or backend accepts it. +**Compact Number Formatting** (`num_format.rs`): Shared user-facing formatters keep ACP backend prompt context and TUI summaries consistent. Token counts use SI suffixes, and whole-second goal elapsed time is rendered compactly as seconds or minute/second text. + ### Things to Know **Module Structure:** The `protocol` module uses a directory layout (`protocol/mod.rs` + submodules) instead of a single `protocol.rs` file. Submodules include `display.rs` (Display impls), `history.rs` (conversation history types), `legacy_events.rs` (legacy event types), `sandbox.rs` (sandbox config types), `token_usage.rs` (token tracking types), and `tests.rs`. diff --git a/nori-rs/protocol/src/num_format.rs b/nori-rs/protocol/src/num_format.rs index 2c64939b7..5f316a9d4 100644 --- a/nori-rs/protocol/src/num_format.rs +++ b/nori-rs/protocol/src/num_format.rs @@ -72,6 +72,18 @@ pub fn format_si_suffix(n: i64) -> String { format_si_suffix_with_formatter(n, formatter()) } +/// Format elapsed whole seconds for compact user-facing status text. +pub fn format_elapsed_seconds(seconds: i64) -> String { + let seconds = seconds.max(0); + if seconds < 60 { + return format!("{seconds}s"); + } + + let minutes = seconds / 60; + let seconds = seconds % 60; + format!("{minutes}m {seconds}s") +} + #[cfg(test)] mod tests { use super::*; @@ -96,4 +108,13 @@ mod tests { // Above 1000G we keep whole‑G precision (no higher unit supported here). assert_eq!(fmt(1_234_000_000_000), "1,234G"); } + + #[test] + fn elapsed_seconds() { + assert_eq!(format_elapsed_seconds(0), "0s"); + assert_eq!(format_elapsed_seconds(59), "59s"); + assert_eq!(format_elapsed_seconds(63), "1m 3s"); + assert_eq!(format_elapsed_seconds(73), "1m 13s"); + assert_eq!(format_elapsed_seconds(-1), "0s"); + } } diff --git a/nori-rs/tui/docs.md b/nori-rs/tui/docs.md index 31c83d8f1..9c1e1dfc4 100644 --- a/nori-rs/tui/docs.md +++ b/nori-rs/tui/docs.md @@ -67,13 +67,13 @@ For replayed ACP conversations, user-authored message chunks are reconstructed u The `/goal` command is a TUI command surface for ACP backend-owned goal state. `@/nori-rs/tui/src/slash_command.rs` advertises the command, while `@/nori-rs/tui/src/chatwidget/goal.rs` maps the command family (viewing, setting, status changes, clearing, and editing) into typed `codex_protocol::protocol::Op::ThreadGoal*` operations. Those operations are handled by `@/nori-rs/acp/src/backend/thread_goal.rs`; the TUI does not persist or derive goal state from prompt text. -`ClientEvent::ThreadGoalUpdated` is treated as the source of truth for the visible current goal. `ChatWidget` stores that snapshot in `current_goal`, renders a compact history summary for new goals and objective/status changes, and uses it to seed `/goal edit` back into the composer. Accounting-only updates from backend usage refresh the cached snapshot without adding history cells. The summary formats token counts with the shared SI formatter and labels them as excluding subagent usage. `ClientEvent::ThreadGoalCleared` clears the cached snapshot and writes a short info message. Goal updates are omitted from view-only transcript rendering in `@/nori-rs/tui/src/viewonly_transcript.rs` because they are state synchronization events rather than conversation messages. +`ClientEvent::ThreadGoalUpdated` is treated as the source of truth for the visible current goal. `ChatWidget` stores that snapshot in `current_goal`, renders a compact history summary for new goals and objective/status changes, and uses it to seed `/goal edit` back into the composer. Accounting-only updates from backend usage refresh the cached snapshot without adding history cells. The summary formats elapsed time and token counts with the shared compact formatters from `@/nori-rs/protocol/src/num_format.rs`. `ClientEvent::ThreadGoalCleared` clears the cached snapshot and writes a short info message. Goal updates are omitted from view-only transcript rendering in `@/nori-rs/tui/src/viewonly_transcript.rs` because they are state synchronization events rather than conversation messages. The TUI validates goal objective text through `@/nori-rs/protocol/src/protocol/mod.rs` before submitting a set operation, matching the backend's validation path. This keeps the UI responsive while preserving the backend as the authority for state transitions, resume rehydration, token accounting, and prompt `` injection. `/goal edit` uses the cached goal immediately when available. If no snapshot is cached, it requests one from the ACP backend and marks the edit as pending until the backend replies. A no-goal response clears that pending flag before rendering the usage hint, preventing a later unrelated goal update from unexpectedly replacing the user's composer contents. -When `/goal ` is used while `current_goal` contains an unfinished ACP goal, the TUI opens a `SelectionView` confirmation instead of immediately sending the mutation. Choosing "Replace current goal" forwards `AppEvent::CodexOp(Op::ThreadGoalSet)` with the replacement objective and `Active` status; choosing "Keep current goal" dismisses the popup without changing backend state. Completed goals are replaced directly because they no longer protect an in-progress objective. This mirrors the Codex goal replacement flow while preserving the invariant that only explicit user confirmation can overwrite an unfinished goal snapshot cached from `ClientEvent::ThreadGoalUpdated`. +When `/goal ` is used while `current_goal` contains an unfinished ACP goal, the TUI opens a `SelectionView` confirmation instead of immediately sending the mutation. Choosing "Replace current goal" forwards `AppEvent::CodexOp(Op::ThreadGoalSet)` with the replacement objective and `Active` status; choosing "Keep current goal" dismisses the popup without changing backend state. Completed goals are replaced directly because they no longer protect an in-progress objective. This mirrors the Codex goal replacement flow while preserving the invariant that only explicit user confirmation can overwrite an unfinished goal snapshot cached from `ClientEvent::ThreadGoalUpdated`; the ACP backend owns the follow-up behavior that starts active goal work immediately when it can. The transcript pager overlay uses each history cell's transcript view rather than the live summary view. To keep reopened transcripts readable, the overlay caps non-patch cells at 20 lines and appends an omission marker, while patch cells keep their full diff output for review. In ACP sessions, `ClientToolCell` provides differentiated `transcript_lines()` for Execute tools (shell-style `$ command` format via `render_execute_transcript_lines()`) while exploring and edit cells reuse their `display_lines()` rendering for transcripts. diff --git a/nori-rs/tui/src/chatwidget/goal.rs b/nori-rs/tui/src/chatwidget/goal.rs index 5f3cddde1..b5dcaa37b 100644 --- a/nori-rs/tui/src/chatwidget/goal.rs +++ b/nori-rs/tui/src/chatwidget/goal.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::num_format::format_elapsed_seconds; use codex_protocol::num_format::format_si_suffix; impl ChatWidget { @@ -174,15 +175,11 @@ impl ChatWidget { Line::from(vec!["Objective: ".dim(), goal.objective.clone().into()]), Line::from(vec![ "Time used: ".dim(), - format!("{}s", goal.time_used_seconds).into(), + format_elapsed_seconds(goal.time_used_seconds).into(), ]), Line::from(vec![ "Tokens used: ".dim(), - format!( - "{} (subagents not counted)", - format_si_suffix(goal.tokens_used) - ) - .into(), + format_si_suffix(goal.tokens_used).into(), ]), Line::default(), Line::from(goal_command_hint(goal.status).dim()), diff --git a/nori-rs/tui/src/chatwidget/tests/part2.rs b/nori-rs/tui/src/chatwidget/tests/part2.rs index 2f81c11f2..8694fed14 100644 --- a/nori-rs/tui/src/chatwidget/tests/part2.rs +++ b/nori-rs/tui/src/chatwidget/tests/part2.rs @@ -184,11 +184,14 @@ fn goal_update_event_renders_summary() { chat.handle_client_event(nori_protocol::ClientEvent::ThreadGoalUpdated( nori_protocol::ThreadGoalUpdated { - goal: test_thread_goal_with_tokens( - "Keep going", - nori_protocol::ThreadGoalStatus::Active, - 195_043, - ), + goal: nori_protocol::ThreadGoal { + time_used_seconds: 63, + ..test_thread_goal_with_tokens( + "Keep going", + nori_protocol::ThreadGoalStatus::Active, + 1_060, + ) + }, }, )); diff --git a/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap index 21ae68513..0cfe5f923 100644 --- a/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap +++ b/nori-rs/tui/src/chatwidget/tests/snapshots/nori_tui__chatwidget__tests__part2__goal_update_event_summary.snap @@ -5,7 +5,7 @@ expression: rendered Goal Status: active Objective: Keep going -Time used: 0s -Tokens used: 195K (subagents not counted) +Time used: 1m 3s +Tokens used: 1.06K Commands: /goal edit, /goal pause, /goal clear