From 998ffe27ebd9e1641ac228407d0c4c01c77e7eb9 Mon Sep 17 00:00:00 2001 From: asto Date: Fri, 30 Oct 2026 18:01:04 +0800 Subject: [PATCH 01/29] feat(engine): host-armed guardrail prompts + authority-transition notice Pinvou tri-mode safety, two registered fork behaviors: (a) ask_on_guardrail: a session-level flag carried on Op::ChangeMode (engine + wire, serde-default-absent) and through LiveRuntimeAuthority like every other posture field. When a host arms it, the guardrail faces that fail closed under an autonomous posture raise an approval request instead, because the host has guaranteed a person can answer: - non-bypassable holds (rlm/rlm_eval/start_mcp_server) under Full Access prompt with approval_force_prompt instead of the pre-event block (the ordinary resolver auto-approves non-bypassable tools under auto-approve, so the hold is set explicitly); - repo-law ask holds prompt instead of blocking; - typed ask-rule Prompts are honored instead of silently ignored; - sandbox escalation takes the Suggest-path approval; - the execpolicy deny face (denied prefixes + typed denies, full matching semantics preserved) becomes an approval carrying the matching evidence; Never posture stays deny regardless; - the UI disposition resolver resolves an armed forced hold under Full Access to the modal (CW-7495 consent-card shape, generalized); Auto-Review and Never denials stay unconditional; - child gates mirror the main line: an armed held-call hold routes to the parent's approval surface, and a delegated typed-ask Prompt refuses even under inherited auto-approve. Unarmed (the default) behavior is byte-identical to the pre-flag doctrine; the existing fail-closed pins are annotated with the armed exception. Composer ('!') provenance stays un-armed (#5191). (b) authority-transition notice: the posture each block reported is now recorded; when the host changes mode, posture, or arming between turns, the next block renders exactly one explicit line ('Authority transition (host): ...; reason: host_authority_change'). Mid-turn flips collapse into one transition; unchanged turns emit nothing so ordinary blocks stay byte-stable (#3947 doctrine); /preview-request reads the same value and stays byte-identical. New forkguard tests: armed/unarmed deny-face and disposition truth tables, an end-to-end armed Full Access hold that prompts and denies as an approval, the armed-child delegated refusal, and the transition contract (first turn/unchanged/change/arming-only/preview wiring). Signed-off-by: asto --- crates/protocol/src/op.rs | 5 + crates/tui/src/acp_server.rs | 5 + crates/tui/src/core/authority.rs | 203 +++++++- crates/tui/src/core/engine.rs | 144 +++++- crates/tui/src/core/engine/handle.rs | 7 + crates/tui/src/core/engine/preview.rs | 1 + crates/tui/src/core/engine/preview/tests.rs | 1 + crates/tui/src/core/engine/tests.rs | 485 +++++++++++++++++++- crates/tui/src/core/engine/turn_loop.rs | 78 +++- crates/tui/src/core/ops.rs | 10 + crates/tui/src/core/protocol_parity.rs | 2 + crates/tui/src/core/session.rs | 12 + crates/tui/src/runtime_threads.rs | 4 + crates/tui/src/tools/subagent/mod.rs | 49 +- crates/tui/src/tools/subagent/tests.rs | 60 +++ crates/tui/src/tui/ui/provider_routes.rs | 1 + crates/tui/src/tui/ui/tests.rs | 2 + 17 files changed, 1037 insertions(+), 32 deletions(-) diff --git a/crates/protocol/src/op.rs b/crates/protocol/src/op.rs index e8e8ae95be..0f86c590ee 100644 --- a/crates/protocol/src/op.rs +++ b/crates/protocol/src/op.rs @@ -294,6 +294,10 @@ pub enum Op { approval_mode: String, #[serde(default, skip_serializing_if = "Option::is_none")] configured_sandbox_mode: Option, + /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety). + /// Absent leaves the session's current arming unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + ask_on_guardrail: Option, }, SetDisallowedTools { @@ -619,6 +623,7 @@ mod tests { auto_approve: false, approval_mode: "bypass".into(), configured_sandbox_mode: Some("workspace-write".into()), + ask_on_guardrail: Some(true), }, Op::SetDisallowedTools { tools: Some(vec!["bash".into()]), diff --git a/crates/tui/src/acp_server.rs b/crates/tui/src/acp_server.rs index 69587e985f..535d748744 100644 --- a/crates/tui/src/acp_server.rs +++ b/crates/tui/src/acp_server.rs @@ -702,6 +702,9 @@ fn prepare_acp_tool_admission( let approval_mode = crate::tui::approval::ApprovalMode::Suggest; let workspace = registry.context().workspace.as_path(); + // ACP admissions carry no arming surface: the Suggest posture here always + // prompts through RequestPermission, so the guardrail deny face stays + // hard (`ask_on_guardrail: false`). let typed_rule = exec_shell_ask_rule_decision_for_policy( &config.exec_policy_engine, &call.name, @@ -709,6 +712,7 @@ fn prepare_acp_tool_admission( workspace, ®istry.context().workspace_roots, approval_mode, + false, ) .or_else(|| { file_tool_ask_rule_decision_for_policy( @@ -718,6 +722,7 @@ fn prepare_acp_tool_admission( workspace, ®istry.context().workspace_roots, approval_mode, + false, ) }); match typed_rule { diff --git a/crates/tui/src/core/authority.rs b/crates/tui/src/core/authority.rs index 6ae987fba7..1ae6cd29ac 100644 --- a/crates/tui/src/core/authority.rs +++ b/crates/tui/src/core/authority.rs @@ -134,6 +134,85 @@ impl PolicyNarrowingEvent { } } +/// The posture a `` block reported for a turn: effective mode, +/// effective approval posture, and the host's ask-on-guardrail arming. +/// The comparison unit for host-driven authority transitions. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct TurnMetaPosture { + pub(crate) mode: AppMode, + pub(crate) approval: ApprovalMode, + pub(crate) ask_on_guardrail: bool, +} + +/// A host-driven authority change between two turns (Pinvou tri-mode +/// safety). Before this existed, a mid-session mode or posture flip reached +/// the model only through the next turn's standing posture lines — the model +/// could observe the new state but never the change itself, so a behavior +/// shift (suddenly running commands without approval, or being asked to +/// approve what previously ran) read as unexplained. The engine records the +/// transition at turn start against the posture the previous block reported +/// and renders one model-visible line; a turn whose posture did not change +/// emits nothing, keeping ordinary blocks byte-stable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct AuthorityTransition { + from_mode: &'static str, + from_approval: ApprovalMode, + from_armed: bool, + to_mode: &'static str, + to_approval: ApprovalMode, + to_armed: bool, +} + +impl AuthorityTransition { + pub(crate) fn from_postures(from: TurnMetaPosture, to: TurnMetaPosture) -> Self { + Self { + from_mode: from.mode.as_setting(), + from_approval: from.approval, + from_armed: from.ask_on_guardrail, + to_mode: to.mode.as_setting(), + to_approval: to.approval, + to_armed: to.ask_on_guardrail, + } + } + + /// Stable machine-readable identifier, shared by the model-visible line + /// and any future host surface so the two cannot drift. + pub(crate) fn reason(&self) -> &'static str { + "host_authority_change" + } + + /// The single model-visible sentence. Renders both postures with their + /// approval chip and arming state so the change is self-describing. + pub(crate) fn render(&self) -> String { + fn part(mode: &str, approval: ApprovalMode, armed: bool) -> String { + format!( + "{mode} ({}, guardrail prompts {})", + approval.permission_chip_label(), + if armed { "armed" } else { "disarmed" } + ) + } + format!( + "Authority transition (host): {} -> {}; reason: {}", + part(self.from_mode, self.from_approval, self.from_armed), + part(self.to_mode, self.to_approval, self.to_armed), + self.reason(), + ) + } +} + +/// Collapse any number of host flips between two turns into the one +/// transition the next block reports: `None` before the first reported +/// turn, `None` when the posture did not change, otherwise the change. +pub(crate) fn authority_transition_between( + from: Option, + to: TurnMetaPosture, +) -> Option { + match from { + Some(last) if last != to => Some(AuthorityTransition::from_postures(last, to)), + _ => None, + } +} + /// Effective authority for one engine turn after provenance narrowing. #[derive(Debug, Clone)] pub(crate) struct TurnAuthority { @@ -142,6 +221,13 @@ pub(crate) struct TurnAuthority { pub(crate) trust_mode: bool, pub(crate) auto_approve: bool, pub(crate) approval_mode: ApprovalMode, + /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety). + /// When set, the approval-disposition resolver treats a forced + /// (non-bypassable) hold under the Full Access posture as a real prompt: + /// the host has guaranteed a person can answer, so failing closed would + /// deny a decision the user asked to make. Defaults to `false` from both + /// constructors, keeping unarmed behavior byte-identical. + pub(crate) ask_on_guardrail: bool, pub(crate) dynamic_active_tools: Vec<&'static str>, /// Structured record of any narrowing applied to this turn (#3947). The /// UI status line, ``, and doctor all render from here, so a @@ -169,11 +255,24 @@ impl TurnAuthority { trust_mode, auto_approve, approval_mode, + ask_on_guardrail: false, dynamic_active_tools: Vec::new(), narrowing: None, } } + /// Arm (or disarm) interactive guardrail prompts on this authority. + /// Hosts that guarantee a person can answer guardrail hits set this + /// after construction; every other surface keeps the default `false`. + /// Test-only because the engine's gate sites consult the session flag + /// directly; the disposition resolver contract below reads the field. + #[cfg(test)] + #[must_use] + pub(crate) fn with_ask_on_guardrail(mut self, ask_on_guardrail: bool) -> Self { + self.ask_on_guardrail = ask_on_guardrail; + self + } + #[must_use] pub(crate) fn approval_mode_for_session(&self) -> ApprovalMode { agent_approval_mode_for_turn(self.auto_approve, self.approval_mode) @@ -277,6 +376,7 @@ pub(crate) fn effective_input_policy( trust_mode, auto_approve, approval_mode, + ask_on_guardrail: false, dynamic_active_tools: Vec::new(), narrowing, } @@ -743,7 +843,14 @@ pub(crate) fn resolve_approval_request_disposition( // treat an ordinary promptable tool? let posture = resolve_tool_permission(authority, ApprovalRequirement::Suggest, false); if approval_force_prompt && posture == ToolPermission::Allow { - return ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold; + // Armed hosts (Pinvou tri-mode safety) have guaranteed a person can + // answer, so the forced hold becomes the modal prompt instead of the + // fail-closed deny (CW-7495 consent-card shape, generalized). The + // Auto-Review and Never denials above stay unconditional: neither + // posture has anyone at the card even when armed. + if !authority.ask_on_guardrail { + return ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold; + } } if !approval_force_prompt && (posture == ToolPermission::Allow || session_approved) { return ApprovalRequestDisposition::AutoApprove; @@ -1424,4 +1531,98 @@ mod tests { ApprovalRequestDisposition::Prompt ); } + + #[test] + fn forkguard_armed_full_access_forced_hold_resolves_to_prompt() { + // Pinvou tri-mode safety: an armed host has guaranteed a person can + // answer, so a forced (non-bypassable) hold under Full Access + // resolves to the modal prompt instead of the fail-closed deny. The + // Auto-Review and Never denials stay unconditional — neither posture + // has anyone at the card even when armed (CW-7495 consent-card + // doctrine, generalized by ask-on-guardrail). + let full_access = + authority(AppMode::Agent, true, ApprovalMode::Bypass).with_ask_on_guardrail(false); + assert_eq!( + resolve_approval_request_disposition(&full_access, false, false, true), + ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold, + "unarmed Full Access keeps the fail-closed hold" + ); + let armed = + authority(AppMode::Agent, true, ApprovalMode::Bypass).with_ask_on_guardrail(true); + assert_eq!( + resolve_approval_request_disposition(&armed, false, false, true), + ApprovalRequestDisposition::Prompt, + "armed Full Access opens the approval modal for the hold" + ); + // A session grant must NOT silently consume an armed forced hold. + assert_eq!( + resolve_approval_request_disposition(&armed, true, false, true), + ApprovalRequestDisposition::Prompt, + "armed forced hold ignores session grants" + ); + let armed_auto_review = + authority(AppMode::Agent, false, ApprovalMode::Auto).with_ask_on_guardrail(true); + assert_eq!( + resolve_approval_request_disposition(&armed_auto_review, false, false, true), + ApprovalRequestDisposition::AutoDenyAutoReview, + "Auto-Review stays autonomous even when armed" + ); + let armed_never = + authority(AppMode::Agent, false, ApprovalMode::Never).with_ask_on_guardrail(true); + assert_eq!( + resolve_approval_request_disposition(&armed_never, false, false, true), + ApprovalRequestDisposition::AutoDenyNeverPosture, + "Never stays fail-closed even when armed" + ); + } + + #[test] + fn forkguard_authority_transition_between_renders_the_host_change() { + let agent_full_armed = TurnMetaPosture { + mode: AppMode::Agent, + approval: ApprovalMode::Bypass, + ask_on_guardrail: true, + }; + // Before the first reported turn there is no transition. + assert_eq!( + authority_transition_between(None, agent_full_armed), + None, + "first turn must not render a transition" + ); + // A turn whose posture did not change emits nothing. + assert_eq!( + authority_transition_between(Some(agent_full_armed), agent_full_armed), + None, + "unchanged posture must keep the block byte-stable" + ); + // A mode/posture/arming change renders exactly one line, and any + // number of mid-turn flips collapses into that one change. + let agent_ask_disarmed = TurnMetaPosture { + mode: AppMode::Agent, + approval: ApprovalMode::Suggest, + ask_on_guardrail: false, + }; + let transition = authority_transition_between(Some(agent_full_armed), agent_ask_disarmed) + .expect("a posture change must produce a transition"); + let line = transition.render(); + assert!( + line.contains("Authority transition (host): agent (Full Access, guardrail prompts armed) -> agent (Ask, guardrail prompts disarmed)"), + "transition must name both postures with chips and arming: {line}" + ); + assert!( + line.ends_with("; reason: host_authority_change"), + "transition must carry the stable reason token: {line}" + ); + // An arming-only flip (same mode and posture) is still a transition. + let armed_only = authority_transition_between( + Some(agent_ask_disarmed), + TurnMetaPosture { + mode: AppMode::Agent, + approval: ApprovalMode::Suggest, + ask_on_guardrail: true, + }, + ) + .expect("an arming flip is a transition"); + assert!(armed_only.render().contains("guardrail prompts armed")); + } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 9187a3bdf2..bf960a37c4 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1270,6 +1270,14 @@ pub struct Engine { /// so doctor and debug surfaces can answer "why is this tool unavailable" /// with the same record the user and the model already saw. last_policy_narrowing: Option, + /// The posture the last `` block reported (Pinvou tri-mode + /// safety). Comparison unit for [`Self::pending_authority_transition`]. + last_turn_meta_authority: Option, + /// Host-driven authority change computed at turn start against the + /// posture the previous block reported. Consumed by the turn metadata + /// builder; reset every turn start, so a turn whose posture did not + /// change emits nothing and ordinary blocks stay byte-stable. + pending_authority_transition: Option, /// The git snapshot line last emitted in a `` block this /// session (#5187, k3-gap F3). The snapshot re-collects branch/dirty /// state every turn, so without change-detection the block's bytes drift @@ -1300,6 +1308,11 @@ struct LiveRuntimeAuthority { auto_approve: bool, approval_mode: crate::tui::approval::ApprovalMode, configured_sandbox_mode: Option, + /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety). + /// `None` means "leave the session's current arming unchanged": turn + /// installs re-record the session's live value so an arming published + /// ahead of the queue is never rolled backward by a stale turn posture. + ask_on_guardrail: Option, } impl LiveRuntimeAuthority { @@ -1310,15 +1323,17 @@ impl LiveRuntimeAuthority { auto_approve: bool, approval_mode: crate::tui::approval::ApprovalMode, configured_sandbox_mode: Option, + ask_on_guardrail: Option, ) -> Self { - let authority = TurnAuthority::from_effective_fields( + Self { mode, allow_shell, trust_mode, auto_approve, approval_mode, - ); - Self::from_turn_authority(&authority, configured_sandbox_mode) + configured_sandbox_mode, + ask_on_guardrail, + } } fn from_turn_authority( @@ -1334,6 +1349,9 @@ impl LiveRuntimeAuthority { || approval_mode == crate::tui::approval::ApprovalMode::Bypass, approval_mode, configured_sandbox_mode, + // Turn installs never change the arming; the recorder patches the + // session's live value so re-recording never rolls it backward. + ask_on_guardrail: None, } } @@ -2115,6 +2133,7 @@ impl Engine { false, crate::tui::approval::ApprovalMode::Suggest, api_config.sandbox_mode.clone(), + None, ), ))); let compaction_cancellation = @@ -2396,6 +2415,8 @@ impl Engine { current_mode: AppMode::Agent, turn_wall_clock: turn_budget::TurnWallClock::start(turn_wall_clock_budget), last_policy_narrowing: None, + last_turn_meta_authority: None, + pending_authority_transition: None, last_turn_meta_git_snapshot: StdMutex::new(None), token_estimate_cache: TokenEstimateCache::new(), shared_paused: shared_paused.clone(), @@ -2534,7 +2555,14 @@ impl Engine { // just typed. Typed exec ask-rules still apply as hard Block // denies, and the sandbox/execpolicy layer stays the real safety // boundary. Model-issued shell calls keep the standard approval - // path; this branch is strictly composer provenance. + // path; this branch is strictly composer provenance. (Pinvou + // tri-mode safety: composer provenance also stays un-armed — the + // guardrail deny face blocks a user-typed command in every mode, + // because no modal can ask more than the user already did.) + // Composer provenance keeps the hard deny face in every posture: + // typing the command IS the approval (#5191), and no modal can + // ask more than the user already did. Arming affects + // model-provenance calls only. let ask_rule_decision = exec_shell_ask_rule_decision( &self.config, &tool_name, @@ -2542,6 +2570,7 @@ impl Engine { &self.session.workspace, &self.session.workspace_roots, self.session.approval_mode, + false, ); if let Some(ToolAskRuleDecision::Block(reason)) = ask_rule_decision { Err(ToolError::permission_denied(reason)) @@ -2643,6 +2672,9 @@ impl Engine { /// /// Single authority source for mode/permission state: both the run loop /// and the active turn's typed live-authority drain land here. + // Pinvou tri-mode safety added the ask-on-guardrail carrier; the + // parameter set mirrors the ChangeMode op it consumes. + #[allow(clippy::too_many_arguments)] async fn apply_change_mode( &mut self, mode: AppMode, @@ -2651,6 +2683,7 @@ impl Engine { auto_approve: bool, approval_mode: crate::tui::approval::ApprovalMode, configured_sandbox_mode: Option, + ask_on_guardrail: Option, ) { let authority = TurnAuthority::from_effective_fields( mode, @@ -2660,7 +2693,7 @@ impl Engine { approval_mode, ); let effective_approval = authority.approval_mode_for_session(); - let changed = self.current_mode != authority.mode + let mut changed = self.current_mode != authority.mode || self.session.allow_shell != authority.allow_shell || self.session.trust_mode != authority.trust_mode || self.session.auto_approve @@ -2669,19 +2702,30 @@ impl Engine { || self.session.approval_mode != effective_approval || self.api_config.sandbox_mode != configured_sandbox_mode; self.api_config.sandbox_mode = configured_sandbox_mode; + if let Some(armed) = ask_on_guardrail + && self.session.ask_on_guardrail != armed + { + self.session.ask_on_guardrail = armed; + changed = true; + } self.apply_runtime_mode_policy(&authority); if !changed { return; } self.emit_session_updated().await; - let _ = self - .tx_event - .send(Event::status(format!( - "Runtime policy changed to: {} / {}", - mode.description(), - effective_approval.permission_chip_label(), - ))) - .await; + let mut status = format!( + "Runtime policy changed to: {} / {}", + mode.description(), + effective_approval.permission_chip_label(), + ); + if let Some(armed) = ask_on_guardrail { + status.push_str(if armed { + " (guardrail prompts armed)" + } else { + " (guardrail prompts disarmed)" + }); + } + let _ = self.tx_event.send(Event::status(status)).await; } fn take_pending_runtime_authority(&self) -> Option { @@ -2712,6 +2756,7 @@ impl Engine { authority.auto_approve, authority.approval_mode, authority.configured_sandbox_mode, + authority.ask_on_guardrail, ) .await; } @@ -2725,10 +2770,15 @@ impl Engine { } fn record_applied_runtime_authority(&self, authority: &TurnAuthority) { - let applied = LiveRuntimeAuthority::from_turn_authority( + let mut applied = LiveRuntimeAuthority::from_turn_authority( authority, self.api_config.sandbox_mode.clone(), ); + // Turn installs never change the arming; re-record the session's live + // value so a re-record after a drain can never roll a published + // arming backward (the None from `from_turn_authority` would not + // compare equal to the applied `Some`). + applied.ask_on_guardrail = Some(self.session.ask_on_guardrail); let mut state = self .live_runtime_authority .lock() @@ -4291,6 +4341,7 @@ impl Engine { approval_mode: self.session.approval_mode, working_set: &self.session.working_set, policy_narrowing, + authority_transition: self.pending_authority_transition.clone(), }, ) } @@ -4322,6 +4373,7 @@ impl Engine { approval_mode, working_set, policy_narrowing, + authority_transition, } = snapshot; let today = chrono::Local::now().format("%Y-%m-%d").to_string(); let working_set_summary = working_set @@ -4416,6 +4468,15 @@ impl Engine { .to_string(), ); } + // Pinvou tri-mode safety: when the host changed the mode, permission + // posture, or guardrail arming since the last reported turn, say so + // explicitly — the standing posture lines above describe the state, + // this line describes the change (upstream #6470/#6321 keep the same + // receipt doctrine). Present only on a changed turn, so ordinary + // blocks stay byte-stable. + if let Some(transition) = authority_transition.as_ref() { + lines.push(transition.render()); + } // On ordinary external turns the user's own message is authoritative by // construction, so provenance is redundant. On non-external turns // (sub-agent handoff, runtime events) the *reduced* authority is the @@ -5369,6 +5430,7 @@ impl Engine { Arc::clone(&self.shared_auto_review_policy), self.config.terminal_chrome_enabled, ) + .with_ask_on_guardrail(self.session.ask_on_guardrail) // Typed permission rules must bind delegated calls like they // bind the parent's own; the handle shares the live rulesets, // so mid-session updates stay effective. @@ -5700,6 +5762,24 @@ impl Engine { // after the UI changed modes (#3568). self.apply_runtime_mode_policy(&input_policy); + // Pinvou tri-mode safety: a host-driven authority change between + // turns is never silent. Compare the installed posture against the + // posture the previous block reported; any number of mid-turn + // `ChangeMode` flips collapses into the one transition the next + // block reports. The `None` arm keeps the first turn (and restored + // sessions) transition-free, so this only ever adds a line when a + // change actually happened. + let current_posture = crate::core::authority::TurnMetaPosture { + mode: input_policy.mode, + approval: self.session.approval_mode, + ask_on_guardrail: self.session.ask_on_guardrail, + }; + self.pending_authority_transition = crate::core::authority::authority_transition_between( + self.last_turn_meta_authority, + current_posture, + ); + self.last_turn_meta_authority = Some(current_posture); + // Publish the destination before TurnStarted so hosts can reserve a // steer against this exact turn generation. let steer_target = self.begin_steer_turn(); @@ -5980,6 +6060,7 @@ impl Engine { approval_mode: self.session.approval_mode, working_set: &self.session.working_set, policy_narrowing: self.last_policy_narrowing.as_ref(), + authority_transition: self.pending_authority_transition.clone(), }, ); self.session.add_message(user_msg); @@ -6917,6 +6998,7 @@ impl Engine { Arc::clone(&self.shared_auto_review_policy), self.config.terminal_chrome_enabled, ) + .with_ask_on_guardrail(self.session.ask_on_guardrail) // Typed permission rules must bind delegated calls like they bind the // parent's own; the handle shares the live rulesets, so mid-session // updates stay effective. @@ -8383,6 +8465,7 @@ pub(super) fn exec_shell_ask_rule_decision( workspace: &Path, workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, + ask_on_guardrail: bool, ) -> Option { exec_shell_ask_rule_decision_for_policy( &config.exec_policy_engine, @@ -8391,6 +8474,7 @@ pub(super) fn exec_shell_ask_rule_decision( workspace, workspace_roots, approval_mode, + ask_on_guardrail, ) } @@ -8404,6 +8488,7 @@ pub(crate) fn exec_shell_ask_rule_decision_for_policy( workspace: &Path, workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, + ask_on_guardrail: bool, ) -> Option { let policy_tool_name = crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input); @@ -8439,6 +8524,7 @@ pub(crate) fn exec_shell_ask_rule_decision_for_policy( workspace, workspace_roots, approval_mode, + ask_on_guardrail, ) } @@ -8449,6 +8535,7 @@ pub(super) fn file_tool_ask_rule_decision( workspace: &Path, workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, + ask_on_guardrail: bool, ) -> Option { file_tool_ask_rule_decision_for_policy( &config.exec_policy_engine, @@ -8457,6 +8544,7 @@ pub(super) fn file_tool_ask_rule_decision( workspace, workspace_roots, approval_mode, + ask_on_guardrail, ) } @@ -8480,6 +8568,7 @@ pub(crate) fn file_tool_ask_rule_decision_for_policy( workspace: &Path, workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, + ask_on_guardrail: bool, ) -> Option { let policy_tool_name = crate::tools::canonical_action::canonical_action_alias(tool_name, tool_input); @@ -8494,6 +8583,7 @@ pub(crate) fn file_tool_ask_rule_decision_for_policy( workspace, workspace_roots, approval_mode, + ask_on_guardrail, ); } @@ -8509,6 +8599,7 @@ pub(crate) fn file_tool_ask_rule_decision_for_policy( workspace, workspace_roots, approval_mode, + ask_on_guardrail, ) { Some(ToolAskRuleDecision::Block(reason)) => { return Some(ToolAskRuleDecision::Block(reason)); @@ -8530,6 +8621,9 @@ pub(crate) fn file_tool_ask_rule_decision_for_policy( } } +// Pinvou tri-mode safety added the ask-on-guardrail carrier alongside the +// existing judgment context. +#[allow(clippy::too_many_arguments)] fn tool_ask_rule_decision_for_context( exec_policy_engine: &codewhale_execpolicy::ExecPolicyEngine, tool_name: &str, @@ -8539,6 +8633,7 @@ fn tool_ask_rule_decision_for_context( workspace: &Path, workspace_roots: &[PathBuf], approval_mode: crate::tui::approval::ApprovalMode, + ask_on_guardrail: bool, ) -> Option { let judged_cwd = cwd.to_string_lossy(); let ask_for_approval = match approval_mode { @@ -8564,7 +8659,20 @@ fn tool_ask_rule_decision_for_context( }) .ok()?; if !decision.allow { - Some(ToolAskRuleDecision::Block(decision.reason().to_string())) + // Pinvou tri-mode safety: when the host has armed interactive + // guardrail prompts, it has guaranteed a person can answer, so a + // denied-prefix / typed-deny face becomes an approval request with + // the full matching evidence instead of a hard block. Unarmed (the + // default) keeps the deny face byte-identical to the pre-flag + // doctrine; `Never` posture still denies below regardless. + if ask_on_guardrail && approval_mode != crate::tui::approval::ApprovalMode::Never { + Some(ToolAskRuleDecision::Prompt(format!( + "Guardrail approval requested (ask-on-guardrail is armed): {}", + decision.reason() + ))) + } else { + Some(ToolAskRuleDecision::Block(decision.reason().to_string())) + } } else if decision.requires_approval { Some(ToolAskRuleDecision::Prompt(decision.reason().to_string())) } else if decision.matched_action == Some(codewhale_execpolicy::PermissionAction::Allow) { @@ -8735,6 +8843,7 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { false, crate::tui::approval::ApprovalMode::Suggest, None, + None, ), ))); let compaction_cancellation = Arc::new(StdMutex::new(CompactionCancellationState::default())); @@ -8778,6 +8887,11 @@ pub(crate) struct TurnMetadataSnapshot<'a> { pub(crate) approval_mode: crate::tui::approval::ApprovalMode, pub(crate) working_set: &'a crate::working_set::WorkingSet, pub(crate) policy_narrowing: Option<&'a PolicyNarrowingEvent>, + /// Host-driven authority change since the last reported turn (Pinvou + /// tri-mode safety). `None` keeps the block byte-identical to the + /// pre-flag shape; both the real turn and `/preview-request` read the + /// same engine-computed value, so the preview stays byte-identical. + pub(crate) authority_transition: Option, } /// Immutable prompt facts for the next accepted turn. diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index bb5875de87..013f7881c2 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -70,6 +70,7 @@ impl EngineHandle { auto_approve, approval_mode, configured_sandbox_mode, + ask_on_guardrail, } = op else { return None; @@ -81,6 +82,7 @@ impl EngineHandle { *auto_approve, *approval_mode, configured_sandbox_mode.clone(), + *ask_on_guardrail, )) } @@ -93,6 +95,9 @@ impl EngineHandle { state.authority = authority; } + // Pinvou tri-mode safety added the ask-on-guardrail carrier; the + // parameter set mirrors LiveRuntimeAuthority::from_fields. + #[allow(clippy::too_many_arguments)] pub(crate) fn publish_turn_authority( &self, mode: crate::tui::app::AppMode, @@ -101,6 +106,7 @@ impl EngineHandle { auto_approve: bool, approval_mode: crate::tui::approval::ApprovalMode, configured_sandbox_mode: Option, + ask_on_guardrail: Option, ) { self.publish_runtime_authority(LiveRuntimeAuthority::from_fields( mode, @@ -109,6 +115,7 @@ impl EngineHandle { auto_approve, approval_mode, configured_sandbox_mode, + ask_on_guardrail, )); } diff --git a/crates/tui/src/core/engine/preview.rs b/crates/tui/src/core/engine/preview.rs index a3d87eac9f..bbd68ba557 100644 --- a/crates/tui/src/core/engine/preview.rs +++ b/crates/tui/src/core/engine/preview.rs @@ -416,6 +416,7 @@ impl Engine { approval_mode: input_policy.approval_mode_for_session(), working_set: &previewed_working_set, policy_narrowing: input_policy.narrowing.as_ref(), + authority_transition: self.pending_authority_transition.clone(), }, ); *self diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 0aeef0d720..297ba129c0 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -314,6 +314,7 @@ fn turn_metadata_uses_planned_cross_route_limits_not_installed_limits() { approval_mode: crate::tui::approval::ApprovalMode::Suggest, working_set: &engine.session.working_set, policy_narrowing: None, + authority_transition: None, }, ); let metadata = message diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 6e27d3ee71..fe6005f910 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -10714,6 +10714,7 @@ fn exec_shell_scenario() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!( @@ -10738,6 +10739,7 @@ fn exec_shell_scenario() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Never, + false, ); assert_eq!( @@ -10761,6 +10763,7 @@ fn exec_shell_scenario() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!(decision, None); @@ -10781,6 +10784,7 @@ fn canonical_bash_run_honors_legacy_typed_ask_rules() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!( @@ -10810,6 +10814,7 @@ fn exec_shell_allow_rule_decision_allows_only_exact_command_in_scoped_repo() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -10821,6 +10826,7 @@ fn exec_shell_allow_rule_decision_allows_only_exact_command_in_scoped_repo() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Suggest, + false, ), None ); @@ -10832,6 +10838,7 @@ fn exec_shell_allow_rule_decision_allows_only_exact_command_in_scoped_repo() { Path::new("/other"), &[], crate::tui::approval::ApprovalMode::Suggest, + false, ), None ); @@ -10864,6 +10871,7 @@ fn exec_shell_scoped_allow_rule_does_not_follow_cwd_into_attached_root() { Path::new("/repo"), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -10877,6 +10885,7 @@ fn exec_shell_scoped_allow_rule_does_not_follow_cwd_into_attached_root() { Path::new("/repo"), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -10890,6 +10899,7 @@ fn exec_shell_scoped_allow_rule_does_not_follow_cwd_into_attached_root() { Path::new("/repo"), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), None, "a /repo-scoped allow must not auto-approve execution under an attached root" @@ -10913,6 +10923,7 @@ fn exec_shell_scoped_allow_rule_does_not_follow_cwd_into_attached_root() { Path::new("/repo"), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -10924,6 +10935,7 @@ fn exec_shell_scoped_allow_rule_does_not_follow_cwd_into_attached_root() { Path::new("/repo"), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), None ); @@ -10957,6 +10969,7 @@ fn exec_shell_attached_root_scoped_deny_reaches_rule_decision() { Path::new("/repo"), &[PathBuf::from("/shared")], crate::tui::approval::ApprovalMode::Suggest, + false, ); assert!( matches!(decision, Some(ToolAskRuleDecision::Block(_))), @@ -10972,6 +10985,7 @@ fn exec_shell_attached_root_scoped_deny_reaches_rule_decision() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Suggest, + false, ); assert_eq!(decision, None); } @@ -10993,6 +11007,7 @@ fn file_ask_scenario() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!( @@ -11017,6 +11032,7 @@ fn file_ask_scenario() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!( @@ -11041,6 +11057,7 @@ fn file_ask_scenario() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Never, + false, ); assert_eq!( @@ -11064,6 +11081,7 @@ fn file_ask_scenario() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!(decision, None); @@ -11084,6 +11102,7 @@ fn canonical_file_action_honors_legacy_path_ask_rules() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!( @@ -11114,6 +11133,7 @@ fn camelCase_file_path_alias_reaches_the_ask_rule_and_carve_out_judgments() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!( camel, @@ -11130,6 +11150,7 @@ fn camelCase_file_path_alias_reaches_the_ask_rule_and_carve_out_judgments() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!( snake, camel, @@ -11146,6 +11167,7 @@ fn camelCase_file_path_alias_reaches_the_ask_rule_and_carve_out_judgments() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Auto, + false, ); assert_eq!(canonical_wins, None, "path wins; other.rs is not ruled"); } @@ -11178,6 +11200,7 @@ fn apply_patch_allow_requires_every_touched_path_to_match() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Suggest, + false, ); assert_eq!(fully_allowed, Some(ToolAskRuleDecision::Allow)); @@ -11193,6 +11216,7 @@ fn apply_patch_allow_requires_every_touched_path_to_match() { Path::new("/repo"), &[], crate::tui::approval::ApprovalMode::Suggest, + false, ); assert_eq!(partially_allowed, None); } @@ -14271,6 +14295,9 @@ async fn yolo_mode_does_not_prompt_for_typed_ask_rule() { // stale ApprovalMode::Auto maps to OnFailure in the execpolicy (honors // ask-rules). The auto_review safety floor and typed deny rules still // apply; only the ask-rule Prompt is suppressed under Full Access. + // (Pinvou tri-mode safety: an `ask_on_guardrail`-armed session is the + // one exception — the Prompt is honored because the host guaranteed a + // person can answer; the unarmed default this test pins is unchanged.) let (mut engine, handle) = Engine::new( EngineConfig { exec_policy_engine: ask_rule_engine("echo"), @@ -14791,7 +14818,12 @@ async fn full_access_blocks_non_bypassable_registered_tools_without_prompting() // This intentionally reverses upstream v0.9.12's // `full_access_auto_approves_non_bypassable_registered_tools`. Pinvou // treats a tool that requires an explicit human decision as unavailable - // in Full Access, whose posture cannot open an approval prompt. + // in Full Access, whose posture cannot open an approval prompt. (Pinvou + // tri-mode safety: an `ask_on_guardrail`-armed session is the one + // exception — it has guaranteed a person can answer, so the hold raises + // an approval instead; pinned by + // `forkguard_armed_full_access_hold_prompts_instead_of_blocking`. This + // test keeps pinning the unarmed default.) let _lock = lock_test_env(); let workspace = tempdir().expect("tempdir"); let marker = workspace.path().join("runtime-tool-must-run"); @@ -14887,6 +14919,7 @@ async fn full_access_permission_allow_cannot_bypass_repo_law() { workspace.path(), &[], crate::tui::approval::ApprovalMode::Bypass, + false, ), Some(ToolAskRuleDecision::Allow), "precondition: the remembered grant must match before repo law tightens the plan" @@ -15237,6 +15270,7 @@ async fn full_access_permission_allow_cannot_bypass_background_catastrophic_floo workspace.path(), &[], crate::tui::approval::ApprovalMode::Bypass, + false, ), Some(ToolAskRuleDecision::Allow), "precondition: the remembered grant must match before the safety floor tightens the plan" @@ -17169,6 +17203,7 @@ async fn change_mode_refreshes_session_prompt_and_updates_session() { auto_approve: true, approval_mode: crate::tui::approval::ApprovalMode::Bypass, configured_sandbox_mode: None, + ask_on_guardrail: None, }) .await .expect("send change mode"); @@ -17256,6 +17291,7 @@ async fn live_runtime_authority_applies_latest_posture_and_sandbox_before_tools( auto_approve, approval_mode: posture, configured_sandbox_mode: sandbox_mode, + ask_on_guardrail: None, }) .expect("publish live runtime authority"); @@ -17814,6 +17850,7 @@ async fn change_mode_op_updates_current_mode_and_emits_status() { auto_approve: true, approval_mode: crate::tui::approval::ApprovalMode::Bypass, configured_sandbox_mode: None, + ask_on_guardrail: None, }) .await .expect("send change mode"); @@ -18570,6 +18607,7 @@ async fn edit_last_turn_preserves_current_mode() { auto_approve: false, approval_mode: crate::tui::approval::ApprovalMode::Suggest, configured_sandbox_mode: None, + ask_on_guardrail: None, }) .await .expect("send plan mode"); @@ -21541,6 +21579,7 @@ fn turn_metadata_is_byte_identical_across_identical_consecutive_turns() { approval_mode: engine.session.approval_mode, working_set: &engine.session.working_set, policy_narrowing: None, + authority_transition: None, }, ) }; @@ -21679,6 +21718,7 @@ fn turn_meta_block_stays_within_measured_ceiling() { approval_mode: engine.session.approval_mode, working_set: &engine.session.working_set, policy_narrowing: None, + authority_transition: None, }, ); let ContentBlock::Text { text, .. } = message.content.last().expect("turn metadata block") @@ -21730,6 +21770,7 @@ fn turn_metadata_names_the_effective_sandbox_posture() { approval_mode: engine.session.approval_mode, working_set: &engine.session.working_set, policy_narrowing: None, + authority_transition: None, }, ); let ContentBlock::Text { text, .. } = message.content.last().expect("turn metadata block") @@ -25795,6 +25836,7 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() { false, crate::tui::approval::ApprovalMode::Suggest, None, + None, ), ))), steer_control: Arc::new(StdMutex::new(SteerControlState::default())), @@ -25817,6 +25859,7 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() { auto_approve: false, approval_mode: crate::tui::approval::ApprovalMode::Auto, configured_sandbox_mode: None, + ask_on_guardrail: None, }); assert!(result.is_err(), "try_send should fail when channel is full"); let authority = handle.runtime_permission_authority(); @@ -25859,6 +25902,7 @@ async fn full_mailbox_posture_update_supersedes_queued_change_mode() { auto_approve: false, approval_mode: ApprovalMode::Suggest, configured_sandbox_mode: None, + ask_on_guardrail: None, }) .expect("queue older posture"); for _ in 1..ENGINE_OP_CHANNEL_CAPACITY { @@ -25874,6 +25918,7 @@ async fn full_mailbox_posture_update_supersedes_queued_change_mode() { auto_approve: false, approval_mode: ApprovalMode::Auto, configured_sandbox_mode: Some("read-only".to_string()), + ask_on_guardrail: None, }); assert!( result.is_err(), @@ -30242,6 +30287,7 @@ fn exec_judged_cwd_resolves_symlinks_like_execution() { Path::new(&primary), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -30256,6 +30302,7 @@ fn exec_judged_cwd_resolves_symlinks_like_execution() { Path::new(&primary), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -30270,6 +30317,7 @@ fn exec_judged_cwd_resolves_symlinks_like_execution() { Path::new(&primary), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -30316,6 +30364,7 @@ fn exec_judged_cwd_resolves_absent_interior_behind_symlink() { Path::new(&primary), &roots, crate::tui::approval::ApprovalMode::Suggest, + false, ), Some(ToolAskRuleDecision::Allow) ); @@ -30352,5 +30401,439 @@ fn composer_bang_ask_rule_site_forwards_session_workspace_roots() { ); } +// ───────────────────────── Pinvou tri-mode safety (ask-on-guardrail) ───────────────────────── +// +// These tests pin the host-armed interactive guardrail prompts: unarmed +// behavior must stay byte-identical to the pre-flag doctrine (the existing +// `full_access_blocks_non_bypassable_registered_tools_without_prompting`, +// `yolo_mode_does_not_prompt_for_typed_ask_rule`, and +// `sandbox_escalation_fails_closed_when_the_posture_cannot_prompt` pins keep +// enforcing that); armed behavior converts the fail-closed guardrail faces +// into approval requests because the host has guaranteed a person can answer. + +#[test] +fn forkguard_armed_deny_face_becomes_guardrail_prompt_unarmed_stays_block() { + let engine = + codewhale_execpolicy::ExecPolicyEngine::new(Vec::new(), vec!["rm -rf".to_string()]); + let workspace = Path::new("/repo"); + let command = "rm -rf /tmp/whatever"; + + // Unarmed (the default): the denied-prefix face stays a hard block with + // the main-line wording. + let unarmed = super::tool_ask_rule_decision_for_context( + &engine, + "exec_shell", + command, + None, + workspace, + workspace, + &[], + crate::tui::approval::ApprovalMode::Bypass, + false, + ); + match unarmed { + Some(ToolAskRuleDecision::Block(reason)) => { + assert!( + reason.contains("denied prefix rule 'rm -rf'"), + "unarmed deny face must carry the main-line reason: {reason}" + ); + } + other => panic!("unarmed deny face must Block, got {other:?}"), + } + + // Armed under Full Access: the same face becomes an approval request + // carrying the full matching evidence — the rich denied-prefix matching + // face is what makes armed prompting meaningful. + let armed = super::tool_ask_rule_decision_for_context( + &engine, + "exec_shell", + command, + None, + workspace, + workspace, + &[], + crate::tui::approval::ApprovalMode::Bypass, + true, + ); + match armed { + Some(ToolAskRuleDecision::Prompt(reason)) => { + assert!( + reason.starts_with("Guardrail approval requested (ask-on-guardrail is armed):"), + "armed deny face must be framed as a guardrail approval: {reason}" + ); + assert!( + reason.contains("denied prefix rule 'rm -rf'"), + "armed prompt must carry the matching evidence: {reason}" + ); + } + other => panic!("armed deny face must Prompt, got {other:?}"), + } + + // `Never` never prompts, even when armed: nobody is at the card. + let never_armed = super::tool_ask_rule_decision_for_context( + &engine, + "exec_shell", + command, + None, + workspace, + workspace, + &[], + crate::tui::approval::ApprovalMode::Never, + true, + ); + assert!( + matches!(never_armed, Some(ToolAskRuleDecision::Block(_))), + "Never posture keeps the deny face even when armed, got {never_armed:?}" + ); +} + +#[test] +fn forkguard_turn_meta_authority_transition_renders_only_on_change() { + use crate::core::authority::{AuthorityTransition, TurnMetaPosture}; + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..EngineConfig::default() + }; + let (engine, _handle) = Engine::new(config, &Config::default()); + let prompt_context = NextTurnPromptContext::for_planned_turn( + ApiProvider::Deepseek, + "deepseek-v4-flash".to_string(), + None, + AppMode::Agent, + None, + crate::tools::goal::GoalStatus::Active, + None, + false, + None, + ); + fn snapshot_with<'a>( + engine: &'a Engine, + prompt_context: &'a NextTurnPromptContext, + transition: Option, + ) -> TurnMetadataSnapshot<'a> { + TurnMetadataSnapshot { + prompt_context, + system_prompt: None, + approval_mode: engine.session.approval_mode, + working_set: &engine.session.working_set, + policy_narrowing: None, + authority_transition: transition, + } + } + let meta_of = |msg: &Message| -> String { + let ContentBlock::Text { text, .. } = msg.content.last().expect("turn metadata block") + else { + panic!("expected text metadata block"); + }; + text.clone() + }; + let message_for = |engine: &Engine, transition: Option| { + engine.user_text_message_from_snapshot( + "mode flip context".to_string(), + &prompt_context.model, + false, + None, + false, + UserInputProvenance::ExternalUser, + snapshot_with(engine, &prompt_context, transition), + ) + }; + + // No transition: the block carries no transition line at all. + let plain_meta = meta_of(&message_for(&engine, None)); + assert!( + !plain_meta.contains("Authority transition"), + "blocks must stay transition-free without a host change: {plain_meta}" + ); + + // A transition renders exactly one explicit line naming both postures. + let transition = AuthorityTransition::from_postures( + TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: true, + }, + TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }, + ); + let changed_meta = meta_of(&message_for(&engine, Some(transition))); + assert!( + changed_meta.contains( + "Authority transition (host): agent (Full Access, guardrail prompts armed) -> agent (Ask, guardrail prompts disarmed); reason: host_authority_change" + ), + "the transition line must be explicit and self-describing: {changed_meta}" + ); + assert_eq!( + changed_meta + .lines() + .filter(|line| line.contains("Authority transition")) + .count(), + 1, + "exactly one transition line" + ); +} + +#[test] +fn forkguard_live_turn_meta_builder_reads_the_pending_transition() { + use crate::core::authority::{AuthorityTransition, TurnMetaPosture}; + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + ..EngineConfig::default() + }; + let (mut engine, _handle) = Engine::new(config, &Config::default()); + let block_text = |engine: &Engine| -> String { + match engine.turn_metadata_block( + "deepseek-v4-flash", + false, + None, + false, + UserInputProvenance::ExternalUser, + "hello", + None, + ) { + ContentBlock::Text { text, .. } => text, + other => panic!("expected text metadata block, got {other:?}"), + } + }; + + // No pending transition: byte-stable ordinary block. + engine.pending_authority_transition = None; + let plain = block_text(&engine); + assert!(!plain.contains("Authority transition"), "{plain}"); + + // The turn-start comparison stores the pending transition; the live + // builder must render it (this is the wiring between the turn-start + // compare and the outbound message). + engine.pending_authority_transition = Some(AuthorityTransition::from_postures( + TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: true, + }, + TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }, + )); + let armed = block_text(&engine); + assert!( + armed.contains("Authority transition (host):"), + "live builder must render the pending transition: {armed}" + ); +} + +#[tokio::test] +async fn forkguard_armed_full_access_hold_prompts_instead_of_blocking() { + // The unarmed sibling of this behavior is pinned by + // `full_access_blocks_non_bypassable_registered_tools_without_prompting`. + // Armed, the same hold must reach the host as an approval request with + // `approval_force_prompt` set (no session grant or posture shortcut may + // consume it silently), and the deny decision must read as an approval + // denial — never as the Full Access block. + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + + let server = MockServer::start().await; + let tool_delta = json!({ + "id": "chatcmpl-armed-hold", + "choices": [{ + "index": 0, + "delta": {"tool_calls": [{ + "index": 0, + "id": "call_armed_hold_0", + "type": "function", + "function": { + "name": "start_mcp_server", + "arguments": json!({"server": "probe-server", "name": "armed-probe"}).to_string(), + }, + }]}, + "finish_reason": serde_json::Value::Null, + }], + }); + let tool_finish = json!({ + "id": "chatcmpl-armed-hold", + "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], + }); + let tool_call_sse = format!("data: {tool_delta}\n\ndata: {tool_finish}\n\ndata: [DONE]\n\n"); + let done_sse = concat!( + "data: {\"id\":\"chatcmpl-armed-done\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"done\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-armed-done\",\"choices\":[{\"index\":0,\"delta\":{},", + "\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + + // `start_mcp_server` is a deferred tool: its first use returns the + // schema receipt (discovery, never execution), and the model's retry is + // the call the armed hold gates. Three-step wire: (lowest priority) + // first request -> tool call; (2) schema-receipt followup -> the same + // tool call again, now hydrated; (highest) denial followup -> done. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("denied by user")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(done_sse), + ) + .expect(1) + .with_priority(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("Retry with the loaded schema")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(tool_call_sse.clone()), + ) + .expect(1) + .with_priority(2) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(tool_call_sse), + ) + .expect(1) + .with_priority(3) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let (engine, handle) = Engine::new(engine_config, &api_config); + let handle_for_approval = handle.clone(); + let run_task = tokio::spawn(engine.run()); + + // Arm through the same `ChangeMode` channel hosts use for the toggle. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: true, + trust_mode: true, + auto_approve: true, + approval_mode: crate::tui::approval::ApprovalMode::Bypass, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .await + .expect("arm guardrail prompts"); + + handle + .send(Op::SendMessage { + content: "exercise the armed Full Access hold".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: true, + trust_mode: true, + auto_approve: true, + approval_mode: crate::tui::approval::ApprovalMode::Bypass, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send armed Full Access model turn"); + + let mut saw_prompt = false; + let mut saw_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for armed hold event") + { + match event { + Event::ApprovalRequired { + id, + tool_name, + description, + approval_force_prompt, + .. + } => { + assert_eq!(tool_name, "start_mcp_server"); + assert!( + approval_force_prompt, + "armed hold must carry approval_force_prompt" + ); + assert!( + description.contains("ask-on-guardrail is armed"), + "armed hold description must say why it prompts: {description}" + ); + saw_prompt = true; + handle_for_approval + .deny_tool_call(id) + .await + .expect("deny armed hold"); + } + Event::ToolCallComplete { name, result, .. } if name == "start_mcp_server" => { + // The first completion is the deferred-tool schema receipt + // (an Ok result, never executed); the hold denial is the + // error completion after the hydrated retry. + let Some(error) = result.as_ref().err() else { + continue; + }; + let error = error.to_string(); + assert!( + error.contains("denied by user"), + "armed hold denial must read as an approval denial: {error}" + ); + assert!( + !error.contains("blocked in Full Access"), + "armed hold must not be phrased as the Full Access block: {error}" + ); + saw_complete = true; + } + Event::TurnComplete { status, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed); + break; + } + _ => {} + } + } + drop(rx); + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run_task.await.expect("engine task"); + assert!(saw_prompt, "armed Full Access hold must prompt"); + assert!(saw_complete, "denied armed hold must complete the turn"); +} + #[path = "partial_input_differential_tests.rs"] mod partial_input_differential_tests; diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 13edb512a0..ed63ce3ed2 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -913,6 +913,9 @@ pub(super) fn registered_tool_forces_prompt( /// Repo-law `ask` rules require a human decision. Only Ask posture can open /// that decision; every autonomous or no-prompt posture must fail closed. +/// (Pinvou tri-mode safety: an `ask_on_guardrail`-armed session is the one +/// exception — the hold prompts because the host guaranteed a person can +/// answer.) pub(super) fn repo_law_must_block_without_prompt( approval_mode: crate::tui::approval::ApprovalMode, auto_approve: bool, @@ -3944,24 +3947,51 @@ impl Engine { if let Some(prepared) = prepared_policy { let registered_non_bypassable = registered_tool_forces_prompt(&tool_name, prepared.call.approval); + let armed_full_access_hold = registered_tool_blocked_in_full_access( + &tool_name, + prepared.call.approval, + prepared.auto_approve, + ) && self.session.ask_on_guardrail; if registered_tool_blocked_in_full_access( &tool_name, prepared.call.approval, prepared.auto_approve, - ) { + ) && !armed_full_access_hold + { approval_required = false; blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' requires explicit approval and is blocked in Full Access because this posture does not open tool-approval prompts. Switch to Ask to review this call." ))); } else { - approval_required = registered_tool_approval_required( - &tool_name, - prepared.call.approval, - prepared.auto_approve, - ); - approval_force_prompt = registered_non_bypassable; + // Armed (ask-on-guardrail) hosts are the one exception to + // the Full Access hold above: they have guaranteed a + // person can answer, so the hold becomes the approval. + // Set it explicitly rather than falling through to the + // ordinary resolver, which auto-approves non-bypassable + // tools under auto-approve (the #3866 reversal) and + // would silently run exactly what the hold exists to ask + // about. `approval_force_prompt` then keeps session + // grants and posture shortcuts from consuming the event + // silently. + approval_required = if armed_full_access_hold { + true + } else { + registered_tool_approval_required( + &tool_name, + prepared.call.approval, + prepared.auto_approve, + ) + }; + approval_force_prompt = registered_non_bypassable || armed_full_access_hold; } - approval_description = prepared.call.description; + approval_description = if armed_full_access_hold { + format!( + "{} — this call requires explicit approval (ask-on-guardrail is armed)", + prepared.call.description + ) + } else { + prepared.call.description + }; supports_parallel = prepared.call.supports_parallel; read_only = prepared.call.read_only; detached_start = prepared.call.starts_detached; @@ -4043,6 +4073,7 @@ impl Engine { &self.session.workspace, &self.session.workspace_roots, self.session.approval_mode, + self.session.ask_on_guardrail, ) .or_else(|| { file_tool_ask_rule_decision( @@ -4052,6 +4083,7 @@ impl Engine { &self.session.workspace, &self.session.workspace_roots, self.session.approval_mode, + self.session.ask_on_guardrail, ) }); if let Some(decision) = ask_rule_decision { @@ -4070,8 +4102,10 @@ impl Engine { // #3790: the mode is the sole authority — a typed // ask-rule prompts in Agent/Plan but never in YOLO // (auto_approve). A typed deny rule still blocks - // hard, in every mode. - if !self.session.auto_approve { + // hard, in every mode. Armed hosts are the one + // exception: their ask-on-guardrail guarantee + // converts the hold into a real prompt. + if !self.session.auto_approve || self.session.ask_on_guardrail { approval_required = true; approval_description = reason; approval_force_prompt = true; @@ -4196,7 +4230,8 @@ impl Engine { if repo_law_must_block_without_prompt( self.session.approval_mode, self.session.auto_approve, - ) { + ) && !self.session.ask_on_guardrail + { approval_required = false; approval_force_prompt = false; blocked_error = Some(ToolError::permission_denied(format!( @@ -4298,12 +4333,31 @@ impl Engine { approval_required = true; approval_force_prompt = true; } - Ok(Some(_)) => { + Ok(Some(_)) if !self.session.ask_on_guardrail => { blocked_error = Some(ToolError::permission_denied(format!( "Sandbox escalation requires a one-shot user approval, but the current {} posture cannot provide it. Switch to Ask or continue without escalation.", batch_approval_mode.permission_chip_label() ))); } + // Armed hosts get the same Suggest-path approval flow as + // above (description already carries the justification). + Ok(Some(_)) => { + let escalation_description = format!( + "Sandbox escalation to '{}' for this exact call: armed guardrail approval", + tool_input["sandbox_permissions"] + .as_str() + .expect("validated sandbox permission") + ); + approval_description = if approval_force_prompt { + format!( + "{escalation_description}. Additional approval gate: {approval_description}" + ) + } else { + escalation_description + }; + approval_required = true; + approval_force_prompt = true; + } Ok(None) => {} Err(error) => blocked_error = Some(error), } diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 4c9795c36c..2fa3e6b1aa 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -419,6 +419,16 @@ pub enum Op { auto_approve: bool, approval_mode: ApprovalMode, configured_sandbox_mode: Option, + /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety). + /// When `Some`, arms or disarms the guardrail sites that otherwise + /// fail closed under an autonomous posture (non-bypassable holds, + /// repo-law holds, typed ask rules, the execpolicy deny face, + /// sandbox escalation): armed, they raise an approval request with a + /// real person guaranteed on the other end instead of denying. + /// `None` leaves the session's current arming unchanged. The flag is + /// session-level state like `auto_approve`; it takes effect when this + /// op is processed and applies to subsequent tool planning. + ask_on_guardrail: Option, }, /// Replace the engine-level tool deny-list for subsequent turns. diff --git a/crates/tui/src/core/protocol_parity.rs b/crates/tui/src/core/protocol_parity.rs index d0bbcebbee..374eb40ff6 100644 --- a/crates/tui/src/core/protocol_parity.rs +++ b/crates/tui/src/core/protocol_parity.rs @@ -1038,6 +1038,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op { auto_approve, approval_mode, configured_sandbox_mode, + ask_on_guardrail, } => wire_op::Op::ChangeMode { mode: app_mode_str(*mode).to_string(), allow_shell: *allow_shell, @@ -1045,6 +1046,7 @@ pub fn op_to_protocol(op: &Op) -> wire_op::Op { auto_approve: *auto_approve, approval_mode: approval_mode_str(*approval_mode).to_string(), configured_sandbox_mode: configured_sandbox_mode.clone(), + ask_on_guardrail: *ask_on_guardrail, }, Op::SetDisallowedTools { tools } => wire_op::Op::SetDisallowedTools { tools: tools.clone(), diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 5079b3c9c8..1cacc1e2d3 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -432,6 +432,17 @@ pub struct Session { /// Live UI approval policy used to steer the system prompt. pub approval_mode: ApprovalMode, + /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety). + /// When set, the guardrail sites that fail closed under an autonomous + /// posture — non-bypassable holds, repo-law holds, typed ask rules, the + /// execpolicy deny face, sandbox escalation, the deterministic floor — + /// raise an approval request instead of denying, because the host has + /// guaranteed a person can answer. Session-level state like + /// `auto_approve`: flipped by `Op::ChangeMode`, unchanged by turn + /// installs, and it never widens what may execute without a decision + /// (unarmed behavior is byte-identical to the pre-flag doctrine). + pub ask_on_guardrail: bool, + /// Notes file path pub notes_path: PathBuf, @@ -536,6 +547,7 @@ impl Session { trust_mode, auto_approve: false, approval_mode: ApprovalMode::Suggest, + ask_on_guardrail: false, notes_path, mcp_config_path, id: uuid::Uuid::new_v4().to_string(), diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index ce41b58e01..4f44c8289a 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -4256,6 +4256,7 @@ impl RuntimeThreadManager { auto_approve: policy.auto_approve(), approval_mode: policy.permission, configured_sandbox_mode, + ask_on_guardrail: None, }); } } @@ -6048,6 +6049,7 @@ impl RuntimeThreadManager { auto_approve: policy.auto_approve(), approval_mode: policy.permission, configured_sandbox_mode: configured_sandbox_mode.clone(), + ask_on_guardrail: None, }); } @@ -7740,6 +7742,7 @@ impl RuntimeThreadManager { auto_approve, policy.permission, configured_sandbox_mode, + None, ); let _sender = permit.send(op); touch_lru(&mut active.lru, thread_id); @@ -8056,6 +8059,7 @@ impl RuntimeThreadManager { policy.auto_approve(), policy.permission, configured_sandbox_mode, + None, ); let _sender = permit.send(op); touch_lru(&mut active.lru, thread_id); diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 7069cd9820..3ea1da2f02 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -2496,6 +2496,14 @@ pub struct SubAgentRuntime { /// Defaults to an empty engine (no rules), which leaves child behavior /// unchanged for embedders that never thread one. pub exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine, + /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety), + /// inherited from the parent session. When set, a guardrail hold that + /// would fail closed under this child's posture routes to the parent's + /// approval surface (when the host can prompt) instead of denying, and a + /// delegated typed-ask Prompt refuses instead of silently passing under + /// inherited auto-approve: children never run what the armed main line + /// would prompt on. + pub ask_on_guardrail: bool, } impl SubAgentRuntime { @@ -2563,6 +2571,7 @@ impl SubAgentRuntime { // tool-only runtimes). governor: None, exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::new(Vec::new(), Vec::new()), + ask_on_guardrail: false, } } @@ -2613,6 +2622,15 @@ impl SubAgentRuntime { self } + /// Carry the parent session's ask-on-guardrail arming into child gates so + /// children mirror the armed main line instead of failing closed around + /// it (see [`Self::ask_on_guardrail`]). + #[must_use] + pub fn with_ask_on_guardrail(mut self, ask_on_guardrail: bool) -> Self { + self.ask_on_guardrail = ask_on_guardrail; + self + } + /// Match generated worker display names to the active session language. #[must_use] pub fn with_locale_tag(mut self, locale_tag: impl Into) -> Self { @@ -2909,6 +2927,7 @@ impl SubAgentRuntime { parent_can_prompt: self.parent_can_prompt, approval_receipt_store: self.approval_receipt_store.clone(), exec_policy_engine: self.exec_policy_engine.clone(), + ask_on_guardrail: self.ask_on_guardrail, } } @@ -14593,6 +14612,9 @@ struct SubAgentToolRegistry { /// command the parent turn loop would hard-deny cannot be delegated to /// this child and run anyway. Empty by default → checks are no-ops. exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine, + /// Host-armed interactive guardrail prompts inherited from the parent + /// session (see [`SubAgentRuntime::ask_on_guardrail`]). + ask_on_guardrail: bool, } /// What the child permission gate decided for one held call. @@ -14684,6 +14706,7 @@ impl SubAgentToolRegistry { // Copied out before `runtime` moves into `gate_runtime` below. let exec_policy_engine = runtime.exec_policy_engine.clone(); + let ask_on_guardrail = runtime.ask_on_guardrail; Self { allowed_tools, disallowed_tools: effective_profile.denied_tools.clone(), @@ -14699,6 +14722,7 @@ impl SubAgentToolRegistry { registry, gate_runtime: runtime, exec_policy_engine, + ask_on_guardrail, } } @@ -14855,8 +14879,18 @@ impl SubAgentToolRegistry { ChildGateVerdict::Deny(reason) } // Full Access has no prompt to force; the plan decision - // already turned every safety-floor hold into a block. - AutoReviewPlanDecision::ForcePrompt(reason) => ChildGateVerdict::Deny(reason), + // already turned every safety-floor hold into a block. An + // armed host is the one exception: it has guaranteed a person + // can answer, so the hold routes to the parent's approval + // surface (which fails closed again for non-prompting hosts). + AutoReviewPlanDecision::ForcePrompt(reason) => { + if self.ask_on_guardrail { + self.prompt_parent_for_child_call(agent_id, name, input, &reason, true) + .await + } else { + ChildGateVerdict::Deny(reason) + } + } AutoReviewPlanDecision::NoChange | AutoReviewPlanDecision::Allow | AutoReviewPlanDecision::ConsultReviewer(_) => ChildGateVerdict::Proceed, @@ -15787,6 +15821,10 @@ impl SubAgentToolRegistry { // on. The engine handle shares the parent's live rulesets, so a rule // installed mid-session binds delegated calls too, and an empty // engine (no rules) leaves this check a no-op. + // The deny face stays hard here in every posture (`ask_on_guardrail: + // false` keeps the Block conversion): an armed parent prompts on this + // call in the main conversation, so the correct child answer is the + // refusal below that points the model there — never a silent pass. let ask_rule_decision = crate::core::engine::exec_shell_ask_rule_decision_for_policy( &self.exec_policy_engine, name, @@ -15794,6 +15832,7 @@ impl SubAgentToolRegistry { &self.registry.context().workspace, &self.registry.context().workspace_roots, crate::tui::approval::ApprovalMode::Auto, + false, ) .or_else(|| { crate::core::engine::file_tool_ask_rule_decision_for_policy( @@ -15803,6 +15842,7 @@ impl SubAgentToolRegistry { &self.registry.context().workspace, &self.registry.context().workspace_roots, crate::tui::approval::ApprovalMode::Auto, + false, ) }); match ask_rule_decision { @@ -15818,8 +15858,11 @@ impl SubAgentToolRegistry { // held-call gate's "Tool {name} requires approval" refusal so a // caller (and a test) can tell which gate spoke. Some(crate::core::engine::ToolAskRuleDecision::Prompt(reason)) - if !self.auto_approve => + if !self.auto_approve || self.ask_on_guardrail => { + // Armed hosts: the main line would prompt on this typed ask + // even under inherited auto-approve, so the child refuses + // instead of silently running what the parent must decide. return Err(anyhow!(format!( "Delegated tool call `{name}` requires approval: {reason}. Sub-agents cannot show an approval prompt; run this tool call in the main conversation so it can be approved." ))); diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 995d2d26c1..50bd00d5a2 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -8762,6 +8762,65 @@ async fn forkguard_subagent_execpolicy_deny_matches_main_line() { assert!(!output.starts_with("Error:"), "{output}"); } +/// Pinvou tri-mode safety: an armed child must refuse a delegated typed-ask +/// Prompt even under inherited auto-approve — the armed main line would +/// prompt on this exact call, so silently running it in the child would +/// bypass the very decision the host asked to make. Unarmed, the #3790 +/// posture authority holds and the same call passes (pinned by +/// `forkguard_subagent_execpolicy_deny_matches_main_line`). +#[tokio::test] +async fn forkguard_armed_child_refuses_delegated_typed_ask_even_under_auto_approve() { + let tmp = tempdir().expect("tempdir"); + let engine = codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![ + codewhale_execpolicy::ToolAskRule::exec_shell("cargo publish"), + ]), + ]); + // Unarmed + auto-approve: passes like the main line's auto-run. + let unarmed = worker_registry_with_engine_and_approval(&tmp, engine.clone(), true); + unarmed + .execute( + "agent_policy_unarmed", + "Bash", + json!({"action": "run", "command": "cargo publish --dry-run"}), + ) + .await + .expect("unarmed auto-approved child must pass the typed ask rule"); + + // Armed + auto-approve: refuses with the main-conversation pointer. + let mut armed_runtime = + stub_runtime().with_agent_tool_surface_options(enabled_agent_surface_options()); + armed_runtime.context = ToolContext::new(tmp.path().to_path_buf()); + armed_runtime.context.auto_approve = true; + armed_runtime.allow_shell = true; + armed_runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Worker); + armed_runtime.ask_on_guardrail = true; + let armed = SubAgentToolRegistry::new( + armed_runtime.with_exec_policy_engine(engine), + FleetRole::Worker, + None, + crate::tools::todo::new_shared_todo_list(), + crate::tools::plan::new_shared_plan_state(), + ); + let error = armed + .execute( + "agent_policy_armed", + "Bash", + json!({"action": "run", "command": "cargo publish --dry-run"}), + ) + .await + .expect_err("armed child must refuse a delegated typed ask under auto-approve") + .to_string(); + assert!( + error.contains("requires approval"), + "armed refusal must name the approval: {error}" + ); + assert!( + error.contains("run this tool call in the main conversation"), + "armed refusal must point at the main conversation: {error}" + ); +} + /// Without a ruleset the delegated path is byte-identical to the unwired /// behavior: the gate is a no-op, and the very same call that a deny rule /// blocks runs when the rule is absent. @@ -14467,6 +14526,7 @@ pub(crate) fn stub_runtime() -> SubAgentRuntime { // path treats `None` as "report nothing". governor: None, exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::new(Vec::new(), Vec::new()), + ask_on_guardrail: false, } } diff --git a/crates/tui/src/tui/ui/provider_routes.rs b/crates/tui/src/tui/ui/provider_routes.rs index a653e33774..bb69b90fe8 100644 --- a/crates/tui/src/tui/ui/provider_routes.rs +++ b/crates/tui/src/tui/ui/provider_routes.rs @@ -583,6 +583,7 @@ pub(crate) async fn sync_mode_update(app: &App, engine_handle: &EngineHandle) { auto_approve: app_auto_approve_enabled(app), approval_mode: app.approval_mode, configured_sandbox_mode: app.configured_sandbox_mode.clone(), + ask_on_guardrail: None, }) .await; } diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 8cc772b52f..9bd87bbfe0 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -7954,6 +7954,7 @@ async fn mode_change_update_notifies_engine() { auto_approve, approval_mode, configured_sandbox_mode, + ask_on_guardrail: None, } => { // The deprecated YOLO alias lands in Agent mode with full-access // compat policies (M6 shim); the engine sees the remapped mode. @@ -7995,6 +7996,7 @@ async fn mode_change_update_sends_restored_agent_policy() { auto_approve, approval_mode, configured_sandbox_mode, + ask_on_guardrail: None, } => { assert_eq!(mode, crate::tui::app::AppMode::Agent); assert!(allow_shell); From 41eab46f2fe2f6cb27f45bc9931afd4060f7c87b Mon Sep 17 00:00:00 2001 From: asto18089 Date: Sat, 31 Oct 2026 05:05:45 +0800 Subject: [PATCH 02/29] test(engine): pin armed guardrail faces and transition wiring Follow-ups from self-review of the host-armed guardrail work: every armed face claimed by the PR now has a dedicated forkguard pin. - armed repo-law ask hold falls through to a forced approval (unarmed stays a hard block, grant-cache cannot consume it silently) - armed sandbox escalation prompts with approval_force_prompt instead of failing closed; unarmed fail-closed face unchanged - armed typed ask-rule Prompt is honored under auto_approve on the model-provenance mainline (composer face stays unarmed) - the preview manifest hashes the exact authority-transition line the live wire body sends; no pending change keeps previews byte-stable - installing a turn authority never rolls back published arming - apply_change_mode status suffixes report arming changes Also merges the duplicated composer-path arming comment. Signed-off-by: asto18089 --- crates/tui/src/core/engine.rs | 11 +- crates/tui/src/core/engine/preview/tests.rs | 146 ++++ crates/tui/src/core/engine/tests.rs | 715 +++++++++++++++++++- 3 files changed, 850 insertions(+), 22 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index bf960a37c4..f3eb6f52df 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2556,13 +2556,10 @@ impl Engine { // denies, and the sandbox/execpolicy layer stays the real safety // boundary. Model-issued shell calls keep the standard approval // path; this branch is strictly composer provenance. (Pinvou - // tri-mode safety: composer provenance also stays un-armed — the - // guardrail deny face blocks a user-typed command in every mode, - // because no modal can ask more than the user already did.) - // Composer provenance keeps the hard deny face in every posture: - // typing the command IS the approval (#5191), and no modal can - // ask more than the user already did. Arming affects - // model-provenance calls only. + // tri-mode safety: composer provenance keeps the hard deny face in + // every posture, armed or not — typing the command IS the approval + // (#5191), no modal can ask more than the user already did, and + // arming affects model-provenance calls only.) let ask_rule_decision = exec_shell_ask_rule_decision( &self.config, &tool_name, diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 297ba129c0..2f797cdd23 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -1252,6 +1252,152 @@ async fn paused_detach_goal_context_matches_captured_first_production_body() { ); } +/// Pinvou tri-mode safety: the preview manifest must be byte-faithful about +/// authority transitions. With a transition pending, the previewed body must +/// hash the exact same `Authority transition (host): ...` bytes the live +/// turn_meta builder renders onto the wire body; without one, consecutive +/// previews stay byte-stable (the transition never leaks into a body that +/// production would not send). +#[tokio::test] +async fn forkguard_preview_manifest_hashes_the_same_authority_transition_line_the_wire_sends() { + use crate::core::authority::{TurnMetaPosture, authority_transition_between}; + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string("data: [DONE]\n\n"), + ) + .mount(&server) + .await; + let mut config = deepseek_config(); + config + .providers + .as_mut() + .expect("providers") + .deepseek + .base_url = Some(server.uri()); + let identity = deepseek_identity(); + let (mut engine, _tmp) = wire_preview_engine(&config); + + let previous = TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: true, + }; + let current = TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }; + let transition = authority_transition_between(Some(previous), current) + .expect("a host posture change yields a transition"); + let transition_line = transition.render(); + + let prompt = "inspect the armed-to-ask handoff"; + + // With a transition pending, the previewed body carries the transition. + engine.pending_authority_transition = Some(transition.clone()); + let planned = plan(&config, &identity, false, prompt).await; + let manifest = engine + .build_request_manifest(inputs(false, Some(planned), prompt)) + .await; + let armed_hash = manifest + .body + .exact() + .expect("preview body is exact") + .body_sha256 + .clone(); + + // Without a pending change the preview output is byte-stable: two + // consecutive builds hash identically, and neither carries the + // transition. + engine.pending_authority_transition = None; + let planned = plan(&config, &identity, false, prompt).await; + let plain_first = engine + .build_request_manifest(inputs(false, Some(planned), prompt)) + .await; + let plain_hash = plain_first + .body + .exact() + .expect("preview body is exact") + .body_sha256 + .clone(); + let planned = plan(&config, &identity, false, prompt).await; + let plain_again = engine + .build_request_manifest(inputs(false, Some(planned), prompt)) + .await; + let plain_again_hash = plain_again + .body + .exact() + .expect("preview body is exact") + .body_sha256 + .clone(); + assert_eq!( + plain_hash, plain_again_hash, + "without a pending change the previewed body must stay byte-stable" + ); + assert_ne!( + armed_hash, plain_hash, + "a pending authority transition must change the previewed body" + ); + + // Production derives the same transition at turn start and sends the + // exact bytes the armed preview hashed. + engine.last_turn_meta_authority = Some(previous); + let planned = plan(&config, &identity, false, prompt).await; + let production_route = planned.route.clone(); + let compaction = planned.compaction.clone(); + let reasoning_effort = planned.effective_reasoning_effort.clone(); + let reasoning_effort_auto = planned.auto_controls_reasoning; + let _ = engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + production_route, + compaction, + None, + None, + GoalStatus::Active, + reasoning_effort, + reasoning_effort_auto, + false, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + false, + None, + Vec::new(), + None, + None, + UserInputProvenance::ExternalUser, + None, + ) + .await; + + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!(requests.len(), 1, "the fixture must make one provider call"); + let wire_body: serde_json::Value = + serde_json::from_slice(&requests[0].body).expect("first HTTP body is JSON"); + let wire_hash = + crate::hashing::sha256_hex(crate::client::canonical_json(&wire_body).as_bytes()); + assert_eq!( + armed_hash, wire_hash, + "preview hash must match the transition-bearing body production sends" + ); + assert!( + wire_body.to_string().contains(&transition_line), + "the wire body must carry the exact transition line the live builder renders: {transition_line}" + ); +} + #[tokio::test] async fn anthropic_preview_matches_the_first_native_messages_wire_body() { use wiremock::matchers::{method, path}; diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index fe6005f910..b8bd82e61c 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -8460,6 +8460,136 @@ async fn sandbox_escalation_fails_closed_when_the_posture_cannot_prompt() { } } +#[tokio::test] +async fn forkguard_armed_sandbox_escalation_prompts_instead_of_failing_closed() { + // The unarmed fail-closed faces are pinned by + // `sandbox_escalation_fails_closed_when_the_posture_cannot_prompt`. + // Armed, the same non-Suggest escalation must reach the host as a + // Suggest-style approval (`approval_force_prompt` set, description + // naming the armed guardrail approval), and denying it must read as an + // approval denial — never as the fail-closed posture block, and never a + // silent execution. + use crate::llm_client::mock::{MockLlmClient, canned}; + + let workspace = tempdir().expect("tempdir"); + let mock = std::sync::Arc::new(MockLlmClient::new(vec![ + canned::tool_call_turn( + "call-armed-escalation", + "bash", + r#"{"command":"echo denied > escalation.txt","sandbox_permissions":"workspace-write","justification":"the command needs workspace write access"}"#, + ), + canned::simple_text_turn("Escalation was denied."), + ])); + let client: crate::core::model_client::SharedModelClient = mock.clone(); + let config = Config { + sandbox_mode: Some("read-only".to_string()), + ..Config::default() + }; + let (engine, handle) = Engine::new_with_model_client( + deterministic_engine_config(workspace.path()), + &config, + client, + ); + let handle_for_approval = handle.clone(); + let task = tokio::spawn(engine.run()); + + // Arm through the same `ChangeMode` channel hosts use for the toggle. + // The posture stays Full Access (non-Suggest): exactly the posture whose + // escalation face flips from deny to prompt when armed. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: true, + trust_mode: true, + auto_approve: true, + approval_mode: crate::tui::approval::ApprovalMode::Bypass, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .await + .expect("arm guardrail prompts"); + + let mut op = external_user_message_op( + "Escalate the sandbox for this one command.", + AppMode::Agent, + &config, + ); + let Op::SendMessage { + approval_mode: op_approval_mode, + auto_approve: op_auto_approve, + .. + } = &mut op + else { + panic!("user message op") + }; + *op_approval_mode = crate::tui::approval::ApprovalMode::Bypass; + *op_auto_approve = true; + handle.send(op).await.expect("send armed escalation turn"); + + let mut saw_prompt = false; + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for armed escalation") + .expect("engine event stream closed"); + match event { + Event::ApprovalRequired { + id, + input, + description, + approval_force_prompt, + .. + } if id == "call-armed-escalation" => { + assert_eq!(input["sandbox_permissions"], "workspace-write"); + assert!( + approval_force_prompt, + "armed escalation must carry approval_force_prompt" + ); + assert!( + description.contains("Sandbox escalation to 'workspace-write'"), + "{description}" + ); + assert!( + description.contains("armed guardrail approval"), + "armed escalation description must name the arming: {description}" + ); + saw_prompt = true; + handle_for_approval + .deny_tool_call(id) + .await + .expect("deny armed escalation"); + } + Event::ToolCallComplete { id, result, .. } if id == "call-armed-escalation" => { + let error = result.expect_err("denied armed escalation must be an error"); + let error = error.to_string(); + assert!( + error.contains("denied by user"), + "the armed denial must read as an approval denial: {error}" + ); + assert!( + !error.contains("requires a one-shot user approval"), + "armed escalation must not take the fail-closed deny face: {error}" + ); + } + Event::TurnComplete { status, error, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}"); + break; + } + _ => {} + } + } + drop(rx); + + assert!(saw_prompt, "armed non-Suggest escalation must prompt"); + assert!( + !workspace.path().join("escalation.txt").exists(), + "a denied armed escalation must not execute" + ); + handle.send(Op::Shutdown).await.expect("shutdown engine"); + task.await.expect("engine task"); +} + #[tokio::test] async fn productive_tool_results_do_not_hit_no_user_input_backstop() { use crate::llm_client::mock::{MockLlmClient, canned}; @@ -14341,6 +14471,190 @@ async fn yolo_mode_does_not_prompt_for_typed_ask_rule() { assert!(saw_complete); } +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn forkguard_armed_full_access_typed_ask_rule_prompts_instead_of_auto_running() { + // The unarmed faces are pinned by + // `yolo_mode_does_not_prompt_for_typed_ask_rule` (composer `!` + // provenance) and + // `full_access_subagent_handoff_keeps_model_shell_free_of_approval_prompts` + // (model-provenance mainline): in the Full Access posture a typed + // ask-rule Prompt is suppressed and the call auto-runs. Armed, the + // mainline turn loop must honor that same Prompt — the ask-rule hold + // reaches the host as a forced approval even though the session grants + // auto-approval, and denying it reads as an approval denial, never as a + // silent auto-run. + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let server = MockServer::start().await; + + let tool_call_sse = concat!( + "data: {\"id\":\"chatcmpl-armed-ask-rule\",\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[", + "{\"index\":0,\"id\":\"call_armed_ask_rule\",\"type\":\"function\",\"function\":{\"name\":\"Bash\",", + "\"arguments\":\"{\\\"action\\\":\\\"run\\\",\\\"command\\\":\\\"echo armed-typed-ask-rule\\\"}\"}}", + "]},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-armed-ask-rule\",\"choices\":[{\"index\":0,\"delta\":{},", + "\"finish_reason\":\"tool_calls\"}]}\n\n", + "data: [DONE]\n\n", + ); + let done_sse = concat!( + "data: {\"id\":\"chatcmpl-armed-ask-rule-done\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"done\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-armed-ask-rule-done\",\"choices\":[{\"index\":0,\"delta\":{},", + "\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("denied by user")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(done_sse), + ) + .expect(1) + .with_priority(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(tool_call_sse), + ) + .expect(1) + .with_priority(2) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let (engine, handle) = Engine::new( + EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + exec_policy_engine: ask_rule_engine("echo"), + ..EngineConfig::default() + }, + &api_config, + ); + let handle_for_approval = handle.clone(); + let run_task = tokio::spawn(engine.run()); + + // Arm through the same `ChangeMode` channel hosts use for the toggle. + // The posture stays Full Access (auto_approve), so only the arming can + // convert the typed ask-rule hold into a prompt. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: true, + trust_mode: true, + auto_approve: true, + approval_mode: crate::tui::approval::ApprovalMode::Bypass, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .await + .expect("arm guardrail prompts"); + + handle + .send(Op::SendMessage { + content: "exercise the armed typed ask-rule hold".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: true, + trust_mode: true, + auto_approve: true, + approval_mode: crate::tui::approval::ApprovalMode::Bypass, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send armed typed ask-rule turn"); + + let mut saw_prompt = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for armed typed ask-rule event") + { + match event { + Event::ApprovalRequired { + id, + tool_name, + description, + approval_force_prompt, + .. + } => { + assert_eq!(tool_name, "Bash"); + assert!( + approval_force_prompt, + "armed typed ask-rule hold must carry approval_force_prompt" + ); + // The plain typed ask-rule face keeps the main-line reason + // (the "ask-on-guardrail is armed" framing belongs to the + // rich denied-prefix face); arming only makes this Prompt + // honored instead of suppressed under auto_approve. + assert!( + description.contains("Typed ask rule") + && description.contains("requires approval"), + "armed typed ask-rule hold must carry the typed rule reason: {description}" + ); + saw_prompt = true; + handle_for_approval + .deny_tool_call(id) + .await + .expect("deny armed typed ask-rule hold"); + } + Event::ToolCallComplete { name, result, .. } if name == "Bash" => { + let error = result.expect_err("denied ask-rule call must be an error"); + let error = error.to_string(); + assert!( + error.contains("denied by user"), + "the armed denial must read as an approval denial: {error}" + ); + } + Event::TurnComplete { status, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed); + break; + } + _ => {} + } + } + drop(rx); + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run_task.await.expect("engine task"); + assert!( + saw_prompt, + "armed Full Access must prompt for a typed ask-rule instead of auto-running" + ); +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn operate_model_shell_uses_normal_approval_and_workspace_sandbox() { @@ -14972,26 +15286,245 @@ async fn full_access_repo_law_holds_writes_under_attached_roots() { subagents_enabled: false, ..EngineConfig::default() }; - let tool_input = json!({ - "action": "write", - "path": target.to_string_lossy(), - "content": "must not be written\n" + let tool_input = json!({ + "action": "write", + "path": target.to_string_lossy(), + "content": "must not be written\n" + }); + + assert_full_access_model_tool_batch_is_blocked( + engine_config, + vec![("File", tool_input)], + &[( + "File", + "Repository law blocked tool 'File' in Full Access: Repo law holds this write: \"Shared root notes need human review\"", + )], + "Repository law blocked tool 'File' in Full Access: Repo law holds this write:", + ) + .await; + + assert!( + !target.exists(), + "repo-law block must prevent the write under the attached root" + ); +} + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn forkguard_armed_repo_law_hold_prompts_instead_of_blocking() { + // The unarmed siblings are pinned by + // `full_access_permission_allow_cannot_bypass_repo_law` and + // `full_access_repo_law_holds_writes_under_attached_roots`: a repo-law + // ForcePrompt hold fails closed to the hard deny in a posture that + // cannot prompt. Armed, the same hold — even behind a remembered allow + // grant — must fall through to a forced approval prompt, and denying it + // must read as an approval denial, never as the Full Access law block. + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let law_dir = workspace.path().join(".codewhale"); + fs::create_dir_all(&law_dir).expect("create law directory"); + fs::write( + law_dir.join("constitution.json"), + r#"{ + "protected_invariants": [{ + "text": "Release notes need human review", + "paths": ["CHANGELOG.md"] + }] + }"#, + ) + .expect("write repo law fixture"); + let target = workspace.path().join("CHANGELOG.md"); + let allow_rule = codewhale_execpolicy::ToolAskRule::file_path("write_file", "CHANGELOG.md") + .into_exact_workspace_allow(workspace.path().to_string_lossy().into_owned()); + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(vec![], vec![]).with_ask_rules(vec![allow_rule]), + ]), + ..EngineConfig::default() + }; + + let server = MockServer::start().await; + let tool_delta = json!({ + "id": "chatcmpl-armed-repo-law", + "choices": [{ + "index": 0, + "delta": {"tool_calls": [{ + "index": 0, + "id": "call_armed_repo_law_0", + "type": "function", + "function": { + "name": "File", + "arguments": json!({ + "action": "write", + "path": "CHANGELOG.md", + "content": "must not be written\n" + }) + .to_string(), + }, + }]}, + "finish_reason": serde_json::Value::Null, + }], + }); + let tool_finish = json!({ + "id": "chatcmpl-armed-repo-law", + "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], }); + let tool_call_sse = format!("data: {tool_delta}\n\ndata: {tool_finish}\n\ndata: [DONE]\n\n"); + let done_sse = concat!( + "data: {\"id\":\"chatcmpl-armed-repo-law-done\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"done\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-armed-repo-law-done\",\"choices\":[{\"index\":0,\"delta\":{},", + "\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); - assert_full_access_model_tool_batch_is_blocked( - engine_config, - vec![("File", tool_input)], - &[( - "File", - "Repository law blocked tool 'File' in Full Access: Repo law holds this write: \"Shared root notes need human review\"", - )], - "Repository law blocked tool 'File' in Full Access: Repo law holds this write:", - ) - .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("denied by user")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(done_sse), + ) + .expect(1) + .with_priority(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(tool_call_sse), + ) + .expect(1) + .with_priority(2) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let (engine, handle) = Engine::new(engine_config, &api_config); + let handle_for_approval = handle.clone(); + let run_task = tokio::spawn(engine.run()); + + // Arm through the same `ChangeMode` channel hosts use for the toggle; + // the posture stays Full Access, whose unarmed face is the hard deny. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: true, + trust_mode: true, + auto_approve: true, + approval_mode: crate::tui::approval::ApprovalMode::Bypass, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .await + .expect("arm guardrail prompts"); + + handle + .send(Op::SendMessage { + content: "exercise the armed repo-law hold".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, crate::config::DEFAULT_TEXT_MODEL), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: true, + trust_mode: true, + auto_approve: true, + approval_mode: crate::tui::approval::ApprovalMode::Bypass, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send armed repo-law turn"); + + let mut saw_prompt = false; + let mut saw_complete = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for armed repo-law event") + { + match event { + Event::ApprovalRequired { + id, + tool_name, + description, + approval_force_prompt, + .. + } => { + assert_eq!(tool_name, "File"); + assert!( + approval_force_prompt, + "armed repo-law hold must carry approval_force_prompt" + ); + assert!( + description.contains("Repo law holds this write"), + "armed hold must carry the repo-law reason: {description}" + ); + assert!( + description.contains("Release notes need human review"), + "armed hold must name the protected invariant: {description}" + ); + saw_prompt = true; + handle_for_approval + .deny_tool_call(id) + .await + .expect("deny armed repo-law hold"); + } + Event::ToolCallComplete { name, result, .. } if name == "File" => { + let error = result.expect_err("denied repo-law write must be an error"); + let error = error.to_string(); + assert!( + error.contains("denied by user"), + "the armed denial must read as an approval denial: {error}" + ); + assert!( + !error.contains("Repository law blocked"), + "armed hold must not be phrased as the hard law block: {error}" + ); + saw_complete = true; + } + Event::TurnComplete { status, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed); + break; + } + _ => {} + } + } + drop(rx); + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run_task.await.expect("engine task"); + assert!(saw_prompt, "armed repo-law hold must prompt, not block"); + assert!(saw_complete, "denied armed repo-law hold must complete"); assert!( !target.exists(), - "repo-law block must prevent the write under the attached root" + "a denied armed repo-law hold must not write" ); } @@ -30835,5 +31368,157 @@ async fn forkguard_armed_full_access_hold_prompts_instead_of_blocking() { assert!(saw_complete, "denied armed hold must complete the turn"); } +#[tokio::test] +async fn forkguard_turn_authority_install_never_rolls_back_published_arming() { + // `record_applied_runtime_authority` rebuilds the applied authority via + // `from_turn_authority`, whose `ask_on_guardrail` is always `None` + // (turn installs carry no arming). The recorder must patch the + // session's live arming back in: a turn authority install after a + // published arming may not roll the recorded `Some(true)` backward to + // `None`, or the equality-based re-record check would flap the + // authority state on every turn install. + let tmp = tempdir().expect("tempdir"); + let (mut engine, handle) = Engine::new( + EngineConfig { + workspace: tmp.path().to_path_buf(), + ..EngineConfig::default() + }, + &Config::default(), + ); + + // The host arms through the same publish channel `Op::ChangeMode` uses. + handle.publish_turn_authority( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + Some(true), + ); + assert!( + engine.apply_pending_runtime_authority().await, + "the armed publish must reach the session" + ); + assert!(engine.session.ask_on_guardrail, "session must be armed"); + let published = engine.runtime_authority_snapshot(); + assert_eq!(published.ask_on_guardrail, Some(true)); + + // A later turn installs its arming-free turn authority; the re-record + // must keep the published arming and stay quiescent. + let turn_authority = TurnAuthority::from_effective_fields( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + ); + engine.record_applied_runtime_authority(&turn_authority); + + let recorded = engine.runtime_authority_snapshot(); + assert_eq!( + recorded.ask_on_guardrail, + Some(true), + "turn authority install must not roll the published arming back to None" + ); + assert_eq!( + recorded, published, + "re-record must be a no-op against the published armed authority" + ); + assert!( + engine.take_pending_runtime_authority().is_none(), + "no spurious re-record may schedule another authority transition" + ); +} + +#[tokio::test] +async fn forkguard_apply_change_mode_status_suffixes_report_the_arming_change() { + // The mode-changed status event is the user-visible receipt of an + // arming toggle: arming and disarming must each append their explicit + // suffix, and a change without the ask carrier must stay suffix-free so + // it never claims an arming flip that did not happen. + async fn status_message(handle: &EngineHandle) -> String { + let mut rx = handle.rx_event.write().await; + loop { + match rx.recv().await { + Some(Event::Status { message }) => return message, + Some(_) => continue, + None => panic!("event stream closed before the status message"), + } + } + } + + let tmp = tempdir().expect("tempdir"); + let (mut engine, handle) = Engine::new( + EngineConfig { + workspace: tmp.path().to_path_buf(), + ..EngineConfig::default() + }, + &Config::default(), + ); + + // Arming. + engine + .apply_change_mode( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + Some(true), + ) + .await; + let armed_status = status_message(&handle).await; + assert!( + armed_status.starts_with("Runtime policy changed to:"), + "{armed_status}" + ); + assert!( + armed_status.ends_with(" (guardrail prompts armed)"), + "arming must be announced: {armed_status}" + ); + + // Disarming. + engine + .apply_change_mode( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + Some(false), + ) + .await; + let disarmed_status = status_message(&handle).await; + assert!( + disarmed_status.ends_with(" (guardrail prompts disarmed)"), + "disarming must be announced: {disarmed_status}" + ); + + // A mode-only change without the ask carrier carries no suffix. + engine + .apply_change_mode( + AppMode::Plan, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + None, + ) + .await; + let mode_status = status_message(&handle).await; + assert!( + mode_status.starts_with("Runtime policy changed to:"), + "{mode_status}" + ); + assert!( + !mode_status.contains("(guardrail prompts"), + "a change without the ask carrier must not claim an arming flip: {mode_status}" + ); +} + #[path = "partial_input_differential_tests.rs"] mod partial_input_differential_tests; From 04c1f7843df8859bf68e02284ff6a3a5fed44081 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Sat, 31 Oct 2026 18:29:47 +0800 Subject: [PATCH 03/29] fix(engine): scope armed faces, consume transitions once Review follow-ups on the host-armed guardrail work: - the authority transition is computed when the turn's metadata block is built (after the NotStarted gate, so a failed send can no longer record a posture the model never saw) and consumed by that block only, so steers and mid-turn runtime notices stop re-rendering the same line; - the preview manifest recomputes the transition from the recorded posture instead of reading the consumed leftover, keeping the preview byte-faithful when a host flips posture between turns; - armed face conversions (execpolicy deny face, repo-law holds, sandbox escalation) now exclude the Auto-Review and Never postures explicitly, making their unconditional denials true at every armed face, and the armed escalation card carries the model's justification; - annotate the fail-safe child ForcePrompt arm and the always-unarmed UI approval authority, document the from_fields raw-field contract, and correct the session.rs face list (the deterministic floor stays a hard deny when armed); - pin the detector end to end (record + consume + second-turn byte-stability) and the armed Auto-Review deny face. Signed-off-by: asto18089 --- crates/tui/src/core/authority.rs | 11 +- crates/tui/src/core/engine.rs | 69 ++++--- crates/tui/src/core/engine/preview.rs | 15 +- crates/tui/src/core/engine/preview/tests.rs | 13 +- crates/tui/src/core/engine/tests.rs | 190 +++++++++++++++++++- crates/tui/src/core/engine/turn_loop.rs | 47 +++-- crates/tui/src/core/session.rs | 16 +- crates/tui/src/exec_agent.rs | 6 + crates/tui/src/tools/subagent/mod.rs | 14 +- crates/tui/src/tui/ui/approval_routing.rs | 8 + 10 files changed, 325 insertions(+), 64 deletions(-) diff --git a/crates/tui/src/core/authority.rs b/crates/tui/src/core/authority.rs index 1ae6cd29ac..fb812bb6ae 100644 --- a/crates/tui/src/core/authority.rs +++ b/crates/tui/src/core/authority.rs @@ -149,11 +149,12 @@ pub(crate) struct TurnMetaPosture { /// the model only through the next turn's standing posture lines — the model /// could observe the new state but never the change itself, so a behavior /// shift (suddenly running commands without approval, or being asked to -/// approve what previously ran) read as unexplained. The engine records the -/// transition at turn start against the posture the previous block reported -/// and renders one model-visible line; a turn whose posture did not change -/// emits nothing, keeping ordinary blocks byte-stable. -#[derive(Debug, Clone, PartialEq, Eq)] +/// approve what previously ran) read as unexplained. The engine computes the +/// transition when the turn's metadata block is built, against the posture +/// the previous block reported, and renders one model-visible line there and +/// only there; a turn whose posture did not change emits nothing, keeping +/// ordinary blocks byte-stable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AuthorityTransition { from_mode: &'static str, from_approval: ApprovalMode, diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f3eb6f52df..5cb661e7f9 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1273,10 +1273,11 @@ pub struct Engine { /// The posture the last `` block reported (Pinvou tri-mode /// safety). Comparison unit for [`Self::pending_authority_transition`]. last_turn_meta_authority: Option, - /// Host-driven authority change computed at turn start against the - /// posture the previous block reported. Consumed by the turn metadata - /// builder; reset every turn start, so a turn whose posture did not - /// change emits nothing and ordinary blocks stay byte-stable. + /// Host-driven authority change computed when the turn's metadata block + /// is built, against the posture the previous block reported. Consumed + /// by that block only and cleared immediately, so a turn whose posture + /// did not change emits nothing, ordinary blocks stay byte-stable, and + /// steers or mid-turn runtime notices never re-render the same line. pending_authority_transition: Option, /// The git snapshot line last emitted in a `` block this /// session (#5187, k3-gap F3). The snapshot re-collects branch/dirty @@ -1316,6 +1317,13 @@ struct LiveRuntimeAuthority { } impl LiveRuntimeAuthority { + /// Store the op's fields as given. Unlike [`Self::from_turn_authority`] + /// this does not renormalize `approval_mode` through + /// `approval_mode_for_session`: every producer (`Op::ChangeMode`, the + /// UI sync, runtime threads) already sends the canonical shape, and a + /// non-canonical publish self-heals at the next + /// [`Self::record_applied_runtime_authority`], which re-records from + /// the session's normalized state. fn from_fields( mode: AppMode, allow_shell: bool, @@ -5759,24 +5767,6 @@ impl Engine { // after the UI changed modes (#3568). self.apply_runtime_mode_policy(&input_policy); - // Pinvou tri-mode safety: a host-driven authority change between - // turns is never silent. Compare the installed posture against the - // posture the previous block reported; any number of mid-turn - // `ChangeMode` flips collapses into the one transition the next - // block reports. The `None` arm keeps the first turn (and restored - // sessions) transition-free, so this only ever adds a line when a - // change actually happened. - let current_posture = crate::core::authority::TurnMetaPosture { - mode: input_policy.mode, - approval: self.session.approval_mode, - ask_on_guardrail: self.session.ask_on_guardrail, - }; - self.pending_authority_transition = crate::core::authority::authority_transition_between( - self.last_turn_meta_authority, - current_posture, - ); - self.last_turn_meta_authority = Some(current_posture); - // Publish the destination before TurnStarted so hosts can reserve a // steer against this exact turn generation. let steer_target = self.begin_steer_turn(); @@ -6044,6 +6034,28 @@ impl Engine { // Add the user message through the same explicit snapshot constructor // preview uses. Route limits and mode in resource metadata therefore // belong to this turn even when the previous route was different. + + // Pinvou tri-mode safety: a host-driven authority change between + // turns is never silent. Compare the installed posture against the + // posture the previous block reported; any number of mid-turn + // `ChangeMode` flips collapses into the one transition the next + // block reports. The `None` arm keeps the first turn (and restored + // sessions) transition-free, so this only ever adds a line when a + // change actually happened. Computed here — at the point the block + // that reports it is built — so a `NotStarted` failure above can + // never record a posture the model never saw; and consumed below, + // so steers and mid-turn runtime notices cannot re-render the same + // line. + let current_posture = crate::core::authority::TurnMetaPosture { + mode: input_policy.mode, + approval: self.session.approval_mode, + ask_on_guardrail: self.session.ask_on_guardrail, + }; + self.pending_authority_transition = crate::core::authority::authority_transition_between( + self.last_turn_meta_authority, + current_posture, + ); + self.last_turn_meta_authority = Some(current_posture); let user_msg = self.user_text_message_from_snapshot( content, &model, @@ -6061,6 +6073,8 @@ impl Engine { }, ); self.session.add_message(user_msg); + // The transition is reported by this block and only this block. + self.pending_authority_transition = None; self.emit_session_updated().await; @@ -8661,8 +8675,15 @@ fn tool_ask_rule_decision_for_context( // denied-prefix / typed-deny face becomes an approval request with // the full matching evidence instead of a hard block. Unarmed (the // default) keeps the deny face byte-identical to the pre-flag - // doctrine; `Never` posture still denies below regardless. - if ask_on_guardrail && approval_mode != crate::tui::approval::ApprovalMode::Never { + // doctrine; Auto-Review and Never postures keep it in every case — + // their denials stay unconditional because nobody can answer the + // card there even when armed. + if ask_on_guardrail + && !matches!( + approval_mode, + crate::tui::approval::ApprovalMode::Auto | crate::tui::approval::ApprovalMode::Never + ) + { Some(ToolAskRuleDecision::Prompt(format!( "Guardrail approval requested (ask-on-guardrail is armed): {}", decision.reason() diff --git a/crates/tui/src/core/engine/preview.rs b/crates/tui/src/core/engine/preview.rs index bbd68ba557..050b23a9d9 100644 --- a/crates/tui/src/core/engine/preview.rs +++ b/crates/tui/src/core/engine/preview.rs @@ -416,7 +416,20 @@ impl Engine { approval_mode: input_policy.approval_mode_for_session(), working_set: &previewed_working_set, policy_narrowing: input_policy.narrowing.as_ref(), - authority_transition: self.pending_authority_transition.clone(), + // Pinvou tri-mode safety: recompute the transition exactly the + // live turn will (recorded posture vs the planned turn's + // posture), never read the turn-start leftover — a host that + // flips posture between turns previews the line the live body + // will send, and an already-consumed change never leaks here. + authority_transition: + crate::core::authority::authority_transition_between( + self.last_turn_meta_authority, + crate::core::authority::TurnMetaPosture { + mode: input_policy.mode, + approval: self.session.approval_mode, + ask_on_guardrail: self.session.ask_on_guardrail, + }, + ), }, ); *self diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 2f797cdd23..d6480ce234 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -1299,8 +1299,9 @@ async fn forkguard_preview_manifest_hashes_the_same_authority_transition_line_th let prompt = "inspect the armed-to-ask handoff"; - // With a transition pending, the previewed body carries the transition. - engine.pending_authority_transition = Some(transition.clone()); + // With the previous block having reported the armed posture, the preview + // recomputes the same transition the live turn will render. + engine.last_turn_meta_authority = Some(previous); let planned = plan(&config, &identity, false, prompt).await; let manifest = engine .build_request_manifest(inputs(false, Some(planned), prompt)) @@ -1312,10 +1313,10 @@ async fn forkguard_preview_manifest_hashes_the_same_authority_transition_line_th .body_sha256 .clone(); - // Without a pending change the preview output is byte-stable: two - // consecutive builds hash identically, and neither carries the - // transition. - engine.pending_authority_transition = None; + // Without a recorded posture (first turn / restored session) the preview + // output is byte-stable: two consecutive builds hash identically, and + // neither carries the transition. + engine.last_turn_meta_authority = None; let planned = plan(&config, &identity, false, prompt).await; let plain_first = engine .build_request_manifest(inputs(false, Some(planned), prompt)) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index b8bd82e61c..bb691b12b0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -31018,6 +31018,24 @@ fn forkguard_armed_deny_face_becomes_guardrail_prompt_unarmed_stays_block() { matches!(never_armed, Some(ToolAskRuleDecision::Block(_))), "Never posture keeps the deny face even when armed, got {never_armed:?}" ); + + // Auto-Review keeps it too: the resolver denies unconditionally there, + // so the armed conversion must not even reword the deny face. + let auto_armed = super::tool_ask_rule_decision_for_context( + &engine, + "exec_shell", + command, + None, + workspace, + workspace, + &[], + crate::tui::approval::ApprovalMode::Auto, + true, + ); + assert!( + matches!(auto_armed, Some(ToolAskRuleDecision::Block(_))), + "Auto-Review posture keeps the deny face even when armed, got {auto_armed:?}" + ); } #[test] @@ -31440,10 +31458,12 @@ async fn forkguard_apply_change_mode_status_suffixes_report_the_arming_change() async fn status_message(handle: &EngineHandle) -> String { let mut rx = handle.rx_event.write().await; loop { - match rx.recv().await { - Some(Event::Status { message }) => return message, - Some(_) => continue, - None => panic!("event stream closed before the status message"), + let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for the status message") + .expect("event stream closed before the status message"); + if let Event::Status { message } = event { + return message; } } } @@ -31520,5 +31540,167 @@ async fn forkguard_apply_change_mode_status_suffixes_report_the_arming_change() ); } +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn forkguard_turn_start_detector_records_the_posture_and_renders_the_transition_once() { + // Pins the production detector end to end (compare → record → render → + // consume): the turn that sees a host flip renders the line on its own + // block, records the posture it reported, and consumes the pending + // change; the next turn under the same posture stays byte-stable. + // Deleting the recorder update turns the second request red (the stale + // posture re-renders); deleting the consumption reset leaves the pending + // transition dangling for mid-turn blocks. + use crate::core::authority::TurnMetaPosture; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let plain_sse = concat!( + "data: {\"id\":\"chatcmpl-detector\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-detector\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(plain_sse), + ) + .expect(2) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + let (mut engine, _handle) = Engine::new(engine_config, &api_config); + + // The previous block reported Full Access disarmed; this turn runs Full + // Access armed, so the arming flip alone must produce the line. + engine.last_turn_meta_authority = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }); + engine.session.ask_on_guardrail = true; + + async fn send_turn( + engine: &mut Engine, + api_config: &Config, + prompt: &str, + ) -> SendMessageOutcome { + engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + *resolved_route_for_test(api_config, crate::config::DEFAULT_TEXT_MODEL), + CompactionConfig::default(), + None, + None, + crate::tools::goal::GoalStatus::Active, + None, + false, + false, + true, + true, + true, + crate::tui::approval::ApprovalMode::Bypass, + false, + None, + Vec::new(), + None, + None, + UserInputProvenance::ExternalUser, + None, + ) + .await + } + + let outcome = send_turn(&mut engine, &api_config, "turn one").await; + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "turn one must complete without starting" + ); + assert!( + engine.pending_authority_transition.is_none(), + "the turn's block must consume the transition so steers and runtime notices cannot re-render it" + ); + assert_eq!( + engine.last_turn_meta_authority, + Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: true, + }), + "the detector must record the posture the block reported" + ); + + let outcome = send_turn(&mut engine, &api_config, "turn two").await; + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "turn two must complete without starting" + ); + + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!(requests.len(), 2, "each turn makes exactly one provider call"); + let first = String::from_utf8_lossy(&requests[0].body); + assert!( + first.contains( + "Authority transition (host): agent (Full Access, guardrail prompts disarmed) -> agent (Full Access, guardrail prompts armed); reason: host_authority_change", + ), + "the changed turn's wire body must carry the exact transition line" + ); + let second = String::from_utf8_lossy(&requests[1].body); + assert_eq!( + second.matches("Authority transition (host)").count(), + 1, + "turn one's rendered line persists in history exactly once" + ); + let wire_body: serde_json::Value = + serde_json::from_slice(&requests[1].body).expect("second request body is JSON"); + let last_user_message = wire_body["messages"] + .as_array() + .expect("messages array") + .iter() + .rev() + .find(|message| message["role"] == "user") + .expect("a user message exists"); + let last_user_content = last_user_message["content"].as_str().expect("string content"); + assert!( + !last_user_content.contains("Authority transition"), + "an unchanged posture must keep the next turn's own block byte-stable: {last_user_content}" + ); +} + #[path = "partial_input_differential_tests.rs"] mod partial_input_differential_tests; diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index ed63ce3ed2..8f17f8de41 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -914,8 +914,9 @@ pub(super) fn registered_tool_forces_prompt( /// Repo-law `ask` rules require a human decision. Only Ask posture can open /// that decision; every autonomous or no-prompt posture must fail closed. /// (Pinvou tri-mode safety: an `ask_on_guardrail`-armed session is the one -/// exception — the hold prompts because the host guaranteed a person can -/// answer.) +/// exception for promptable postures — Full Access / auto-approve — because +/// the host guaranteed a person can answer. Auto-Review and Never keep the +/// fail-closed block even when armed.) pub(super) fn repo_law_must_block_without_prompt( approval_mode: crate::tui::approval::ApprovalMode, auto_approve: bool, @@ -3947,17 +3948,13 @@ impl Engine { if let Some(prepared) = prepared_policy { let registered_non_bypassable = registered_tool_forces_prompt(&tool_name, prepared.call.approval); - let armed_full_access_hold = registered_tool_blocked_in_full_access( + let full_access_hold = registered_tool_blocked_in_full_access( &tool_name, prepared.call.approval, prepared.auto_approve, - ) && self.session.ask_on_guardrail; - if registered_tool_blocked_in_full_access( - &tool_name, - prepared.call.approval, - prepared.auto_approve, - ) && !armed_full_access_hold - { + ); + let armed_full_access_hold = full_access_hold && self.session.ask_on_guardrail; + if full_access_hold && !armed_full_access_hold { approval_required = false; blocked_error = Some(ToolError::permission_denied(format!( "Tool '{tool_name}' requires explicit approval and is blocked in Full Access because this posture does not open tool-approval prompts. Switch to Ask to review this call." @@ -4227,10 +4224,19 @@ impl Engine { ); match decision { crate::repo_law::RepoLawPlanDecision::ForcePrompt(reason) => { + // The armed exception applies only where a person can + // actually answer the card (Full Access / auto-approve). + // Auto-Review and Never keep the fail-closed block: + // their denials stay unconditional even when armed. if repo_law_must_block_without_prompt( self.session.approval_mode, self.session.auto_approve, - ) && !self.session.ask_on_guardrail + ) && !(self.session.ask_on_guardrail + && !matches!( + self.session.approval_mode, + crate::tui::approval::ApprovalMode::Auto + | crate::tui::approval::ApprovalMode::Never + )) { approval_required = false; approval_force_prompt = false; @@ -4333,17 +4339,28 @@ impl Engine { approval_required = true; approval_force_prompt = true; } - Ok(Some(_)) if !self.session.ask_on_guardrail => { + Ok(Some(_)) + if !self.session.ask_on_guardrail + || matches!( + batch_approval_mode, + crate::tui::approval::ApprovalMode::Auto + | crate::tui::approval::ApprovalMode::Never + ) => + { + // Unarmed, or armed under an unattended posture + // (Auto-Review / Never): nobody can answer the card, + // so the escalation keeps its fail-closed deny — the + // arming guarantee does not override the posture. blocked_error = Some(ToolError::permission_denied(format!( "Sandbox escalation requires a one-shot user approval, but the current {} posture cannot provide it. Switch to Ask or continue without escalation.", batch_approval_mode.permission_chip_label() ))); } // Armed hosts get the same Suggest-path approval flow as - // above (description already carries the justification). - Ok(Some(_)) => { + // above, with the model's justification on the card. + Ok(Some((_policy, justification))) => { let escalation_description = format!( - "Sandbox escalation to '{}' for this exact call: armed guardrail approval", + "Sandbox escalation to '{}' for this exact call: {justification} (armed guardrail approval)", tool_input["sandbox_permissions"] .as_str() .expect("validated sandbox permission") diff --git a/crates/tui/src/core/session.rs b/crates/tui/src/core/session.rs index 1cacc1e2d3..9477e9f4d1 100644 --- a/crates/tui/src/core/session.rs +++ b/crates/tui/src/core/session.rs @@ -435,12 +435,18 @@ pub struct Session { /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety). /// When set, the guardrail sites that fail closed under an autonomous /// posture — non-bypassable holds, repo-law holds, typed ask rules, the - /// execpolicy deny face, sandbox escalation, the deterministic floor — - /// raise an approval request instead of denying, because the host has - /// guaranteed a person can answer. Session-level state like + /// execpolicy deny face, sandbox escalation — raise an approval request + /// instead of denying, because the host has guaranteed a person can + /// answer. Auto-Review and Never keep every deny face unconditionally + /// (nobody can answer there even when armed), and the built-in + /// deterministic safety floor never converts. Session-level state like /// `auto_approve`: flipped by `Op::ChangeMode`, unchanged by turn - /// installs, and it never widens what may execute without a decision - /// (unarmed behavior is byte-identical to the pre-flag doctrine). + /// installs, and it never widens what may execute without a decision. + /// Not persisted: a restored session starts disarmed and the host must + /// re-arm. With the flag unset, guard behavior is byte-identical to the + /// pre-flag doctrine; the only unarmed delta in protocol output is the + /// `` authority-transition line, which appears whenever the + /// host changes mode, posture, or arming between turns. pub ask_on_guardrail: bool, /// Notes file path diff --git a/crates/tui/src/exec_agent.rs b/crates/tui/src/exec_agent.rs index 127f60a02e..a05f45eb49 100644 --- a/crates/tui/src/exec_agent.rs +++ b/crates/tui/src/exec_agent.rs @@ -751,6 +751,12 @@ pub(crate) async fn run_exec_agent( emit_exec_stream_event(&ExecStreamEvent::WorkflowEvent { run_id, event })?; } Event::ApprovalRequired { id, .. } => { + // Pinvou tri-mode safety tripwire: this auto-approve currently + // cannot see an armed forced hold because headless runs have + // no arming surface — every `ask_on_guardrail` producer sends + // `None`/`false`. If arming ever reaches headless runs, this + // arm must refuse `approval_force_prompt` events instead of + // approving them silently. if auto_approve { let _ = engine_handle.approve_tool_call(id).await; } else { diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 3ea1da2f02..eaccc2472c 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -14879,10 +14879,16 @@ impl SubAgentToolRegistry { ChildGateVerdict::Deny(reason) } // Full Access has no prompt to force; the plan decision - // already turned every safety-floor hold into a block. An - // armed host is the one exception: it has guaranteed a person - // can answer, so the hold routes to the parent's approval - // surface (which fails closed again for non-prompting hosts). + // already turned every safety-floor hold into a block + // (`auto_review_plan_decision_for_context` collapses + // built-in-gate holds to `Block` under Bypass and ordinary + // asks to `NoChange`), so this armed arm is fail-safe + // future-proofing, not a live face — today's live armed child + // behavior is the delegated typed-ask refusal below. If a + // `ForcePrompt` ever reaches this arm, an armed host has + // guaranteed a person can answer, so the hold routes to the + // parent's approval surface (which fails closed again for + // non-prompting hosts). AutoReviewPlanDecision::ForcePrompt(reason) => { if self.ask_on_guardrail { self.prompt_parent_for_child_call(agent_id, name, input, &reason, true) diff --git a/crates/tui/src/tui/ui/approval_routing.rs b/crates/tui/src/tui/ui/approval_routing.rs index e26b3c1fda..2769e91f04 100644 --- a/crates/tui/src/tui/ui/approval_routing.rs +++ b/crates/tui/src/tui/ui/approval_routing.rs @@ -87,6 +87,14 @@ pub(super) fn app_auto_approve_enabled(app: &App) -> bool { /// /// Shell/trust bits do not affect disposition; mode + approval_mode + the /// full-access shape (Bypass) are what the shared resolver consults. +/// +/// Pinvou tri-mode safety: this authority is deliberately built with +/// `ask_on_guardrail = false` (the `from_effective_fields` default). The +/// built-in TUI never arms a session — arming is a wire-host gesture, and +/// armed hosts consume `Event::ApprovalRequired { approval_force_prompt }` +/// themselves. If a UI-facing arming toggle ever ships, this constructor +/// must carry the session's arming, or `resolve_approval_request_disposition` +/// would auto-deny every armed forced hold the engine correctly raised. fn app_turn_authority_for_approvals(app: &App) -> crate::core::authority::TurnAuthority { crate::core::authority::TurnAuthority::from_effective_fields( app.mode, From 5c13fc561d1da6778eb31e2278e6e423a167f0a4 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 11:36:37 +0800 Subject: [PATCH 04/29] fix(engine): keep arming sticky through publishes, preview the planned posture Re-review follow-ups on the host-armed guardrail work: - the authority publish path replaces the whole snapshot, and the op loop applies the snapshot rather than the queued op payload, so a posture-only publish (a turn install, or a ChangeMode op carrying no arming) racing a host arm silently dropped the request. `None` now means "keep" at the publish layer too, mirroring apply_change_mode; only an explicit Some may move arming; - the preview recomputes the transition from the same posture helper as the live compare, so hypothetical preview inputs render exactly the body the live turn sends instead of reading the stale session state; - the subagent arming doc now scopes its claim to the faces the child gate actually evaluates (repo-law stays a main-line face), the /provider switch documents that respawning drops session arming, and the ops.rs arming wording matches the drain semantics; - pin the publish stickiness end to end, the armed composer hard-deny face, the armed Auto-Review escalation fail-closed face, and the preview planned-posture parity (preview hash equals the live wire body hash for divergent inputs) - each verified red under its mutant. Signed-off-by: asto18089 --- crates/tui/src/core/engine.rs | 39 ++- crates/tui/src/core/engine/handle.rs | 12 + crates/tui/src/core/engine/preview.rs | 17 +- crates/tui/src/core/engine/preview/tests.rs | 123 +++++++++ crates/tui/src/core/engine/tests.rs | 282 +++++++++++++++++++- crates/tui/src/core/ops.rs | 6 +- crates/tui/src/tools/subagent/mod.rs | 15 +- crates/tui/src/tui/ui/provider_routes.rs | 7 + 8 files changed, 474 insertions(+), 27 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 5cb661e7f9..57ccdee288 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -78,7 +78,8 @@ use crate::working_set::WorkingSet; #[cfg(test)] use super::authority::agent_approval_mode_for_turn; use super::authority::{ - PolicyNarrowingEvent, TurnAuthority, effective_input_policy, shell_policy_for_mode, + PolicyNarrowingEvent, TurnAuthority, TurnMetaPosture, effective_input_policy, + shell_policy_for_mode, }; use super::events::{Event, TurnOutcomeStatus, TurnRoute}; use super::ops::{ @@ -4333,6 +4334,11 @@ impl Engine { policy_narrowing: Option<&PolicyNarrowingEvent>, ) -> ContentBlock { let prompt_context = self.installed_next_turn_prompt_context(); + // Pinvou tri-mode safety: production only ever observes `Some` here + // inside the synchronous turn-start window — the pending transition is + // set and consumed around the block build with no `.await` between + // them. Keep that stretch await-free: a yield inserted there would let + // a steer or runtime notice re-render the line this builder reads. self.turn_metadata_block_from_snapshot( routed_model, auto_model, @@ -4586,6 +4592,22 @@ impl Engine { content } + /// The posture the turn built from `input_policy` reports in its + /// `` block: the planned posture from the turn authority, + /// the live arming from the session. Single source for the live + /// compare (which runs after `apply_runtime_mode_policy` installs this + /// same authority, so `approval_mode_for_session()` is exactly the + /// installed session value) and the `/preview-request` recompute, so + /// the two surfaces cannot drift when preview inputs carry a + /// hypothetical posture (Pinvou tri-mode safety). + fn turn_meta_posture_for(&self, input_policy: &TurnAuthority) -> TurnMetaPosture { + TurnMetaPosture { + mode: input_policy.mode, + approval: input_policy.approval_mode_for_session(), + ask_on_guardrail: self.session.ask_on_guardrail, + } + } + /// The user message a turn would build, from an explicit state snapshot. /// /// Same block order and same constructor as @@ -6045,12 +6067,12 @@ impl Engine { // that reports it is built — so a `NotStarted` failure above can // never record a posture the model never saw; and consumed below, // so steers and mid-turn runtime notices cannot re-render the same - // line. - let current_posture = crate::core::authority::TurnMetaPosture { - mode: input_policy.mode, - approval: self.session.approval_mode, - ask_on_guardrail: self.session.ask_on_guardrail, - }; + // line. Residual boundary: the record precedes the provider send, + // so a mid-turn provider failure leaves the posture recorded — the + // block stays in append-only history and the next successful + // request still carries it, but an edit-last-turn truncation would + // drop it. + let current_posture = self.turn_meta_posture_for(&input_policy); self.pending_authority_transition = crate::core::authority::authority_transition_between( self.last_turn_meta_authority, current_posture, @@ -8681,7 +8703,8 @@ fn tool_ask_rule_decision_for_context( if ask_on_guardrail && !matches!( approval_mode, - crate::tui::approval::ApprovalMode::Auto | crate::tui::approval::ApprovalMode::Never + crate::tui::approval::ApprovalMode::Auto + | crate::tui::approval::ApprovalMode::Never ) { Some(ToolAskRuleDecision::Prompt(format!( diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index 013f7881c2..d4dfa6bf33 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -91,6 +91,18 @@ impl EngineHandle { .live_runtime_authority .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); + // Pinvou tri-mode safety: `None` arming means "keep the session's + // current arming" — the same doctrine `apply_change_mode` applies — + // so a posture-only publish (a turn install, or a ChangeMode op that + // carries no arming) can never erase a host arming published ahead + // of it in the queue. The op loop applies this snapshot, never the + // queued op payload, so an erasing publish would silently drop the + // arm request with no event and no receipt. Only an explicit `Some` + // may move arming. + let mut authority = authority; + if authority.ask_on_guardrail.is_none() { + authority.ask_on_guardrail = state.authority.ask_on_guardrail; + } state.revision = state.revision.wrapping_add(1).max(1); state.authority = authority; } diff --git a/crates/tui/src/core/engine/preview.rs b/crates/tui/src/core/engine/preview.rs index 050b23a9d9..19dc975336 100644 --- a/crates/tui/src/core/engine/preview.rs +++ b/crates/tui/src/core/engine/preview.rs @@ -421,15 +421,14 @@ impl Engine { // posture), never read the turn-start leftover — a host that // flips posture between turns previews the line the live body // will send, and an already-consumed change never leaks here. - authority_transition: - crate::core::authority::authority_transition_between( - self.last_turn_meta_authority, - crate::core::authority::TurnMetaPosture { - mode: input_policy.mode, - approval: self.session.approval_mode, - ask_on_guardrail: self.session.ask_on_guardrail, - }, - ), + // The posture comes from the same helper as the live compare: + // planned approval from the preview inputs, live arming from + // the session, so a hypothetical posture in the inputs is + // rendered exactly as the live turn would render it. + authority_transition: crate::core::authority::authority_transition_between( + self.last_turn_meta_authority, + self.turn_meta_posture_for(&input_policy), + ), }, ); *self diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index d6480ce234..c88ae99d9c 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -1399,6 +1399,129 @@ async fn forkguard_preview_manifest_hashes_the_same_authority_transition_line_th ); } +/// Pinvou tri-mode safety: even when the preview inputs carry a posture +/// that differs from the session's stale one, the previewed body must hash +/// exactly the body the live turn sends for those same inputs — the +/// transition is computed against the planned posture, not the session +/// state. (The sibling hash test pins the matching-inputs case and the +/// exact rendered bytes; this pin holds the divergence case, where a +/// session-sourced posture would silently skip the line the live turn +/// renders.) +#[tokio::test] +async fn forkguard_preview_transition_uses_the_planned_posture_not_the_stale_session() { + use crate::core::authority::TurnMetaPosture; + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string("data: [DONE]\n\n"), + ) + .mount(&server) + .await; + let mut config = deepseek_config(); + config + .providers + .as_mut() + .expect("providers") + .deepseek + .base_url = Some(server.uri()); + let identity = deepseek_identity(); + let (mut engine, _tmp) = wire_preview_engine(&config); + + // The recorded posture says Suggest; the session still sits in Suggest + // (fresh engine default) — but the planned turn (preview inputs and the + // live turn below) carries Full Access. Only a planned-posture source + // renders the Suggest -> Full Access transition in the previewed body. + engine.last_turn_meta_authority = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }); + let prompt = "inspect the planned posture"; + let planned = plan(&config, &identity, false, prompt).await; + let mut preview_inputs = inputs(false, Some(planned), prompt); + preview_inputs.auto_approve = true; + preview_inputs.approval_mode = crate::tui::approval::ApprovalMode::Bypass; + let manifest = engine.build_request_manifest(preview_inputs).await; + let preview_hash = manifest + .body + .exact() + .expect("preview body is exact") + .body_sha256 + .clone(); + + // Preview purity: the build must not consume the recorded posture. + let planned_again = plan(&config, &identity, false, prompt).await; + let mut again_inputs = inputs(false, Some(planned_again), prompt); + again_inputs.auto_approve = true; + again_inputs.approval_mode = crate::tui::approval::ApprovalMode::Bypass; + let again_hash = engine + .build_request_manifest(again_inputs) + .await + .body + .exact() + .expect("preview body is exact") + .body_sha256 + .clone(); + assert_eq!( + preview_hash, again_hash, + "previewing must not consume the recorded posture" + ); + + // The live turn for the same planned posture installs Full Access and + // renders the transition onto the wire body; the previewed body must be + // exactly that body — not the stale-session body with no line. + let planned_live = plan(&config, &identity, false, prompt).await; + let production_route = planned_live.route.clone(); + let compaction = planned_live.compaction.clone(); + let reasoning_effort = planned_live.effective_reasoning_effort.clone(); + let reasoning_effort_auto = planned_live.auto_controls_reasoning; + let _ = engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + production_route, + compaction, + None, + None, + GoalStatus::Active, + reasoning_effort, + reasoning_effort_auto, + false, + false, + false, + true, + crate::tui::approval::ApprovalMode::Bypass, + false, + None, + Vec::new(), + None, + None, + UserInputProvenance::ExternalUser, + None, + ) + .await; + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!(requests.len(), 1, "the fixture must make one provider call"); + let wire_body: serde_json::Value = + serde_json::from_slice(&requests[0].body).expect("first HTTP body is JSON"); + let wire_hash = + crate::hashing::sha256_hex(crate::client::canonical_json(&wire_body).as_bytes()); + assert_eq!( + preview_hash, wire_hash, + "preview must hash the transition-bearing body the live turn sends \ + for the planned posture — a session-sourced posture would skip the \ + line and hash the stale body" + ); +} + #[tokio::test] async fn anthropic_preview_matches_the_first_native_messages_wire_body() { use wiremock::matchers::{method, path}; diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bb691b12b0..b25bab9fc1 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -8590,6 +8590,115 @@ async fn forkguard_armed_sandbox_escalation_prompts_instead_of_failing_closed() task.await.expect("engine task"); } +#[tokio::test] +async fn forkguard_armed_auto_review_escalation_stays_fail_closed() { + // The armed escalation prompt exists only where a person can answer the + // card: it is reachable in Full Access (pinned by + // `forkguard_armed_sandbox_escalation_prompts_instead_of_failing_closed`). + // Auto-Review keeps the fail-closed deny even when armed — its + // unattended flow has nobody to answer — so this pin turns red if the + // posture exclusion at the escalation site is ever dropped and arming + // starts overriding Auto-Review into a card nobody can answer. + use crate::llm_client::mock::{MockLlmClient, canned}; + + let workspace = tempdir().expect("tempdir"); + let mock = std::sync::Arc::new(MockLlmClient::new(vec![ + canned::tool_call_turn( + "call-armed-auto-escalation", + "bash", + r#"{"command":"echo denied > escalation.txt","sandbox_permissions":"workspace-write","justification":"the command needs workspace write access"}"#, + ), + canned::simple_text_turn("Escalation was denied."), + ])); + // Let Auto-Review's independent guardian approve the bounded fixture + // call so the turn reaches the escalation rule under test. + mock.push_message_response(guardian_fixture_response( + r#"{"risk_level":"low","decision":"allow","reason":"isolated fixture write"}"#, + )); + let client: crate::core::model_client::SharedModelClient = mock.clone(); + let config = Config { + sandbox_mode: Some("read-only".to_string()), + ..Config::default() + }; + let (engine, handle) = Engine::new_with_model_client( + deterministic_engine_config(workspace.path()), + &config, + client, + ); + let task = tokio::spawn(engine.run()); + + // Arm through the same `ChangeMode` channel hosts use for the toggle. + // The posture stays Auto-Review (auto_approve cleared): nobody can + // answer a card there, arming or not. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: true, + trust_mode: true, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .await + .expect("arm guardrail prompts"); + + let mut op = external_user_message_op( + "Escalate the sandbox for this one command.", + AppMode::Agent, + &config, + ); + let Op::SendMessage { + approval_mode: op_approval_mode, + auto_approve: op_auto_approve, + .. + } = &mut op + else { + panic!("user message op") + }; + *op_approval_mode = crate::tui::approval::ApprovalMode::Auto; + *op_auto_approve = false; + handle + .send(op) + .await + .expect("send armed Auto-Review escalation turn"); + + let mut saw_denial = false; + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for armed Auto-Review escalation") + .expect("engine event stream closed"); + match event { + Event::ApprovalRequired { .. } => { + panic!("armed Auto-Review must not open an escalation prompt") + } + Event::ToolCallComplete { id, result, .. } if id == "call-armed-auto-escalation" => { + let error = result.expect_err("armed Auto-Review escalation must be denied"); + let error = error.to_string(); + assert!( + error.contains("Sandbox escalation requires a one-shot user approval"), + "{error}" + ); + assert!(error.contains("Auto-Review"), "{error}"); + saw_denial = true; + } + Event::TurnComplete { status, error, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}"); + break; + } + _ => {} + } + } + drop(rx); + + assert!(saw_denial); + assert!(!workspace.path().join("escalation.txt").exists()); + handle.send(Op::Shutdown).await.expect("shutdown engine"); + task.await.expect("engine task"); +} + #[tokio::test] async fn productive_tool_results_do_not_hit_no_user_input_backstop() { use crate::llm_client::mock::{MockLlmClient, canned}; @@ -14286,6 +14395,80 @@ async fn run_shell_command_op_executes_without_approval_modal() { ); } +#[tokio::test] +async fn forkguard_armed_composer_shell_keeps_the_hard_deny_face() { + // #5191 pins the unarmed doctrine: typing a `!` command IS the approval, + // so the composer face keeps its hard deny in every posture, armed or + // not — the composer hands the ask-rule judgment the unarmed flag and + // only the `Block` arm is consumed. This pin holds that line under an + // armed Full Access session: a deny-rule match must stay a denial, and + // arming must neither raise a composer modal (one the user never asked + // for — they typed the command themselves) nor, if the face were ever + // "taught" to honor arming, silently auto-run a deny-matched command + // through its Prompt return. Arming delivery itself is pinned by the + // ChangeMode tests; this test targets the composer face only. + let _guard = lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let (mut engine, handle) = Engine::new( + EngineConfig { + workspace: tmp.path().to_path_buf(), + exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine::with_rulesets(vec![ + codewhale_execpolicy::Ruleset::user(Vec::new(), vec!["echo".to_string()]), + ]), + ..EngineConfig::default() + }, + &Config::default(), + ); + engine.session.ask_on_guardrail = true; + engine.session.approval_mode = crate::tui::approval::ApprovalMode::Bypass; + engine.session.auto_approve = true; + + engine + .handle_run_shell_command( + "echo armed-composer-deny-pin".to_string(), + AppMode::Agent, + true, + false, + true, + crate::tui::approval::ApprovalMode::Bypass, + ) + .await; + + let mut saw_denial = false; + let mut saw_approval = false; + let mut rx = handle.rx_event.write().await; + while let Some(event) = rx.recv().await { + match event { + Event::ApprovalRequired { .. } => saw_approval = true, + Event::ToolCallComplete { id, name, result } if name == "Bash" => { + assert!(id.starts_with(USER_SHELL_TOOL_ID_PREFIX)); + let error = result.expect_err("an armed composer deny-rule hit must stay denied"); + assert!( + error.to_string().contains("denied prefix rule 'echo'"), + "composer denial must carry the deny evidence: {error}" + ); + saw_denial = true; + } + Event::TurnComplete { status, .. } => { + assert_eq!( + status, + TurnOutcomeStatus::Failed, + "a denied composer command fails its turn" + ); + break; + } + _ => {} + } + } + drop(rx); + + assert!(saw_denial); + assert!( + !saw_approval, + "arming must not raise a composer modal (#5191)" + ); +} + #[tokio::test] async fn run_shell_command_op_skips_approval_when_auto_approved() { let workspace = tempdir().expect("tempdir"); @@ -31449,6 +31632,95 @@ async fn forkguard_turn_authority_install_never_rolls_back_published_arming() { ); } +#[tokio::test] +async fn forkguard_pending_arming_survives_arming_free_authority_publishes() { + // The publish path REPLACES the whole authority snapshot, and the op + // loop applies the snapshot, never the queued op payload. A posture-only + // publish racing a host arm — a mid-turn turn install, or a ChangeMode + // op that carries no arming — used to overwrite the pending + // `Some(true)` with `None`, so the drain silently dropped the arm + // request with no event and no receipt. `None` must mean "keep" at the + // publish layer too (the same doctrine `apply_change_mode` applies); + // only an explicit `Some` may move arming. + let tmp = tempdir().expect("tempdir"); + let (mut engine, handle) = Engine::new( + EngineConfig { + workspace: tmp.path().to_path_buf(), + ..EngineConfig::default() + }, + &Config::default(), + ); + + // The host arms through the publish channel `Op::ChangeMode` uses. + handle.publish_turn_authority( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + Some(true), + ); + + // Before the engine drains, a turn installs its arming-free authority. + handle.publish_turn_authority( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + None, + ); + let snapshot = engine.runtime_authority_snapshot(); + assert_eq!( + snapshot.ask_on_guardrail, + Some(true), + "an arming-free publish must not erase the pending host arming" + ); + + // Draining the mangled-but-now-correct snapshot must arm the session. + assert!( + engine.apply_pending_runtime_authority().await, + "the armed publish must reach the session" + ); + assert!( + engine.session.ask_on_guardrail, + "the interleaved turn install must not have swallowed the arm request" + ); + + // Disarming is explicit and still lands: `None` keeps, `Some(false)` + // clears. + handle.publish_turn_authority( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + None, + ); + let _ = engine.apply_pending_runtime_authority().await; + assert!( + engine.session.ask_on_guardrail, + "an arming-free publish must not disarm either" + ); + handle.publish_turn_authority( + AppMode::Agent, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + Some(false), + ); + assert!(engine.apply_pending_runtime_authority().await); + assert!( + !engine.session.ask_on_guardrail, + "an explicit host disarm must still land" + ); +} + #[tokio::test] async fn forkguard_apply_change_mode_status_suffixes_report_the_arming_change() { // The mode-changed status event is the user-visible receipt of an @@ -31672,7 +31944,11 @@ async fn forkguard_turn_start_detector_records_the_posture_and_renders_the_trans .received_requests() .await .expect("wire mock records requests"); - assert_eq!(requests.len(), 2, "each turn makes exactly one provider call"); + assert_eq!( + requests.len(), + 2, + "each turn makes exactly one provider call" + ); let first = String::from_utf8_lossy(&requests[0].body); assert!( first.contains( @@ -31695,7 +31971,9 @@ async fn forkguard_turn_start_detector_records_the_posture_and_renders_the_trans .rev() .find(|message| message["role"] == "user") .expect("a user message exists"); - let last_user_content = last_user_message["content"].as_str().expect("string content"); + let last_user_content = last_user_message["content"] + .as_str() + .expect("string content"); assert!( !last_user_content.contains("Authority transition"), "an unchanged posture must keep the next turn's own block byte-stable: {last_user_content}" diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 2fa3e6b1aa..bedcb9b928 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -426,8 +426,10 @@ pub enum Op { /// sandbox escalation): armed, they raise an approval request with a /// real person guaranteed on the other end instead of denying. /// `None` leaves the session's current arming unchanged. The flag is - /// session-level state like `auto_approve`; it takes effect when this - /// op is processed and applies to subsequent tool planning. + /// session-level state like `auto_approve`; the engine applies the + /// live published authority snapshot as ops drain (mid-turn drains + /// included), so it lands before subsequent tool planning either + /// way, and an arming-free op cannot erase a pending arming. ask_on_guardrail: Option, }, diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index eaccc2472c..da59acbe83 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -2497,12 +2497,15 @@ pub struct SubAgentRuntime { /// unchanged for embedders that never thread one. pub exec_policy_engine: codewhale_execpolicy::ExecPolicyEngine, /// Host-armed interactive guardrail prompts (Pinvou tri-mode safety), - /// inherited from the parent session. When set, a guardrail hold that - /// would fail closed under this child's posture routes to the parent's - /// approval surface (when the host can prompt) instead of denying, and a - /// delegated typed-ask Prompt refuses instead of silently passing under - /// inherited auto-approve: children never run what the armed main line - /// would prompt on. + /// inherited from the parent session. The live armed child face is the + /// delegated typed-ask: it refuses instead of silently passing under + /// inherited auto-approve. The held-call route to the parent's approval + /// surface is fail-safe future-proofing, not a live face (Bypass plan + /// decisions collapse built-in holds to `Block` today). Faces this + /// child gate does not evaluate — repo-law holds stay a main-line face + /// — follow the child's own posture, so "never silently runs what the + /// armed main line would prompt on" holds for the gated faces, not for + /// repo-law. pub ask_on_guardrail: bool, } diff --git a/crates/tui/src/tui/ui/provider_routes.rs b/crates/tui/src/tui/ui/provider_routes.rs index bb69b90fe8..e59851fafd 100644 --- a/crates/tui/src/tui/ui/provider_routes.rs +++ b/crates/tui/src/tui/ui/provider_routes.rs @@ -592,6 +592,13 @@ pub(crate) async fn sync_mode_update(app: &App, engine_handle: &EngineHandle) { /// mutating state, then respawning the engine so the API client picks up the /// new base URL/key. When `model_override` is set, it replaces the active /// model post-switch after provider-scoped normalization. +/// +/// Pinvou tri-mode safety: respawning the engine drops session-level arming +/// (`ask_on_guardrail` starts disarmed on every fresh engine, same doctrine +/// as `auto_approve`). The built-in TUI never arms, so nothing here needs to +/// re-arm — but any host that arms a session it drives through this path +/// must re-send its arm request after a provider switch; the respawn emits +/// no arming-loss signal of its own. pub(crate) async fn switch_provider( app: &mut App, engine_handle: &mut EngineHandle, From 10b28ef5f3751df1403939aff2aa08b8fa8294b0 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 13:49:23 +0800 Subject: [PATCH 05/29] fix(engine): drop clippy-flagged Copy clones 04c1f784 made AuthorityTransition Copy but left two .clone() calls on Option in the turn-meta builders. The fork CI clippy gate (-D warnings, no --all-targets needed to see them) fails on both with clippy::clone_on_copy. Copy semantics make the removal byte-identical; forkguard_ suite stays 258 passed / 0 failed and cargo fmt --all -- --check stays clean. Signed-off-by: asto --- crates/tui/src/core/engine.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 57ccdee288..7e1d93b713 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -4352,7 +4352,7 @@ impl Engine { approval_mode: self.session.approval_mode, working_set: &self.session.working_set, policy_narrowing, - authority_transition: self.pending_authority_transition.clone(), + authority_transition: self.pending_authority_transition, }, ) } @@ -6091,7 +6091,7 @@ impl Engine { approval_mode: self.session.approval_mode, working_set: &self.session.working_set, policy_narrowing: self.last_policy_narrowing.as_ref(), - authority_transition: self.pending_authority_transition.clone(), + authority_transition: self.pending_authority_transition, }, ); self.session.add_message(user_msg); From f972884e5dc99c16241941c87a094140e5e7600a Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 14:11:09 +0800 Subject: [PATCH 06/29] fix(engine): honor force_prompt in the runtime pump, announce only real arming flips Fresh re-review (COMMENT 5732557068) findings F1-F3: the runtime-threads approval pump dropped approval_force_prompt and auto-approved forced holds under Full Access postures; the ChangeMode status suffix keyed off the mere presence of the ask carrier and announced '(guardrail prompts disarmed)' on every ordinary posture switch; and the NotStarted ordering fix had no test exercising it. Three new mutation-verified pins. Signed-off-by: asto18089 --- crates/tui/src/core/engine.rs | 11 +- crates/tui/src/core/engine/tests.rs | 173 ++++++++++++++++++++++++ crates/tui/src/runtime_threads.rs | 12 +- crates/tui/src/runtime_threads/tests.rs | 97 +++++++++++++ 4 files changed, 291 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 7e1d93b713..7a45a00de7 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -2708,10 +2708,19 @@ impl Engine { || self.session.approval_mode != effective_approval || self.api_config.sandbox_mode != configured_sandbox_mode; self.api_config.sandbox_mode = configured_sandbox_mode; + // The suffix below must key off an actual arming flip, not the mere + // presence of the carrier: production always delivers `Some` here + // (the op loop applies the snapshot that + // record_applied_runtime_authority backfilled with the session + // value), so a carrier echoing the unchanged session value would + // otherwise announce an arming change that never happened on every + // ordinary posture switch. + let mut arming_change: Option = None; if let Some(armed) = ask_on_guardrail && self.session.ask_on_guardrail != armed { self.session.ask_on_guardrail = armed; + arming_change = Some(armed); changed = true; } self.apply_runtime_mode_policy(&authority); @@ -2724,7 +2733,7 @@ impl Engine { mode.description(), effective_approval.permission_chip_label(), ); - if let Some(armed) = ask_on_guardrail { + if let Some(armed) = arming_change { status.push_str(if armed { " (guardrail prompts armed)" } else { diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index b25bab9fc1..2d5208450f 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -31810,6 +31810,32 @@ async fn forkguard_apply_change_mode_status_suffixes_report_the_arming_change() !mode_status.contains("(guardrail prompts"), "a change without the ask carrier must not claim an arming flip: {mode_status}" ); + + // A carrier echoing the unchanged session value is the production + // shape: the op loop applies the snapshot that + // record_applied_runtime_authority backfilled, so an ordinary mode + // switch arrives as `Some(disarmed)` on a disarmed session and must + // not claim an arming flip either. + engine + .apply_change_mode( + AppMode::Operate, + false, + false, + false, + crate::tui::approval::ApprovalMode::Suggest, + None, + Some(false), + ) + .await; + let echoed_status = status_message(&handle).await; + assert!( + echoed_status.starts_with("Runtime policy changed to:"), + "{echoed_status}" + ); + assert!( + !echoed_status.contains("(guardrail prompts"), + "a carrier echoing the unchanged arming must not claim a flip: {echoed_status}" + ); } #[tokio::test] @@ -31980,5 +32006,152 @@ async fn forkguard_turn_start_detector_records_the_posture_and_renders_the_trans ); } +#[tokio::test] +async fn forkguard_a_not_started_turn_neither_consumes_nor_records_the_transition() { + // Pins the NotStarted ordering: the posture comparison sits after the + // early returns, so a turn that fails before any block build must + // neither record the posture (which would swallow the pending host + // change) nor consume anything — the next successful turn's block is + // the one that renders the transition line. + use crate::core::authority::TurnMetaPosture; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let plain_sse = concat!( + "data: {\"id\":\"chatcmpl-notstarted\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-notstarted\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + // Only the second turn reaches the provider. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(plain_sse), + ) + .expect(1) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + let (mut engine, _handle) = Engine::new(engine_config, &api_config); + + // The previous block reported Full Access disarmed; the next real turn + // runs Full Access armed, so the arming flip alone must produce the + // line — no matter that a turn failed in between. + engine.last_turn_meta_authority = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }); + engine.session.ask_on_guardrail = true; + + async fn send_turn( + engine: &mut Engine, + api_config: &Config, + prompt: &str, + ) -> SendMessageOutcome { + engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + *resolved_route_for_test(api_config, crate::config::DEFAULT_TEXT_MODEL), + CompactionConfig::default(), + None, + None, + crate::tools::goal::GoalStatus::Active, + None, + false, + false, + true, + true, + true, + crate::tui::approval::ApprovalMode::Bypass, + false, + None, + Vec::new(), + None, + None, + UserInputProvenance::ExternalUser, + None, + ) + .await + } + + // Turn one cannot start: no model client, so the turn exits before any + // block build. `model_client_injected` must ride along, or + // `install_resolved_runtime_route` would resurrect the client from the + // resolved route before the not-configured check runs. + let real_client = engine.model_client.take(); + let real_injected = engine.model_client_injected; + engine.model_client_injected = true; + let outcome = send_turn(&mut engine, &api_config, "doomed turn").await; + match outcome { + SendMessageOutcome::NotStarted { .. } => {} + SendMessageOutcome::Finished { status, error } => panic!( + "the first turn must fail before the block build, got Finished status={status:?} error={error:?}" + ), + } + assert_eq!( + engine.last_turn_meta_authority, + Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }), + "a not-started turn must not record the posture (which would swallow the pending host change)" + ); + + // Turn two starts fine and must be the block that renders the line. + engine.model_client_injected = real_injected; + engine.model_client = real_client; + let outcome = send_turn(&mut engine, &api_config, "turn after the failure").await; + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "the second turn must complete" + ); + + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!( + requests.len(), + 1, + "the not-started turn makes no provider call" + ); + let body = String::from_utf8_lossy(&requests[0].body); + assert!( + body.contains( + "Authority transition (host): agent (Full Access, guardrail prompts disarmed) -> agent (Full Access, guardrail prompts armed); reason: host_authority_change", + ), + "the turn after the failed start must still render the pending transition: {body}" + ); +} + #[path = "partial_input_differential_tests.rs"] mod partial_input_differential_tests; diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 4f44c8289a..d69da3d349 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -9464,6 +9464,7 @@ impl RuntimeThreadManager { tool_name, description, intent_summary, + approval_force_prompt, .. } => { let Some(authority) = self @@ -9485,7 +9486,16 @@ impl RuntimeThreadManager { intent_summary: intent_summary.clone(), }; - if auto_approve { + // `approval_force_prompt` is the CW-7495 / host-armed + // consent contract: the engine raised this card + // specifically so a human answers it, and posture + // shortcuts must not consume it silently (the engine + // already withheld session grants for the same reason). + // Tripwire for future arming surfaces: if a host ever + // arms `ask_on_guardrail` on this deployment, forced + // holds keep reaching the human path below instead of + // this auto-approve branch. + if auto_approve && !approval_force_prompt { self.emit_event( &thread_id, Some(&turn_id), diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 6044ea717b..98c91ac4ff 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -10439,6 +10439,103 @@ async fn auto_review_force_prompt_is_denied_without_opening_a_modal() -> Result< Ok(()) } +#[tokio::test] +async fn forkguard_pump_never_auto_approves_a_force_prompt_hold() -> Result<()> { + // `approval_force_prompt` is the host-armed consent contract: even under + // a Full Access posture whose shortcuts would auto-approve, the pump + // must register the request for a human decision instead of resolving + // it itself. Without this pin, an armed hold that reaches this + // deployment would execute the guarded call with no human answer. + let manager = test_manager(test_runtime_dir())?; + let thread = manager + .create_thread(CreateThreadRequest { + auto_approve: Some(true), + ..Default::default() + }) + .await?; + assert_eq!(thread.permission_posture.as_deref(), Some("full_access")); + + let mut harness = install_mock_engine(&manager, &thread.id).await; + let turn = manager + .start_turn( + &thread.id, + StartTurnRequest { + prompt: "armed hold must reach a human".to_string(), + ..Default::default() + }, + ) + .await?; + assert!(matches!( + harness.rx_op.recv().await, + Some(Op::SendMessage { + auto_approve: true, + .. + }) + )); + + harness + .tx_event + .send(EngineEvent::ApprovalRequired { + approval_key: "armed_hold".to_string(), + approval_grouping_key: "armed_hold".to_string(), + id: "tool_armed_hold".to_string(), + tool_name: "exec_command".to_string(), + description: "armed guardrail hold under full access".to_string(), + input: serde_json::json!({}), + intent_summary: None, + approval_force_prompt: true, + }) + .await?; + + // The forced hold must be pending for a human, not auto-resolved. + let deadline = Instant::now() + Duration::from_secs(2); + while Instant::now() < deadline && manager.pending_approvals_count() == 0 { + sleep(Duration::from_millis(20)).await; + } + assert_eq!( + manager.pending_approvals_count(), + 1, + "a force_prompt hold must stay pending for a human decision" + ); + assert!( + !manager.events_since(&thread.id, None)?.iter().any(|event| { + event.event == "approval.decided" + && event.payload.get("approval_id").and_then(Value::as_str) + == Some("tool_armed_hold") + }), + "the pump must not auto-decide a force_prompt hold" + ); + + // The human denies; only then does the engine observe the outcome. + assert!(manager.deliver_external_approval( + "tool_armed_hold", + ExternalApprovalDecision::Deny { remember: false }, + )); + assert_eq!( + harness.recv_approval_event().await, + Some(MockApprovalEvent::Denied { + id: "tool_armed_hold".to_string(), + }) + ); + + harness + .tx_event + .send(EngineEvent::TurnComplete { + turn_id: None, + submission_id: None, + compaction_id: None, + usage: Usage::default(), + status: TurnOutcomeStatus::Completed, + error: None, + tool_catalog: None, + base_url: None, + }) + .await?; + let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?; + assert_eq!(terminal.status, RuntimeTurnStatus::Completed); + Ok(()) +} + #[tokio::test] async fn approval_pends_until_interrupt_and_next_turn_can_start() -> Result<()> { let manager = test_manager(test_runtime_dir())?; From 7e03162d104b7b9474bcc6c2fbfd67c83f2a4381 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 17:14:09 +0800 Subject: [PATCH 07/29] test(protocol): pin legacy ChangeMode payloads Pre-arming hosts emit ChangeMode without the ask_on_guardrail key at all. The absent field must deserialize to None (= keep the session's current arming) and an unarmed op must serialize without the key; a serde(default) or skip_serializing_if regression would hard-require the field on the wire and break every legacy sender. Found by a negative mutation in the round-4 review: removing the attributes shipped green across the whole protocol suite. Signed-off-by: asto18089 --- crates/protocol/src/op.rs | 54 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/crates/protocol/src/op.rs b/crates/protocol/src/op.rs index 0f86c590ee..8c7cc386e0 100644 --- a/crates/protocol/src/op.rs +++ b/crates/protocol/src/op.rs @@ -765,6 +765,60 @@ mod tests { } } + #[test] + fn change_mode_without_ask_on_guardrail_keeps_the_current_arming() { + // Pre-arming hosts emit ChangeMode with no `ask_on_guardrail` key at + // all. The absent field must deserialize to `None` (= keep the + // session's current arming), and an unarmed op must serialize without + // the key: a `serde(default)` or `skip_serializing_if` regression + // would hard-require the field on the wire and break every legacy + // sender (or leak the key into every unarmed op). + let full = serde_json::to_value(Op::ChangeMode { + mode: "operate".into(), + allow_shell: true, + trust_mode: true, + auto_approve: false, + approval_mode: "bypass".into(), + configured_sandbox_mode: Some("workspace-write".into()), + ask_on_guardrail: Some(true), + }) + .unwrap(); + let legacy = { + let mut value = full.clone(); + value + .as_object_mut() + .expect("ChangeMode serializes to an object") + .remove("ask_on_guardrail") + .expect("the armed op carries the field"); + value + }; + let op: Op = serde_json::from_value(legacy).expect("legacy ChangeMode payload must parse"); + match op { + Op::ChangeMode { + ask_on_guardrail, .. + } => assert_eq!( + ask_on_guardrail, None, + "an absent arming key must decode to keep-current, not a default arm" + ), + other => panic!("expected ChangeMode, got {other:?}"), + } + + let unarmed = serde_json::to_value(Op::ChangeMode { + mode: "agent".into(), + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: "suggest".into(), + configured_sandbox_mode: None, + ask_on_guardrail: None, + }) + .unwrap(); + assert!( + unarmed.get("ask_on_guardrail").is_none(), + "an unarmed op must keep the arming key off the wire: {unarmed}" + ); + } + #[test] fn every_variant_round_trips_and_tags_by_kind() { for op in every_variant() { From 5910426c5598e76fb5a0c466eea9097ff04f2ab5 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 17:14:21 +0800 Subject: [PATCH 08/29] fix(engine): sync and isolated posture receipts Two receipt-honesty gaps from the round-4 review: - Op::SyncSession installs a conversation whose model never saw the previous conversation's receipts. The recorded turn_meta posture now resets with the rest of the conversation-boundary state, so the first turn establishes a fresh baseline instead of rendering a transition whose from-side it never reported. Arming itself is host-owned and deliberately survives the sync. - runtime_chat_isolated builds no block, so a posture recorded on an isolated turn was rendered nowhere and a pending transition could be consumed by a block-less turn. Isolated turns now neither consume nor record; the next block-building turn compares against the last posture a block actually reported. Both faces pinned (forkguard_sync_session_clears_the_recorded_turn_meta_posture, forkguard_an_isolated_turn_neither_consumes_nor_records_the_transition), mutation-verified red. Signed-off-by: asto18089 --- crates/tui/src/core/engine.rs | 28 ++- crates/tui/src/core/engine/tests.rs | 283 ++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 7a45a00de7..0c4ad2f871 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1933,6 +1933,14 @@ impl Engine { // non-turn reader inherits cross-conversation state. self.session.tool_activation_cache.clear(); self.session.pending_toolbox_eviction_notes.clear(); + // The recorded turn_meta posture belongs to the previous + // conversation: its model saw that receipt, the installed one never + // did. Reset it so the installed conversation's first turn + // establishes a fresh baseline instead of rendering a transition + // whose "from" side it never reported. Arming itself + // (`session.ask_on_guardrail`) is host-owned and deliberately stays + // — the host did not disarm across this edge. + self.last_turn_meta_authority = None; let plugin_workspace_changed = self.plugin_registry.workspace() != workspace.as_path(); let previous_session_id = self.session.id.clone(); let next_session_id = if let Some(session_id) = session_id { @@ -6081,12 +6089,20 @@ impl Engine { // block stays in append-only history and the next successful // request still carries it, but an edit-last-turn truncation would // drop it. - let current_posture = self.turn_meta_posture_for(&input_policy); - self.pending_authority_transition = crate::core::authority::authority_transition_between( - self.last_turn_meta_authority, - current_posture, - ); - self.last_turn_meta_authority = Some(current_posture); + // `runtime_chat_isolated` builds no `` block at all (see + // the block constructors), so a posture recorded on an isolated turn + // would be rendered nowhere: an isolated turn neither consumes nor + // records, and the next block-building turn compares against the + // last posture a block actually reported. + if !self.api_config.runtime_chat_isolated { + let current_posture = self.turn_meta_posture_for(&input_policy); + self.pending_authority_transition = + crate::core::authority::authority_transition_between( + self.last_turn_meta_authority, + current_posture, + ); + self.last_turn_meta_authority = Some(current_posture); + } let user_msg = self.user_text_message_from_snapshot( content, &model, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 2d5208450f..03dfd0be01 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -32153,5 +32153,288 @@ async fn forkguard_a_not_started_turn_neither_consumes_nor_records_the_transitio ); } +#[tokio::test] +async fn forkguard_sync_session_clears_the_recorded_turn_meta_posture() { + // The recorded posture belongs to one conversation: the installed + // conversation's model never saw the previous one's receipts, so its + // first turn must establish a fresh baseline instead of rendering a + // transition whose "from" side it never reported. (Arming itself is + // host-owned and deliberately survives the sync.) + use crate::core::authority::TurnMetaPosture; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let plain_sse = concat!( + "data: {\"id\":\"chatcmpl-syncposture\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-syncposture\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(plain_sse), + ) + .expect(1) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + let (mut engine, _handle) = Engine::new(engine_config, &api_config); + + // The previous conversation's last block reported Full Access armed. + engine.last_turn_meta_authority = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: true, + }); + + engine + .apply_synced_session( + Some("fresh-conversation".to_string()), + Vec::new(), + None, + false, + crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace.path().to_path_buf(), + Vec::new(), + AppMode::Agent, + ) + .await; + assert_eq!( + engine.last_turn_meta_authority, None, + "the sync must clear the previous conversation's recorded posture" + ); + + // A host disarm lands before the installed conversation's first turn, + // so that turn reports disarmed. It must stay silent: it has no earlier + // receipt in this conversation to be a transition from. + engine.session.ask_on_guardrail = false; + + async fn send_turn( + engine: &mut Engine, + api_config: &Config, + prompt: &str, + ) -> SendMessageOutcome { + engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + *resolved_route_for_test(api_config, crate::config::DEFAULT_TEXT_MODEL), + CompactionConfig::default(), + None, + None, + crate::tools::goal::GoalStatus::Active, + None, + false, + false, + true, + true, + true, + crate::tui::approval::ApprovalMode::Bypass, + false, + None, + Vec::new(), + None, + None, + UserInputProvenance::ExternalUser, + None, + ) + .await + } + + let outcome = send_turn( + &mut engine, + &api_config, + "first turn of the new conversation", + ) + .await; + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "the first turn must complete" + ); + + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!( + requests.len(), + 1, + "the turn makes exactly one provider call" + ); + let wire_body: serde_json::Value = + serde_json::from_slice(&requests[0].body).expect("request body is JSON"); + let last_user_message = wire_body["messages"] + .as_array() + .expect("messages array") + .iter() + .rev() + .find(|message| message["role"] == "user") + .expect("a user message exists"); + let last_user_content = last_user_message["content"] + .as_str() + .expect("string content"); + assert!( + !last_user_content.contains("Authority transition"), + "the installed conversation's first turn must not inherit the previous conversation's receipt: {last_user_content}" + ); +} + +#[tokio::test] +async fn forkguard_an_isolated_turn_neither_consumes_nor_records_the_transition() { + // `runtime_chat_isolated` builds no `` block at all, so a + // posture recorded on an isolated turn would be rendered nowhere. The + // turn must leave the record (and any pending transition) untouched: + // the next block-building turn compares against the last posture a + // block actually reported and renders the change then. + use crate::core::authority::TurnMetaPosture; + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let plain_sse = concat!( + "data: {\"id\":\"chatcmpl-isolatedposture\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-isolatedposture\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(plain_sse), + ) + .expect(1) + .mount(&server) + .await; + + let api_config = Config { + runtime_chat_isolated: true, + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + let (mut engine, _handle) = Engine::new(engine_config, &api_config); + + // The last block reported Full Access disarmed; this isolated turn runs + // armed. Nothing may be recorded or consumed — there is no block here + // to report the change. + engine.last_turn_meta_authority = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }); + engine.session.ask_on_guardrail = true; + + async fn send_turn( + engine: &mut Engine, + api_config: &Config, + prompt: &str, + ) -> SendMessageOutcome { + engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + *resolved_route_for_test(api_config, crate::config::DEFAULT_TEXT_MODEL), + CompactionConfig::default(), + None, + None, + crate::tools::goal::GoalStatus::Active, + None, + false, + false, + true, + true, + true, + crate::tui::approval::ApprovalMode::Bypass, + false, + None, + Vec::new(), + None, + None, + UserInputProvenance::ExternalUser, + None, + ) + .await + } + + let outcome = send_turn(&mut engine, &api_config, "isolated turn").await; + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "the isolated turn must complete" + ); + assert_eq!( + engine.last_turn_meta_authority, + Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }), + "an isolated turn must not record a posture no block will report" + ); + assert!( + engine.pending_authority_transition.is_none(), + "nothing was set, so nothing may be consumed" + ); + + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!( + requests.len(), + 1, + "the turn makes exactly one provider call" + ); + let body = String::from_utf8_lossy(&requests[0].body); + assert!( + !body.contains(""), + "isolated runs build no turn_meta block at all: {body}" + ); +} + #[path = "partial_input_differential_tests.rs"] mod partial_input_differential_tests; From 9b1e1cf13ca75fb9c5f03dc181e9e743e2180b41 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 17:14:21 +0800 Subject: [PATCH 09/29] fix(runtime): carry force_prompt on approval cards The runtime-thread pump emitted approval.required without the approval_force_prompt flag, so clients on pump deployments could not render an armed consent card as anything but an ordinary approval (enforcement stayed in-repo; this is the signal half). Both emission paths now carry the flag, and the pump pin asserts the payload on the forced (true) and ordinary (false) paths, mutation-verified red. Signed-off-by: asto18089 --- crates/tui/src/runtime_threads.rs | 2 + crates/tui/src/runtime_threads/tests.rs | 97 +++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index d69da3d349..9811594a83 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -9507,6 +9507,7 @@ impl RuntimeThreadManager { "tool_name": tool_name, "description": description, "intent_summary": intent_summary, + "approval_force_prompt": approval_force_prompt, }), ) .await?; @@ -9604,6 +9605,7 @@ impl RuntimeThreadManager { "tool_name": tool_name, "description": description, "intent_summary": intent_summary, + "approval_force_prompt": approval_force_prompt, }), ) .await diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 98c91ac4ff..75f80764dd 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -10506,6 +10506,24 @@ async fn forkguard_pump_never_auto_approves_a_force_prompt_hold() -> Result<()> "the pump must not auto-decide a force_prompt hold" ); + // The card the client receives must itself carry the consent contract: + // an armed host renders the hold as a consent card only if the flag + // rides the payload, not just the engine event. + let events = manager.events_since(&thread.id, None)?; + let forced_card = events + .iter() + .find(|event| { + event.event == "approval.required" + && event.payload.get("approval_id").and_then(Value::as_str) + == Some("tool_armed_hold") + }) + .expect("the forced hold must reach clients as approval.required"); + assert_eq!( + forced_card.payload.get("approval_force_prompt"), + Some(&Value::Bool(true)), + "the approval.required payload must carry the force_prompt flag" + ); + // The human denies; only then does the engine observe the outcome. assert!(manager.deliver_external_approval( "tool_armed_hold", @@ -10533,6 +10551,85 @@ async fn forkguard_pump_never_auto_approves_a_force_prompt_hold() -> Result<()> .await?; let terminal = wait_for_terminal_turn(&manager, &turn.id, Duration::from_secs(2)).await?; assert_eq!(terminal.status, RuntimeTurnStatus::Completed); + + // Phase two: an ordinary (unforced) hold under the same auto-approve + // posture keeps the short-circuit path, and its card reports the flag + // as false so clients see one stable payload schema on both paths. + let turn_two = manager + .start_turn( + &thread.id, + StartTurnRequest { + prompt: "ordinary hold still auto-resolves".to_string(), + ..Default::default() + }, + ) + .await?; + assert!(matches!( + harness.rx_op.recv().await, + Some(Op::SendMessage { + auto_approve: true, + .. + }) + )); + harness + .tx_event + .send(EngineEvent::ApprovalRequired { + approval_key: "ordinary_hold".to_string(), + approval_grouping_key: "ordinary_hold".to_string(), + id: "tool_ordinary_hold".to_string(), + tool_name: "exec_command".to_string(), + description: "ordinary approval under full access".to_string(), + input: serde_json::json!({}), + intent_summary: None, + approval_force_prompt: false, + }) + .await?; + let deadline = Instant::now() + Duration::from_secs(2); + loop { + let decided = manager.events_since(&thread.id, None)?.iter().any(|event| { + event.event == "approval.decided" + && event.payload.get("approval_id").and_then(Value::as_str) + == Some("tool_ordinary_hold") + }); + if decided { + break; + } + assert!( + Instant::now() < deadline, + "an ordinary hold under auto-approve must still auto-decide" + ); + sleep(Duration::from_millis(20)).await; + } + let events = manager.events_since(&thread.id, None)?; + let ordinary_card = events + .iter() + .find(|event| { + event.event == "approval.required" + && event.payload.get("approval_id").and_then(Value::as_str) + == Some("tool_ordinary_hold") + }) + .expect("the ordinary hold must still be announced as approval.required"); + assert_eq!( + ordinary_card.payload.get("approval_force_prompt"), + Some(&Value::Bool(false)), + "the auto-path payload carries the same schema with the flag false" + ); + + harness + .tx_event + .send(EngineEvent::TurnComplete { + turn_id: None, + submission_id: None, + compaction_id: None, + usage: Usage::default(), + status: TurnOutcomeStatus::Completed, + error: None, + tool_catalog: None, + base_url: None, + }) + .await?; + let terminal = wait_for_terminal_turn(&manager, &turn_two.id, Duration::from_secs(2)).await?; + assert_eq!(terminal.status, RuntimeTurnStatus::Completed); Ok(()) } From b94cc3175a24d21180dfce0ff70512a0f5839a5e Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 17:14:28 +0800 Subject: [PATCH 10/29] refactor(engine): share the armed-face predicate guardrail_prompts_answerable replaces the three inline spellings of "armed and a person can answer" (deny face, repo-law holds, sandbox escalation), mirroring the permission_posture_allows_questions precedent in authority.rs. The two sandbox-escalation arms fold into one: they differed only in the armed marker, and the deny arm becomes the explicit complement. The force_prompt computation drops the armed_full_access_hold disjunct - the hold implies registered_non_bypassable (both are gated on registered_tool_forces_prompt), now documented at the site. The host transition line's docs distinguish it from the narrowing receipt family. No behavior change: the deny/repo-law/escalation pins stay green, and mutating the helper turns the deny-face pin red. Signed-off-by: asto18089 --- crates/tui/src/core/authority.rs | 20 +++++++ crates/tui/src/core/engine.rs | 12 ++--- crates/tui/src/core/engine/turn_loop.rs | 71 +++++++++++-------------- 3 files changed, 55 insertions(+), 48 deletions(-) diff --git a/crates/tui/src/core/authority.rs b/crates/tui/src/core/authority.rs index fb812bb6ae..d64cd0aed4 100644 --- a/crates/tui/src/core/authority.rs +++ b/crates/tui/src/core/authority.rs @@ -184,6 +184,12 @@ impl AuthorityTransition { /// The single model-visible sentence. Renders both postures with their /// approval chip and arming state so the change is self-describing. + /// + /// Deliberately distinct from the engine's narrowing receipt (the + /// `Authority transition: …` line emitted under `Authority narrowing` — + /// a provenance-driven policy change): the `(host)` marker and the + /// machine-readable reason keep the two families separable when a + /// narrowing and a host flip land in the same block. pub(crate) fn render(&self) -> String { fn part(mode: &str, approval: ApprovalMode, armed: bool) -> String { format!( @@ -404,6 +410,20 @@ pub(crate) fn permission_posture_allows_questions(approval_mode: ApprovalMode) - approval_mode != ApprovalMode::Auto } +/// Whether an armed host's guardrail prompts can actually be answered in +/// this posture. Auto-Review and Never are unattended: even with the host's +/// arming guarantee there is nobody at the card, so every armed face (deny +/// face, repo-law holds, sandbox escalation) keeps its fail-closed deny +/// there. This is the shared half of the armed-face doctrine — the posture +/// question only; each face keeps its own arming and matching semantics. +#[must_use] +pub(crate) fn guardrail_prompts_answerable( + approval_mode: ApprovalMode, + ask_on_guardrail: bool, +) -> bool { + ask_on_guardrail && !matches!(approval_mode, ApprovalMode::Auto | ApprovalMode::Never) +} + #[must_use] pub(crate) fn agent_approval_mode_for_turn( auto_approve: bool, diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 0c4ad2f871..61da33f517 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -4520,6 +4520,10 @@ impl Engine { // Emitted only on a narrowed turn, so the ordinary turn's metadata // stays byte-stable. if let Some(event) = policy_narrowing { + // This narrowing receipt ("Authority transition: …") is a + // different line family from the host-armed + // `Authority transition (host): …` (AuthorityTransition::render); + // both may coexist in one block. lines.push(format!("Authority narrowing: {}", event.reason().as_str())); lines.push(format!("Authority transition: {}", event.transition())); lines.push(format!("Authority narrowing status: {}", event.message())); @@ -8725,13 +8729,7 @@ fn tool_ask_rule_decision_for_context( // doctrine; Auto-Review and Never postures keep it in every case — // their denials stay unconditional because nobody can answer the // card there even when armed. - if ask_on_guardrail - && !matches!( - approval_mode, - crate::tui::approval::ApprovalMode::Auto - | crate::tui::approval::ApprovalMode::Never - ) - { + if crate::core::authority::guardrail_prompts_answerable(approval_mode, ask_on_guardrail) { Some(ToolAskRuleDecision::Prompt(format!( "Guardrail approval requested (ask-on-guardrail is armed): {}", decision.reason() diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 8f17f8de41..a5fd0c6d68 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -3979,7 +3979,11 @@ impl Engine { prepared.auto_approve, ) }; - approval_force_prompt = registered_non_bypassable || armed_full_access_hold; + // `armed_full_access_hold` adds nothing here: both sides + // of the hold are gated on `registered_tool_forces_prompt` + // (see `registered_tool_blocked_in_full_access`), so the + // hold implies `registered_non_bypassable`. + approval_force_prompt = registered_non_bypassable; } approval_description = if armed_full_access_hold { format!( @@ -4231,13 +4235,10 @@ impl Engine { if repo_law_must_block_without_prompt( self.session.approval_mode, self.session.auto_approve, - ) && !(self.session.ask_on_guardrail - && !matches!( - self.session.approval_mode, - crate::tui::approval::ApprovalMode::Auto - | crate::tui::approval::ApprovalMode::Never - )) - { + ) && !crate::core::authority::guardrail_prompts_answerable( + self.session.approval_mode, + self.session.ask_on_guardrail, + ) { approval_required = false; approval_force_prompt = false; blocked_error = Some(ToolError::permission_denied(format!( @@ -4320,11 +4321,25 @@ impl Engine { // elevated approval grants. A hard block above still wins. if blocked_error.is_none() { match requested_sandbox_escalation(&tool_name, &tool_input, &batch_sandbox_policy) { + // Suggest always prompts; an armed host under an + // answerable posture gets the same Suggest-path approval + // flow with the model's justification on the card (the + // armed arm adds its marker to the receipt). Ok(Some((_policy, justification))) - if batch_approval_mode == crate::tui::approval::ApprovalMode::Suggest => + if batch_approval_mode == crate::tui::approval::ApprovalMode::Suggest + || crate::core::authority::guardrail_prompts_answerable( + batch_approval_mode, + self.session.ask_on_guardrail, + ) => { + let armed_marker = + if batch_approval_mode == crate::tui::approval::ApprovalMode::Suggest { + "" + } else { + " (armed guardrail approval)" + }; let escalation_description = format!( - "Sandbox escalation to '{}' for this exact call: {justification}", + "Sandbox escalation to '{}' for this exact call: {justification}{armed_marker}", tool_input["sandbox_permissions"] .as_str() .expect("validated sandbox permission") @@ -4339,42 +4354,16 @@ impl Engine { approval_required = true; approval_force_prompt = true; } - Ok(Some(_)) - if !self.session.ask_on_guardrail - || matches!( - batch_approval_mode, - crate::tui::approval::ApprovalMode::Auto - | crate::tui::approval::ApprovalMode::Never - ) => - { - // Unarmed, or armed under an unattended posture - // (Auto-Review / Never): nobody can answer the card, - // so the escalation keeps its fail-closed deny — the - // arming guarantee does not override the posture. + // The complement: unarmed, or armed under an unattended + // posture (Auto-Review / Never) — nobody can answer the + // card, so the escalation keeps its fail-closed deny; the + // arming guarantee does not override the posture. + Ok(Some(_)) => { blocked_error = Some(ToolError::permission_denied(format!( "Sandbox escalation requires a one-shot user approval, but the current {} posture cannot provide it. Switch to Ask or continue without escalation.", batch_approval_mode.permission_chip_label() ))); } - // Armed hosts get the same Suggest-path approval flow as - // above, with the model's justification on the card. - Ok(Some((_policy, justification))) => { - let escalation_description = format!( - "Sandbox escalation to '{}' for this exact call: {justification} (armed guardrail approval)", - tool_input["sandbox_permissions"] - .as_str() - .expect("validated sandbox permission") - ); - approval_description = if approval_force_prompt { - format!( - "{escalation_description}. Additional approval gate: {approval_description}" - ) - } else { - escalation_description - }; - approval_required = true; - approval_force_prompt = true; - } Ok(None) => {} Err(error) => blocked_error = Some(error), } From 1785dae1584f0005ceacbc5153e3a48f010abbaa Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 2 Nov 2026 19:03:19 +0800 Subject: [PATCH 11/29] fix(engine): scope host transitions to standing postures A provenance-narrowed turn reported its reduced posture through the same turn_meta comparison the host transition uses, so on a raised- posture runtime thread every narrowed turn rendered an 'Authority transition (host)' line that misattributed the per-turn reduction to a host flip, and the next authoritative turn rendered the unwind. The narrowing receipt family already reports the reduction: a narrowed turn now emits no host line and leaves the standing baseline untouched for the next authoritative turn to measure against. SyncSession clears the baseline with the recorded posture. Auto-Review was also standing auto authority that the narrowing downgraded without a receipt; it now records the same structured event the Bypass downgrade carries. Signed-off-by: asto18089 --- crates/tui/src/core/authority.rs | 9 +- crates/tui/src/core/engine.rs | 38 +- crates/tui/src/core/engine/preview.rs | 32 +- crates/tui/src/core/engine/preview/tests.rs | 8 + crates/tui/src/core/engine/tests.rs | 411 ++++++++++++++++++++ 5 files changed, 480 insertions(+), 18 deletions(-) diff --git a/crates/tui/src/core/authority.rs b/crates/tui/src/core/authority.rs index d64cd0aed4..fbbde1b189 100644 --- a/crates/tui/src/core/authority.rs +++ b/crates/tui/src/core/authority.rs @@ -349,8 +349,13 @@ pub(crate) fn effective_input_policy( if !provenance_can_inherit_standing_auto_authority(provenance) { let from_mode = mode; let from_approval = approval_mode; - let had_auto_authority = - trust_mode || auto_approve || matches!(approval_mode, ApprovalMode::Bypass); + // Auto-Review is standing auto authority too: the non-inheriting turn + // still loses it (Auto -> Suggest below), so it must carry the same + // structured event — a silent downgrade would leave the model with no + // receipt for why approval prompts appeared. + let had_auto_authority = trust_mode + || auto_approve + || matches!(approval_mode, ApprovalMode::Auto | ApprovalMode::Bypass); trust_mode = false; auto_approve = false; if matches!(approval_mode, ApprovalMode::Auto | ApprovalMode::Bypass) { diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 61da33f517..a311c9ce4b 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1274,6 +1274,14 @@ pub struct Engine { /// The posture the last `` block reported (Pinvou tri-mode /// safety). Comparison unit for [`Self::pending_authority_transition`]. last_turn_meta_authority: Option, + /// The last posture an authoritative-provenance turn reported — the + /// standing baseline a host flip is measured against. A + /// provenance-narrowed turn runs a per-turn reduction that neither emits + /// the host transition nor updates this baseline (the narrowing receipt + /// family reports the reduction), so a narrowed turn between two + /// authoritative turns can never be misread as a host flip — nor its + /// unwind on the next authoritative turn. + last_authoritative_turn_meta_posture: Option, /// Host-driven authority change computed when the turn's metadata block /// is built, against the posture the previous block reported. Consumed /// by that block only and cleared immediately, so a turn whose posture @@ -1939,8 +1947,10 @@ impl Engine { // establishes a fresh baseline instead of rendering a transition // whose "from" side it never reported. Arming itself // (`session.ask_on_guardrail`) is host-owned and deliberately stays - // — the host did not disarm across this edge. + // — the host did not disarm across this edge. The standing baseline + // resets with it: the next authoritative turn re-establishes it. self.last_turn_meta_authority = None; + self.last_authoritative_turn_meta_posture = None; let plugin_workspace_changed = self.plugin_registry.workspace() != workspace.as_path(); let previous_session_id = self.session.id.clone(); let next_session_id = if let Some(session_id) = session_id { @@ -2433,6 +2443,7 @@ impl Engine { turn_wall_clock: turn_budget::TurnWallClock::start(turn_wall_clock_budget), last_policy_narrowing: None, last_turn_meta_authority: None, + last_authoritative_turn_meta_posture: None, pending_authority_transition: None, last_turn_meta_git_snapshot: StdMutex::new(None), token_estimate_cache: TokenEstimateCache::new(), @@ -6098,14 +6109,33 @@ impl Engine { // would be rendered nowhere: an isolated turn neither consumes nor // records, and the next block-building turn compares against the // last posture a block actually reported. + // + // A provenance-narrowed turn runs a per-turn authority reduction that + // the narrowing receipt family below already reports. It therefore + // neither emits the host transition (labeling the reduction a host + // flip would misattribute the cause) nor updates the standing + // baseline (otherwise its unwind on the next authoritative turn + // would render a reverse "host" line no host ever caused). Its + // reported posture still lands in `last_turn_meta_authority`, so the + // wire record stays faithful; only the host-flip measurement is + // scoped to authoritative turns. A genuine host flip that lands in + // the same window renders on the next authoritative turn, measured + // from the pre-narrowing standing posture. if !self.api_config.runtime_chat_isolated { let current_posture = self.turn_meta_posture_for(&input_policy); - self.pending_authority_transition = + let narrowed_turn = input_policy.narrowing.is_some(); + self.pending_authority_transition = if narrowed_turn { + None + } else { crate::core::authority::authority_transition_between( - self.last_turn_meta_authority, + self.last_authoritative_turn_meta_posture, current_posture, - ); + ) + }; self.last_turn_meta_authority = Some(current_posture); + if !narrowed_turn { + self.last_authoritative_turn_meta_posture = Some(current_posture); + } } let user_msg = self.user_text_message_from_snapshot( content, diff --git a/crates/tui/src/core/engine/preview.rs b/crates/tui/src/core/engine/preview.rs index 19dc975336..948596efef 100644 --- a/crates/tui/src/core/engine/preview.rs +++ b/crates/tui/src/core/engine/preview.rs @@ -417,18 +417,26 @@ impl Engine { working_set: &previewed_working_set, policy_narrowing: input_policy.narrowing.as_ref(), // Pinvou tri-mode safety: recompute the transition exactly the - // live turn will (recorded posture vs the planned turn's - // posture), never read the turn-start leftover — a host that - // flips posture between turns previews the line the live body - // will send, and an already-consumed change never leaks here. - // The posture comes from the same helper as the live compare: - // planned approval from the preview inputs, live arming from - // the session, so a hypothetical posture in the inputs is - // rendered exactly as the live turn would render it. - authority_transition: crate::core::authority::authority_transition_between( - self.last_turn_meta_authority, - self.turn_meta_posture_for(&input_policy), - ), + // live turn will, never read the turn-start leftover — a host + // that flips posture between turns previews the line the live + // body will send, and an already-consumed change never leaks + // here. The posture comes from the same helper as the live + // compare: planned approval from the preview inputs, live + // arming from the session, so a hypothetical posture in the + // inputs is rendered exactly as the live turn would render + // it. Both sides of the compare mirror the live turn-start + // detector: an authoritative planned turn measures against + // the standing authoritative baseline, and a provenance- + // narrowed planned turn emits no host transition at all (the + // narrowing receipt above reports the reduction). + authority_transition: if input_policy.narrowing.is_some() { + None + } else { + crate::core::authority::authority_transition_between( + self.last_authoritative_turn_meta_posture, + self.turn_meta_posture_for(&input_policy), + ) + }, }, ); *self diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index c88ae99d9c..5682da4125 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -1302,6 +1302,7 @@ async fn forkguard_preview_manifest_hashes_the_same_authority_transition_line_th // With the previous block having reported the armed posture, the preview // recomputes the same transition the live turn will render. engine.last_turn_meta_authority = Some(previous); + engine.last_authoritative_turn_meta_posture = Some(previous); let planned = plan(&config, &identity, false, prompt).await; let manifest = engine .build_request_manifest(inputs(false, Some(planned), prompt)) @@ -1317,6 +1318,7 @@ async fn forkguard_preview_manifest_hashes_the_same_authority_transition_line_th // output is byte-stable: two consecutive builds hash identically, and // neither carries the transition. engine.last_turn_meta_authority = None; + engine.last_authoritative_turn_meta_posture = None; let planned = plan(&config, &identity, false, prompt).await; let plain_first = engine .build_request_manifest(inputs(false, Some(planned), prompt)) @@ -1349,6 +1351,7 @@ async fn forkguard_preview_manifest_hashes_the_same_authority_transition_line_th // Production derives the same transition at turn start and sends the // exact bytes the armed preview hashed. engine.last_turn_meta_authority = Some(previous); + engine.last_authoritative_turn_meta_posture = Some(previous); let planned = plan(&config, &identity, false, prompt).await; let production_route = planned.route.clone(); let compaction = planned.compaction.clone(); @@ -1441,6 +1444,11 @@ async fn forkguard_preview_transition_uses_the_planned_posture_not_the_stale_ses approval: crate::tui::approval::ApprovalMode::Suggest, ask_on_guardrail: false, }); + engine.last_authoritative_turn_meta_posture = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }); let prompt = "inspect the planned posture"; let planned = plan(&config, &identity, false, prompt).await; let mut preview_inputs = inputs(false, Some(planned), prompt); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 03dfd0be01..5c2583cb5d 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -31895,6 +31895,11 @@ async fn forkguard_turn_start_detector_records_the_posture_and_renders_the_trans approval: crate::tui::approval::ApprovalMode::Bypass, ask_on_guardrail: false, }); + engine.last_authoritative_turn_meta_posture = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }); engine.session.ask_on_guardrail = true; async fn send_turn( @@ -32006,6 +32011,384 @@ async fn forkguard_turn_start_detector_records_the_posture_and_renders_the_trans ); } +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn forkguard_a_provenance_narrowed_turn_neither_emits_the_host_line_nor_pollutes_the_baseline() + { + // Pins the narrowing attribution: a provenance-narrowed turn runs a + // per-turn authority reduction that the narrowing receipt family already + // reports, so it must neither emit the "(host)" transition line + // (misattributing the reduction to a host flip) nor update the standing + // baseline (otherwise its unwind on the next authoritative turn would + // render a reverse "host" line no host ever caused). A genuine host flip + // in the same window still renders, measured from the pre-narrowing + // standing posture. Removing the suppression gate turns the first body + // red; updating the baseline on the narrowed turn turns the second body + // red; erasing the baseline turns the third body red. + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let plain_sse = concat!( + "data: {\"id\":\"chatcmpl-narrow\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-narrow\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(plain_sse), + ) + .expect(3) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + let (mut engine, _handle) = Engine::new(engine_config, &api_config); + + // The standing posture is Full Access; the previous block reported it. + let standing = TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }; + engine.last_turn_meta_authority = Some(standing); + engine.last_authoritative_turn_meta_posture = Some(standing); + + async fn send_turn( + engine: &mut Engine, + api_config: &Config, + prompt: &str, + approval: crate::tui::approval::ApprovalMode, + auto_approve: bool, + provenance: UserInputProvenance, + ) -> SendMessageOutcome { + engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + *resolved_route_for_test(api_config, crate::config::DEFAULT_TEXT_MODEL), + CompactionConfig::default(), + None, + None, + crate::tools::goal::GoalStatus::Active, + None, + false, + false, + true, + true, + auto_approve, + approval, + false, + None, + Vec::new(), + None, + None, + provenance, + None, + ) + .await + } + + async fn assert_completed(outcome: SendMessageOutcome, label: &str) { + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "{label} must complete without starting" + ); + } + + // Turn one: a provenance-narrowed turn under the Full Access standing + // posture. Its reduction is reported by the narrowing receipt family, + // never by the "(host)" line, and the standing baseline survives it. + let outcome = send_turn( + &mut engine, + &api_config, + "narrowed turn", + crate::tui::approval::ApprovalMode::Bypass, + true, + UserInputProvenance::ImportedTranscript, + ) + .await; + assert_completed(outcome, "the narrowed turn").await; + assert!( + engine.pending_authority_transition.is_none(), + "a narrowed turn must leave no pending host transition" + ); + assert_eq!( + engine.last_turn_meta_authority, + Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }), + "the narrowed turn records the posture its block reported" + ); + assert_eq!( + engine.last_authoritative_turn_meta_posture, + Some(standing), + "the narrowed turn must not update the standing baseline" + ); + + // Turn two: authoritative turn under the unchanged standing posture — + // the narrowing unwound, and no host flip ever happened, so the block + // must stay free of the "(host)" line entirely. + let outcome = send_turn( + &mut engine, + &api_config, + "authoritative turn", + crate::tui::approval::ApprovalMode::Bypass, + true, + UserInputProvenance::ExternalUser, + ) + .await; + assert_completed(outcome, "the unwind turn").await; + assert_eq!( + engine.last_authoritative_turn_meta_posture, + Some(standing), + "the authoritative turn re-establishes the same standing baseline" + ); + + // Turn three: a genuine host flip (Full Access -> Ask) must render, + // measured from the pre-narrowing standing posture — not from the + // narrowed posture the previous block happened to report. + let outcome = send_turn( + &mut engine, + &api_config, + "host flip turn", + crate::tui::approval::ApprovalMode::Suggest, + false, + UserInputProvenance::ExternalUser, + ) + .await; + assert_completed(outcome, "the host flip turn").await; + + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!( + requests.len(), + 3, + "each turn makes exactly one provider call" + ); + let narrowed_body = String::from_utf8_lossy(&requests[0].body); + assert!( + !narrowed_body.contains("Authority transition (host)"), + "a provenance-narrowed turn must not emit the host transition line" + ); + assert!( + narrowed_body.contains("Authority narrowing:"), + "the narrowing receipt family must report the reduction" + ); + let unwind_body = String::from_utf8_lossy(&requests[1].body); + assert_eq!( + unwind_body.matches("Authority transition (host)").count(), + 0, + "the narrowing unwind on an authoritative turn must not render a \ + reverse host line" + ); + let flip_body = String::from_utf8_lossy(&requests[2].body); + assert!( + flip_body.contains( + "Authority transition (host): agent (Full Access, guardrail prompts disarmed) -> agent (Ask, guardrail prompts disarmed); reason: host_authority_change", + ), + "a genuine host flip renders from the standing baseline, not the \ + narrowed record: {flip_body}" + ); +} + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn forkguard_an_auto_review_turn_narrowed_by_provenance_still_records_its_narrowing() { + // Pins the Auto-Review narrowing receipt: Auto-Review is standing auto + // authority, so a provenance-narrowed turn that downgrades Auto -> + // Suggest must carry the structured narrowing event (a silent downgrade + // would leave the model no receipt for why prompts appeared) — and the + // reduced turn still emits no "(host)" line, with the standing baseline + // untouched so the next authoritative Auto turn stays byte-stable. + // Reverting the Auto arm of the narrowing predicate turns the first + // body red (the misattributed host line returns). + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let workspace = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let plain_sse = concat!( + "data: {\"id\":\"chatcmpl-autonarrow\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"ok\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-autonarrow\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(plain_sse), + ) + .expect(2) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let engine_config = EngineConfig { + model: crate::config::DEFAULT_TEXT_MODEL.to_string(), + workspace: workspace.path().to_path_buf(), + mcp_config_path: workspace.path().join("mcp.json"), + snapshots_enabled: false, + subagents_enabled: false, + ..EngineConfig::default() + }; + let (mut engine, _handle) = Engine::new(engine_config, &api_config); + + let auto_standing = TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Auto, + ask_on_guardrail: false, + }; + engine.last_turn_meta_authority = Some(auto_standing); + engine.last_authoritative_turn_meta_posture = Some(auto_standing); + + async fn send_turn( + engine: &mut Engine, + api_config: &Config, + prompt: &str, + provenance: UserInputProvenance, + ) -> SendMessageOutcome { + engine + .handle_send_message( + prompt.to_string(), + AppMode::Agent, + *resolved_route_for_test(api_config, crate::config::DEFAULT_TEXT_MODEL), + CompactionConfig::default(), + None, + None, + crate::tools::goal::GoalStatus::Active, + None, + false, + false, + false, + false, + false, + crate::tui::approval::ApprovalMode::Auto, + false, + None, + Vec::new(), + None, + None, + provenance, + None, + ) + .await + } + + let outcome = send_turn( + &mut engine, + &api_config, + "narrowed auto turn", + UserInputProvenance::ImportedTranscript, + ) + .await; + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "the narrowed Auto turn must complete without starting" + ); + assert_eq!( + engine.last_turn_meta_authority, + Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }), + "the narrowed turn records the Suggest posture it reported" + ); + assert_eq!( + engine.last_authoritative_turn_meta_posture, + Some(auto_standing), + "the narrowed turn must not update the standing Auto baseline" + ); + + let outcome = send_turn( + &mut engine, + &api_config, + "authoritative auto turn", + UserInputProvenance::ExternalUser, + ) + .await; + assert!( + matches!( + outcome, + SendMessageOutcome::Finished { + status: TurnOutcomeStatus::Completed, + .. + } + ), + "the authoritative Auto turn must complete without starting" + ); + + let requests = server + .received_requests() + .await + .expect("wire mock records requests"); + assert_eq!( + requests.len(), + 2, + "each turn makes exactly one provider call" + ); + let narrowed_body = String::from_utf8_lossy(&requests[0].body); + assert!( + !narrowed_body.contains("Authority transition (host)"), + "the Auto downgrade is provenance narrowing, not a host flip" + ); + assert!( + narrowed_body.contains("Authority narrowing:"), + "the Auto -> Suggest downgrade must carry its narrowing receipt" + ); + let auth_body = String::from_utf8_lossy(&requests[1].body); + assert_eq!( + auth_body.matches("Authority transition (host)").count(), + 0, + "an authoritative Auto turn after the narrowed one must stay \ + byte-stable: {auth_body}" + ); +} + #[tokio::test] async fn forkguard_a_not_started_turn_neither_consumes_nor_records_the_transition() { // Pins the NotStarted ordering: the posture comparison sits after the @@ -32062,6 +32445,11 @@ async fn forkguard_a_not_started_turn_neither_consumes_nor_records_the_transitio approval: crate::tui::approval::ApprovalMode::Bypass, ask_on_guardrail: false, }); + engine.last_authoritative_turn_meta_posture = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }); engine.session.ask_on_guardrail = true; async fn send_turn( @@ -32206,6 +32594,11 @@ async fn forkguard_sync_session_clears_the_recorded_turn_meta_posture() { approval: crate::tui::approval::ApprovalMode::Bypass, ask_on_guardrail: true, }); + engine.last_authoritative_turn_meta_posture = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: true, + }); engine .apply_synced_session( @@ -32223,6 +32616,10 @@ async fn forkguard_sync_session_clears_the_recorded_turn_meta_posture() { engine.last_turn_meta_authority, None, "the sync must clear the previous conversation's recorded posture" ); + assert_eq!( + engine.last_authoritative_turn_meta_posture, None, + "the sync must clear the standing baseline with it" + ); // A host disarm lands before the installed conversation's first turn, // so that turn reports disarmed. It must stay silent: it has no earlier @@ -32361,6 +32758,11 @@ async fn forkguard_an_isolated_turn_neither_consumes_nor_records_the_transition( approval: crate::tui::approval::ApprovalMode::Bypass, ask_on_guardrail: false, }); + engine.last_authoritative_turn_meta_posture = Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }); engine.session.ask_on_guardrail = true; async fn send_turn( @@ -32415,6 +32817,15 @@ async fn forkguard_an_isolated_turn_neither_consumes_nor_records_the_transition( }), "an isolated turn must not record a posture no block will report" ); + assert_eq!( + engine.last_authoritative_turn_meta_posture, + Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Bypass, + ask_on_guardrail: false, + }), + "an isolated turn must not touch the standing baseline either" + ); assert!( engine.pending_authority_transition.is_none(), "nothing was set, so nothing may be consumed" From fd066bf2eaa023b93dedeeb1d45c65586dcb0c11 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 22:56:16 +0800 Subject: [PATCH 12/29] fix(engine): fresh text-fallback tool-call ids The legacy text parser restarts its generated ids at 1 for every response, so two adjacent text-encoded tool responses both presented `xml_tool_1`. The turn loop carried that id verbatim into ToolUseState, ToolCallStarted, and every approval event, while the approval receipt log keys receipts by tool-call id and rejects an id that was asked more than once: under decision gating, the second fallback call failed its receipt commit and could never execute. Rewrite each parsed id at the turn-loop ingestion point into a fresh incarnation `#x` backed by a process- global AtomicU64 plus wall-clock millis, so ids stay unique across responses and across engine restarts within the same session. The id is opaque to every downstream consumer and keeps flowing through ToolUseState, so everything downstream automatically uses the rewritten value. Correct the parser's stale module doc, which claimed the engine never invokes it. Signed-off-by: asto18089 --- crates/core/src/tool_parser.rs | 7 +- crates/tui/src/core/engine/tests.rs | 129 ++++++++++++++++++ crates/tui/src/core/engine/turn_loop.rs | 39 +++++- .../tests/integration/protocol_recovery.rs | 30 ++++ 4 files changed, 201 insertions(+), 4 deletions(-) diff --git a/crates/core/src/tool_parser.rs b/crates/core/src/tool_parser.rs index 2b66ee81ef..d75ec3eede 100644 --- a/crates/core/src/tool_parser.rs +++ b/crates/core/src/tool_parser.rs @@ -1,7 +1,10 @@ //! Legacy parser for text-based tool calls from DeepSeek models. //! -//! Structured tool-call items are preferred, so the engine no longer invokes -//! this parser. It is kept for reference/debugging. +//! Structured tool-call items are preferred, but the engine still invokes +//! this parser as a fallback when a response carries tool-call markers in +//! plain text. The generated ids restart at 1 for every invocation and are +//! only unique within one parsed response; the engine rewrites them into +//! fresh incarnations before persisting or asking for approval. //! //! Some DeepSeek outputs tool calls as text in various formats: //! ```text diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 5c2583cb5d..5e03111862 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -9432,6 +9432,135 @@ async fn injected_model_receives_malformed_tool_feedback_and_recovers() { task.await.expect("engine task"); } +#[tokio::test] +async fn injected_model_fallback_text_tool_ids_stay_unique_across_responses() { + use crate::llm_client::mock::{MockLlmClient, canned}; + + // Regression: the text-fallback parser restarts its generated ids at 1 + // for every response, so two adjacent text-encoded tool responses both + // presented `xml_tool_1`. Approval receipts are keyed by tool-call id and + // reject a repeated ask, so every ingestion must mint a fresh incarnation + // or the second call can never be approved or executed. + let workspace = tempdir().expect("tempdir"); + fs::write(workspace.path().join("fallback-1.txt"), "first").expect("write fixture"); + fs::write(workspace.path().join("fallback-2.txt"), "second").expect("write fixture"); + let fallback_turn = |message_id: &str, path: &str| { + vec![ + canned::message_start(message_id), + canned::text_block_start(0), + canned::text_delta( + 0, + &format!( + "Reading the fixture. \ + read\ + {path}" + ), + ), + canned::block_stop(0), + canned::message_delta("end_turn", None), + canned::message_stop(), + ] + }; + let mock = std::sync::Arc::new(MockLlmClient::new(vec![ + fallback_turn("mock_msg_fallback_1", "fallback-1.txt"), + fallback_turn("mock_msg_fallback_2", "fallback-2.txt"), + canned::simple_text_turn("Done."), + ])); + let client: crate::core::model_client::SharedModelClient = mock.clone(); + let (engine, handle) = Engine::new_with_model_client( + deterministic_engine_config(workspace.path()), + &Config::default(), + client, + ); + let task = tokio::spawn(engine.run()); + handle + .send(external_user_message_op( + "Read both fixtures from plain text.", + AppMode::Agent, + &Config::default(), + )) + .await + .expect("send fallback trajectory"); + + let mut started_ids = Vec::new(); + let mut rx = handle.rx_event.write().await; + while let Some(event) = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for fallback trajectory") + { + match event { + Event::ToolCallStarted { id, name, .. } => { + assert_eq!(name, "File"); + started_ids.push(id); + } + Event::ToolCallComplete { id, result, .. } => { + let result = result.expect("fallback tool call closes with a result"); + assert!( + result.success, + "fallback tool call must execute: {result:?}" + ); + assert!( + started_ids.contains(&id), + "results must carry the started id, got {id}" + ); + } + Event::TurnComplete { status, error, .. } => { + assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}"); + break; + } + _ => {} + } + } + drop(rx); + + assert_eq!( + started_ids.len(), + 2, + "both fallback responses must produce a tool call: {started_ids:?}" + ); + assert_ne!( + started_ids[0], started_ids[1], + "adjacent fallback responses must not share a tool-call id" + ); + for id in &started_ids { + let (parsed_prefix, incarnation) = id + .split_once('#') + .expect("incarnated fallback id keeps its parsed prefix"); + assert!( + parsed_prefix.starts_with("xml_tool_"), + "fallback id must keep the parser shape as a prefix: {id}" + ); + let (millis, counter) = + incarnation.split_once('x').expect("incarnation must be x"); + assert!( + !millis.is_empty() && millis.bytes().all(|byte| byte.is_ascii_digit()), + "incarnation millis must be numeric: {id}" + ); + assert!( + !counter.is_empty() && counter.bytes().all(|byte| byte.is_ascii_digit()), + "incarnation counter must be numeric: {id}" + ); + } + assert_eq!( + mock.captured_requests().len(), + 3, + "each fallback tool result must continue the turn" + ); + handle.send(Op::Shutdown).await.expect("shutdown engine"); + task.await.expect("engine task"); +} + +#[test] +fn fresh_fallback_tool_ids_are_unique_within_a_process() { + let first = super::turn_loop::fresh_fallback_tool_id("xml_tool_1"); + let second = super::turn_loop::fresh_fallback_tool_id("xml_tool_1"); + assert_ne!(first, second, "the same parsed id must incarnate differently"); + assert_eq!( + first.split_once('#').map(|(prefix, _)| prefix), + Some("xml_tool_1") + ); +} + #[tokio::test] async fn engine_cancellation_drops_active_injected_model_request() { let workspace = tempdir().expect("tempdir"); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index a5fd0c6d68..d7ec156aab 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -5,6 +5,8 @@ //! event handling, tool planning/execution, LSP post-edit hooks, capacity //! checkpoints, and loop termination. +use std::sync::atomic::{AtomicU64, Ordering}; + use super::dispatch::normalize_schema_json_containers; use super::*; use crate::core::authority::{ToolPermission, resolve_tool_permission}; @@ -21,6 +23,32 @@ use codewhale_core::request::{PrimaryTurnRequest, prepare_primary_turn_request}; const MAX_APPROVAL_INTENT_SUMMARY_CHARS: usize = 2_000; +/// Monotonic counter for rewritten text-fallback tool-call ids. The wall-clock +/// millis alone can repeat within one process, so the counter disambiguates +/// incarnations; the millis keep ids unique across engine restarts within the +/// same session, where the approval receipt log persists. +static FALLBACK_TOOL_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// Rewrite a parser-generated fallback tool-call id into a fresh incarnation +/// (`#x`). +/// +/// `tool_parser` restarts its generated ids at 1 for every response, and the +/// approval receipt log rejects a tool-call id that was asked more than once, +/// so every ingestion must mint a never-before-seen id for the session. The +/// id is opaque to everything downstream (events, approvals, receipts, +/// session persistence). +pub(super) fn fresh_fallback_tool_id(parsed_id: &str) -> String { + let unix_millis = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis()) + .unwrap_or_default(); + format!( + "{parsed_id}#{}x{}", + unix_millis, + FALLBACK_TOOL_ID_COUNTER.fetch_add(1, Ordering::Relaxed) + ) +} + /// Most images a single tool result may attach to its message. /// /// Each image rides the request body as a base64 data URL, so an uncapped @@ -2749,16 +2777,23 @@ impl Engine { let parsed = tool_parser::parse_tool_calls(¤t_text_raw); final_text = parsed.clean_text; for call in parsed.tool_calls { + // The fallback parser restarts its generated ids at 1 for + // every response, so two adjacent text-tool responses both + // present `xml_tool_1`. Approval receipts are keyed by + // tool-call id and reject a repeated ask, so each parsed id + // is rewritten into a fresh incarnation here; everything + // downstream treats the id as opaque. + let id = fresh_fallback_tool_id(&call.id); let _ = self .tx_event .send(Event::ToolCallStarted { - id: call.id.clone(), + id: id.clone(), name: call.name.clone(), input: call.args.clone(), }) .await; tool_uses.push(ToolUseState { - id: call.id, + id, name: call.name, input: call.args, caller: None, diff --git a/crates/tui/tests/integration/protocol_recovery.rs b/crates/tui/tests/integration/protocol_recovery.rs index e4d90c0d45..ff1632bdb9 100644 --- a/crates/tui/tests/integration/protocol_recovery.rs +++ b/crates/tui/tests/integration/protocol_recovery.rs @@ -162,6 +162,36 @@ fn legacy_parser_marker_helper_flags_fake_wrappers_without_enabling_execution() ); } +#[test] +fn fallback_tool_call_ids_are_reincarnated_at_ingestion() { + // The parser restarts its generated ids at 1 for every response, so the + // turn loop must rewrite each parsed id into a fresh incarnation before + // the id reaches approvals and the receipt log (which reject a repeated + // ask of the same id). The wall-clock millis keep restarts of one session + // unique; the process-global counter disambiguates same-millisecond + // incarnations inside one process. + assert!( + any_engine_source_contains("fresh_fallback_tool_id"), + "the turn loop must rewrite fallback ids through the incarnation helper" + ); + assert!( + any_engine_source_contains("FALLBACK_TOOL_ID_COUNTER"), + "the incarnation helper must be backed by a process-global counter" + ); + + // Within a single response the parser still numbers its calls apart; + // only across responses do the generated ids repeat. + let parsed = crate::tool_parser::parse_tool_calls( + "a b ", + ); + let ids: Vec<&str> = parsed + .tool_calls + .iter() + .map(|call| call.id.as_str()) + .collect(); + assert_eq!(ids, vec!["xml_tool_1", "xml_tool_2"]); +} + #[test] fn engine_source_file_still_exists_and_is_non_trivial() { // Sanity check so the `include_str!` above is meaningful — if the engine From 47ada186f9d81bad7e76fc912091b3819765d8f6 Mon Sep 17 00:00:00 2001 From: asto Date: Mon, 2 Nov 2026 23:42:15 +0800 Subject: [PATCH 13/29] fix(engine): ack approval decisions to callers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Handle::approve_tool_call/deny_tool_call were fire-and-forget: they sent ApprovalDecision into the channel and returned Ok(()) meaning only "enqueued". When no turn was awaiting, the idle loop received decisions and silently dropped every non-child id, and inside await_tool_approval the durable receipt commit happened only for the matching id — so a caller (the pinvou3-app approval surface) could believe a decision was accepted while the engine never validated or committed it. Attach an ack oneshot to every ApprovalDecision variant. The handle methods now await the ack under a bounded 15s timeout and return Ok(()) only when the engine consumed the decision and durably committed the outcome for the matching id (or a waiting child took it); Err otherwise. await_tool_approval resolves the ack from the commit result and nacks stale non-child ids instead of dropping them; the idle loop nacks unroutable decisions the same way, mirroring the existing child-approval oneshot style. The test-only mock handle resolves acks in a small pump so mock-based suites keep their enqueue/observe ordering. Tests: the stale-decision and failed-commit cases now assert the handle-level Err and that no receipt was written; the idle-loop child test also covers the parent-shaped nack. Signed-off-by: asto18089 --- crates/tui/src/core/engine.rs | 45 +++++++--- crates/tui/src/core/engine/approval.rs | 114 ++++++++++++++++++++----- crates/tui/src/core/engine/handle.rs | 63 +++++++++++--- crates/tui/src/core/engine/tests.rs | 18 +++- 4 files changed, 196 insertions(+), 44 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index a311c9ce4b..283c87b07a 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -3132,10 +3132,12 @@ impl Engine { } // A background child may be waiting on a person's answer // while the parent turn is idle: route it. Any other - // decision has no waiter and is dropped, as before. + // decision has no waiter here, so its caller is nacked + // instead of being left hanging on the ack. decision = self.rx_approval.recv() => { - if let Some(decision) = decision { - self.route_child_approval_decision(decision).await; + if let Some(mut decision) = decision { + let routed = self.route_child_approval_decision(&decision).await; + decision.resolve_ack(routed); } } update = async { @@ -3167,14 +3169,14 @@ impl Engine { /// a child took it; the parent's own awaiting call keeps every other id. async fn route_child_approval_decision( &self, - decision: super::engine::approval::ApprovalDecision, + decision: &super::engine::approval::ApprovalDecision, ) -> bool { use crate::tools::subagent::{ChildApprovalOutcome, SubAgentManager}; - let (id, outcome) = match &decision { - super::engine::approval::ApprovalDecision::Approved { id } => { + let (id, outcome) = match decision { + super::engine::approval::ApprovalDecision::Approved { id, .. } => { (id.clone(), ChildApprovalOutcome::Approved) } - super::engine::approval::ApprovalDecision::Denied { id } => { + super::engine::approval::ApprovalDecision::Denied { id, .. } => { (id.clone(), ChildApprovalOutcome::Denied) } // A sandbox retry only exists for the parent's own tool call. @@ -8883,9 +8885,9 @@ pub(crate) enum MockApprovalEvent { impl MockEngineHandle { pub(crate) async fn recv_approval_event(&mut self) -> Option { match self.rx_approval.recv().await? { - ApprovalDecision::Approved { id } => Some(MockApprovalEvent::Approved { id }), - ApprovalDecision::Denied { id } => Some(MockApprovalEvent::Denied { id }), - ApprovalDecision::RetryWithPolicy { id, policy } => { + ApprovalDecision::Approved { id, .. } => Some(MockApprovalEvent::Approved { id }), + ApprovalDecision::Denied { id, .. } => Some(MockApprovalEvent::Denied { id }), + ApprovalDecision::RetryWithPolicy { id, policy, .. } => { Some(MockApprovalEvent::RetryWithPolicy { id, policy }) } } @@ -8919,7 +8921,28 @@ impl MockEngineHandle { pub(crate) fn mock_engine_handle() -> MockEngineHandle { let (tx_op, rx_op) = mpsc::channel(32); let (tx_event, rx_event) = mpsc::channel(256); - let (tx_approval, rx_approval) = mpsc::channel(64); + // Decisions sent through the mock handle carry the same ack the real + // engine contract expects, but no engine loop runs behind this handle. + // A small pump resolves each ack as consumed — a stand-in for the + // engine's wait or idle loop — and forwards the ack-stripped decision to + // the channel the test observes via `recv_approval_event`. + let (tx_approval, rx_approval) = if let Ok(runtime) = tokio::runtime::Handle::try_current() { + let (tx_pump, mut rx_pump) = mpsc::channel::(64); + let (tx_forward, rx_forward) = mpsc::channel::(64); + runtime.spawn(async move { + while let Some(mut decision) = rx_pump.recv().await { + decision.resolve_ack(true); + if tx_forward.send(decision).await.is_err() { + break; + } + } + }); + (tx_pump, rx_forward) + } else { + // No tokio runtime (plain sync test): no decision can be awaited + // through this handle anyway. + mpsc::channel(64) + }; let (tx_user_input, rx_user_input) = mpsc::channel(32); let (tx_steer, rx_steer) = mpsc::channel(64); let cancel_token = CancellationToken::new(); diff --git a/crates/tui/src/core/engine/approval.rs b/crates/tui/src/core/engine/approval.rs index 7894c26f5e..e084b5a981 100644 --- a/crates/tui/src/core/engine/approval.rs +++ b/crates/tui/src/core/engine/approval.rs @@ -9,24 +9,50 @@ use crate::approval_log::{ApprovalOutcome, ApprovalReceipt}; use crate::core::events::Event; use crate::tools::spec::ToolError; use crate::tools::user_input::{UserInputRequest, UserInputResponse}; +use tokio::sync::oneshot; use super::Engine; -#[derive(Debug, Clone)] +#[derive(Debug)] pub(super) enum ApprovalDecision { Approved { id: String, + /// Acknowledges consumption back to the decision's caller: `true` + /// when the engine applied the decision (committed for the matching + /// id, or routed it to a waiting child), `false` when it was stale + /// or its outcome could not be committed. + ack: Option>, }, Denied { id: String, + /// See `Approved::ack`. + ack: Option>, }, /// Retry a tool with an elevated sandbox policy. RetryWithPolicy { id: String, policy: crate::sandbox::SandboxPolicy, + /// See `Approved::ack`. + ack: Option>, }, } +impl ApprovalDecision { + /// Resolve the decision's ack, if its caller still waits on one. A + /// dropped ack (no `Some` sender left) is read by the caller as a + /// failed delivery. + pub(super) fn resolve_ack(&mut self, consumed: bool) { + let ack = match self { + Self::Approved { ack, .. } + | Self::Denied { ack, .. } + | Self::RetryWithPolicy { ack, .. } => ack.take(), + }; + if let Some(ack) = ack { + let _ = ack.send(consumed); + } + } +} + #[derive(Debug, Clone)] pub(super) enum UserInputDecision { Submitted { @@ -96,6 +122,32 @@ impl Engine { .await } + /// Commit `outcome` for `tool_id` and resolve the decision caller's ack: + /// `true` only when the durable receipt committed, so a failed write + /// nacks the decision instead of letting the caller believe an + /// uncommitted approval took effect. + async fn commit_approval_outcome_and_ack( + &self, + tool_id: &str, + outcome: ApprovalOutcome, + mut ack: Option>, + ) -> Result<(), ToolError> { + match self.commit_approval_outcome(tool_id, outcome).await { + Ok(()) => { + if let Some(ack) = ack.take() { + let _ = ack.send(true); + } + Ok(()) + } + Err(error) => { + if let Some(ack) = ack.take() { + let _ = ack.send(false); + } + Err(error) + } + } + } + pub(super) async fn request_tool_approval( &mut self, tool_id: &str, @@ -154,7 +206,7 @@ impl Engine { )); } decision = self.rx_approval.recv() => { - let Some(decision) = decision else { + let Some(mut decision) = decision else { self.commit_approval_outcome(tool_id, ApprovalOutcome::Unavailable).await?; return Err(ToolError::execution_failed( "Approval channel closed — engine is shutting down. \ @@ -164,25 +216,37 @@ impl Engine { )); }; match decision { - ApprovalDecision::Approved { id } if id == tool_id => { - self.commit_approval_outcome(tool_id, ApprovalOutcome::ApprovedOnce).await?; + ApprovalDecision::Approved { id, ack } if id == tool_id => { + self.commit_approval_outcome_and_ack( + tool_id, + ApprovalOutcome::ApprovedOnce, + ack, + ).await?; return Ok(ApprovalResult::Approved); } - ApprovalDecision::Denied { id } if id == tool_id => { - self.commit_approval_outcome(tool_id, ApprovalOutcome::Denied).await?; + ApprovalDecision::Denied { id, ack } if id == tool_id => { + self.commit_approval_outcome_and_ack( + tool_id, + ApprovalOutcome::Denied, + ack, + ).await?; return Ok(ApprovalResult::Denied); } - ApprovalDecision::RetryWithPolicy { id, policy } if id == tool_id => { - self.commit_approval_outcome( + ApprovalDecision::RetryWithPolicy { id, policy, ack } if id == tool_id => { + self.commit_approval_outcome_and_ack( tool_id, ApprovalOutcome::RetryWithPolicy { policy: policy.clone() }, + ack, ).await?; return Ok(ApprovalResult::RetryWithPolicy(policy)); } // A child prompt answered while the parent itself is - // waiting: hand it to the child instead of dropping it. - other => { - self.route_child_approval_decision(other).await; + // waiting: hand it to the child instead of dropping + // it. A stale non-child id has no waiter here, so its + // caller is nacked instead of left hanging. + ref mut other => { + let routed = self.route_child_approval_decision(other).await; + other.resolve_ack(routed); continue; } } @@ -409,16 +473,25 @@ mod tests { .await .expect("approval event"); assert!(matches!(emitted, Event::ApprovalRequired { .. })); - handle - .approve_tool_call("tool-stale") - .await - .expect("deliver stale decision"); + // A stale non-child id has no waiter: the caller is nacked instead of + // being told its decision was accepted. + assert!( + handle.approve_tool_call("tool-stale").await.is_err(), + "a stale decision must not report success" + ); assert!( tokio::time::timeout(Duration::from_millis(50), &mut task) .await .is_err(), "a stale decision must not grant or close the current request" ); + // The stale decision must not have written any receipt: the ask is + // still the only recorded entry for the session. + let replay = store + .replay(&session_id) + .expect("replay after stale attempt"); + assert!(replay.completed.is_empty()); + assert_eq!(replay.unmatched_asks.len(), 1); handle .deny_tool_call("tool-current") .await @@ -465,10 +538,13 @@ mod tests { .join("approval_receipts.jsonl"); std::fs::remove_file(&log_path).expect("remove log after durable ask"); std::fs::create_dir(&log_path).expect("replace log with unwritable directory"); - handle - .approve_tool_call("tool-write-fails") - .await - .expect("deliver approval decision"); + // The failed durable commit nacks the decision, so the caller learns + // its approval never took effect instead of reading a bare enqueue + // as success. + assert!( + handle.approve_tool_call("tool-write-fails").await.is_err(), + "a decision whose outcome could not be committed must not report success" + ); assert!( task.await.expect("approval task").is_err(), diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index d4dfa6bf33..aad5d7e4ab 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -10,9 +10,10 @@ use std::sync::Arc; use std::sync::atomic::Ordering; +use std::time::Duration; -use anyhow::Result; -use tokio::sync::mpsc; +use anyhow::{Result, anyhow}; +use tokio::sync::{mpsc, oneshot}; use super::approval::{ApprovalDecision, UserInputDecision}; use super::{ @@ -20,6 +21,9 @@ use super::{ RuntimePermissionAuthority, SteerWithdrawal, UserInputResponse, }; +/// Bound on waiting for the engine to acknowledge an approval decision. +const APPROVAL_ACK_TIMEOUT: Duration = Duration::from_secs(15); + impl EngineHandle { /// True when the caller must preflight a concrete provider client before /// committing UI/runtime turn state. Test and embedding handles with an @@ -290,35 +294,74 @@ impl EngineHandle { } } - /// Approve a pending tool call + /// Approve a pending tool call. + /// + /// `Ok(())` means the engine consumed the decision and durably committed + /// the approval for this exact id (or routed it to a waiting child). An + /// `Err` means the decision was stale, its outcome could not be + /// committed, or no engine waiter accepted it before the ack deadline; + /// the caller must treat the tool as not approved. pub async fn approve_tool_call(&self, id: impl Into) -> Result<()> { + let (ack, rx_ack) = oneshot::channel(); self.tx_approval - .send(ApprovalDecision::Approved { id: id.into() }) + .send(ApprovalDecision::Approved { + id: id.into(), + ack: Some(ack), + }) .await?; - Ok(()) + Self::await_approval_ack(rx_ack).await } - /// Deny a pending tool call + /// Deny a pending tool call. The ack contract is the same as + /// [`EngineHandle::approve_tool_call`]. pub async fn deny_tool_call(&self, id: impl Into) -> Result<()> { + let (ack, rx_ack) = oneshot::channel(); self.tx_approval - .send(ApprovalDecision::Denied { id: id.into() }) + .send(ApprovalDecision::Denied { + id: id.into(), + ack: Some(ack), + }) .await?; - Ok(()) + Self::await_approval_ack(rx_ack).await } - /// Retry a tool call with an elevated sandbox policy. + /// Retry a tool call with an elevated sandbox policy. The ack contract + /// is the same as [`EngineHandle::approve_tool_call`]. pub async fn retry_tool_with_policy( &self, id: impl Into, policy: crate::sandbox::SandboxPolicy, ) -> Result<()> { + let (ack, rx_ack) = oneshot::channel(); self.tx_approval .send(ApprovalDecision::RetryWithPolicy { id: id.into(), policy, + ack: Some(ack), }) .await?; - Ok(()) + Self::await_approval_ack(rx_ack).await + } + + /// Wait, bounded, for the engine to acknowledge a delivered decision. + /// The engine's receiver is a per-turn wait loop or the always-running + /// idle loop, so a live engine answers quickly; the bound only backstops + /// a wedged or already-shut-down engine. + async fn await_approval_ack(rx_ack: oneshot::Receiver) -> Result<()> { + match tokio::time::timeout(APPROVAL_ACK_TIMEOUT, rx_ack).await { + Ok(Ok(true)) => Ok(()), + Ok(Ok(false)) => Err(anyhow!( + "approval decision was not applied: the tool call id is stale \ + or its outcome could not be committed" + )), + Ok(Err(_)) => Err(anyhow!( + "approval decision was dropped without being consumed by the engine" + )), + Err(_) => Err(anyhow!( + "engine did not acknowledge the approval decision within {}s", + APPROVAL_ACK_TIMEOUT.as_secs() + )), + } } /// Submit a response for request_user_input. diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 5e03111862..ac1835c7dd 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -9530,8 +9530,9 @@ async fn injected_model_fallback_text_tool_ids_stay_unique_across_responses() { parsed_prefix.starts_with("xml_tool_"), "fallback id must keep the parser shape as a prefix: {id}" ); - let (millis, counter) = - incarnation.split_once('x').expect("incarnation must be x"); + let (millis, counter) = incarnation + .split_once('x') + .expect("incarnation must be x"); assert!( !millis.is_empty() && millis.bytes().all(|byte| byte.is_ascii_digit()), "incarnation millis must be numeric: {id}" @@ -9554,7 +9555,10 @@ async fn injected_model_fallback_text_tool_ids_stay_unique_across_responses() { fn fresh_fallback_tool_ids_are_unique_within_a_process() { let first = super::turn_loop::fresh_fallback_tool_id("xml_tool_1"); let second = super::turn_loop::fresh_fallback_tool_id("xml_tool_1"); - assert_ne!(first, second, "the same parsed id must incarnate differently"); + assert_ne!( + first, second, + "the same parsed id must incarnate differently" + ); assert_eq!( first.split_once('#').map(|(prefix, _)| prefix), Some("xml_tool_1") @@ -28258,8 +28262,14 @@ async fn idle_engine_routes_child_approval_decisions_to_the_waiting_child() { .expect("child prompt resolved"); assert_eq!(outcome, ChildApprovalOutcome::Denied); - // A decision for a parent-shaped id has no child waiter and is not routed. + // A decision for a parent-shaped id has no child waiter and is not + // routed: the idle loop nacks it so the caller does not read a bare + // enqueue as acceptance. assert!(!crate::tools::subagent::SubAgentManager::is_child_approval_id("call_123")); + assert!( + handle.approve_tool_call("call_123").await.is_err(), + "an idle engine must nack a decision no waiter can consume" + ); handle.send(Op::Shutdown).await.expect("shutdown engine"); run.await.expect("engine task"); } From 20b00e0ef97befe233b7222254f03f416a6068dc Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 02:50:45 +0800 Subject: [PATCH 14/29] fix(engine): settle slow approval acks before reporting refusal The ack oneshot's 15s deadline dropped the receiver, but the engine's durable receipt commit has no deadline: it waits on the receipt file lock and fsync. A legitimate approval whose persistence crossed the deadline therefore got a determinate-looking 'not applied' error while the engine later committed the receipt, returned Approved, and executed the tool - the remote control bridge had already acknowledged the decision as failed. Keep the ack receiver alive past the deadline into a settle window: the engine answers only after the commit finished, so anything arriving there is the true terminal outcome, and a merely-slow commit now converges on applied/refused instead of a false refusal. Only when both windows expire does the handle return a typed ApprovalAckUnknownError whose contract says the decision may still be applied later; the remote control bridge acknowledges it as 'unknown' (new status alongside applied/failed) and keeps the approval card open instead of claiming failure. Tests: a held receipt lock crossing the ack deadline converges on the real outcome (committed receipt + executing waiter), and a lock held past both windows surfaces the typed unknown error - never a refusal - while the waiter still executes once persistence lands; the bridge status mapping pins unknown vs failed. Red-checked against the old single-window behavior. Signed-off-by: asto18089 Signed-off-by: asto --- crates/tui/src/core/engine.rs | 9 + crates/tui/src/core/engine/approval.rs | 163 +++++++++++++++++ crates/tui/src/core/engine/handle.rs | 167 +++++++++++++++--- crates/tui/src/core/engine/tests.rs | 2 + .../tui/src/tui/ui/remote_control_bridge.rs | 60 ++++++- 5 files changed, 366 insertions(+), 35 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 283c87b07a..258b94ebee 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1011,6 +1011,10 @@ pub struct EngineHandle { /// be awaiting a provider while its bounded op mailbox is unable to drain, /// so cancellation cannot depend on processing a later mailbox entry. compaction_cancellation: Arc>, + /// Fast ack deadline for approval decisions; see `engine/handle.rs`. + approval_ack_timeout: Duration, + /// Settle window after the ack deadline; see `engine/handle.rs`. + approval_ack_settle_timeout: Duration, } const MAX_PENDING_COMPACTION_CANCELLATIONS: usize = 64; @@ -2464,6 +2468,8 @@ impl Engine { live_runtime_authority, steer_control, compaction_cancellation, + approval_ack_timeout: handle::APPROVAL_ACK_TIMEOUT, + approval_ack_settle_timeout: handle::APPROVAL_ACK_SETTLE_TIMEOUT, }; (engine, handle) @@ -8980,6 +8986,8 @@ pub(crate) fn mock_engine_handle() -> MockEngineHandle { live_runtime_authority, steer_control: Arc::new(StdMutex::new(steer_control_state)), compaction_cancellation, + approval_ack_timeout: handle::APPROVAL_ACK_TIMEOUT, + approval_ack_settle_timeout: handle::APPROVAL_ACK_SETTLE_TIMEOUT, }; MockEngineHandle { @@ -9301,6 +9309,7 @@ mod approval; mod context; mod handle; pub mod preview; +pub(crate) use self::handle::ApprovalAckUnknownError; use crate::compaction::estimate_input_tokens_conservative; #[cfg(test)] pub(crate) use context::compact_tool_result_for_context; diff --git a/crates/tui/src/core/engine/approval.rs b/crates/tui/src/core/engine/approval.rs index e084b5a981..295c3246b2 100644 --- a/crates/tui/src/core/engine/approval.rs +++ b/crates/tui/src/core/engine/approval.rs @@ -630,4 +630,167 @@ mod tests { "the wait must end as a cancelled error, got {resolved:?}" ); } + + /// Spawn a native thread that acquires the receipt write lock, reports + /// once it holds it, keeps the lock for `hold_for`, then releases. The + /// engine's decided-receipt append then blocks exactly like slow + /// persistence (contended file lock or fsync) would, with no code-level + /// seam. The lock file must already exist (the durable ask creates it). + fn hold_receipt_lock_for( + lock_path: &std::path::Path, + hold_for: Duration, + ) -> (std::sync::mpsc::Receiver<()>, std::thread::JoinHandle<()>) { + let lock_path = lock_path.to_path_buf(); + let (held_tx, held_rx) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let lock_file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .expect("open receipt lock"); + let mut receipt_lock = fd_lock::RwLock::new(lock_file); + let guard = receipt_lock.write().expect("acquire receipt lock"); + held_tx.send(()).expect("report held receipt lock"); + std::thread::sleep(hold_for); + drop(guard); + }); + (held_rx, holder) + } + + #[tokio::test] + async fn slow_receipt_commit_converges_on_the_real_outcome() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut handle) = Engine::new(EngineConfig::default(), &Config::default()); + let store = crate::approval_log::ApprovalReceiptStore::new(tmp.path().join("sessions")); + engine.approval_receipt_store = Ok(store.clone()); + let session_id = engine.session.id.clone(); + handle + .set_approval_ack_windows_for_test(Duration::from_millis(150), Duration::from_secs(5)); + let task = tokio::spawn(async move { + engine + .request_tool_approval( + "tool-slow-commit", + "exec_shell", + approval_event("tool-slow-commit"), + ) + .await + }); + + let emitted = handle + .rx_event + .write() + .await + .recv() + .await + .expect("approval event"); + assert!(matches!(emitted, Event::ApprovalRequired { .. })); + + // Hold the receipt lock so the engine's decided-receipt append + // blocks past the fast ack deadline, exactly like slow persistence + // (file lock or fsync) would. + let lock_path = store + .sessions_dir() + .join(&session_id) + .join("approval_receipts.lock"); + let (held, holder) = hold_receipt_lock_for(&lock_path, Duration::from_millis(400)); + held.recv() + .expect("holder thread must hold the receipt lock"); + + // The ack deadline (150ms) expires, but the settle window keeps the + // receiver alive and reports the real outcome once persistence + // lands. Reporting "not applied" here would be the false refusal: + // the receipt commits and the tool proceeds right after it. + handle + .approve_tool_call("tool-slow-commit") + .await + .expect("a slow commit crossing the ack deadline must converge on applied"); + + assert!(matches!( + task.await.expect("approval task"), + Ok(ApprovalResult::Approved) + )); + let replay = store.replay(&session_id).expect("replay approvals"); + assert_eq!(replay.completed.len(), 1); + assert_eq!(replay.completed[0].outcome, ApprovalOutcome::ApprovedOnce); + assert!(replay.unmatched_asks.is_empty()); + holder.join().expect("holder thread"); + } + + #[tokio::test] + async fn settle_expiry_reports_unknown_outcome_not_refusal() { + let tmp = tempfile::tempdir().expect("tempdir"); + let (mut engine, mut handle) = Engine::new(EngineConfig::default(), &Config::default()); + let store = crate::approval_log::ApprovalReceiptStore::new(tmp.path().join("sessions")); + engine.approval_receipt_store = Ok(store.clone()); + let session_id = engine.session.id.clone(); + handle.set_approval_ack_windows_for_test( + Duration::from_millis(120), + Duration::from_millis(250), + ); + let mut task = tokio::spawn(async move { + engine + .request_tool_approval( + "tool-still-committing", + "exec_shell", + approval_event("tool-still-committing"), + ) + .await + }); + + let emitted = handle + .rx_event + .write() + .await + .recv() + .await + .expect("approval event"); + assert!(matches!(emitted, Event::ApprovalRequired { .. })); + + // Hold the receipt lock past both windows: the commit is still in + // flight when the caller gives up. + let lock_path = store + .sessions_dir() + .join(&session_id) + .join("approval_receipts.lock"); + let (held, holder) = hold_receipt_lock_for(&lock_path, Duration::from_millis(700)); + held.recv() + .expect("holder thread must hold the receipt lock"); + + let error = handle + .approve_tool_call("tool-still-committing") + .await + .expect_err("both ack windows expired"); + assert!( + error + .downcast_ref::() + .is_some(), + "settle expiry must surface the typed unknown-outcome error, got {error:#}" + ); + assert!( + !error.to_string().contains("not applied"), + "an unknown outcome must not be worded as a refusal: {error}" + ); + + // The decision was not refused: the tool is still awaiting its + // committed approval. + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut task) + .await + .is_err(), + "an unconfirmed decision must not have granted execution yet" + ); + + // Once persistence lands the approval applies for real — the + // unknown report stayed honest instead of claiming a refusal. + holder.join().expect("holder thread releases the lock"); + assert!(matches!( + task.await.expect("approval task"), + Ok(ApprovalResult::Approved) + )); + let replay = store.replay(&session_id).expect("replay approvals"); + assert_eq!(replay.completed.len(), 1); + assert_eq!(replay.completed[0].outcome, ApprovalOutcome::ApprovedOnce); + } } diff --git a/crates/tui/src/core/engine/handle.rs b/crates/tui/src/core/engine/handle.rs index aad5d7e4ab..1851ecc5f8 100644 --- a/crates/tui/src/core/engine/handle.rs +++ b/crates/tui/src/core/engine/handle.rs @@ -22,7 +22,77 @@ use super::{ }; /// Bound on waiting for the engine to acknowledge an approval decision. -const APPROVAL_ACK_TIMEOUT: Duration = Duration::from_secs(15); +/// A live engine answers quickly; this only backstops a wedged engine. +pub(super) const APPROVAL_ACK_TIMEOUT: Duration = Duration::from_secs(15); + +/// Extra settle window after the ack deadline. The engine sends the ack +/// only after its durable receipt commit finished and that commit has no +/// deadline, so the handle keeps waiting on the same oneshot instead of +/// reporting a false "not applied" while persistence is merely slow. +pub(super) const APPROVAL_ACK_SETTLE_TIMEOUT: Duration = Duration::from_secs(75); + +/// Terminal result when neither the ack deadline nor the settle window +/// produced an engine answer. Deliberately distinct from "not applied": +/// the engine answers only after the durable receipt commit finishes and +/// that commit has no deadline, so the decision may still be committed +/// and the tool executed after this error is returned. Callers must +/// surface the outcome as unconfirmed instead of claiming the decision +/// was refused. +#[derive(Debug)] +pub(crate) struct ApprovalAckUnknownError { + ack_timeout: Duration, + settle_timeout: Duration, +} + +impl ApprovalAckUnknownError { + /// Test-only constructor for consumers pinning the remote mapping. + #[cfg(test)] + pub(crate) fn for_test(ack_timeout: Duration, settle_timeout: Duration) -> Self { + Self { + ack_timeout, + settle_timeout, + } + } +} + +impl std::fmt::Display for ApprovalAckUnknownError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "approval decision outcome is unknown: the engine did not \ + acknowledge within {}s ack + {}s settle, and the decision may \ + still be committed and executed", + self.ack_timeout.as_secs(), + self.settle_timeout.as_secs() + ) + } +} + +impl std::error::Error for ApprovalAckUnknownError {} + +/// One bounded wait on the approval ack oneshot. `TimedOut` deliberately +/// leaves the receiver alive with its caller: the decision may still be +/// mid-commit, and dropping the receiver here is what used to turn a slow +/// commit into a false "not applied". +enum AckAnswer { + Applied, + NotApplied(anyhow::Error), + TimedOut, +} + +async fn ack_answer(rx_ack: &mut oneshot::Receiver, budget: Duration) -> AckAnswer { + match tokio::time::timeout(budget, &mut *rx_ack).await { + Ok(Ok(true)) => AckAnswer::Applied, + Ok(Ok(false)) => AckAnswer::NotApplied(anyhow!( + "approval decision was not applied: the tool call id is stale \ + or its outcome could not be committed" + )), + Ok(Err(_)) => AckAnswer::NotApplied(anyhow!( + "approval decision was dropped without being consumed by the engine" + )), + Err(_elapsed) => AckAnswer::TimedOut, + } +} impl EngineHandle { /// True when the caller must preflight a concrete provider client before @@ -297,10 +367,17 @@ impl EngineHandle { /// Approve a pending tool call. /// /// `Ok(())` means the engine consumed the decision and durably committed - /// the approval for this exact id (or routed it to a waiting child). An - /// `Err` means the decision was stale, its outcome could not be - /// committed, or no engine waiter accepted it before the ack deadline; - /// the caller must treat the tool as not approved. + /// the approval for this exact id (or routed it to a waiting child). + /// `Err` means exactly one of: + /// + /// - the decision was refused (stale id, its durable outcome could not + /// be committed, or the engine dropped it unconsumed): the tool is + /// not approved; + /// - the outcome is unknown ([`ApprovalAckUnknownError`]): neither the + /// ack deadline nor the settle window got an answer. The engine's + /// durable commit has no deadline, so the decision may still be + /// committed and executed later; the caller must report the outcome + /// as unconfirmed — not as "not approved". pub async fn approve_tool_call(&self, id: impl Into) -> Result<()> { let (ack, rx_ack) = oneshot::channel(); self.tx_approval @@ -309,7 +386,12 @@ impl EngineHandle { ack: Some(ack), }) .await?; - Self::await_approval_ack(rx_ack).await + Self::await_approval_ack( + rx_ack, + self.approval_ack_timeout, + self.approval_ack_settle_timeout, + ) + .await } /// Deny a pending tool call. The ack contract is the same as @@ -322,7 +404,12 @@ impl EngineHandle { ack: Some(ack), }) .await?; - Self::await_approval_ack(rx_ack).await + Self::await_approval_ack( + rx_ack, + self.approval_ack_timeout, + self.approval_ack_settle_timeout, + ) + .await } /// Retry a tool call with an elevated sandbox policy. The ack contract @@ -340,30 +427,54 @@ impl EngineHandle { ack: Some(ack), }) .await?; - Self::await_approval_ack(rx_ack).await - } - - /// Wait, bounded, for the engine to acknowledge a delivered decision. - /// The engine's receiver is a per-turn wait loop or the always-running - /// idle loop, so a live engine answers quickly; the bound only backstops - /// a wedged or already-shut-down engine. - async fn await_approval_ack(rx_ack: oneshot::Receiver) -> Result<()> { - match tokio::time::timeout(APPROVAL_ACK_TIMEOUT, rx_ack).await { - Ok(Ok(true)) => Ok(()), - Ok(Ok(false)) => Err(anyhow!( - "approval decision was not applied: the tool call id is stale \ - or its outcome could not be committed" - )), - Ok(Err(_)) => Err(anyhow!( - "approval decision was dropped without being consumed by the engine" - )), - Err(_) => Err(anyhow!( - "engine did not acknowledge the approval decision within {}s", - APPROVAL_ACK_TIMEOUT.as_secs() - )), + Self::await_approval_ack( + rx_ack, + self.approval_ack_timeout, + self.approval_ack_settle_timeout, + ) + .await + } + + /// Wait for the engine to acknowledge a delivered decision, then keep + /// converging through a settle window. + /// + /// The engine sends the ack only after the durable receipt commit + /// finished, so an answer received in either window is always the real + /// terminal outcome. The fast ack deadline backstops a wedged engine; + /// the settle window then covers the slow-persistence case (the receipt + /// append waits on a file lock and fsync with no deadline): the receiver + /// is deliberately kept alive so a decision whose commit merely crossed + /// the deadline resolves to its true outcome instead of a false "not + /// applied". Only when both windows expire does the caller receive + /// [`ApprovalAckUnknownError`]. + async fn await_approval_ack( + rx_ack: oneshot::Receiver, + ack_timeout: Duration, + settle_timeout: Duration, + ) -> Result<()> { + let mut rx_ack = rx_ack; + match ack_answer(&mut rx_ack, ack_timeout).await { + AckAnswer::Applied => Ok(()), + AckAnswer::NotApplied(error) => Err(error), + AckAnswer::TimedOut => match ack_answer(&mut rx_ack, settle_timeout).await { + AckAnswer::Applied => Ok(()), + AckAnswer::NotApplied(error) => Err(error), + AckAnswer::TimedOut => Err(anyhow::Error::new(ApprovalAckUnknownError { + ack_timeout, + settle_timeout, + })), + }, } } + /// Override the ack/settle windows in tests so slow-persistence + /// regressions run in milliseconds instead of wall-clock minutes. + #[cfg(test)] + pub(crate) fn set_approval_ack_windows_for_test(&mut self, ack: Duration, settle: Duration) { + self.approval_ack_timeout = ack; + self.approval_ack_settle_timeout = settle; + } + /// Submit a response for request_user_input. pub async fn submit_user_input( &self, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index ac1835c7dd..11f442ede0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -26690,6 +26690,8 @@ fn engine_handle_try_send_does_not_block_when_op_channel_is_full() { ))), steer_control: Arc::new(StdMutex::new(SteerControlState::default())), compaction_cancellation: Arc::new(StdMutex::new(CompactionCancellationState::default())), + approval_ack_timeout: super::handle::APPROVAL_ACK_TIMEOUT, + approval_ack_settle_timeout: super::handle::APPROVAL_ACK_SETTLE_TIMEOUT, }; // Fill the op channel with one message (capacity = 1). diff --git a/crates/tui/src/tui/ui/remote_control_bridge.rs b/crates/tui/src/tui/ui/remote_control_bridge.rs index fcd81d7e79..80fdec10e5 100644 --- a/crates/tui/src/tui/ui/remote_control_bridge.rs +++ b/crates/tui/src/tui/ui/remote_control_bridge.rs @@ -5,6 +5,18 @@ use super::*; +/// Map an approval ack failure to its remote acknowledgement status. +/// `unknown` is reserved for the unconfirmed-outcome error, whose decision +/// may still be committed and executed; every other failure shape is a +/// definite refusal and keeps the existing "failed" status. +fn approval_ack_status(error: &anyhow::Error) -> &'static str { + if error.is::() { + "unknown" + } else { + "failed" + } +} + pub(crate) async fn drain_remote_control_events( app: &mut App, config: &Config, @@ -283,13 +295,29 @@ pub(crate) async fn drain_remote_control_events( app.remote_control .acknowledge(&run_id, seq, &command, "applied", None); } - Err(error) => app.remote_control.acknowledge( - &run_id, - seq, - &command, - "failed", - Some(error.to_string()), - ), + Err(error) => { + // An ack that outlived both windows means the + // outcome is unconfirmed, not refused: the + // engine's commit has no deadline and the + // decision may still be committed and executed. + // Tell the controller the truth and keep the + // card open while the engine converges. + let status = approval_ack_status(&error); + app.remote_control.acknowledge( + &run_id, + seq, + &command, + status, + Some(error.to_string()), + ); + if status == "unknown" { + app.status_message = Some( + "Approval outcome not confirmed yet; the card stays \ + open until the engine reports." + .to_string(), + ); + } + } } } crate::remote_control::RemoteCommand::Control { @@ -460,3 +488,21 @@ pub(crate) fn start_remote_control_session(app: &mut App, config: &Config) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn approval_ack_status_separates_unknown_from_refusal() { + let unknown = anyhow::Error::new(crate::core::engine::ApprovalAckUnknownError::for_test( + std::time::Duration::from_secs(15), + std::time::Duration::from_secs(75), + )); + assert_eq!(approval_ack_status(&unknown), "unknown"); + + let refused = + anyhow::anyhow!("approval decision was not applied: the tool call id is stale"); + assert_eq!(approval_ack_status(&refused), "failed"); + } +} From 28c0a04491fe89b113a8d0e1aa3b76550f7d6275 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 03:13:32 +0800 Subject: [PATCH 15/29] fix(engine): restore the standing posture baseline on edit_last_turn EditLastTurn truncated the last user turn - including the block that reported an authority transition - but left the standing baseline that turn had installed. A posture-A round followed by a switch to posture B and an edit of the B round kept the baseline at B while retained history still ended on A, so the replacement turn omitted the A->B host transition the retained history still implies; the same window opened after a mid-turn provider failure. Every non-isolated turn build now snapshots the pre-advance baseline (one entry per persisted user turn; narrowed turns record their unchanged value, so they round-trip exactly), and the EditLastTurn truncation pops the truncated turn's entry and restores the baseline it replaced. The replacement turn re-reports exactly the transition retained history implies; the preview builder shares the field and stays consistent. The new-session reset clears the stack. Tests: edit_last_turn_restores_the_standing_posture_baseline drives two real provider turns across an approval-posture change and asserts the wire bodies - the first turn renders no transition, the turn after the flip reports it, and the replacement turn reports it again. Red-checked: without the restore the replacement turn omits the line. Signed-off-by: asto18089 Signed-off-by: asto --- crates/tui/src/core/engine.rs | 32 +++++ crates/tui/src/core/engine/tests.rs | 194 ++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 258b94ebee..d8e2e1f22c 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1292,6 +1292,15 @@ pub struct Engine { /// did not change emits nothing, ordinary blocks stay byte-stable, and /// steers or mid-turn runtime notices never re-render the same line. pending_authority_transition: Option, + /// The standing posture baseline each persisted user turn replaced, one + /// snapshot per non-isolated turn build in order. `EditLastTurn` pops the + /// truncated turn's entry and restores the baseline it replaced, so the + /// replacement turn re-reports the authority transition its predecessor + /// carried instead of silently swallowing it (the truncated block is gone + /// from retained history, but the baseline it installed would otherwise + /// survive). Each entry is the pre-advance value, which makes narrowed + /// turns — whose builds never advance the baseline — round-trip exactly. + standing_posture_baseline_history: Vec>, /// The git snapshot line last emitted in a `` block this /// session (#5187, k3-gap F3). The snapshot re-collects branch/dirty /// state every turn, so without change-detection the block's bytes drift @@ -1955,6 +1964,7 @@ impl Engine { // resets with it: the next authoritative turn re-establishes it. self.last_turn_meta_authority = None; self.last_authoritative_turn_meta_posture = None; + self.standing_posture_baseline_history.clear(); let plugin_workspace_changed = self.plugin_registry.workspace() != workspace.as_path(); let previous_session_id = self.session.id.clone(); let next_session_id = if let Some(session_id) = session_id { @@ -2449,6 +2459,7 @@ impl Engine { last_turn_meta_authority: None, last_authoritative_turn_meta_posture: None, pending_authority_transition: None, + standing_posture_baseline_history: Vec::new(), last_turn_meta_git_snapshot: StdMutex::new(None), token_estimate_cache: TokenEstimateCache::new(), shared_paused: shared_paused.clone(), @@ -4011,6 +4022,20 @@ impl Engine { }; self.session.messages.truncate_to(idx); self.session.bump_messages_revision(); + // The truncated turn's `` block — including + // any authority transition it reported — is gone from + // retained history, but the standing baseline it + // installed would survive and make the replacement + // turn omit that transition. Restore the baseline the + // truncated turn replaced (None included, e.g. when + // the session's first turn is edited), so the + // replacement re-reports exactly what retained + // history still implies. + if let Some(prior_standing_posture) = + self.standing_posture_baseline_history.pop() + { + self.last_authoritative_turn_meta_posture = prior_standing_posture; + } // Now dispatch the new message as a normal send, // reusing the engine's stored mode/model config. let mode = self.current_mode; @@ -6132,6 +6157,7 @@ impl Engine { if !self.api_config.runtime_chat_isolated { let current_posture = self.turn_meta_posture_for(&input_policy); let narrowed_turn = input_policy.narrowing.is_some(); + let prior_standing_posture = self.last_authoritative_turn_meta_posture; self.pending_authority_transition = if narrowed_turn { None } else { @@ -6144,6 +6170,12 @@ impl Engine { if !narrowed_turn { self.last_authoritative_turn_meta_posture = Some(current_posture); } + // Snapshot what this persisted turn replaced so an EditLastTurn + // truncation can restore the baseline its block carried away. + // One entry per non-isolated build keeps the stack aligned 1:1 + // with the user turns `edit_last_turn_target` can drop. + self.standing_posture_baseline_history + .push(prior_standing_posture); } let user_msg = self.user_text_message_from_snapshot( content, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 11f442ede0..ef75eada59 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -19514,6 +19514,200 @@ async fn edit_last_turn_preserves_current_mode() { run.await.expect("engine task"); } +#[allow(clippy::await_holding_lock)] +async fn wait_for_turn_complete(handle: &EngineHandle) { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for the turn to complete") + .expect("engine event channel closed before turn completion"); + if matches!(event, Event::TurnComplete { .. }) { + return; + } + } +} + +#[tokio::test] +async fn edit_last_turn_restores_the_standing_posture_baseline() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // The truncated turn's block carried the authority + // transition into append-only history; the standing baseline it + // installed must not survive the truncation, or the replacement turn + // silently drops the transition the retained history still implies. + let _lock = lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let done_sse = concat!( + "data: {\"id\":\"chatcmpl-edit-baseline\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"Turn answer.\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-edit-baseline\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(done_sse), + ) + .expect(3) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + snapshots_enabled: false, + subagents_enabled: false, + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &api_config); + let run = tokio::spawn(engine.run()); + + let seeded_messages = vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "seed the conversation".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: "seeded response".to_string(), + cache_control: None, + }], + }, + ]; + handle + .send(Op::SyncSession { + session_id: Some("edit-baseline-test".to_string()), + messages: seeded_messages, + system_prompt: None, + system_prompt_override: false, + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), + mode: AppMode::Agent, + }) + .await + .expect("sync session"); + + // Turn 1 runs in the installed Agent posture: the first reported turn + // renders no transition and establishes the standing baseline. + handle + .send(Op::SendMessage { + content: "turn in the original posture".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the original-posture turn"); + wait_for_turn_complete(&handle).await; + + // Switch the host posture (approval chip SUGGEST -> AUTO, same tool + // surface), then run a real turn whose block reports the transition + // into history. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + configured_sandbox_mode: None, + ask_on_guardrail: None, + }) + .await + .expect("send the approval posture change"); + handle + .send(Op::SendMessage { + content: "turn that reports the transition".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the transition-reporting turn"); + wait_for_turn_complete(&handle).await; + + handle + .send(Op::EditLastTurn { + new_message: "revise this turn".to_string(), + submission_id: Some("sub-edit-baseline".to_string()), + }) + .await + .expect("send edit"); + wait_for_turn_complete(&handle).await; + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run.await.expect("engine task"); + + let requests = server.received_requests().await.expect("recorded requests"); + assert_eq!(requests.len(), 3, "two real turns plus the replacement"); + let wire_body = |request: &[u8]| String::from_utf8_lossy(request).to_string(); + assert!( + !wire_body(&requests[0].body).contains("Authority transition (host)"), + "the first reported turn must render no transition" + ); + assert!( + wire_body(&requests[1].body).contains("Authority transition (host)"), + "the turn after the host flip must report the transition" + ); + assert!( + wire_body(&requests[2].body).contains("Authority transition (host)"), + "the replacement turn must re-report the transition: the truncated \ + block left history but its baseline must not survive" + ); +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() { From 2be1ca3c1e3729f37f58a98e834b21a130e747ff Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 04:49:52 +0800 Subject: [PATCH 16/29] fix(engine): scope the standing baseline stack to editable user turns The baseline stack recorded one entry per non-isolated turn build, but the edit selector skips non-authoritative runtime messages, so one truncate could remove several pushed entries - the true user turn plus every runtime turn after it - while the pop restored only one. An edit after an AgentMail runtime turn then restored the runtime turn's baseline (the posture the truncated turn had already installed) and the replacement turn compared it against the same value, silently dropping the A->B host transition the retained history still implies. Only authoritative (external-user) turns now record an entry: those are exactly the turns edit_last_turn_target can select, because every other provenance renders the block's (non-authoritative) line the selector reads as runtime-owned. The single pop therefore always removes the edited turn's own snapshot, restoring the baseline the retained history still implies no matter how many runtime turns the truncation rides over. Reset and mid-turn-failure behavior are unchanged. Tests: two regressions drive real provider turns across a guardrail arming flip (Ask/Suggest posture, arming only, so the runtime turn has no auto authority to narrow) and complete one / two AgentMail runtime turns before the edit; the replacement turn must re-report the arming transition. Red-checked: with the unconditional push restored, both go red while the adjacent no-runtime-turn test stays green. Signed-off-by: asto --- crates/tui/src/core/engine.rs | 44 +++-- crates/tui/src/core/engine/tests.rs | 255 ++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index d8e2e1f22c..78e5a93d3a 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1292,14 +1292,17 @@ pub struct Engine { /// did not change emits nothing, ordinary blocks stay byte-stable, and /// steers or mid-turn runtime notices never re-render the same line. pending_authority_transition: Option, - /// The standing posture baseline each persisted user turn replaced, one - /// snapshot per non-isolated turn build in order. `EditLastTurn` pops the - /// truncated turn's entry and restores the baseline it replaced, so the - /// replacement turn re-reports the authority transition its predecessor - /// carried instead of silently swallowing it (the truncated block is gone - /// from retained history, but the baseline it installed would otherwise - /// survive). Each entry is the pre-advance value, which makes narrowed - /// turns — whose builds never advance the baseline — round-trip exactly. + /// The standing posture baseline each authoritative (external-user) turn + /// replaced, one entry per such persisted turn in order. These are + /// exactly the turns `edit_last_turn_target` can select: every other + /// provenance renders the block's `(non-authoritative)` line, which the + /// selector reads as runtime-owned. Non-authoritative runtime turns + /// therefore record no entry, so an edit after any number of them still + /// pops exactly the edited turn's snapshot and restores the baseline it + /// replaced — the replacement turn re-reports the authority transition + /// its predecessor carried instead of silently swallowing it (the + /// truncated block is gone from retained history, but the baseline it + /// installed would otherwise survive). standing_posture_baseline_history: Vec>, /// The git snapshot line last emitted in a `` block this /// session (#5187, k3-gap F3). The snapshot re-collects branch/dirty @@ -4027,10 +4030,12 @@ impl Engine { // retained history, but the standing baseline it // installed would survive and make the replacement // turn omit that transition. Restore the baseline the - // truncated turn replaced (None included, e.g. when - // the session's first turn is edited), so the - // replacement re-reports exactly what retained - // history still implies. + // edited turn replaced (None included, e.g. when the + // session's first turn is edited). One pop is exact: + // only the turns this selector can target ever pushed, + // so the top entry is the edited turn's own snapshot, + // and any non-authoritative runtime turns the + // truncation removes alongside it left no entries. if let Some(prior_standing_posture) = self.standing_posture_baseline_history.pop() { @@ -6172,10 +6177,17 @@ impl Engine { } // Snapshot what this persisted turn replaced so an EditLastTurn // truncation can restore the baseline its block carried away. - // One entry per non-isolated build keeps the stack aligned 1:1 - // with the user turns `edit_last_turn_target` can drop. - self.standing_posture_baseline_history - .push(prior_standing_posture); + // Only authoritative turns record an entry: + // `edit_last_turn_target` selects exactly these — every other + // provenance renders the `(non-authoritative)` line its selector + // reads as runtime-owned — so the stack stays aligned 1:1 with + // the turns an edit can drop even when non-authoritative runtime + // turns complete between them (a truncation removes those turns + // without popping their entries, because they never pushed any). + if provenance.can_authorize_work() { + self.standing_posture_baseline_history + .push(prior_standing_posture); + } } let user_msg = self.user_text_message_from_snapshot( content, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index ef75eada59..b2193ed849 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -19708,6 +19708,261 @@ async fn edit_last_turn_restores_the_standing_posture_baseline() { ); } +/// Drive the standing-posture-baseline edit scenario with non-authoritative +/// runtime turns parked between the true user turns. +/// +/// The posture pair stays inside the Ask/Suggest family and flips only the +/// host's guardrail arming, so the AgentMail runtime turn has no standing +/// auto authority to narrow and the only transition in play is the host +/// re-authorization. Each runtime turn persists a real user-role turn with a +/// non-authoritative provenance line — exactly the production AgentMail +/// shape — and the edit selector must skip it; the baseline stack must not +/// record it either, or the edit after it pops a snapshot the truncation +/// never removed and the replacement turn drops the arming transition the +/// retained history still implies. +#[allow(clippy::await_holding_lock)] +async fn run_edit_last_turn_baseline_past_non_authoritative_runtime_turns(runtime_turns: usize) { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + let done_sse = concat!( + "data: {\"id\":\"chatcmpl-edit-runtime-baseline\",\"choices\":[{\"index\":0,", + "\"delta\":{\"content\":\"Turn answer.\"},\"finish_reason\":null}]}\n\n", + "data: {\"id\":\"chatcmpl-edit-runtime-baseline\",\"choices\":[{\"index\":0,", + "\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n", + "data: [DONE]\n\n", + ); + // Two true user turns plus the replacement, with every requested runtime + // turn in between. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(done_sse), + ) + .expect(u64::try_from(3 + runtime_turns).expect("request count fits u64")) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + snapshots_enabled: false, + subagents_enabled: false, + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &api_config); + let run = tokio::spawn(engine.run()); + + let seeded_messages = vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "seed the conversation".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Text { + text: "seeded response".to_string(), + cache_control: None, + }], + }, + ]; + handle + .send(Op::SyncSession { + session_id: Some(format!("edit-runtime-baseline-{runtime_turns}")), + messages: seeded_messages, + system_prompt: None, + system_prompt_override: false, + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), + mode: AppMode::Agent, + }) + .await + .expect("sync session"); + + // Turn 1 runs unarmed: the first reported turn renders no transition and + // establishes the standing baseline. + handle + .send(Op::SendMessage { + content: "turn in the unarmed posture".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the unarmed turn"); + wait_for_turn_complete(&handle).await; + + // Arm the host's guardrail prompts (Ask/Suggest posture, arming flip + // only), then run a real turn whose block reports the transition into + // history. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .await + .expect("send the guardrail arming change"); + handle + .send(Op::SendMessage { + content: "turn that reports the arming transition".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the transition-reporting turn"); + wait_for_turn_complete(&handle).await; + + // Complete real non-isolated runtime turns in the production AgentMail + // shape: user-role, non-authoritative provenance, no authority to + // narrow under the Ask/Suggest posture. + for runtime_index in 0..runtime_turns { + handle + .send(Op::SendMessage { + content: format!("agent mail turn #{runtime_index}"), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::AgentMail, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the agent mail runtime turn"); + wait_for_turn_complete(&handle).await; + } + + handle + .send(Op::EditLastTurn { + new_message: "revise this turn".to_string(), + submission_id: Some("sub-edit-runtime-baseline".to_string()), + }) + .await + .expect("send edit"); + wait_for_turn_complete(&handle).await; + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run.await.expect("engine task"); + + let requests = server.received_requests().await.expect("recorded requests"); + assert_eq!( + requests.len(), + 3 + runtime_turns, + "two true turns, every runtime turn, and the replacement" + ); + let wire_body = |request: &[u8]| String::from_utf8_lossy(request).to_string(); + assert!( + !wire_body(&requests[0].body).contains("Authority transition (host)"), + "the first reported turn must render no transition" + ); + assert!( + wire_body(&requests[1].body).contains("Authority transition (host)"), + "the turn after the arming flip must report the transition" + ); + for runtime_index in 0..runtime_turns { + let body = wire_body(&requests[2 + runtime_index].body); + assert!( + body.contains("Input provenance: agent_mail (non-authoritative)"), + "the runtime turn must carry the production AgentMail shape" + ); + assert_eq!( + body.matches("Authority transition (host)").count(), + 1, + "the runtime turn runs in the reported posture: the only \ + transition line in its request is the retained history block, \ + its own block must render none" + ); + } + assert!( + wire_body(&requests[2 + runtime_turns].body).contains("Authority transition (host)"), + "the replacement turn must re-report the arming transition: the \ + truncation removes the runtime turns too, so their baseline entries \ + must not survive the edit" + ); +} + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn edit_last_turn_restores_the_baseline_after_a_non_authoritative_runtime_turn() { + run_edit_last_turn_baseline_past_non_authoritative_runtime_turns(1).await; +} + +#[tokio::test] +#[allow(clippy::await_holding_lock)] +async fn edit_last_turn_restores_the_baseline_after_multiple_non_authoritative_runtime_turns() { + run_edit_last_turn_baseline_past_non_authoritative_runtime_turns(2).await; +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() { From fb4f5e63f0b486090fc1c0ce757fc6eea349f47b Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 05:48:24 +0800 Subject: [PATCH 17/29] fix(engine): pop the standing baseline only when the edit target owns the entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A committed steer (or a step-budget notice) persists an ExternalUser user message through user_text_message_with_turn_metadata without going through the turn-build push site, so it owns no standing-posture-baseline entry — yet the edit_last_turn selector selects it like any authoritative prompt. The single unconditional pop assumed every selectable message owns the top stack entry: editing such a message after an arming transition consumed the armed turn's snapshot, the replacement recomputed against the pre-arming baseline, and the retained history re-rendered the A→B host transition as a false duplicate. Link each stack entry to the exact persisted user message it was recorded for (identity, not stack position, decides the pop — persisted user messages are never mutated in place, so equality is stable). On EditLastTurn, pop only when the truncated target IS the top entry's message; steer and notice targets pop nothing and their replacement recomputes against the unchanged baseline, which renders no transition. Real turn-build edits still pop exactly as before. Regressions drive the Jensen scenario end to end for both faces (steer via the live steer channel, budget notices via a one-step ceiling): the capture request after editing must keep the armed turn's original transition line exactly once in retained history and emit no new host-change line from the replacement. Reverting to the unconditional pop turns both red with the duplicate-line signature (count 2 vs 1). Signed-off-by: asto --- crates/tui/src/core/engine.rs | 105 +++++++--- crates/tui/src/core/engine/tests.rs | 299 ++++++++++++++++++++++++++++ 2 files changed, 379 insertions(+), 25 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 78e5a93d3a..c5041c83a3 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1106,6 +1106,26 @@ enum McpBootUpdate { }, } +/// One entry of the engine's `standing_posture_baseline_history`: the +/// standing baseline an authoritative turn build replaced, recorded with the +/// exact persisted user message that carries the block reporting it. +/// +/// The message is the linkage unit for `EditLastTurn`: the edit selector +/// selects by message, so the pop must too. Steer commits and step-budget +/// notices persist selectable ExternalUser messages without pushing entries; +/// comparing identities at pop time keeps those edits from consuming a +/// turn-build snapshot their truncation never removed. +#[derive(Debug, Clone)] +struct StandingPostureBaselineEntry { + /// The baseline this turn's block replaced — restored when the turn is + /// edited away. + prior_posture: Option, + /// The persisted user message this entry belongs to (a full clone of the + /// `Message` added to history at the same turn start; persisted user + /// messages are never mutated in place, so equality is stable). + turn_message: Message, +} + /// The core engine that processes operations and emits events pub struct Engine { config: EngineConfig, @@ -1293,17 +1313,25 @@ pub struct Engine { /// steers or mid-turn runtime notices never re-render the same line. pending_authority_transition: Option, /// The standing posture baseline each authoritative (external-user) turn - /// replaced, one entry per such persisted turn in order. These are - /// exactly the turns `edit_last_turn_target` can select: every other + /// replaced, one entry per such persisted turn in order, each linked to + /// the exact persisted user message it belongs to. Authoritative turn + /// builds are the turns `edit_last_turn_target` can select — every other /// provenance renders the block's `(non-authoritative)` line, which the - /// selector reads as runtime-owned. Non-authoritative runtime turns - /// therefore record no entry, so an edit after any number of them still - /// pops exactly the edited turn's snapshot and restores the baseline it - /// replaced — the replacement turn re-reports the authority transition - /// its predecessor carried instead of silently swallowing it (the - /// truncated block is gone from retained history, but the baseline it - /// installed would otherwise survive). - standing_posture_baseline_history: Vec>, + /// selector reads as runtime-owned — but not every selectable message is + /// a turn build: a committed steer or step-budget notice persists an + /// ExternalUser message through `user_text_message_with_turn_metadata` + /// and is selectable too, while owning no entry of its own. The + /// message-identity linkage is what keeps the stack honest at edit time: + /// an edit pops only when the truncated target IS the message the top + /// entry was recorded with, so editing a steer or notice recomputes its + /// replacement against the unchanged standing baseline (same posture → + /// no transition line) while a real turn-build edit still pops exactly + /// the edited turn's snapshot and restores the baseline it replaced — + /// the replacement turn re-reports the authority transition its + /// predecessor carried instead of silently swallowing it (the truncated + /// block is gone from retained history, but the baseline it installed + /// would otherwise survive). + standing_posture_baseline_history: Vec, /// The git snapshot line last emitted in a `` block this /// session (#5187, k3-gap F3). The snapshot re-collects branch/dirty /// state every turn, so without change-detection the block's bytes drift @@ -4023,6 +4051,7 @@ impl Engine { continue; } }; + let edited_message = self.session.messages.get(idx).cloned(); self.session.messages.truncate_to(idx); self.session.bump_messages_revision(); // The truncated turn's `` block — including @@ -4031,15 +4060,28 @@ impl Engine { // installed would survive and make the replacement // turn omit that transition. Restore the baseline the // edited turn replaced (None included, e.g. when the - // session's first turn is edited). One pop is exact: - // only the turns this selector can target ever pushed, - // so the top entry is the edited turn's own snapshot, - // and any non-authoritative runtime turns the - // truncation removes alongside it left no entries. - if let Some(prior_standing_posture) = - self.standing_posture_baseline_history.pop() + // session's first turn is edited) — but only when the + // edited message IS the persisted turn-build message + // the top entry was recorded with. The selector can + // also target committed steer or step-budget notice + // messages (ExternalUser provenance, no entry of + // their own): truncating one of those removes only + // the message itself, so the standing baseline must + // stay untouched and the replacement recomputes + // against it (same posture → no transition line). + // Identity, not position, decides: entries are linked + // to their persisted message at push time. + let top_matches_edited = match ( + edited_message.as_ref(), + self.standing_posture_baseline_history.last(), + ) { + (Some(edited), Some(entry)) => *edited == entry.turn_message, + _ => false, + }; + if top_matches_edited + && let Some(entry) = self.standing_posture_baseline_history.pop() { - self.last_authoritative_turn_meta_posture = prior_standing_posture; + self.last_authoritative_turn_meta_posture = entry.prior_posture; } // Now dispatch the new message as a normal send, // reusing the engine's stored mode/model config. @@ -6159,6 +6201,8 @@ impl Engine { // scoped to authoritative turns. A genuine host flip that lands in // the same window renders on the next authoritative turn, measured // from the pre-narrowing standing posture. + let mut standing_baseline_entry: Option> = + None; if !self.api_config.runtime_chat_isolated { let current_posture = self.turn_meta_posture_for(&input_policy); let narrowed_turn = input_policy.narrowing.is_some(); @@ -6177,16 +6221,20 @@ impl Engine { } // Snapshot what this persisted turn replaced so an EditLastTurn // truncation can restore the baseline its block carried away. - // Only authoritative turns record an entry: - // `edit_last_turn_target` selects exactly these — every other + // Only authoritative turn builds record an entry, and each entry + // is linked to the exact persisted user message the record is + // taken for (identity, not stack position, decides the pop): + // `edit_last_turn_target` selects these builds — every other // provenance renders the `(non-authoritative)` line its selector - // reads as runtime-owned — so the stack stays aligned 1:1 with - // the turns an edit can drop even when non-authoritative runtime - // turns complete between them (a truncation removes those turns + // reads as runtime-owned — but it can also select committed + // steer / step-budget-notice messages, which persist an + // ExternalUser message without pushing any entry, so the stack + // stays aligned 1:1 with the turn-build messages an edit can + // drop even when non-authoritative runtime turns or steers + // complete between them (a truncation removes those messages // without popping their entries, because they never pushed any). if provenance.can_authorize_work() { - self.standing_posture_baseline_history - .push(prior_standing_posture); + standing_baseline_entry = Some(prior_standing_posture); } } let user_msg = self.user_text_message_from_snapshot( @@ -6205,6 +6253,13 @@ impl Engine { authority_transition: self.pending_authority_transition, }, ); + if let Some(prior_posture) = standing_baseline_entry { + self.standing_posture_baseline_history + .push(StandingPostureBaselineEntry { + prior_posture, + turn_message: user_msg.clone(), + }); + } self.session.add_message(user_msg); // The transition is reported by this block and only this block. self.pending_authority_transition = None; diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index b2193ed849..a27e8fc46c 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -19963,6 +19963,305 @@ async fn edit_last_turn_restores_the_baseline_after_multiple_non_authoritative_r run_edit_last_turn_baseline_past_non_authoritative_runtime_turns(2).await; } +/// Driver for the entryless-selectable-message edit scenarios: an unarmed +/// turn establishes the standing baseline, the host arms the guardrail +/// prompts (Ask/Suggest pair, arming flip only), the armed turn reports the +/// A→B transition into history and records prior=A on the baseline stack, +/// an ExternalUser message with no entry of its own lands in history +/// mid-turn (a committed steer, or — with `max_steps = 1` — the step-budget +/// notices; both persist through the same turn-metadata helper as a turn +/// build while pushing no entry), and the edit then targets THAT message. +/// +/// Returns the captured provider requests: index 0 is the unarmed turn, +/// 1..=2 the armed turn's two requests, 3 the replacement. +/// +/// The truncation removes only the entryless message(s) closest to the tail +/// (the armed turn's first block and its original A→B notice are retained), +/// so the pop must not take the armed turn's snapshot: the replacement +/// recomputes against the unchanged standing baseline (B→B, no transition +/// line). An unconditional pop consumed the armed turn's prior=A and +/// re-rendered the transition as a false duplicate (PR #131 review +/// residual). +#[allow(clippy::await_holding_lock)] +async fn run_edit_last_turn_baseline_past_entryless_selectable_message( + max_steps: u32, + commit_steer_mid_turn: bool, +) -> Vec { + use crate::llm_client::mock::{MockLlmClient, canned}; + + let workspace = tempdir().expect("workspace"); + let mut engine_config = deterministic_engine_config(workspace.path()); + engine_config.max_steps = max_steps; + let mock = Arc::new(MockLlmClient::new(vec![canned::simple_text_turn( + "unarmed posture answer", + )])); + let client: crate::core::model_client::SharedModelClient = mock.clone(); + let (engine, handle) = Engine::new_with_model_client(engine_config, &Config::default(), client); + if commit_steer_mid_turn { + // Armed turn, request 1: commit the steer while the turn is live, + // then answer with a read-only tool call so the turn loops and the + // top-of-loop steer drain injects it into history before request 2 + // (a text-only response would end the turn before that drain). + let tx_steer = handle.tx_steer.clone(); + let steer_control = Arc::clone(&handle.steer_control); + mock.push_factory(move |_request| { + let target = { + let mut control = steer_control + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let target = control + .active_target() + .expect("armed turn accepts test steer"); + control + .register("steer-edit-target".to_string(), target) + .expect("register steer-edit-target"); + target + }; + tx_steer + .try_send(SteerMessage { + id: "steer-edit-target".to_string(), + target, + content: "MID_TURN_STEER_EDIT_TARGET".to_string(), + }) + .expect("steer channel remains open"); + canned::tool_call_turn( + "call-steer-edit-read", + "read_file", + r#"{"path":"steer-edit-probe.txt"}"#, + ) + }); + } else { + // Budget-notice path: the armed turn's first response is a read-only + // tool call; with max_steps = 1 that tool round consumes the ceiling + // and the A1 soft-landing plus A2 exhaustion notices persist through + // the same ExternalUser turn-metadata helper as a steer. + mock.push_turn(canned::tool_call_turn( + "call-notice-read", + "read_file", + r#"{"path":"notice-probe.txt"}"#, + )); + } + mock.push_turn(canned::simple_text_turn( + "The mid-turn input is now handled.", + )); + mock.push_turn(canned::simple_text_turn("replacement answer")); + let run = tokio::spawn(engine.run()); + + // Turn 1 runs unarmed: it renders no transition and establishes the + // standing baseline. + handle + .send(Op::SendMessage { + content: "turn in the unarmed posture".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&Config::default(), "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the unarmed turn"); + wait_for_turn_complete(&handle).await; + + // Arm the host's guardrail prompts, then run a real turn whose block + // reports the A→B arming transition into history. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .await + .expect("send the guardrail arming change"); + handle + .send(Op::SendMessage { + content: "turn that reports the arming transition".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&Config::default(), "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the armed turn"); + wait_for_turn_complete(&handle).await; + + // The selector's latest editable user prompt is now the entryless + // mid-turn message (steer or budget notice) — not the armed turn build. + handle + .send(Op::EditLastTurn { + new_message: "revise this committed input".to_string(), + submission_id: Some("sub-edit-entryless".to_string()), + }) + .await + .expect("send edit"); + wait_for_turn_complete(&handle).await; + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run.await.expect("engine task"); + + let requests = mock.captured_requests(); + assert_eq!( + requests.len(), + 4, + "unarmed turn, the armed turn's two requests, and the replacement" + ); + requests +} + +/// Wire-visible text of one captured request: every `Text` content block +/// joined, so turn-meta assertions read exactly what the model would see. +fn captured_request_wire_text(requests: &[crate::models::MessageRequest], index: usize) -> String { + requests[index] + .messages + .iter() + .flat_map(|message| message.content.iter()) + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") +} + +/// Jensen residual P2, steer face (PR #131 review 5743286690): a committed +/// steer persists a selectable ExternalUser user message that owns no +/// standing-baseline entry. Editing it after the armed turn completed must +/// leave the stack untouched — the retained history keeps the arming turn's +/// original A→B line exactly once and the replacement steer message emits no +/// new host-change line (same posture → no transition). +#[tokio::test] +async fn edit_last_turn_keeps_the_baseline_when_the_edited_target_is_a_committed_steer() { + let requests = run_edit_last_turn_baseline_past_entryless_selectable_message(32, true).await; + + let unarmed = captured_request_wire_text(&requests, 0); + assert!( + !unarmed.contains("Authority transition (host)"), + "the first reported turn must render no transition" + ); + + let armed_first = captured_request_wire_text(&requests, 1); + assert!( + !armed_first.contains("MID_TURN_STEER_EDIT_TARGET"), + "the steer must commit between the armed turn's requests, not before it" + ); + assert_eq!( + armed_first.matches("Authority transition (host)").count(), + 1, + "the armed turn's block must report the arming transition exactly once" + ); + + let armed_second = captured_request_wire_text(&requests, 2); + assert!( + armed_second.contains("MID_TURN_STEER_EDIT_TARGET"), + "the steer must be committed mid-turn and reach the provider with the \ + armed turn's follow-up request" + ); + assert_eq!( + armed_second.matches("Authority transition (host)").count(), + 1, + "the steer block renders no transition line of its own" + ); + + let replacement = captured_request_wire_text(&requests, 3); + assert!( + replacement.contains("revise this committed input"), + "the replacement must carry the edited steer text" + ); + assert!( + !replacement.contains("MID_TURN_STEER_EDIT_TARGET"), + "the truncation must remove the committed steer, not the armed turn \ + build before it" + ); + assert!( + !replacement.contains("The mid-turn input is now handled."), + "the armed turn's truncated assistant response must be gone too" + ); + assert_eq!( + replacement.matches("Authority transition (host)").count(), + 1, + "the retained arming block stays the only transition: editing the \ + entryless steer must not pop the armed turn's baseline snapshot and \ + re-render the A→B line" + ); +} + +/// Jensen residual P2, budget-notice face: the step-budget notices persist +/// through the same ExternalUser helper as a steer and are equally +/// selectable, so editing the exhaustion notice must also leave the standing +/// baseline untouched — the retained arming line stays exactly once and the +/// replacement emits no new host-change line. +#[tokio::test] +async fn edit_last_turn_keeps_the_baseline_when_the_edited_target_is_a_budget_notice() { + let requests = run_edit_last_turn_baseline_past_entryless_selectable_message(1, false).await; + + let armed_second = captured_request_wire_text(&requests, 2); + assert!( + armed_second.contains("Step budget soft landing") + && armed_second.contains("model-step budget was exhausted"), + "the ceiling notices must be persisted mid-turn and reach the \ + armed turn's final request" + ); + assert_eq!( + armed_second.matches("Authority transition (host)").count(), + 1, + "the notice blocks render no transition line of their own" + ); + + let replacement = captured_request_wire_text(&requests, 3); + assert!( + replacement.contains("revise this committed input"), + "the replacement must carry the edited notice text" + ); + assert!( + !replacement.contains("model-step budget was exhausted"), + "the truncation must remove the exhaustion notice, not the armed \ + turn build before it" + ); + assert_eq!( + replacement.matches("Authority transition (host)").count(), + 1, + "the retained arming block stays the only transition: editing the \ + entryless budget notice must not pop the armed turn's baseline \ + snapshot and re-render the A→B line" + ); +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() { From d80f1c37a99379f994cdbbda1a4ddabc6597150e Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 10:34:29 +0800 Subject: [PATCH 18/29] fix(engine): link standing baseline entries to history positions EditLastTurn's pop decided by full-message value equality: the entry recorded the cloned persisted message and popped when the edited message compared equal. Message equality is structural (role plus content), so two different persisted messages can be value-equal and one history rewrite moves values away from the messages their entries were recorded for - both directions misattribute the snapshot. Concrete counterexample (review 5745427306, field-by-field): while the first unarmed turn is still live the host flips only the guardrail arming and a steer commits with text identical to that first prompt; same day, empty non-git workspace, low context pressure - the steer message is value-equal to the surviving first-turn message. Editing the steer then matched the first turn's entry, restored its prior (None), and the armed replacement turn omitted the A->B host transition the retained first-turn block still implies. The reverse face (a legitimate history rewrite changing values so an edited real turn no longer matches its entry) is the same defect. Link each entry to the history index its message was appended at instead. The engine appends every message itself, so the recorded index is exact; an EditLastTurn truncation removes exactly the suffix at the selector's cut and never moves prefix indices, so the edited message owns an entry exactly when the top entry's index equals the cut. Committed steers and step-budget notices persist selectable messages without entries - no entry sits at their cut, the stack is untouched, and the replacement recomputes against the unchanged baseline (same posture, no transition), keeping the existing editable semantics. Session resume and compaction replace the history with provider-authored content whose layout no longer maps to the recorded indices, so both paths vacate the stack; post-replacement turn builds re-establish their own entries immediately, and an edit targeting a pre-replacement survivor recomputes against the unchanged standing baseline rather than restoring a misattributed snapshot. Regression edit_last_turn_value_equal_steer_leaves_the_first_turn_baseline_in_place drives the counterexample end to end (same-text steer committed while the first turn runs, arming flipped mid-turn, snapshot pin proving the two messages are value-equal, edit removing only the steer) and asserts the replacement wire request re-reports the transition exactly once; red-checked against the value-equality guard, which restores None and drops the line. The existing entryless-steer, budget-notice, and different-text regressions stay green. Signed-off-by: asto18089 Signed-off-by: asto --- crates/tui/src/core/engine.rs | 79 ++++++---- crates/tui/src/core/engine/tests.rs | 186 ++++++++++++++++++++++++ crates/tui/src/core/engine/turn_loop.rs | 11 ++ 3 files changed, 245 insertions(+), 31 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index c5041c83a3..f09cceda64 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1108,22 +1108,29 @@ enum McpBootUpdate { /// One entry of the engine's `standing_posture_baseline_history`: the /// standing baseline an authoritative turn build replaced, recorded with the -/// exact persisted user message that carries the block reporting it. +/// history index its persisted user message was appended at. /// -/// The message is the linkage unit for `EditLastTurn`: the edit selector -/// selects by message, so the pop must too. Steer commits and step-budget -/// notices persist selectable ExternalUser messages without pushing entries; -/// comparing identities at pop time keeps those edits from consuming a -/// turn-build snapshot their truncation never removed. +/// Position is the linkage unit for `EditLastTurn`, not message value: +/// `Message` equality is structural (role + content), so a committed steer +/// carrying the same text as the turn it ran inside is value-equal to that +/// turn's message, and a history rewrite moves values away from the messages +/// an entry was recorded for. The engine appends every message itself, so it +/// always knows the index an entry belongs to; `EditLastTurn` truncates a +/// suffix at the selector's cut, and a suffix cut never moves prefix indices, +/// so an entry is the edited message's exactly when its index equals the cut. +/// Steer commits and step-budget notices persist selectable ExternalUser +/// messages without pushing entries — no entry sits at their cut, so editing +/// them pops nothing. #[derive(Debug, Clone)] struct StandingPostureBaselineEntry { /// The baseline this turn's block replaced — restored when the turn is /// edited away. prior_posture: Option, - /// The persisted user message this entry belongs to (a full clone of the - /// `Message` added to history at the same turn start; persisted user - /// messages are never mutated in place, so equality is stable). - turn_message: Message, + /// History index the entry's message was appended at (the + /// `session.messages` length before its `add_message`). Only meaningful + /// while no history replacement (session resume, compaction) has rebuilt + /// the list underneath it; those paths clear the stack. + push_index: usize, } /// The core engine that processes operations and emits events @@ -4051,7 +4058,6 @@ impl Engine { continue; } }; - let edited_message = self.session.messages.get(idx).cloned(); self.session.messages.truncate_to(idx); self.session.bump_messages_revision(); // The truncated turn's `` block — including @@ -4060,28 +4066,32 @@ impl Engine { // installed would survive and make the replacement // turn omit that transition. Restore the baseline the // edited turn replaced (None included, e.g. when the - // session's first turn is edited) — but only when the - // edited message IS the persisted turn-build message - // the top entry was recorded with. The selector can + // session's first turn is edited) — by position, not + // message value. Message equality is structural, so a + // committed steer carrying the same text as the turn + // it ran inside is value-equal to that turn's message + // and a value match would pop the surviving turn's + // snapshot. The truncation removes exactly the suffix + // at `idx` and never moves prefix indices, so the + // edited message owns an entry exactly when the top + // entry was appended at `idx` itself. The selector can // also target committed steer or step-budget notice // messages (ExternalUser provenance, no entry of - // their own): truncating one of those removes only - // the message itself, so the standing baseline must - // stay untouched and the replacement recomputes - // against it (same posture → no transition line). - // Identity, not position, decides: entries are linked - // to their persisted message at push time. - let top_matches_edited = match ( - edited_message.as_ref(), - self.standing_posture_baseline_history.last(), - ) { - (Some(edited), Some(entry)) => *edited == entry.turn_message, - _ => false, - }; - if top_matches_edited - && let Some(entry) = self.standing_posture_baseline_history.pop() + // their own): no entry sits at their cut, the stack is + // untouched, and the replacement recomputes against + // the unchanged baseline (same posture → no + // transition line). + while let Some(entry) = self + .standing_posture_baseline_history + .pop_if(|entry| entry.push_index >= idx) { - self.last_authoritative_turn_meta_posture = entry.prior_posture; + // The edit selector targets the last genuine user + // prompt, so no entry can sit above the cut; an + // entry at exactly `idx` is the edited turn's own, + // and an entry below the cut ends the drain. + if entry.push_index == idx { + self.last_authoritative_turn_meta_posture = entry.prior_posture; + } } // Now dispatch the new message as a normal send, // reusing the engine's stored mode/model config. @@ -6254,10 +6264,17 @@ impl Engine { }, ); if let Some(prior_posture) = standing_baseline_entry { + // The message appends right after this line, so the current + // history length is the index its entry is linked to. The engine + // appends every history message itself, so the index stays exact + // until a history replacement clears the stack (session resume, + // compaction); suffix truncations from EditLastTurn never move + // prefix indices. + let push_index = self.session.messages.len(); self.standing_posture_baseline_history .push(StandingPostureBaselineEntry { prior_posture, - turn_message: user_msg.clone(), + push_index, }); } self.session.add_message(user_msg); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index a27e8fc46c..71be161de4 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -20262,6 +20262,192 @@ async fn edit_last_turn_keeps_the_baseline_when_the_edited_target_is_a_budget_no ); } +/// JensenChen28 review 5745427306 counterexample (PR #131): while the first +/// (unarmed) turn is still live, the host flips only the guardrail arming and +/// a steer commits with text identical to that first prompt. Same day, empty +/// non-git workspace, low context pressure: the steer message is then +/// value-equal to the surviving first-turn message, so a value-based pop +/// would consume the first turn's snapshot when the steer is edited away and +/// leave the baseline at `None` — the replacement turn (armed posture) would +/// omit the A→B host transition the retained first-turn block still implies. +/// The pop must decide by position: no entry sits at the steer's cut, so the +/// baseline stays on the first turn's posture and the replacement re-reports +/// the transition. +#[tokio::test] +async fn edit_last_turn_value_equal_steer_leaves_the_first_turn_baseline_in_place() { + use crate::llm_client::mock::{MockLlmClient, canned}; + + const FIRST_PROMPT: &str = "turn in the unarmed posture"; + let workspace = tempdir().expect("workspace"); + let mut engine_config = deterministic_engine_config(workspace.path()); + engine_config.max_steps = 32; + let mock = Arc::new(MockLlmClient::new(vec![])); + let client: crate::core::model_client::SharedModelClient = mock.clone(); + let (engine, handle) = Engine::new_with_model_client(engine_config, &Config::default(), client); + + // First turn, request 1: commit the same-text steer while the turn is + // live and flip the arming mid-run, then answer with a read-only tool + // call so the turn loops and the top-of-loop steer drain injects the + // steer into history before request 2. + let steer_handle = handle.clone(); + mock.push_factory(move |_request| { + steer_handle + .try_send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + configured_sandbox_mode: None, + ask_on_guardrail: Some(true), + }) + .expect("live arming publish accepts the mid-turn flip"); + let tx_steer = steer_handle.tx_steer.clone(); + let steer_control = Arc::clone(&steer_handle.steer_control); + let target = { + let mut control = steer_control + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let target = control + .active_target() + .expect("first turn accepts test steer"); + control + .register("same-text-steer".to_string(), target) + .expect("register same-text-steer"); + target + }; + tx_steer + .try_send(SteerMessage { + id: "same-text-steer".to_string(), + target, + content: FIRST_PROMPT.to_string(), + }) + .expect("steer channel remains open"); + canned::tool_call_turn( + "call-same-text-steer-read", + "read_file", + r#"{"path":"same-text-steer-probe.txt"}"#, + ) + }); + mock.push_turn(canned::simple_text_turn("first turn final answer")); + mock.push_turn(canned::simple_text_turn("replacement answer")); + let run = tokio::spawn(engine.run()); + + handle + .send(Op::SendMessage { + content: FIRST_PROMPT.to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&Config::default(), "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the unarmed first turn"); + wait_for_turn_complete(&handle).await; + + // Precondition pin: the committed steer and the surviving first-turn + // message are value-equal, exactly the reviewed condition a value-based + // pop cannot decide. + { + let (tx, rx) = tokio::sync::oneshot::channel(); + handle + .send(Op::GetSessionSnapshot { + tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))), + }) + .await + .expect("request snapshot"); + let snapshot = tokio::time::timeout(model_turn_event_timeout(), rx) + .await + .expect("snapshot response") + .expect("snapshot"); + // Tool results persist with role "user" too; locate the first turn's + // message and the drained steer by their shared prompt text. + let same_text_messages: Vec<&Message> = snapshot + .messages + .iter() + .filter(|message| { + message.role.as_str() == "user" + && message.content.iter().any(|block| { + matches!(block, ContentBlock::Text { text, .. } if text.contains(FIRST_PROMPT)) + }) + }) + .collect(); + assert_eq!( + same_text_messages.len(), + 2, + "exactly the first turn's message and the drained steer carry the prompt text" + ); + assert_eq!( + same_text_messages[0], same_text_messages[1], + "the same-text steer must be value-equal to the surviving first-turn message" + ); + } + + // The selector's latest editable user prompt is the entryless same-text + // steer — not the first turn build before it. + handle + .send(Op::EditLastTurn { + new_message: "revise this committed input".to_string(), + submission_id: Some("sub-edit-same-text-steer".to_string()), + }) + .await + .expect("send edit"); + wait_for_turn_complete(&handle).await; + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run.await.expect("engine task"); + + let requests = mock.captured_requests(); + assert_eq!( + requests.len(), + 3, + "the first turn's two requests and the replacement" + ); + let first_request = captured_request_wire_text(&requests, 0); + assert!( + !first_request.contains("Authority transition (host)"), + "the first reported turn must render no transition" + ); + let second_request = captured_request_wire_text(&requests, 1); + assert!( + second_request.contains(FIRST_PROMPT), + "the drained same-text steer must reach the first turn's follow-up request" + ); + assert!( + !second_request.contains("Authority transition (host)"), + "the steer block renders no transition line of its own" + ); + let replacement = captured_request_wire_text(&requests, 2); + assert!( + replacement.contains("revise this committed input"), + "the replacement must carry the edited steer text" + ); + assert_eq!( + replacement.matches("Authority transition (host)").count(), + 1, + "the replacement must re-report the A→B arming transition: the \ + edited steer owned no entry, so the first turn's snapshot stays and \ + the retained first-turn block remains the measured baseline" + ); +} + #[tokio::test] #[allow(clippy::await_holding_lock)] async fn edit_last_turn_cuts_at_user_prompt_before_tool_results() { diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index d7ec156aab..280eed90c7 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1962,6 +1962,17 @@ impl Engine { let auto_messages_after = result.messages.len(); let retries_used = result.retries_used; let coverage_clause = result.coverage.receipt_clause(); + // The replacement history is provider-authored + // (summaries, pruned tool results, coverage + // floors), so the standing-baseline stack's + // recorded indices no longer map onto it. Vacate + // the entries instead of guessing a rebase: + // post-compaction turn builds re-establish their + // own entries immediately, and an edit that + // targets a pre-compaction survivor recomputes + // against the unchanged standing baseline rather + // than restoring a misattributed snapshot. + self.standing_posture_baseline_history.clear(); self.session.replace_messages(result.messages); turn.clear_parent_input_tokens(); if let Some(pm) = self.session.prefix_stability.as_mut() { From 555ab08525971f184ad43011c10e15932ff6cfd5 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 11:57:26 +0800 Subject: [PATCH 19/29] fix(engine): re-anchor standing baseline on history mutations Position-linked standing-posture baseline entries assumed the history prefix never moves, but only auto compaction vacated the stack. /purge, manual compaction, emergency compaction (including its verbatim fallback through the agent-topology checkpoint rewrite), and the emergency trim all mutated history without restoring the push_index invariant, so an edit of a survived turn discarded its entry without restoring the prior posture and the replacement silently dropped the transition its block had carried away. Unify every mutation site: purge now reports each survivor's pre-purge slot (PurgeResult::survivor_origins) and the engine remaps entries through it, dropping entries whose message the purge removed; the trim shifts surviving indices by the removed front count; manual and emergency compaction vacate the stack like auto compaction unless the replacement is value-identical to the history it replaces (equal lists keep equal slots). Signed-off-by: asto --- crates/tui/src/core/engine.rs | 106 ++++++++++++- crates/tui/src/core/engine/tests.rs | 236 ++++++++++++++++++++++++++++ crates/tui/src/purge.rs | 44 ++++++ 3 files changed, 380 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index f09cceda64..07704a873b 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1127,9 +1127,26 @@ struct StandingPostureBaselineEntry { /// edited away. prior_posture: Option, /// History index the entry's message was appended at (the - /// `session.messages` length before its `add_message`). Only meaningful - /// while no history replacement (session resume, compaction) has rebuilt - /// the list underneath it; those paths clear the stack. + /// `session.messages` length before its `add_message`). Invariant: the + /// index is only meaningful against the CURRENT retained history, and + /// every history mutation must restore that property before the next + /// `EditLastTurn` reads it: + /// - a suffix truncation (`EditLastTurn`) never moves prefix indices — + /// its drain drops entries at or above the cut and restores the cut + /// entry's `prior_posture`; + /// - a mechanical removal that keeps survivors in order (`/purge`, + /// `trim_oldest_messages_to_budget`) remaps each surviving entry to + /// its message's new slot and drops entries whose message is gone — + /// a survived turn keeps restoring its prior posture even when its + /// index shifted; + /// - a replacement the engine did not author slot-by-slot (auto, + /// manual, and emergency compaction results; the emergency fallback + /// can also re-carry history through the agent-topology checkpoint + /// rewrite) cannot be rebased, so it vacates the stack — except when + /// the replacement is value-identical to the previous history, which + /// keeps every slot exact; + /// - a conversation replacement (`SyncSession`) clears the stack with + /// the rest of the posture state. push_index: usize, } @@ -6267,9 +6284,11 @@ impl Engine { // The message appends right after this line, so the current // history length is the index its entry is linked to. The engine // appends every history message itself, so the index stays exact - // until a history replacement clears the stack (session resume, - // compaction); suffix truncations from EditLastTurn never move - // prefix indices. + // across suffix truncations (EditLastTurn never moves prefix + // indices), and every other history mutation — purge and trim + // re-anchoring survivors, compaction replacements vacating the + // stack, session resume clearing it — restores that invariant + // before the next edit reads it (see `push_index`). let push_index = self.session.messages.len(); self.standing_posture_baseline_history .push(StandingPostureBaselineEntry { @@ -6716,6 +6735,20 @@ impl Engine { let messages_after = result.messages.len(); let retries_used = result.retries_used; let coverage_clause = result.coverage.receipt_clause(); + // Unified standing-baseline handling for history + // replacements the engine did not author slot-by-slot + // (see `StandingPostureBaselineEntry::push_index`): a + // provider-authored compaction result moves survivors to + // unknowable slots, so vacate the entries rather than + // guess a rebase — the same rationale as the + // auto-compaction path in core/engine/turn_loop.rs. + // Post-compaction turn builds re-establish their own + // entries. The value-identity guard keeps entries + // anchored when the replacement is byte-identical to the + // history it replaces: equal lists keep equal slots. + if result.messages.as_slice() != &self.session.messages[..] { + self.standing_posture_baseline_history.clear(); + } self.session.replace_messages(result.messages); if let Some(pm) = self.session.prefix_stability.as_mut() { pm.note_history_reset("compaction"); @@ -6829,6 +6862,16 @@ impl Engine { { Ok(result) => { let messages_after = result.messages.len(); + // Purge keeps survivors in order and rewrites them in place, + // so `survivor_origins` maps every retained message back to + // its pre-purge slot. Re-anchor the standing baseline through + // that mapping before the history is replaced (see + // `StandingPostureBaselineEntry::push_index`): entries whose + // turn message the purge removed are dropped, survivors keep + // a corrected `push_index`, so an EditLastTurn cut still + // matches the retained turn's entry and a survived turn — + // even at a shifted index — restores its prior posture. + self.relocate_standing_baseline_entries(&result.survivor_origins); self.session.replace_messages(result.messages); self.emit_session_updated().await; @@ -6922,6 +6965,25 @@ impl Engine { ) } + /// Re-anchor standing-baseline entries after a mechanical history + /// removal that keeps survivors in order. `survivor_origins[i]` is the + /// previous-history index of the message now at index `i` (ascending, + /// each index at most once — `/purge`'s `PurgeResult::survivor_origins` + /// has exactly this shape). An entry is kept with `push_index` remapped + /// to its message's new slot, or dropped when the removal took its + /// message (see `StandingPostureBaselineEntry::push_index`). + fn relocate_standing_baseline_entries(&mut self, survivor_origins: &[usize]) { + self.standing_posture_baseline_history.retain_mut(|entry| { + match survivor_origins.binary_search(&entry.push_index) { + Ok(new_index) => { + entry.push_index = new_index; + true + } + Err(_) => false, + } + }); + } + fn trim_oldest_messages_to_budget(&mut self, target_input_budget: usize) -> usize { let mut removed = 0usize; while self.session.messages.len() > MIN_RECENT_MESSAGES_TO_KEEP @@ -6931,6 +6993,22 @@ impl Engine { self.session.bump_messages_revision(); removed = removed.saturating_add(1); } + if removed > 0 { + // A front trim removes exactly the first `removed` messages and + // keeps the survivors in order, so re-anchor the standing + // baseline (see `StandingPostureBaselineEntry::push_index`): + // entries below the cut lost their message and are dropped, + // survivors shift down by `removed` and keep restoring their + // prior posture on an edit. + self.standing_posture_baseline_history.retain_mut(|entry| { + if entry.push_index < removed { + false + } else { + entry.push_index -= removed; + true + } + }); + } removed } @@ -7046,6 +7124,22 @@ impl Engine { self.emit_compaction_cancelled(id, true, message).await; return false; } + // Unified standing-baseline handling for history replacements + // the engine did not author slot-by-slot (see + // `StandingPostureBaselineEntry::push_index`): whether the API + // pass produced a provider-authored replacement or the local + // fallback re-carried history through the agent-topology + // checkpoint rewrite (arbitrary retain/insert positions), + // survivors land in slots that no longer map onto the recorded + // indices, so vacate the entries rather than guess a rebase — + // the same rationale as the auto-compaction path in + // core/engine/turn_loop.rs. The value-identity guard keeps + // entries anchored when the fallback left the history + // value-identical: equal lists keep equal slots, so a + // survived-turn edit still restores its prior posture. + if compacted_messages.as_slice() != &self.session.messages[..] { + self.standing_posture_baseline_history.clear(); + } self.session.replace_messages(compacted_messages); } self.commit_compaction_checkpoint(summary_prompt); diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 71be161de4..3397951871 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -19708,6 +19708,242 @@ async fn edit_last_turn_restores_the_standing_posture_baseline() { ); } +#[tokio::test] +async fn purge_then_edit_restores_the_standing_posture_baseline() { + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // A `/purge` that removes an earlier assistant while keeping both user + // blocks shifts the second turn's block to a lower history index. The + // standing-baseline entry recorded the index at push time, so the purge + // must re-anchor the surviving entry — an edit of that turn then still + // restores the baseline its block replaced and re-reports the transition + // the truncated block carried away. Without the re-anchor, the drain + // discards the entry at the stale index and the replacement recomputes + // against the unchanged baseline (B→B), silently dropping the A→B line. + let _lock = lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + + let sse_body = |id: &str, answer: &str| { + format!( + "data: {{\"id\":\"chatcmpl-{id}\",\"choices\":[{{\"index\":0,\ + \"delta\":{{\"content\":\"{answer}\"}},\"finish_reason\":null}}]}}\n\n\ + data: {{\"id\":\"chatcmpl-{id}\",\"choices\":[{{\"index\":0,\ + \"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\n\n\ + data: [DONE]\n\n", + ) + }; + // First-match-wins: the purge request is the only one carrying + // `purge_context`; the replacement is the only one carrying the revised + // text; the two real turns are told apart by their own prompt text — + // the B-turn mock is mounted first because the B request replays the + // retained A-turn text, so the A-turn matcher would otherwise capture + // it too (the purge request carries both texts, but the purge mock + // matches first). + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("purge_context")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string( + r#"{"id":"chatcmpl-purge-fix","model":"deepseek-v4-pro","choices":[{"index":0,"message":{"role":"assistant","content":null,"tool_calls":[{"id":"call_purge_fix","type":"function","function":{"name":"purge_context","arguments":"{\"operations\":[{\"op\":\"remove\",\"msg\":2}]}"}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#, + ), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("revise this turn")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("purge-fix-revised", "Revised answer.")), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("turn that reports the transition")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("purge-fix-b", "B answer.")), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("purge-fix-a", "A answer.")), + ) + .expect(1) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + snapshots_enabled: false, + subagents_enabled: false, + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &api_config); + let run = tokio::spawn(engine.run()); + + // No seeded history: the first authoritative block appends at index 0, + // its ordinary assistant reply at index 1 — the message the purge will + // legally remove while keeping both user blocks. + handle + .send(Op::SyncSession { + session_id: Some("purge-baseline-test".to_string()), + messages: Vec::new(), + system_prompt: None, + system_prompt_override: false, + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), + mode: AppMode::Agent, + }) + .await + .expect("sync session"); + + handle + .send(Op::SendMessage { + content: "turn in the original posture".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the original-posture turn"); + wait_for_turn_complete(&handle).await; + + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + configured_sandbox_mode: None, + ask_on_guardrail: None, + }) + .await + .expect("send the approval posture change"); + handle + .send(Op::SendMessage { + content: "turn that reports the transition".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the transition-reporting turn"); + wait_for_turn_complete(&handle).await; + + // History: [A block(0), A assistant(1), B block(2), B assistant(3)]. + // Purge removes msg 2 (the index-1 assistant) without cascading to the + // user blocks; B's block shifts to index 1. + handle.send(Op::PurgeContext).await.expect("send purge"); + wait_for_turn_complete(&handle).await; + + handle + .send(Op::EditLastTurn { + new_message: "revise this turn".to_string(), + submission_id: Some("sub-edit-after-purge".to_string()), + }) + .await + .expect("send edit after purge"); + wait_for_turn_complete(&handle).await; + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run.await.expect("engine task"); + + let requests = server.received_requests().await.expect("recorded requests"); + assert_eq!(requests.len(), 4, "two turns, one purge, one replacement"); + let wire_body = |request: &[u8]| String::from_utf8_lossy(request).to_string(); + assert!( + !wire_body(&requests[0].body).contains("Authority transition (host)"), + "the first reported turn must render no transition" + ); + assert!( + wire_body(&requests[1].body).contains("Authority transition (host)"), + "the turn after the host flip must report the transition" + ); + assert!( + wire_body(&requests[2].body).contains("purge_context"), + "the third request must be the purge" + ); + let replacement = wire_body(&requests[3].body); + assert!( + !replacement.contains("A answer."), + "the purge must have removed the first turn's assistant reply: the \ + replacement replays retained history only" + ); + assert!( + !replacement.contains("B answer."), + "the edit must have truncated the edited turn's suffix" + ); + assert!( + replacement.contains("turn in the original posture"), + "the purge must have kept the first user block" + ); + assert!( + replacement.contains("Authority transition (host)"), + "editing the purged-and-shifted turn must restore the baseline its \ + block replaced, re-reporting the transition the truncated block \ + carried away" + ); +} + /// Drive the standing-posture-baseline edit scenario with non-authoritative /// runtime turns parked between the true user turns. /// diff --git a/crates/tui/src/purge.rs b/crates/tui/src/purge.rs index 04f1b6c7a9..b84a0626fa 100644 --- a/crates/tui/src/purge.rs +++ b/crates/tui/src/purge.rs @@ -92,6 +92,13 @@ pub struct PurgeResult { pub removed_count: usize, /// How many replace operations were applied. pub replaced_count: usize, + /// The pre-purge index of the message now at each output slot: purge + /// only removes whole messages and rewrites survivors in place, so + /// output slot `i` descends from input slot `survivor_origins[i]` and + /// every input slot appears at most once (ascending). The engine uses + /// this to re-anchor position-linked standing-baseline entries — see + /// `StandingPostureBaselineEntry::push_index`. + pub survivor_origins: Vec, } // ── Event emission helpers ────────────────────────────────────────────────── @@ -349,6 +356,10 @@ pub fn parse_purge_operations( /// prevent orphaned blocks. pub fn execute_purge_operations(messages: &[Message], ops: &[PurgeOp]) -> PurgeResult { let mut msgs = messages.to_vec(); + // `survivor_origins[i]` tracks which input slot the message now at + // output slot `i` came from; removals below keep the two lists in + // lockstep. + let mut survivor_origins: Vec = (0..messages.len()).collect(); let mut msg_indices_to_remove: FastHashSet = FastHashSet::default(); let mut replaced_count = 0usize; @@ -391,12 +402,14 @@ pub fn execute_purge_operations(messages: &[Message], ops: &[PurgeOp]) -> PurgeR let removed_count = to_remove.len(); for idx in to_remove { msgs.remove(idx); + survivor_origins.remove(idx); } PurgeResult { messages: msgs, removed_count, replaced_count, + survivor_origins, } } @@ -707,6 +720,37 @@ mod tests { assert_eq!(result.messages.len(), 2); } + #[test] + fn survivor_origins_track_removals_and_in_place_rewrites() { + // Removing the middle message shifts later survivors down; an + // in-place replace keeps its slot, so the origins stay exact for + // the engine's standing-baseline re-anchoring. + let msgs = vec![ + msg_text("user", "first turn"), + msg_text("assistant", "remove me"), + msg_text("user", "second turn"), + ]; + let pattern = Regex::new("turn").unwrap(); + let ops = vec![ + PurgeOp::Remove { msg_id: 2 }, + PurgeOp::Replace { + msg_id: 3, + block_idx: 0, + pattern, + with: "block".to_string(), + }, + ]; + let result = execute_purge_operations(&msgs, &ops); + assert_eq!(result.removed_count, 1); + assert_eq!(result.replaced_count, 1); + assert_eq!(result.survivor_origins, vec![0, 2]); + if let ContentBlock::Text { text, .. } = &result.messages[1].content[0] { + assert_eq!(text, "second block"); + } else { + panic!("expected text block"); + } + } + #[test] fn execute_replace_text_block() { let msgs = vec![msg_text("assistant", "Hello world! Hello again!")]; From 3cf2cdff9d3a93fdc344eb2ee0cdad2eedb2ccb0 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 11:58:19 +0800 Subject: [PATCH 20/29] fix(engine): scope child approval cache keys to the operation A child prompt raised for a held call used the fresh child approval id (the prompt's transport incarnation) as BOTH operation-cache scopes: approval_key = approval_grouping_key = "${approval_id}:${name}". Every equivalent child operation therefore minted a brand-new operation, and neither remembered decision the approval interface promises could ever be reused - not the session grant (lossy grouping scope) and not the exact denial that must keep asking when parameters change. Keep transport identity in the event id and derive both cache scopes from the operation, exactly like the main line: build_approval_key over the tool and input for the exact scope, build_approval_grouping_key over the tool, input and the session workspace for the lossy grouping scope. force_prompt keeps riding its own event field and is never answered by either cache (the pump's force_prompt discipline is untouched). Regression: two identical child operations under a prompting host must raise prompts whose exact and grouping keys are identical across the two incarnations and embed neither approval id. Signed-off-by: asto --- crates/tui/src/tools/subagent/mod.rs | 20 ++++++- crates/tui/src/tools/subagent/tests.rs | 79 ++++++++++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index da59acbe83..0c1e64409f 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -15126,7 +15126,23 @@ impl SubAgentToolRegistry { self.owner_agent_name, agent_id.chars().take(12).collect::() ); - let approval_key = format!("{approval_id}:{name}"); + // The approval id is this prompt's transport incarnation (fresh per + // event); it must not double as the operation-cache identity. Keying + // BOTH cache scopes on `{approval_id}:{name}` made every equivalent + // child operation a brand-new operation, so the next identical call + // could never reuse the session grant (lossy grouping scope) or the + // exact denial the approval interface promises to remember. Derive + // both keys from the operation like the main line does (tool + input; + // the grouping key anchors cwd operands against the session + // workspace). Transport identity stays in `id`; force_prompt keeps + // riding its own field and is never answered by either cache. + let approval_key = crate::tools::approval_cache::build_approval_key(name, input).0; + let approval_grouping_key = crate::tools::approval_cache::build_approval_grouping_key( + name, + input, + &self.gate_runtime.context.workspace, + ) + .0; let sent = event_tx .send(Event::ApprovalRequired { id: approval_id.clone(), @@ -15134,7 +15150,7 @@ impl SubAgentToolRegistry { description, input: input.clone(), approval_key: approval_key.clone(), - approval_grouping_key: approval_key, + approval_grouping_key, intent_summary: None, approval_force_prompt: force_prompt, }) diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 50bd00d5a2..27b9c6ddc2 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -22182,6 +22182,85 @@ mod child_permission_gate { } } + /// Review P2: a child prompt's cache keys must be operation-scoped, not + /// incarnation-scoped. The approval id is per-event transport identity; + /// keying the exact denial AND the session grant on + /// `{approval_id}:{name}` made every equivalent child operation a + /// brand-new operation, so neither remembered decision could ever be + /// reused by the next identical call. Two identical operations must + /// raise prompts with identical keys that do not embed the approval id; + /// force_prompt keeps riding its own field (never answered by a cache). + #[tokio::test] + async fn child_approval_cache_keys_are_operation_scoped_across_incarnations() { + let (registry, mut rx, manager) = worker_registry(ApprovalMode::Suggest, false, true, None); + let manager_for_answer = Arc::clone(&manager); + let answerer = tokio::spawn(async move { + let mut prompts = Vec::new(); + for _ in 0..2 { + let mut captured = None; + for _ in 0..200 { + if let Ok(event) = rx.try_recv() + && let Event::ApprovalRequired { + id, + approval_key, + approval_grouping_key, + approval_force_prompt, + .. + } = event + { + captured = Some((id, approval_key, approval_grouping_key, approval_force_prompt)); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let prompt = captured.expect("child prompt must reach the host"); + assert!( + manager_for_answer + .write() + .await + .resolve_child_approval(&prompt.0, ChildApprovalOutcome::Denied), + "the waiting child must accept the denial" + ); + prompts.push(prompt); + } + prompts + }); + for _ in 0..2 { + let err = registry + .execute("agent_gate", "bash", json!({"command": "echo gated"})) + .await + .expect_err("the denied call must not run"); + assert!(err.to_string().contains("denied by the user"), "{err}"); + } + let prompts = answerer.await.expect("answerer task"); + assert_eq!(prompts.len(), 2); + let (first_id, first_key, first_grouping, first_force) = prompts[0].clone(); + let (second_id, second_key, second_grouping, second_force) = prompts[1].clone(); + assert_ne!( + first_id, second_id, + "each prompt carries a fresh transport incarnation" + ); + assert_eq!( + first_key, second_key, + "the exact-denial scope must be operation-scoped" + ); + assert_eq!( + first_grouping, second_grouping, + "the session-grant scope must be operation-scoped" + ); + assert!( + !first_key.contains(&first_id), + "the exact key must not embed the transport incarnation: {first_key}" + ); + assert!( + !first_grouping.contains(&first_id), + "the grouping key must not embed the transport incarnation: {first_grouping}" + ); + assert!(!first_force, "the ordinary ask stays a non-force prompt"); + assert!(!second_force, "force_prompt is independent of the key scopes"); + assert_eq!(manager.read().await.pending_child_approvals(), 0); + } + #[tokio::test] async fn closed_child_approval_waiter_persists_unavailable_not_cancelled() { let (registry, mut rx, manager) = worker_registry(ApprovalMode::Suggest, false, true, None); From 8c4e8e7aa446cec2f9d0653f671b449424339be8 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Tue, 3 Nov 2026 12:59:49 +0800 Subject: [PATCH 21/29] fix(remote): carry force_prompt on SSE surface The compat SSE approval.required envelope still omitted the approval_force_prompt field that the runtime pump payloads carry, so a client driven by this surface could not tell an armed consent-hold card from an ordinary approval. The event loop already destructured the flag; thread it through record_remote_approval and assert both the ordinary (false) and forced (true) paths on the wire payload. Signed-off-by: asto18089 --- crates/tui/src/remote_control.rs | 33 ++++++++++++++++++++++++++ crates/tui/src/tools/subagent/tests.rs | 12 ++++++++-- crates/tui/src/tui/ui/event_loop.rs | 1 + 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/remote_control.rs b/crates/tui/src/remote_control.rs index f5afccf7d9..3b961ddf5d 100644 --- a/crates/tui/src/remote_control.rs +++ b/crates/tui/src/remote_control.rs @@ -2193,6 +2193,7 @@ impl RemoteControlController { _input: &Value, _approval_key: &str, _intent_summary: Option<&str>, + approval_force_prompt: bool, ) -> String { let gate = projected_approval_id(tool_id); self.pending_approvals.insert( @@ -2211,6 +2212,7 @@ impl RemoteControlController { "approval_id": gate, "tool_name": tool_name, "description": description, + "approval_force_prompt": approval_force_prompt, }), ); } @@ -6235,6 +6237,7 @@ mod tests { &json!({ "credential": "must-not-cross" }), "approval-key", None, + false, ); let mut envelopes = Vec::new(); @@ -6263,9 +6266,39 @@ mod tests { envelopes[2]["payload"]["approval_id"].as_str(), Some(gate.as_str()) ); + // The compat SSE surface must carry the same force-prompt flag the + // runtime pump payloads carry, so external clients can tell a + // consent-hold card from an ordinary approval (#809 review follow-up). + assert_eq!(envelopes[2]["payload"]["approval_force_prompt"], false); let projected = serde_json::to_string(&envelopes).unwrap(); assert!(!projected.contains("must-not-cross")); assert!(!projected.contains("approval-key")); + let forced_gate = controller.record_remote_approval( + "tool_forced", + "start_mcp_server", + "armed consent hold fixture", + &json!({}), + "approval-key", + None, + true, + ); + let WorkerCommand::Upload { + envelopes: forced_batch, + .. + } = worker_rx.try_recv().expect("forced-hold upload envelope") + else { + panic!("active-turn state must use the runtime envelope channel"); + }; + let forced_envelopes = forced_batch; + let forced = forced_envelopes + .iter() + .find(|event| event["event"] == "approval.required") + .expect("forced approval.required envelope"); + assert_eq!( + forced["payload"]["approval_id"].as_str(), + Some(forced_gate.as_str()) + ); + assert_eq!(forced["payload"]["approval_force_prompt"], true); assert!(worker_rx.try_recv().is_err()); } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 27b9c6ddc2..551eb83102 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -22208,7 +22208,12 @@ mod child_permission_gate { .. } = event { - captured = Some((id, approval_key, approval_grouping_key, approval_force_prompt)); + captured = Some(( + id, + approval_key, + approval_grouping_key, + approval_force_prompt, + )); break; } tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -22257,7 +22262,10 @@ mod child_permission_gate { "the grouping key must not embed the transport incarnation: {first_grouping}" ); assert!(!first_force, "the ordinary ask stays a non-force prompt"); - assert!(!second_force, "force_prompt is independent of the key scopes"); + assert!( + !second_force, + "force_prompt is independent of the key scopes" + ); assert_eq!(manager.read().await.pending_child_approvals(), 0); } diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 52564545e1..321903379b 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -3399,6 +3399,7 @@ pub(crate) async fn run_event_loop( &input, &approval_key, intent_summary.as_deref(), + approval_force_prompt, ); true } else { From cce7f7b75abb9e3b6509714d9949ff6f3f263097 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 13:50:51 +0800 Subject: [PATCH 22/29] fix(engine): keep standing baseline entries across mechanical compaction The layout-preservation guard treated every whole-list value difference as an unmappable rewrite and vacated all standing-baseline entries. But compaction has a deterministic local branch: when pruning stale tool output alone lands under the threshold, compact_messages_safe returns the PruneOnly result without any provider call - ToolResult content is rewritten in place, the message count and roles stay identical, and the A/B user turns survive at their exact slots. Clearing there meant an edit of a survived turn discarded its snapshot without restoring the prior posture, and the replacement silently dropped the transition its block had carried away (review 5747388050). Decide by layout instead of value: equal length with equal roles at every index keeps the entries (PruneOnly prunes and value-identical fallbacks); anything that merges or drops turns cannot be mapped slot-for-slot and still vacates. The engine-authored topology checkpoint insertion after the compaction result is slot bookkeeping too, so replace_agent_topology_checkpoint now reports its superseded-carrier indices and insert position and the compaction sites rebase the surviving entries through it; the auto, manual, and emergency sites all measure against the pre-topology replacement. Regression manual_prune_only_compaction_keeps_the_standing_baseline_entries seeds a stale oversized tool output outside the protected window, runs two real turns across an approval-posture flip, manually compacts to the PruneOnly result (the captured-request count proves no provider summary call; snapshot pins prove the prune ran in place with every slot kept), then edits the B turn and asserts the replacement re-reports the A->B transition exactly once. Red-checked against the value-identity guard: the prune changes the whole list, the entries are vacated, and the line disappears. Signed-off-by: asto18089 Signed-off-by: asto --- crates/tui/src/core/engine.rs | 107 +++++-- crates/tui/src/core/engine/tests.rs | 358 ++++++++++++++++++++++++ crates/tui/src/core/engine/turn_loop.rs | 43 ++- crates/tui/src/runtime_handoff.rs | 54 +++- 4 files changed, 518 insertions(+), 44 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 07704a873b..8007073f56 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1150,6 +1150,26 @@ struct StandingPostureBaselineEntry { push_index: usize, } +/// True when a replacement history occupies the same slots as the history it +/// replaces: equal length and equal role at every index. +/// +/// Local mechanical rewrites (tool-result pruning, sanitization) rewrite +/// ToolResult content in place and keep every slot, so recorded +/// `push_index`es stay exact — a PruneOnly compaction that only pruned old +/// tool output must keep the entries, or an edit of a surviving turn loses +/// the snapshot its block still needs. Provider-authored summaries merge or +/// drop turns, so those replacements cannot be mapped slot-for-slot and +/// vacate the stack instead (see the compaction call sites). +fn standing_baseline_layout_preserved(replacement: &[Message], replaced: &[Message]) -> bool { + replacement.len() == replaced.len() + && replacement + .iter() + .zip(replaced.iter()) + .all(|(replacement_message, original_message)| { + replacement_message.role == original_message.role + }) +} + /// The core engine that processes operations and emits events pub struct Engine { config: EngineConfig, @@ -6706,7 +6726,29 @@ impl Engine { match compaction_result { Ok(mut result) => { if !result.messages.is_empty() || self.session.messages.is_empty() { - self.append_compaction_agent_topology(&mut result.messages) + // Unified standing-baseline handling for history + // replacements the engine did not author slot-by-slot + // (see `StandingPostureBaselineEntry::push_index` and + // `standing_baseline_layout_preserved`): a provider- + // authored compaction result moves survivors to + // unknowable slots, so vacate the entries rather than + // guess a rebase — the same rationale as the + // auto-compaction path in core/engine/turn_loop.rs. + // Post-compaction turn builds re-establish their own + // entries. A local mechanical prune (PruneOnly) rewrites + // ToolResult content in place and keeps every slot, so + // the layout check keeps the entries anchored there; + // byte-identical replacements also keep equal slots. + // Measured against the pre-topology replacement: the + // checkpoint the topology step inserts below is + // engine-authored slot bookkeeping whose shift is + // rebased onto the entries at replace time. + let layout_preserved = standing_baseline_layout_preserved( + &result.messages, + &self.session.messages, + ); + let topology_rebase = self + .append_compaction_agent_topology(&mut result.messages) .await; if cancel_token.is_cancelled() { self.finish_compaction(&id); @@ -6735,18 +6777,11 @@ impl Engine { let messages_after = result.messages.len(); let retries_used = result.retries_used; let coverage_clause = result.coverage.receipt_clause(); - // Unified standing-baseline handling for history - // replacements the engine did not author slot-by-slot - // (see `StandingPostureBaselineEntry::push_index`): a - // provider-authored compaction result moves survivors to - // unknowable slots, so vacate the entries rather than - // guess a rebase — the same rationale as the - // auto-compaction path in core/engine/turn_loop.rs. - // Post-compaction turn builds re-establish their own - // entries. The value-identity guard keeps entries - // anchored when the replacement is byte-identical to the - // history it replaces: equal lists keep equal slots. - if result.messages.as_slice() != &self.session.messages[..] { + if layout_preserved { + for entry in &mut self.standing_posture_baseline_history { + entry.push_index = topology_rebase.rebase_index(entry.push_index); + } + } else { self.standing_posture_baseline_history.clear(); } self.session.replace_messages(result.messages); @@ -7110,7 +7145,14 @@ impl Engine { } if !compacted_messages.is_empty() || self.session.messages.is_empty() { - self.append_compaction_agent_topology(&mut compacted_messages) + // Measured against the pre-topology replacement (see the manual + // compaction site): the checkpoint the topology step inserts + // below is engine-authored slot bookkeeping whose shift is + // rebased onto the entries at replace time. + let layout_preserved = + standing_baseline_layout_preserved(&compacted_messages, &self.session.messages); + let topology_rebase = self + .append_compaction_agent_topology(&mut compacted_messages) .await; let turn_was_canceled = turn_cancel.is_cancelled(); if turn_was_canceled || compaction_cancel.is_cancelled() { @@ -7126,18 +7168,24 @@ impl Engine { } // Unified standing-baseline handling for history replacements // the engine did not author slot-by-slot (see - // `StandingPostureBaselineEntry::push_index`): whether the API - // pass produced a provider-authored replacement or the local - // fallback re-carried history through the agent-topology - // checkpoint rewrite (arbitrary retain/insert positions), - // survivors land in slots that no longer map onto the recorded - // indices, so vacate the entries rather than guess a rebase — - // the same rationale as the auto-compaction path in - // core/engine/turn_loop.rs. The value-identity guard keeps - // entries anchored when the fallback left the history - // value-identical: equal lists keep equal slots, so a - // survived-turn edit still restores its prior posture. - if compacted_messages.as_slice() != &self.session.messages[..] { + // `StandingPostureBaselineEntry::push_index` and + // `standing_baseline_layout_preserved`): whether the API pass + // produced a provider-authored replacement or the local fallback + // re-carried history through the agent-topology checkpoint + // rewrite (arbitrary retain/insert positions), survivors land in + // slots that no longer map onto the recorded indices, so vacate + // the entries rather than guess a rebase — the same rationale as + // the auto-compaction path in core/engine/turn_loop.rs. A local + // mechanical prune (PruneOnly) rewrites ToolResult content in + // place and keeps every slot, so the layout check keeps the + // entries anchored there; a fallback that left the history + // layout-identical also keeps equal slots, so a survived-turn + // edit still restores its prior posture. + if layout_preserved { + for entry in &mut self.standing_posture_baseline_history { + entry.push_index = topology_rebase.rebase_index(entry.push_index); + } + } else { self.standing_posture_baseline_history.clear(); } self.session.replace_messages(compacted_messages); @@ -8559,12 +8607,15 @@ impl Engine { /// its world-state reference after standalone compaction so the next turn /// receives fresh environment/subagent context instead of trusting the /// narrative summary as live process state. - async fn append_compaction_agent_topology(&self, messages: &mut Vec) { + async fn append_compaction_agent_topology( + &self, + messages: &mut Vec, + ) -> crate::runtime_handoff::AgentTopologyCheckpointRebase { let snapshots = { let manager = self.subagent_manager.read().await; manager.list_for_session(&self.session.id) }; - crate::runtime_handoff::replace_agent_topology_checkpoint(messages, &snapshots); + crate::runtime_handoff::replace_agent_topology_checkpoint(messages, &snapshots) } } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 3397951871..5fb8f19226 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -19944,6 +19944,364 @@ async fn purge_then_edit_restores_the_standing_posture_baseline() { ); } +/// JensenChen28 review 5747388050 counterexample (PR #131): a manual +/// compaction that lands on the local PruneOnly branch rewrites old +/// ToolResult content in place — same length, same role at every index — so +/// the whole-list value comparison sees a changed list and (pre-fix) vacated +/// every standing-baseline entry even though the A/B user turns survived in +/// place. Editing the B turn after such a prune must still restore its +/// prior (A) and the replacement must re-report the A→B transition the +/// truncated B block carried away. The captured-request count also proves +/// the compaction took the mechanical prune path: PruneOnly makes no +/// provider call, so only the two real turns and the replacement appear. +#[tokio::test] +async fn manual_prune_only_compaction_keeps_the_standing_baseline_entries() { + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + + let sse_body = |id: &str, answer: &str| { + format!( + "data: {{\"id\":\"chatcmpl-{id}\",\"choices\":[{{\"index\":0,\ + \"delta\":{{\"content\":\"{answer}\"}},\"finish_reason\":null}}]}}\n\n\ + data: {{\"id\":\"chatcmpl-{id}\",\"choices\":[{{\"index\":0,\ + \"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\n\n\ + data: [DONE]\n\n", + ) + }; + // First-match-wins, mirroring the purge regression: the replacement and + // the B turn are told apart by their own prompt text; the fallback + // serves the A turn. A compaction provider call would land on the + // fallback and blow its expect(1), so the request count also pins the + // mechanical-prune path. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("revise this committed input")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("prune-fix-revised", "Revised answer.")), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("turn that reports the transition")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("prune-fix-b", "B answer.")), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("prune-fix-a", "A answer.")), + ) + .expect(1) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + // Auto compaction stays off so the seeded pressure cannot trigger it + // mid-turn; the manual op below carries its own enabled config. + let mut config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + snapshots_enabled: false, + subagents_enabled: false, + compaction: CompactionConfig { + enabled: false, + ..CompactionConfig::default() + }, + ..Default::default() + }; + config.max_steps = 32; + let (engine, handle) = Engine::new(config, &api_config); + let run = tokio::spawn(engine.run()); + + // A stale, oversized tool output well outside the protected recent + // window, paired with the assistant tool call that owns it (the prune + // planner only candidates ToolResults whose tool_use_id was seen). Its + // estimate comfortably crosses the manual op's threshold. + const OLD_TOOL_OUTPUT: &str = "prunable stale tool output padding. "; + let seeded_messages = vec![ + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "old-prunable-tool".to_string(), + name: "read_file".to_string(), + input: serde_json::json!({"path": "old-probe.txt"}), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "old-prunable-tool".to_string(), + content: OLD_TOOL_OUTPUT.repeat(2000), + is_error: None, + content_blocks: None, + }], + }, + ]; + handle + .send(Op::SyncSession { + session_id: Some("prune-baseline-test".to_string()), + messages: seeded_messages, + system_prompt: None, + system_prompt_override: false, + model: "deepseek-v4-pro".to_string(), + workspace: tmp.path().to_path_buf(), + workspace_roots: Vec::new(), + mode: AppMode::Agent, + }) + .await + .expect("sync session"); + + // Turn A runs unarmed: it renders no transition and establishes the + // standing baseline. + handle + .send(Op::SendMessage { + content: "turn in the original posture".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig { + enabled: false, + ..CompactionConfig::default() + }), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the original-posture turn"); + wait_for_turn_complete(&handle).await; + + // Arm the approval posture, then run the B turn whose block reports the + // A→B transition into history. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + configured_sandbox_mode: None, + ask_on_guardrail: None, + }) + .await + .expect("send the approval posture change"); + handle + .send(Op::SendMessage { + content: "turn that reports the transition".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig { + enabled: false, + ..CompactionConfig::default() + }), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the transition-reporting turn"); + wait_for_turn_complete(&handle).await; + + // Manual compaction over a threshold the prunable stale output crosses: + // pruning it lands under, so compact_messages_safe returns the local + // PruneOnly result without any provider summary call. + handle + .send(Op::CompactContext { + id: "manual-prune-1".to_string(), + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig { + enabled: true, + token_threshold: 5_000, + ..CompactionConfig::default() + }), + }) + .await + .expect("send manual compaction"); + { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for the manual compaction") + .expect("engine event channel closed before compaction settled"); + if let Event::TurnComplete { + compaction_id: Some(id), + .. + } = event + { + assert_eq!(id, "manual-prune-1", "the manual compaction must settle"); + break; + } + } + } + + // Precondition pins: the mechanical prune actually ran (the stale output + // shrank) and the layout survived (same message count and roles as + // before the compaction). + let (pre_len, pre_roles) = { + let (tx, rx) = tokio::sync::oneshot::channel(); + handle + .send(Op::GetSessionSnapshot { + tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))), + }) + .await + .expect("request snapshot"); + let snapshot = tokio::time::timeout(model_turn_event_timeout(), rx) + .await + .expect("snapshot response") + .expect("snapshot"); + ( + snapshot.messages.len(), + snapshot + .messages + .iter() + .map(|message| message.role.as_str().to_string()) + .collect::>(), + ) + }; + { + let (tx, rx) = tokio::sync::oneshot::channel(); + handle + .send(Op::GetSessionSnapshot { + tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))), + }) + .await + .expect("request snapshot"); + let snapshot = tokio::time::timeout(model_turn_event_timeout(), rx) + .await + .expect("snapshot response") + .expect("snapshot"); + let post_roles: Vec<_> = snapshot + .messages + .iter() + .map(|message| message.role.as_str().to_string()) + .collect(); + assert_eq!( + (snapshot.messages.len(), post_roles.as_slice()), + (pre_len, pre_roles.as_slice()), + "a PruneOnly compaction must keep every message slot: pre roles \ + {pre_roles:?}, post roles {post_roles:?}" + ); + let stale_output_len = snapshot + .messages + .iter() + .flat_map(|message| message.content.iter()) + .filter_map(|block| match block { + ContentBlock::ToolResult { content, .. } => Some(content.len()), + _ => None, + }) + .max() + .expect("the seeded stale tool result must still exist"); + assert!( + stale_output_len < OLD_TOOL_OUTPUT.len() * 2000, + "the manual compaction must have pruned the stale tool output in \ + place (pruned length {stale_output_len})" + ); + } + + // The selector's latest editable user prompt is the B turn build; its + // entry was pushed at the index it still occupies after the layout- + // preserving prune. + handle + .send(Op::EditLastTurn { + new_message: "revise this committed input".to_string(), + submission_id: Some("sub-edit-after-prune".to_string()), + }) + .await + .expect("send edit"); + wait_for_turn_complete(&handle).await; + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run.await.expect("engine task"); + + let requests = server.received_requests().await.expect("recorded requests"); + assert_eq!( + requests.len(), + 3, + "two real turns and the replacement: a PruneOnly compaction makes \ + no provider call, so an extra request would mean the summary path \ + ran and the mechanical-prune scenario did not" + ); + let wire_body = |request: &[u8]| String::from_utf8_lossy(request).to_string(); + assert!( + !wire_body(&requests[0].body).contains("Authority transition (host)"), + "the first reported turn must render no transition" + ); + assert!( + wire_body(&requests[1].body).contains("Authority transition (host)"), + "the turn after the posture flip must report the transition" + ); + let replacement = wire_body(&requests[2].body); + assert!( + replacement.contains("revise this committed input"), + "the replacement must carry the edited turn text" + ); + assert!( + !replacement.contains("B answer."), + "the edit must have truncated the edited turn's suffix" + ); + assert!( + replacement.contains("turn in the original posture"), + "the prune must have kept the first user block" + ); + assert!( + replacement.contains("Authority transition (host)"), + "editing the B turn after a PruneOnly compaction must restore its \ + prior (A) and re-report the transition the truncated block carried \ + away" + ); +} + /// Drive the standing-posture-baseline edit scenario with non-authoritative /// runtime turns parked between the true user turns. /// diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 280eed90c7..44d88a8bb8 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1940,7 +1940,29 @@ impl Engine { Ok(mut result) => { // Only update if we got valid messages (never corrupt state) if !result.messages.is_empty() || self.session.messages.is_empty() { - self.append_compaction_agent_topology(&mut result.messages) + // Distinguish the two replacement shapes (see + // `standing_baseline_layout_preserved`): a local + // mechanical prune rewrites ToolResult content in + // place and keeps every slot, so the recorded + // indices stay exact and the entries survive; a + // provider-authored summary merges or drops turns + // into unknowable slots, so the entries are + // vacated instead of guessed — post-compaction + // turn builds re-establish their own entries + // immediately, and an edit that targets a + // pre-compaction survivor recomputes against the + // unchanged standing baseline rather than + // restoring a misattributed snapshot. Measured + // against the pre-topology replacement: the + // checkpoint the topology step inserts below is + // engine-authored slot bookkeeping whose shift is + // rebased onto the entries at replace time. + let layout_preserved = standing_baseline_layout_preserved( + &result.messages, + &self.session.messages, + ); + let topology_rebase = self + .append_compaction_agent_topology(&mut result.messages) .await; let turn_was_canceled = turn_cancel.is_cancelled(); if turn_was_canceled || compaction_cancel.is_cancelled() { @@ -1962,17 +1984,14 @@ impl Engine { let auto_messages_after = result.messages.len(); let retries_used = result.retries_used; let coverage_clause = result.coverage.receipt_clause(); - // The replacement history is provider-authored - // (summaries, pruned tool results, coverage - // floors), so the standing-baseline stack's - // recorded indices no longer map onto it. Vacate - // the entries instead of guessing a rebase: - // post-compaction turn builds re-establish their - // own entries immediately, and an edit that - // targets a pre-compaction survivor recomputes - // against the unchanged standing baseline rather - // than restoring a misattributed snapshot. - self.standing_posture_baseline_history.clear(); + if layout_preserved { + for entry in &mut self.standing_posture_baseline_history { + entry.push_index = + topology_rebase.rebase_index(entry.push_index); + } + } else { + self.standing_posture_baseline_history.clear(); + } self.session.replace_messages(result.messages); turn.clear_parent_input_tokens(); if let Some(pm) = self.session.prefix_stability.as_mut() { diff --git a/crates/tui/src/runtime_handoff.rs b/crates/tui/src/runtime_handoff.rs index 5bd8e20ab7..ffdd4f2a8a 100644 --- a/crates/tui/src/runtime_handoff.rs +++ b/crates/tui/src/runtime_handoff.rs @@ -818,13 +818,21 @@ pub(crate) fn compaction_anchor_in_era( pub(crate) fn replace_agent_topology_checkpoint( messages: &mut Vec, snapshots: &[SubAgentResult], -) { +) -> AgentTopologyCheckpointRebase { // Both forms: a restored carrier is the projection of a sidecar a previous // save already wrote, and keeping it beside the fresh one would persist a // superseded duplicate that no later replay removes. - messages.retain(|message| { - !is_agent_topology_checkpoint(message) && !is_restored_agent_topology_checkpoint(message) - }); + let mut retained = Vec::with_capacity(messages.len()); + let mut removed_indices = Vec::new(); + for (index, message) in messages.drain(..).enumerate() { + if is_agent_topology_checkpoint(&message) || is_restored_agent_topology_checkpoint(&message) + { + removed_indices.push(index); + } else { + retained.push(message); + } + } + *messages = retained; let checkpoints = crate::history_recognition::CheckpointEra::of_history(messages); let ends_with_tool_result = messages.last().is_some_and(carries_tool_result); let ends_with_compaction_summary = messages @@ -837,6 +845,44 @@ pub(crate) fn replace_agent_topology_checkpoint( messages.len() }; messages.insert(position, agent_topology_checkpoint_message(snapshots)); + AgentTopologyCheckpointRebase { + removed_indices, + insert_position: position, + } +} + +/// How [`replace_agent_topology_checkpoint`] moved history slots: the +/// original indices of the superseded checkpoint carriers it removed, and +/// the post-removal index the fresh checkpoint was inserted at. Positions +/// recorded against the pre-call history translate through +/// [`AgentTopologyCheckpointRebase::rebase_index`]. +#[derive(Debug, Clone)] +pub(crate) struct AgentTopologyCheckpointRebase { + removed_indices: Vec, + insert_position: usize, +} + +impl AgentTopologyCheckpointRebase { + /// Translate a position recorded against the pre-call history to the + /// post-call history: superseded carriers removed before it shift it + /// down, the fresh checkpoint inserted at or before it shifts it up. + /// Positions are slot bookkeeping only — the checkpoint carriers are + /// engine-authored messages no turn-build record ever points at, so a + /// translated index always refers to the same surviving message. + #[must_use] + pub(crate) fn rebase_index(&self, push_index: usize) -> usize { + let removed_before = self + .removed_indices + .iter() + .filter(|&&removed| removed < push_index) + .count(); + let shifted = push_index.saturating_sub(removed_before); + if shifted >= self.insert_position { + shifted + 1 + } else { + shifted + } + } } #[cfg(test)] From 746c883eaa45f45abf26d094af356d9e4546b0d1 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 13:50:51 +0800 Subject: [PATCH 23/29] fix(tui): localize the remote unknown-approval notice The unknown-approval branch acknowledged the decision as "unknown" but surfaced the explanation through a hand-written English status_message write. Route it through the typed warning toast and a new RemoteApprovalOutcomeUnconfirmed MessageId so every shipped locale renders it (review 5747325425, non-blocking P3). Signed-off-by: asto18089 Signed-off-by: asto --- crates/tui/locales/ca.json | 3 ++- crates/tui/locales/de.json | 3 ++- crates/tui/locales/en.json | 3 ++- crates/tui/locales/es-419.json | 3 ++- crates/tui/locales/fr.json | 3 ++- crates/tui/locales/hi.json | 3 ++- crates/tui/locales/id.json | 3 ++- crates/tui/locales/ja.json | 3 ++- crates/tui/locales/ko.json | 3 ++- crates/tui/locales/pt-BR.json | 3 ++- crates/tui/locales/ru.json | 3 ++- crates/tui/locales/uk.json | 3 ++- crates/tui/locales/vi.json | 3 ++- crates/tui/locales/zh-Hans.json | 3 ++- crates/tui/locales/zh-Hant.json | 3 ++- crates/tui/src/localization.rs | 2 ++ crates/tui/src/tui/ui/remote_control_bridge.rs | 12 ++++++++---- 17 files changed, 40 insertions(+), 19 deletions(-) diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index 3b36db2d5a..383e45c653 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Dv", "AutomationEditorSaturday": "Ds", "AutomationEditorSunday": "Dg", - "AttachedRootsNotRevertedNote": "Només s'ha restaurat l'workspace primari; les arrels adjuntes del workspace no s'han revertit (els directoris exclosos de la instantània —com vendor/— i els repositoris niats conserven els canvis)." + "AttachedRootsNotRevertedNote": "Només s'ha restaurat l'workspace primari; les arrels adjuntes del workspace no s'han revertit (els directoris exclosos de la instantània —com vendor/— i els repositoris niats conserven els canvis).", + "RemoteApprovalOutcomeUnconfirmed": "El resultat de l'aprovació encara no està confirmat; la targeta roman oberta fins que el motor ho informi." } diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index 047d80bb55..f2298a0a06 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Fr", "AutomationEditorSaturday": "Sa", "AutomationEditorSunday": "So", - "AttachedRootsNotRevertedNote": "Nur der primäre Workspace wurde wiederhergestellt; angehängte Workspace-Roots wurden nicht zurückgerollt (vom Snapshot ausgeschlossene Verzeichnisse — etwa vendor/ — und verschachtelte Repositories behalten ihre Änderungen)." + "AttachedRootsNotRevertedNote": "Nur der primäre Workspace wurde wiederhergestellt; angehängte Workspace-Roots wurden nicht zurückgerollt (vom Snapshot ausgeschlossene Verzeichnisse — etwa vendor/ — und verschachtelte Repositories behalten ihre Änderungen).", + "RemoteApprovalOutcomeUnconfirmed": "Das Genehmigungsergebnis ist noch nicht bestätigt; die Karte bleibt offen, bis die Engine berichtet." } diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index a6a66b3168..387425fb5e 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Fri", "AutomationEditorSaturday": "Sat", "AutomationEditorSunday": "Sun", - "AttachedRootsNotRevertedNote": "Only the primary workspace was reverted; attached workspace roots were not rolled back (roots excluded from the snapshot — such as vendor/ — and nested repositories keep their changes)." + "AttachedRootsNotRevertedNote": "Only the primary workspace was reverted; attached workspace roots were not rolled back (roots excluded from the snapshot — such as vendor/ — and nested repositories keep their changes).", + "RemoteApprovalOutcomeUnconfirmed": "Approval outcome not confirmed yet; the card stays open until the engine reports." } diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index 1a5326961b..4ed776cc43 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Vie", "AutomationEditorSaturday": "Sáb", "AutomationEditorSunday": "Dom", - "AttachedRootsNotRevertedNote": "Solo se revirtió el workspace primario; las raíces adjuntas del workspace no se revirtieron (los directorios excluidos de la instantánea —como vendor/— y los repositorios anidados conservan sus cambios)." + "AttachedRootsNotRevertedNote": "Solo se revirtió el workspace primario; las raíces adjuntas del workspace no se revirtieron (los directorios excluidos de la instantánea —como vendor/— y los repositorios anidados conservan sus cambios).", + "RemoteApprovalOutcomeUnconfirmed": "Resultado de aprobación aún sin confirmar; la tarjeta permanece abierta hasta que el motor reporte." } diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index 0d6baacfba..43c39c8944 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Ven", "AutomationEditorSaturday": "Sam", "AutomationEditorSunday": "Dim", - "AttachedRootsNotRevertedNote": "Seul l'espace de travail principal a été restauré ; les racines attachées n'ont pas été annulées (les répertoires exclus de l'instantané — comme vendor/ — et les dépôts imbriqués conservent leurs modifications)." + "AttachedRootsNotRevertedNote": "Seul l'espace de travail principal a été restauré ; les racines attachées n'ont pas été annulées (les répertoires exclus de l'instantané — comme vendor/ — et les dépôts imbriqués conservent leurs modifications).", + "RemoteApprovalOutcomeUnconfirmed": "Résultat d'approbation pas encore confirmé ; la carte reste ouverte jusqu'au rapport du moteur." } diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index 2fef6d02c9..11ccc363e0 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "शुक्र", "AutomationEditorSaturday": "शनि", "AutomationEditorSunday": "रवि", - "AttachedRootsNotRevertedNote": "केवल प्राथमिक वर्कस्पेस पूर्ववत हुआ; संलग्न वर्कस्पेस रूट वापस नहीं लपेटे गए (स्नैपशॉट से बाहर रखे गए डायरेक्टरीज़ — जैसे vendor/ — और नेस्टेड रिपॉज़िटरी अपने परिवर्तन बनाए रखती हैं)।" + "AttachedRootsNotRevertedNote": "केवल प्राथमिक वर्कस्पेस पूर्ववत हुआ; संलग्न वर्कस्पेस रूट वापस नहीं लपेटे गए (स्नैपशॉट से बाहर रखे गए डायरेक्टरीज़ — जैसे vendor/ — और नेस्टेड रिपॉज़िटरी अपने परिवर्तन बनाए रखती हैं)।", + "RemoteApprovalOutcomeUnconfirmed": "अनुमोदन का परिणाम अभी पुष्ट नहीं हुआ; इंजन के रिपोर्ट करने तक कार्ड खुला रहेगा।" } diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index 24f32c0b5f..ec0cef4d1b 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Jum", "AutomationEditorSaturday": "Sab", "AutomationEditorSunday": "Min", - "AttachedRootsNotRevertedNote": "Hanya workspace utama yang dikembalikan; root workspace terlampir tidak di-rollbalik (direktori yang dikecualikan dari snapshot — seperti vendor/ — dan repositori bersarang mempertahankan perubahannya)." + "AttachedRootsNotRevertedNote": "Hanya workspace utama yang dikembalikan; root workspace terlampir tidak di-rollbalik (direktori yang dikecualikan dari snapshot — seperti vendor/ — dan repositori bersarang mempertahankan perubahannya).", + "RemoteApprovalOutcomeUnconfirmed": "Hasil persetujuan belum dikonfirmasi; kartu tetap terbuka sampai engine melaporkan." } diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 3b1065f736..ef764fcb49 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "金", "AutomationEditorSaturday": "土", "AutomationEditorSunday": "日", - "AttachedRootsNotRevertedNote": "プライマリワークスペースのみ復元されました。アタッチされたワークスペースルートはロールバックされていません(スナップショットから除外されるディレクトリ(vendor/ など)やネストされたリポジトリは変更が残ります)。" + "AttachedRootsNotRevertedNote": "プライマリワークスペースのみ復元されました。アタッチされたワークスペースルートはロールバックされていません(スナップショットから除外されるディレクトリ(vendor/ など)やネストされたリポジトリは変更が残ります)。", + "RemoteApprovalOutcomeUnconfirmed": "承認結果はまだ確定していません。エンジンが報告するまでカードは開いたままです。" } diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index e7ee397be4..050dfe93a5 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "금", "AutomationEditorSaturday": "토", "AutomationEditorSunday": "일", - "AttachedRootsNotRevertedNote": "기본 워크스페이스만 되돌렸습니다; 첨부된 워크스페이스 루트는 롤백되지 않았습니다(스냅샷에서 제외되는 디렉터리 — 예: vendor/ — 및 중첩 저장소는 변경 사항이 유지됩니다)." + "AttachedRootsNotRevertedNote": "기본 워크스페이스만 되돌렸습니다; 첨부된 워크스페이스 루트는 롤백되지 않았습니다(스냅샷에서 제외되는 디렉터리 — 예: vendor/ — 및 중첩 저장소는 변경 사항이 유지됩니다).", + "RemoteApprovalOutcomeUnconfirmed": "승인 결과가 아직 확정되지 않았습니다. 엔진이 보고할 때까지 카드는 열려 있습니다." } diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index 79e824ca56..cd55fb18e4 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Sex", "AutomationEditorSaturday": "Sáb", "AutomationEditorSunday": "Dom", - "AttachedRootsNotRevertedNote": "Apenas o workspace primário foi revertido; as raízes anexadas do workspace não foram revertidas (diretórios excluídos do snapshot — como vendor/ — e repositórios aninhados mantêm suas alterações)." + "AttachedRootsNotRevertedNote": "Apenas o workspace primário foi revertido; as raízes anexadas do workspace não foram revertidas (diretórios excluídos do snapshot — como vendor/ — e repositórios aninhados mantêm suas alterações).", + "RemoteApprovalOutcomeUnconfirmed": "Resultado da aprovação ainda não confirmado; o cartão permanece aberto até o engine reportar." } diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index fdc75d602c..218bdb19ed 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Пт", "AutomationEditorSaturday": "Сб", "AutomationEditorSunday": "Вс", - "AttachedRootsNotRevertedNote": "Откачено только основное рабочее пространство; прикреплённые корни не откатывались (исключённые из снимка каталоги — например vendor/ — и вложенные репозитории сохраняют свои изменения)." + "AttachedRootsNotRevertedNote": "Откачено только основное рабочее пространство; прикреплённые корни не откатывались (исключённые из снимка каталоги — например vendor/ — и вложенные репозитории сохраняют свои изменения).", + "RemoteApprovalOutcomeUnconfirmed": "Результат одобрения ещё не подтверждён; карточка остаётся открытой, пока движок не сообщит." } diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index adab71eab9..ee849ea089 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "Пт", "AutomationEditorSaturday": "Сб", "AutomationEditorSunday": "Нд", - "AttachedRootsNotRevertedNote": "Відновлено лише основний робочий простір; долучені корені не відкатувалися (виключені зі знімка каталоги — як-от vendor/ — та вкладені репозиторії зберігають свої зміни)." + "AttachedRootsNotRevertedNote": "Відновлено лише основний робочий простір; долучені корені не відкатувалися (виключені зі знімка каталоги — як-от vendor/ — та вкладені репозиторії зберігають свої зміни).", + "RemoteApprovalOutcomeUnconfirmed": "Результат схвалення ще не підтверджено; картка залишається відкритою, поки рушій не повідомить." } diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index 5005d985dc..8da9299a48 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "T6", "AutomationEditorSaturday": "T7", "AutomationEditorSunday": "CN", - "AttachedRootsNotRevertedNote": "Chỉ vùng làm việc chính được khôi phục; các root workspace đính kèm không được hoàn tác (các thư mục bị loại khỏi bản chụp — như vendor/ — và các kho lồng nhau giữ nguyên thay đổi)." + "AttachedRootsNotRevertedNote": "Chỉ vùng làm việc chính được khôi phục; các root workspace đính kèm không được hoàn tác (các thư mục bị loại khỏi bản chụp — như vendor/ — và các kho lồng nhau giữ nguyên thay đổi).", + "RemoteApprovalOutcomeUnconfirmed": "Kết quả phê duyệt chưa được xác nhận; thẻ vẫn mở cho đến khi engine báo cáo." } diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 92d1f6eb16..e4dc7e0939 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "周五", "AutomationEditorSaturday": "周六", "AutomationEditorSunday": "周日", - "AttachedRootsNotRevertedNote": "仅回退了主工作区;附加的工作区根未回滚(快照排除的目录(如 vendor/)与嵌套仓库会保留其更改)。" + "AttachedRootsNotRevertedNote": "仅回退了主工作区;附加的工作区根未回滚(快照排除的目录(如 vendor/)与嵌套仓库会保留其更改)。", + "RemoteApprovalOutcomeUnconfirmed": "审批结果尚未确认;在引擎回报前卡片保持打开。" } diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 9e6636f5b5..b2602d75a5 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -2098,5 +2098,6 @@ "AutomationEditorFriday": "週五", "AutomationEditorSaturday": "週六", "AutomationEditorSunday": "週日", - "AttachedRootsNotRevertedNote": "僅回退了主工作區;附加的工作區根未回滾(快照排除的目錄(如 vendor/)與嵌套倉庫會保留其更改)。" + "AttachedRootsNotRevertedNote": "僅回退了主工作區;附加的工作區根未回滾(快照排除的目錄(如 vendor/)與嵌套倉庫會保留其更改)。", + "RemoteApprovalOutcomeUnconfirmed": "審批結果尚未確認;在引擎回報前卡片保持開啟。" } diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index edcdd21ba7..a89be7e7af 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -2308,6 +2308,7 @@ pub enum MessageId { ConfigHintFeatureVisionModel, ConfigHintGoalCommand, ConfigHintWorkflow, + RemoteApprovalOutcomeUnconfirmed, } #[allow(dead_code)] @@ -4413,6 +4414,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::ConfigHintFeatureVisionModel, MessageId::ConfigHintGoalCommand, MessageId::ConfigHintWorkflow, + MessageId::RemoteApprovalOutcomeUnconfirmed, ]; pub fn tr(locale: Locale, id: MessageId) -> Cow<'static, str> { diff --git a/crates/tui/src/tui/ui/remote_control_bridge.rs b/crates/tui/src/tui/ui/remote_control_bridge.rs index 80fdec10e5..15ce173bed 100644 --- a/crates/tui/src/tui/ui/remote_control_bridge.rs +++ b/crates/tui/src/tui/ui/remote_control_bridge.rs @@ -311,10 +311,14 @@ pub(crate) async fn drain_remote_control_events( Some(error.to_string()), ); if status == "unknown" { - app.status_message = Some( - "Approval outcome not confirmed yet; the card stays \ - open until the engine reports." - .to_string(), + let notice = app + .tr(crate::localization::MessageId::RemoteApprovalOutcomeUnconfirmed) + .into_owned(); + app.status_message = Some(notice.clone()); + app.push_status_toast( + notice, + StatusToastLevel::Warning, + Some(8_000), ); } } From 7c735bebd8c8d609146f941d7b971f6b2a45529e Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 15:57:13 +0800 Subject: [PATCH 24/29] fix(engine): bind child approval keys to the context Operation-scoped keys fixed the incarnation churn but left the authorization reuse scope wider than the existing directory-scope contract: both child cache scopes were derived from the operation alone, and the grouping key only anchored the workspace for patch families and cwd operands. Sibling workers share the session's decision caches while running under DIFFERENT workspace roots at the same time, so a grant or denial decided under one root could cover the identical operation raised under another. Bind BOTH cache scopes to the effective execution context with the same length-prefixed @root tag the grouping side uses for patch families (round-37 M37-6). Same-context operation families keep identical keys (reuse preserved); a different context now mints a different identity; force_prompt keeps riding its own event field and is never answered by either cache. The main line needs no key change: its single execution context invalidates the whole session grant/denial sets on every workspace or conversation switch. Regression: the cross-context case joins the incarnation-scoping test - identical operation, different workspace root -> different exact and grouping keys bound to their own root; same workspace -> identical keys; force independence unchanged. Signed-off-by: asto --- crates/tui/src/tools/approval_cache.rs | 74 +++++++++++++ crates/tui/src/tools/subagent/mod.rs | 23 ++-- crates/tui/src/tools/subagent/tests.rs | 141 +++++++++++++++++++++++-- 3 files changed, 226 insertions(+), 12 deletions(-) diff --git a/crates/tui/src/tools/approval_cache.rs b/crates/tui/src/tools/approval_cache.rs index 987750617f..60a93d03a4 100644 --- a/crates/tui/src/tools/approval_cache.rs +++ b/crates/tui/src/tools/approval_cache.rs @@ -170,6 +170,27 @@ pub fn build_approval_grouping_key( ApprovalKey(fingerprint) } +/// Bind an operation key to the effective execution context it was judged +/// under — the length-prefixed `@root::` spelling the grouping +/// side already uses to bind patch families to their primary (round-37 +/// M37-6). +/// +/// Both digests above are operation-scoped only: the same tool + input +/// minted the same key under every workspace. Within one conversation that +/// is safe because a workspace or session switch clears the whole session +/// grant/denial sets (`apply_workspace_runtime_state`). Delegated child +/// calls are different: sibling workers share the session's decision +/// caches while running under DIFFERENT workspace roots at the same time, +/// so an identical child operation must not ride a grant or denial decided +/// under another root. Binding the root into the key keeps same-context +/// operation families reusable (identical keys) while any other context +/// mints a different identity. +#[must_use] +pub fn bind_workspace_scope(key: ApprovalKey, workspace: &std::path::Path) -> ApprovalKey { + let root_tag = workspace.display().to_string(); + ApprovalKey(format!("{}@root:{}:{root_tag}", key.0, root_tag.len())) +} + /// Return the canonical command prefix for the shell command in `input`. /// /// Uses [`classify_command`] from the arity dictionary so that approving @@ -374,6 +395,59 @@ mod tests { assert_ne!(key_a, key_b); } + /// zhuowp review 5747926699: the operation identity must cover the + /// effective execution context. Binding the judged root keeps the + /// same-context operation family reusable (identical keys) while any + /// other workspace mints a different identity, for BOTH cache scopes. + #[test] + fn workspace_scope_binding_separates_contexts_and_keeps_families() { + let input = json!({"command": "echo gated"}); + let exact_a = bind_workspace_scope( + build_approval_key("bash", &input), + std::path::Path::new("/repo-a"), + ); + let exact_a_again = bind_workspace_scope( + build_approval_key("bash", &input), + std::path::Path::new("/repo-a"), + ); + let exact_b = bind_workspace_scope( + build_approval_key("bash", &input), + std::path::Path::new("/repo-b"), + ); + assert_eq!( + exact_a, exact_a_again, + "the same context must keep the operation family reusable" + ); + assert_ne!( + exact_a, exact_b, + "a different context must mint a different exact identity" + ); + assert!( + exact_a.0.contains("@root:7:/repo-a"), + "the binding uses the length-prefixed root tag: {}", + exact_a.0 + ); + let grouping_a = + bind_workspace_scope(gkey("bash", &input), std::path::Path::new("/repo-a")); + let grouping_a_again = + bind_workspace_scope(gkey("bash", &input), std::path::Path::new("/repo-a")); + let grouping_b = + bind_workspace_scope(gkey("bash", &input), std::path::Path::new("/repo-b")); + assert_eq!( + grouping_a, grouping_a_again, + "the same context must keep the grant family reusable" + ); + assert_ne!( + grouping_a, grouping_b, + "a different context must mint a different grant identity" + ); + assert!( + grouping_a.0.ends_with("@root:7:/repo-a"), + "the grouping key carries the same root tag: {}", + grouping_a.0 + ); + } + /// Round-37 M37-6 pins: the grouping key resolves the cwd operand /// against the CURRENT primary and binds the patch family to it, so a /// session grant approved in one root cannot ride a `/workspace` move. diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 0c1e64409f..2db7def176 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -15134,13 +15134,24 @@ impl SubAgentToolRegistry { // exact denial the approval interface promises to remember. Derive // both keys from the operation like the main line does (tool + input; // the grouping key anchors cwd operands against the session - // workspace). Transport identity stays in `id`; force_prompt keeps + // workspace), then bind BOTH to the effective execution context — + // the workspace root this call is judged under — with the same + // `@root` tag the grouping side uses for patch families. Sibling + // workers share this session's decision caches while running under + // different roots, so the identical operation in another workspace + // must mint a different identity instead of riding this context's + // grant or denial, while the same context keeps the operation family + // reusable. Transport identity stays in `id`; force_prompt keeps // riding its own field and is never answered by either cache. - let approval_key = crate::tools::approval_cache::build_approval_key(name, input).0; - let approval_grouping_key = crate::tools::approval_cache::build_approval_grouping_key( - name, - input, - &self.gate_runtime.context.workspace, + let workspace = &self.gate_runtime.context.workspace; + let approval_key = crate::tools::approval_cache::bind_workspace_scope( + crate::tools::approval_cache::build_approval_key(name, input), + workspace, + ) + .0; + let approval_grouping_key = crate::tools::approval_cache::bind_workspace_scope( + crate::tools::approval_cache::build_approval_grouping_key(name, input, workspace), + workspace, ) .0; let sent = event_tx diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 551eb83102..c207c61b13 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -21922,19 +21922,46 @@ mod child_permission_gate { SharedSubAgentManager, ) { let tmp = tempdir().expect("tempdir"); + let workspace = tmp.path().to_path_buf(); + // Keep the tempdir alive for the registry's lifetime by leaking it + // into the workspace path (tests are short-lived). + std::mem::forget(tmp); + worker_registry_in( + approval_mode, + auto_approve, + parent_can_prompt, + client, + workspace, + ) + } + + /// [`worker_registry`] under an explicit workspace root, so a test can + /// raise the same child operation under two different execution + /// contexts — the approval cache keys bind to this root. + fn worker_registry_in( + approval_mode: ApprovalMode, + auto_approve: bool, + parent_can_prompt: bool, + client: Option, + workspace: std::path::PathBuf, + ) -> ( + SubAgentToolRegistry, + tokio::sync::mpsc::Receiver, + SharedSubAgentManager, + ) { + let receipt_root = workspace.join("sessions"); let (tx, rx) = tokio::sync::mpsc::channel(32); let mut runtime = stub_runtime(); if let Some(client) = client { runtime.client = client; } let session_id = format!("child_gate_{}", uuid::Uuid::new_v4().simple()); - runtime.context = - ToolContext::new(tmp.path().to_path_buf()).with_state_namespace(session_id); + runtime.context = ToolContext::new(workspace).with_state_namespace(session_id); runtime.context.auto_approve = auto_approve; runtime.allow_shell = true; runtime.event_tx = Some(tx); runtime = runtime.with_approval_receipt_store(Ok( - crate::approval_log::ApprovalReceiptStore::new(tmp.path().join("sessions")), + crate::approval_log::ApprovalReceiptStore::new(receipt_root), )); runtime = runtime.with_permission_posture( approval_mode, @@ -21942,9 +21969,6 @@ mod child_permission_gate { parent_can_prompt, ); let manager = Arc::clone(&runtime.manager); - // Keep the tempdir alive for the registry's lifetime by leaking it - // into the workspace path (tests are short-lived). - std::mem::forget(tmp); let registry = SubAgentToolRegistry::new( runtime, FleetRole::Worker, @@ -22190,6 +22214,14 @@ mod child_permission_gate { /// reused by the next identical call. Two identical operations must /// raise prompts with identical keys that do not embed the approval id; /// force_prompt keeps riding its own field (never answered by a cache). + /// + /// zhuowp review 5747926699: the operation identity must ALSO cover the + /// effective execution context. Sibling workers share the session's + /// decision caches while running under different workspace roots, and + /// the existing directory-scope contract (round-37 M37-6) binds a + /// decision to the root it was judged under — so the identical + /// operation under another workspace must mint different keys, while + /// the same workspace keeps the operation family reusable. #[tokio::test] async fn child_approval_cache_keys_are_operation_scoped_across_incarnations() { let (registry, mut rx, manager) = worker_registry(ApprovalMode::Suggest, false, true, None); @@ -22267,6 +22299,103 @@ mod child_permission_gate { "force_prompt is independent of the key scopes" ); assert_eq!(manager.read().await.pending_child_approvals(), 0); + + // Cross-context (zhuowp review 5747926699): the operation identity + // must also cover the effective execution context. Sibling workers + // share the session's decision caches while running under different + // workspace roots, and the directory-scope contract (round-37 + // M37-6) binds a decision to the root it was judged under — so the + // IDENTICAL operation under another root must mint different keys, + // while the two incarnations above (same workspace) stayed + // identical: same-context family reuse is preserved. + let tmp_other = tempdir().expect("tempdir"); + let (other_registry, mut other_rx, other_manager) = worker_registry_in( + ApprovalMode::Suggest, + false, + true, + None, + tmp_other.path().to_path_buf(), + ); + std::mem::forget(tmp_other); + let other_workspace = other_registry + .gate_runtime + .context + .workspace + .display() + .to_string(); + let this_workspace = registry + .gate_runtime + .context + .workspace + .display() + .to_string(); + let manager_for_other_answer = Arc::clone(&other_manager); + let other_answerer = tokio::spawn(async move { + let mut captured = None; + for _ in 0..200 { + if let Ok(event) = other_rx.try_recv() + && let Event::ApprovalRequired { + id, + approval_key, + approval_grouping_key, + approval_force_prompt, + .. + } = event + { + captured = Some(( + id, + approval_key, + approval_grouping_key, + approval_force_prompt, + )); + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + let prompt = captured.expect("the cross-context prompt must reach the host"); + assert!( + manager_for_other_answer + .write() + .await + .resolve_child_approval(&prompt.0, ChildApprovalOutcome::Denied), + "the waiting cross-context child must accept the denial" + ); + prompt + }); + let err = other_registry + .execute("agent_gate", "bash", json!({"command": "echo gated"})) + .await + .expect_err("the denied cross-context call must not run"); + assert!(err.to_string().contains("denied by the user"), "{err}"); + let (other_id, other_key, other_grouping, other_force) = + other_answerer.await.expect("other answerer task"); + assert_ne!( + first_key, other_key, + "the exact-denial scope must not cross workspace contexts: {first_key} vs {other_key}" + ); + assert_ne!( + first_grouping, other_grouping, + "the session-grant scope must not cross workspace contexts: {first_grouping} vs {other_grouping}" + ); + assert!( + first_key.contains(&this_workspace) && !first_key.contains(&other_workspace), + "this context's exact key must be bound to this workspace only: {first_key}" + ); + assert!( + other_key.contains(&other_workspace) && !other_key.contains(&this_workspace), + "the other context's exact key must be bound to its workspace only: {other_key}" + ); + assert!( + other_grouping.contains(&other_workspace), + "the other context's grouping key must be bound to its workspace: {other_grouping}" + ); + assert_ne!(other_id, first_id, "transport incarnations stay distinct"); + assert!( + !other_force, + "force_prompt stays independent of the context binding" + ); + assert_eq!(other_manager.read().await.pending_child_approvals(), 0); + assert_eq!(manager.read().await.pending_child_approvals(), 0); } #[tokio::test] From 5420b3e0803874758f84be5399fc1e62c63876fc Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 16:25:11 +0800 Subject: [PATCH 25/29] fix(engine): keep baseline linkage across summary compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The summary compaction path replaces history with the local builder's layout (retained early user turns, bound last round, one checkpoint), so the role lists before and after no longer match and the layout check vacated the whole standing-baseline stack — even though the builder retains those turns deterministically. Editing a retained turn then popped no entry, and the replacement silently dropped the transition the truncated block had carried away. Thread the builder's origin map through CompactionResult (summary_origins: for each replacement slot, the pre-compaction index it was carried from; None for the authored checkpoint) and relocate standing-baseline entries through it at the manual, auto, and emergency compaction sites: a retained turn's entry follows its message to the new slot (plus the agent-topology shift), a dropped turn's entry goes away with its turn, and genuinely unmappable shapes keep vacating. Regression: ordinary no-tool A turn, host-armed B turn, manual summary compaction, then edit B — the captured replacement re-reports the A→B transition. Unit tests pin the origin map for plain rounds and for a second compaction whose mid-history checkpoint is dropped. Signed-off-by: asto --- crates/tui/src/compaction.rs | 65 ++++- crates/tui/src/compaction/last_round.rs | 116 ++++++++- crates/tui/src/core/engine.rs | 146 +++++++---- crates/tui/src/core/engine/tests.rs | 331 ++++++++++++++++++++++++ crates/tui/src/core/engine/turn_loop.rs | 37 +-- 5 files changed, 598 insertions(+), 97 deletions(-) diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 6c72aaa440..72044cdf41 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -1006,6 +1006,15 @@ pub struct CompactionResult { pub messages: Vec, /// Host-persistence copy of the history checkpoint. pub summary_prompt: Option, + /// The local summary builder's origin map for `messages` (see + /// `last_round::ReplacementHistory`): `Some` only when the summary path + /// authored the replacement, with `map[i]` naming the pre-compaction + /// history index `messages[i]` was carried from (`None` for the appended + /// checkpoint). `None` for every other shape — the mechanical PruneOnly + /// prune keeps every slot, so positional records stay exact there, and a + /// caller that cannot map a replacement slot-for-slot must treat its + /// positional bookkeeping as vacated. + pub summary_origins: Option>>, /// Number of retries used before success pub retries_used: u32, /// Last-round coverage for inspector receipts. @@ -1186,6 +1195,10 @@ pub async fn compact_messages_safe( return Ok(CompactionResult { messages: kept, summary_prompt: None, + // An in-place mechanical prune keeps every slot, so there is + // nothing to relocate (the layout check re-anchors the + // entries directly). + summary_origins: None, retries_used: 0, coverage, }); @@ -1218,7 +1231,7 @@ pub async fn compact_messages_safe( ) .await { - Ok((msgs, prompt, mut coverage)) => { + Ok((msgs, prompt, mut coverage, origins)) => { let kept = sanitize_retained_messages(msgs); last_round::validate_last_round_coverage(compaction_input, &kept)?; let keep: CompactionKeep = inspect_compaction_keep(&kept); @@ -1228,6 +1241,10 @@ pub async fn compact_messages_safe( return Ok(CompactionResult { messages: kept, summary_prompt: prompt, + // `sanitize_retained_messages` rewrites oversized blocks + // in place, so the builder's origin map still describes + // `kept` slot for slot. + summary_origins: Some(origins), retries_used: attempt.saturating_add(quality_retries), coverage, }); @@ -1273,9 +1290,25 @@ pub(crate) fn retained_user_messages( messages: &[Message], max_tokens: usize, ) -> Vec { + retained_user_messages_with_origins(checkpoints, messages, max_tokens).0 +} + +/// [`retained_user_messages`] plus each selected message's origin: the +/// parallel result carries, for every rebuilt message, the index it was +/// carried from in `messages`. The selection is value-preserving (newest +/// texts may be prefix-truncated to fit), so the origin is what a caller +/// needs to keep positional bookkeeping — e.g. the standing-baseline +/// entries the summary replacement relocates — aligned with the turn a +/// record was taken for. +pub(crate) fn retained_user_messages_with_origins( + checkpoints: crate::history_recognition::CheckpointEra, + messages: &[Message], + max_tokens: usize, +) -> (Vec, Vec>) { let mut selected: Vec = Vec::new(); + let mut origins: Vec> = Vec::new(); let mut remaining = max_tokens; - for msg in messages.iter().rev() { + for (index, msg) in messages.iter().enumerate().rev() { if remaining == 0 { break; } @@ -1306,9 +1339,11 @@ pub(crate) fn retained_user_messages( cache_control: None, }], }); + origins.push(Some(index)); } selected.reverse(); - selected + origins.reverse(); + (selected, origins) } /// User-pinned facts from `/anchor` (`.codewhale/anchors.md`). These are the @@ -1328,21 +1363,32 @@ async fn compact_messages( config: &CompactionConfig, ) -> Result<(Vec, Option, Vec)> { let mut quality_retries = 0; - let (messages, summary_prompt, _coverage) = + let (messages, summary_prompt, _coverage, _origins) = compact_messages_with_metadata(client, messages, config, &mut quality_retries, &mut None) .await?; Ok((messages, summary_prompt, Vec::new())) } +/// The summary path's result pieces: the replacement history, the host +/// checkpoint prompt, inspector coverage, and the builder's origin map (see +/// [`last_round::ReplacementHistory`]). `None` origins never leave this +/// function — every `Ok` return here is a builder-authored replacement. +type SummaryMetadata = ( + Vec, + Option, + CompactionCoverage, + Vec>, +); + async fn compact_messages_with_metadata( client: &dyn ModelClient, messages: &[Message], config: &CompactionConfig, quality_retries: &mut u32, summary_operation: &mut Option<(String, uuid::Uuid)>, -) -> Result<(Vec, Option, CompactionCoverage)> { +) -> Result { if messages.is_empty() { - return Ok((Vec::new(), None, CompactionCoverage::default())); + return Ok((Vec::new(), None, CompactionCoverage::default(), Vec::new())); } let summary = @@ -1357,23 +1403,24 @@ async fn compact_messages_with_metadata( }), }; - let retained = last_round::build_replacement_history( + let replacement = last_round::build_replacement_history( messages, &checkpoint_text, pinned_anchors_text(config.workspace.as_deref()).as_deref(), )?; let coverage = last_round::measure_coverage( messages, - &retained, + &replacement.messages, CompactionPath::Summary, pinned_anchors_text(config.workspace.as_deref()) .map(|text| text.chars().count()) .unwrap_or(0), ); Ok(( - retained, + replacement.messages, Some(SystemPrompt::Blocks(vec![summary_block])), coverage, + replacement.origins, )) } diff --git a/crates/tui/src/compaction/last_round.rs b/crates/tui/src/compaction/last_round.rs index 1bc3e5fe55..7d15283b60 100644 --- a/crates/tui/src/compaction/last_round.rs +++ b/crates/tui/src/compaction/last_round.rs @@ -18,8 +18,8 @@ use crate::runtime_handoff::UserTurnPromptKind; use super::{ COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS, compaction_checkpoint_message, - is_compaction_checkpoint_message, retained_user_messages, truncate_retained_block, - user_text_of, + is_compaction_checkpoint_message, retained_user_messages, retained_user_messages_with_origins, + truncate_retained_block, user_text_of, }; const LAST_ROUND_TOOL_RESULT_MAX_CHARS: usize = 8 * 1024; @@ -468,24 +468,51 @@ pub(super) fn measure_coverage( } } +/// The local summary builder's replacement history plus where every slot +/// came from: `origins[i]` is the index in the source history that +/// `messages[i]` was carried from, or `None` for a message the builder +/// authored itself (the appended checkpoint). The map is order-preserving +/// and injective, so positional bookkeeping recorded against the source +/// history — the standing-posture baseline entries — can follow a retained +/// turn to its new slot instead of being vacated with the rest of the +/// provider-unmappable layout. +pub(super) struct ReplacementHistory { + pub(super) messages: Vec, + pub(super) origins: Vec>, +} + pub(super) fn build_replacement_history( messages: &[Message], checkpoint_text: &str, anchors: Option<&str>, -) -> Result> { +) -> Result { let checkpoints = CheckpointEra::of_history(messages); let (start, end) = last_round_range(messages); - let mut retained = retained_user_messages( + let (mut retained, mut origins) = retained_user_messages_with_origins( checkpoints, &messages[..start], COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS, ); - retained.extend(bound_last_round(checkpoints, &messages[start..end])); + let round = &messages[start..end]; + // `bound_last_round` keeps every non-checkpoint slot of the round + // (rewriting oversized blocks in place), so each kept message's origin is + // its source index; the dropped checkpoints have no counterpart below. + for (offset, message) in round.iter().enumerate() { + if !checkpoints.is_checkpoint(message) { + origins.push(Some(start + offset)); + } + } + retained.extend(bound_last_round(checkpoints, round)); retained.push(compaction_checkpoint_message(&SystemPrompt::Text( checkpoint_text.to_string(), ))); + origins.push(None); + debug_assert_eq!(retained.len(), origins.len()); validate_survival_contract(messages, &retained, anchors)?; - Ok(retained) + Ok(ReplacementHistory { + messages: retained, + origins, + }) } #[cfg(test)] @@ -674,7 +701,8 @@ mod tests { let next = crate::compaction::build_compaction_summary_block_text("second summary", ""); let replacement = build_replacement_history(&messages, &next, None) - .expect("a pasted-header turn must not break or lose the round"); + .expect("a pasted-header turn must not break or lose the round") + .messages; assert!( replacement @@ -713,7 +741,8 @@ mod tests { let next = crate::compaction::build_compaction_summary_block_text("second summary", ""); let replacement = build_replacement_history(&messages, &next, None) - .expect("legacy loose recognition still replaces the bare carrier"); + .expect("legacy loose recognition still replaces the bare carrier") + .messages; assert!( replacement @@ -830,7 +859,8 @@ mod tests { let next = crate::compaction::build_compaction_summary_block_text("second summary", ""); let replacement = build_replacement_history(&messages, &next, None) - .expect("a quoting user turn must not break the round"); + .expect("a quoting user turn must not break the round") + .messages; assert!( replacement @@ -921,7 +951,9 @@ mod tests { let (start, end) = last_round_range(&messages); assert_eq!((start, end), (0, messages.len())); let next = crate::compaction::build_compaction_summary_block_text("new summary", ""); - let replacement = build_replacement_history(&messages, &next, None).unwrap(); + let replacement = build_replacement_history(&messages, &next, None) + .unwrap() + .messages; let texts: Vec<_> = replacement.iter().filter_map(user_text_of).collect(); assert!(texts.iter().any(|text| text == "Run the suite now.")); assert!(texts.iter().any(|text| text == "Explain the next step.")); @@ -957,7 +989,8 @@ mod tests { assert_eq!(last_round_start(&original), 0); let next = format!("{COMPACTION_SUMMARY_MARKER}: keep the failing test result"); let replaced = build_replacement_history(&original, &next, None) - .expect("toolless tails must not drop the last tool result"); + .expect("toolless tails must not drop the last tool result") + .messages; assert!(replaced.iter().any(|message| { message.content.iter().any(|block| { matches!( @@ -980,6 +1013,64 @@ mod tests { assert_eq!(last_round_start(&messages), 2); } + #[test] + fn replacement_history_reports_where_every_retained_slot_came_from() { + // Ordinary no-tool rounds: the builder retains the early user turn + // (rebuilt), keeps the latest round verbatim, and appends a + // checkpoint it authored. The origin map is what lets positional + // records — the standing-baseline entries — follow a retained turn + // to its new slot instead of being vacated with the layout. + let messages = vec![ + msg("user", "earlier question"), + msg("assistant", "earlier answer"), + msg("user", "latest question"), + msg("assistant", "latest answer"), + ]; + let next = crate::compaction::build_compaction_summary_block_text("handoff", ""); + let replacement = build_replacement_history(&messages, &next, None) + .expect("chat-only history must compact"); + assert_eq!(replacement.messages.len(), replacement.origins.len()); + assert_eq!( + replacement.origins, + vec![Some(0), Some(2), Some(3), None], + "retained prefix user, last-round slots, then the authored checkpoint" + ); + } + + #[test] + fn replacement_history_origins_skip_dropped_mid_history_checkpoints() { + // A second compaction: the history carries an old checkpoint inside + // the round range, which the builder drops. The origin map must have + // no slot for it, so records relocated through the map never land on + // a removed message. + let messages = vec![ + msg("user", "Run the suite now."), + tool_use("live", "Bash", json!({"command": "cargo test"})), + tool_result("live", "ok"), + checkpoint("first handoff: suite still running"), + msg("user", "next question"), + msg("assistant", "done"), + ]; + let next = crate::compaction::build_compaction_summary_block_text("second handoff", ""); + let replacement = build_replacement_history(&messages, &next, None) + .expect("a mid-history checkpoint must not break the round"); + assert_eq!(replacement.messages.len(), replacement.origins.len()); + assert_eq!( + replacement.origins, + vec![Some(0), Some(1), Some(2), Some(4), Some(5), None], + "the dropped checkpoint (source index 3) has no slot in the map" + ); + assert_eq!( + replacement + .messages + .iter() + .filter(|message| is_compaction_checkpoint_message(message)) + .count(), + 1, + "only the fresh checkpoint survives" + ); + } + #[derive(serde::Deserialize)] struct FixtureMatrix { schema_version: u32, @@ -1047,7 +1138,8 @@ mod tests { "{COMPACTION_SUMMARY_MARKER}: second handoff with User-pinned anchors (verbatim):\nship 0.9.12" ); let replaced = build_replacement_history(&first, &next, Some("ship 0.9.12")) - .expect("second compact must keep last round and one receipt"); + .expect("second compact must keep last round and one receipt") + .messages; let checkpoints = replaced .iter() .filter(|message| is_compaction_checkpoint_message(message)) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 8007073f56..53bb5368ef 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1139,12 +1139,18 @@ struct StandingPostureBaselineEntry { /// its message's new slot and drops entries whose message is gone — /// a survived turn keeps restoring its prior posture even when its /// index shifted; - /// - a replacement the engine did not author slot-by-slot (auto, - /// manual, and emergency compaction results; the emergency fallback - /// can also re-carry history through the agent-topology checkpoint - /// rewrite) cannot be rebased, so it vacates the stack — except when - /// the replacement is value-identical to the previous history, which - /// keeps every slot exact; + /// - a replacement the engine did not author slot-by-slot cannot be + /// rebased, so it vacates the stack — except when the replacement is + /// value-identical to the previous history (equal lists keep every + /// slot exact) or when it is the local summary builder's output, + /// which reports where every retained slot came from + /// (`CompactionResult::summary_origins`): a retained turn's entry + /// relocates to its message's new slot and a dropped turn's entry + /// goes away with the turn (see + /// `rebase_standing_baseline_after_compaction`; auto, manual, and + /// emergency compaction all share it, and the emergency fallback can + /// also re-carry history through the agent-topology checkpoint + /// rewrite); /// - a conversation replacement (`SyncSession`) clears the stack with /// the rest of the posture state. push_index: usize, @@ -1157,9 +1163,12 @@ struct StandingPostureBaselineEntry { /// ToolResult content in place and keep every slot, so recorded /// `push_index`es stay exact — a PruneOnly compaction that only pruned old /// tool output must keep the entries, or an edit of a surviving turn loses -/// the snapshot its block still needs. Provider-authored summaries merge or -/// drop turns, so those replacements cannot be mapped slot-for-slot and -/// vacate the stack instead (see the compaction call sites). +/// the snapshot its block still needs. The summary path shrinks the list, +/// so this check is false there and the compaction sites fall through to +/// the builder's origin map (`CompactionResult::summary_origins`): the +/// local builder reports where every retained slot came from, so retained +/// turns relocate instead of being vacated with the unmappable shapes (see +/// `rebase_standing_baseline_after_compaction`). fn standing_baseline_layout_preserved(replacement: &[Message], replaced: &[Message]) -> bool { replacement.len() == replaced.len() && replacement @@ -6726,23 +6735,13 @@ impl Engine { match compaction_result { Ok(mut result) => { if !result.messages.is_empty() || self.session.messages.is_empty() { - // Unified standing-baseline handling for history - // replacements the engine did not author slot-by-slot - // (see `StandingPostureBaselineEntry::push_index` and - // `standing_baseline_layout_preserved`): a provider- - // authored compaction result moves survivors to - // unknowable slots, so vacate the entries rather than - // guess a rebase — the same rationale as the - // auto-compaction path in core/engine/turn_loop.rs. - // Post-compaction turn builds re-establish their own - // entries. A local mechanical prune (PruneOnly) rewrites - // ToolResult content in place and keeps every slot, so - // the layout check keeps the entries anchored there; - // byte-identical replacements also keep equal slots. // Measured against the pre-topology replacement: the // checkpoint the topology step inserts below is // engine-authored slot bookkeeping whose shift is - // rebased onto the entries at replace time. + // rebased onto the entries at replace time. The + // re-anchoring itself is shared with the auto and + // emergency paths (see + // `rebase_standing_baseline_after_compaction`). let layout_preserved = standing_baseline_layout_preserved( &result.messages, &self.session.messages, @@ -6777,13 +6776,11 @@ impl Engine { let messages_after = result.messages.len(); let retries_used = result.retries_used; let coverage_clause = result.coverage.receipt_clause(); - if layout_preserved { - for entry in &mut self.standing_posture_baseline_history { - entry.push_index = topology_rebase.rebase_index(entry.push_index); - } - } else { - self.standing_posture_baseline_history.clear(); - } + self.rebase_standing_baseline_after_compaction( + layout_preserved, + result.summary_origins.as_deref(), + &topology_rebase, + ); self.session.replace_messages(result.messages); if let Some(pm) = self.session.prefix_stability.as_mut() { pm.note_history_reset("compaction"); @@ -7082,6 +7079,7 @@ impl Engine { let mut retries_used = 0u32; let mut summary_prompt = None; + let mut summary_origins = None; let mut compacted_messages: Vec = self.session.messages.clone().into(); let mut forced_config = self.config.compaction.clone(); @@ -7120,6 +7118,7 @@ impl Engine { retries_used = result.retries_used; compacted_messages = result.messages; summary_prompt = result.summary_prompt; + summary_origins = result.summary_origins; } Err(err) => { let _ = self @@ -7167,27 +7166,17 @@ impl Engine { return false; } // Unified standing-baseline handling for history replacements - // the engine did not author slot-by-slot (see - // `StandingPostureBaselineEntry::push_index` and - // `standing_baseline_layout_preserved`): whether the API pass - // produced a provider-authored replacement or the local fallback - // re-carried history through the agent-topology checkpoint - // rewrite (arbitrary retain/insert positions), survivors land in - // slots that no longer map onto the recorded indices, so vacate - // the entries rather than guess a rebase — the same rationale as - // the auto-compaction path in core/engine/turn_loop.rs. A local - // mechanical prune (PruneOnly) rewrites ToolResult content in - // place and keeps every slot, so the layout check keeps the - // entries anchored there; a fallback that left the history - // layout-identical also keeps equal slots, so a survived-turn - // edit still restores its prior posture. - if layout_preserved { - for entry in &mut self.standing_posture_baseline_history { - entry.push_index = topology_rebase.rebase_index(entry.push_index); - } - } else { - self.standing_posture_baseline_history.clear(); - } + // (see `rebase_standing_baseline_after_compaction`): whether the + // API pass produced a summary replacement, a mechanical prune, + // or — after an API failure — the local fallback re-carried + // history through the agent-topology checkpoint rewrite, the + // shared rebase keeps the recorded indices meaningful before + // the next edit reads them. + self.rebase_standing_baseline_after_compaction( + layout_preserved, + summary_origins.as_deref(), + &topology_rebase, + ); self.session.replace_messages(compacted_messages); } self.commit_compaction_checkpoint(summary_prompt); @@ -8617,6 +8606,61 @@ impl Engine { }; crate::runtime_handoff::replace_agent_topology_checkpoint(messages, &snapshots) } + + /// Re-anchor the standing-posture baseline entries onto a compaction + /// replacement (see `StandingPostureBaselineEntry::push_index`; the + /// manual, auto, and emergency paths all install replacements through + /// this). Three shapes: + /// - a layout-preserving replacement — the mechanical PruneOnly prune + /// rewrites ToolResult content in place, and the emergency fallback can + /// re-carry the history verbatim — keeps every slot, so each entry only + /// takes the agent-topology shift; + /// - a summary-path replacement is authored by the LOCAL builder + /// (`CompactionResult::summary_origins`), which reports where every + /// slot came from: an entry whose user message the builder retained + /// relocates to that message's new slot (then takes the topology + /// shift), and an entry whose message the builder dropped goes away + /// with its turn — no later edit can target it. Without the relocation + /// the retained turns would lose their linkage even though the builder + /// kept them deterministically: editing a retained turn would pop + /// nothing and silently drop the transition its truncated block + /// carried away; + /// - any other replacement (provider-authored content, or the emergency + /// fallback rewritten through the agent-topology checkpoint) moves + /// survivors to unknowable slots and vacates the stack — + /// post-compaction turn builds re-establish their own entries. + /// + /// `layout_preserved` and `summary_origins` must both be measured against + /// the pre-topology replacement (the shift `topology_rebase` describes is + /// applied to the re-anchored indices here). + fn rebase_standing_baseline_after_compaction( + &mut self, + layout_preserved: bool, + summary_origins: Option<&[Option]>, + topology_rebase: &crate::runtime_handoff::AgentTopologyCheckpointRebase, + ) { + if layout_preserved { + for entry in &mut self.standing_posture_baseline_history { + entry.push_index = topology_rebase.rebase_index(entry.push_index); + } + } else if let Some(origins) = summary_origins { + self.standing_posture_baseline_history = self + .standing_posture_baseline_history + .drain(..) + .filter_map(|entry| { + origins + .iter() + .position(|origin| *origin == Some(entry.push_index)) + .map(|index| StandingPostureBaselineEntry { + prior_posture: entry.prior_posture, + push_index: topology_rebase.rebase_index(index), + }) + }) + .collect(); + } else { + self.standing_posture_baseline_history.clear(); + } + } } fn default_plugin_tools_dir() -> PathBuf { diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 5fb8f19226..aef76fa3c2 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -20302,6 +20302,337 @@ async fn manual_prune_only_compaction_keeps_the_standing_baseline_entries() { ); } +/// JensenChen28 review 5747963791 (PR #131): the summary compaction path +/// replaces history with the LOCAL builder's layout — retained early user +/// turns, the bound last round, and one checkpoint — so the role lists +/// before and after no longer match and (pre-fix) the whole standing-baseline +/// stack was vacated even though the builder retained both turns +/// deterministically. After an ordinary no-tool A turn and a transition- +/// reporting B turn, a manual compaction with nothing to prune must keep the +/// retained turns' entries anchored: editing B then still restores its prior +/// (A) posture and the replacement re-reports the A→B transition the +/// truncated B block carried away. The captured-request count pins the +/// summary path: with no stale ToolResult to prune, the compaction makes +/// exactly one provider call — the summary request, which is non-streaming +/// and therefore served as chat-completions JSON rather than SSE. +#[tokio::test] +async fn manual_summary_compaction_keeps_the_retained_turn_linkage() { + use wiremock::matchers::{body_string_contains, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let _lock = lock_test_env(); + let tmp = tempdir().expect("tempdir"); + let server = MockServer::start().await; + + let sse_body = |id: &str, answer: &str| { + format!( + "data: {{\"id\":\"chatcmpl-{id}\",\"choices\":[{{\"index\":0,\ + \"delta\":{{\"content\":\"{answer}\"}},\"finish_reason\":null}}]}}\n\n\ + data: {{\"id\":\"chatcmpl-{id}\",\"choices\":[{{\"index\":0,\ + \"delta\":{{}},\"finish_reason\":\"stop\"}}]}}\n\n\ + data: [DONE]\n\n", + ) + }; + // First-match-wins, mirroring the prune regression. The summary request + // is the only one carrying the compaction prompt, so it is matched first + // and served as JSON. Its answer text must not contain that prompt text, + // or the retained checkpoint inside the later replacement request would + // match this mock again. + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("context checkpoint compaction")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "application/json") + .set_body_string( + r#"{"id":"chatcmpl-summary-fix","model":"deepseek-v4-pro","choices":[{"index":0,"message":{"role":"assistant","content":"handoff summary of the earlier turns"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}"#, + ), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("revise this committed input")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("summary-fix-revised", "Revised answer.")), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(body_string_contains("turn that reports the transition")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("summary-fix-b", "B answer.")), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(sse_body("summary-fix-a", "A answer.")), + ) + .expect(1) + .mount(&server) + .await; + + let api_config = Config { + api_key: Some("test-key".to_string()), + base_url: Some(server.uri()), + ..Config::default() + }; + // Auto compaction stays off so nothing triggers it mid-turn; the manual + // op below carries its own enabled config. + let mut config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + snapshots_enabled: false, + subagents_enabled: false, + compaction: CompactionConfig { + enabled: false, + ..CompactionConfig::default() + }, + ..Default::default() + }; + config.max_steps = 32; + let (engine, handle) = Engine::new(config, &api_config); + let run = tokio::spawn(engine.run()); + + // Turn A runs unarmed: it renders no transition and establishes the + // standing baseline. No tool calls anywhere, so there is nothing the + // mechanical pruner could candidate — the compaction below can only + // take the summary path. + handle + .send(Op::SendMessage { + content: "turn in the original posture".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig { + enabled: false, + ..CompactionConfig::default() + }), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the original-posture turn"); + wait_for_turn_complete(&handle).await; + + // Arm the approval posture, then run the B turn whose block reports the + // A→B transition into history. + handle + .send(Op::ChangeMode { + mode: AppMode::Agent, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + configured_sandbox_mode: None, + ask_on_guardrail: None, + }) + .await + .expect("send the approval posture change"); + handle + .send(Op::SendMessage { + content: "turn that reports the transition".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig { + enabled: false, + ..CompactionConfig::default() + }), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Auto, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send the transition-reporting turn"); + wait_for_turn_complete(&handle).await; + + // Manual compaction. Nothing prunable means compact_messages_safe falls + // through to the LLM summary pass, whose replacement the local builder + // authors: retained early user turns + last round + checkpoint. + handle + .send(Op::CompactContext { + id: "manual-summary-1".to_string(), + route: resolved_route_for_test(&api_config, "deepseek-v4-pro"), + compaction: Box::new(CompactionConfig { + enabled: true, + token_threshold: 5_000, + ..CompactionConfig::default() + }), + }) + .await + .expect("send manual compaction"); + { + let mut rx = handle.rx_event.write().await; + loop { + let event = tokio::time::timeout(model_turn_event_timeout(), rx.recv()) + .await + .expect("timed out waiting for the manual compaction") + .expect("engine event channel closed before compaction settled"); + if let Event::TurnComplete { + compaction_id: Some(id), + status, + .. + } = event + { + assert_eq!(id, "manual-summary-1", "the manual compaction must settle"); + assert_eq!( + status, + TurnOutcomeStatus::Completed, + "the summary pass must succeed: a Failed status means the \ + compaction left the history unchanged" + ); + break; + } + } + } + + // Precondition pins: the summary path actually ran and the builder + // retained both user turns verbatim. A's assistant output is gone + // (summarized away) — that loss of the A/B slot pairing is exactly why + // the role-list layout check cannot hold here. + let snapshot = { + let (tx, rx) = tokio::sync::oneshot::channel(); + handle + .send(Op::GetSessionSnapshot { + tx: std::sync::Arc::new(std::sync::Mutex::new(Some(tx))), + }) + .await + .expect("request snapshot"); + tokio::time::timeout(model_turn_event_timeout(), rx) + .await + .expect("snapshot response") + .expect("snapshot") + }; + { + let rendered = |snapshot: &crate::core::ops::SessionSnapshot| { + snapshot + .messages + .iter() + .flat_map(|message| message.content.iter()) + .filter_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join("\n") + }; + let history = rendered(&snapshot); + assert!( + history.contains("turn in the original posture"), + "the builder must retain the early A user turn: {history}" + ); + assert!( + history.contains("turn that reports the transition"), + "the builder must retain the latest B user turn verbatim" + ); + assert!( + history.contains("handoff summary of the earlier turns"), + "the builder must append the summary checkpoint" + ); + assert!( + !history.contains("A answer."), + "the summary path must have replaced A's assistant turn: the \ + layout can no longer match slot for slot" + ); + } + + // The selector's latest editable user prompt is the retained B turn + // build; its relocated entry must restore its prior (A) posture. + handle + .send(Op::EditLastTurn { + new_message: "revise this committed input".to_string(), + submission_id: Some("sub-edit-after-summary".to_string()), + }) + .await + .expect("send edit"); + wait_for_turn_complete(&handle).await; + + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run.await.expect("engine task"); + + let requests = server.received_requests().await.expect("recorded requests"); + assert_eq!( + requests.len(), + 4, + "two real turns, one summary call, and the replacement: an extra \ + request would mean the scenario did not run the shapes it pins" + ); + let wire_body = |request: &[u8]| String::from_utf8_lossy(request).to_string(); + assert!( + !wire_body(&requests[0].body).contains("Authority transition (host)"), + "the first reported turn must render no transition" + ); + assert!( + wire_body(&requests[1].body).contains("Authority transition (host)"), + "the turn after the posture flip must report the transition" + ); + assert!( + wire_body(&requests[2].body).contains("context checkpoint compaction"), + "the third request must be the compaction summary call" + ); + let replacement = wire_body(&requests[3].body); + assert!( + replacement.contains("revise this committed input"), + "the replacement must carry the edited turn text" + ); + assert!( + !replacement.contains("B answer."), + "the edit must have truncated the edited turn's suffix" + ); + assert!( + replacement.contains("turn in the original posture"), + "the summary must have kept the first user block" + ); + assert!( + replacement.contains("Authority transition (host)"), + "editing the retained B turn after a summary compaction must restore \ + its prior (A) and re-report the transition the truncated block \ + carried away" + ); +} + /// Drive the standing-posture-baseline edit scenario with non-authoritative /// runtime turns parked between the true user turns. /// diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 44d88a8bb8..c01e675186 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -1940,23 +1940,13 @@ impl Engine { Ok(mut result) => { // Only update if we got valid messages (never corrupt state) if !result.messages.is_empty() || self.session.messages.is_empty() { - // Distinguish the two replacement shapes (see - // `standing_baseline_layout_preserved`): a local - // mechanical prune rewrites ToolResult content in - // place and keeps every slot, so the recorded - // indices stay exact and the entries survive; a - // provider-authored summary merges or drops turns - // into unknowable slots, so the entries are - // vacated instead of guessed — post-compaction - // turn builds re-establish their own entries - // immediately, and an edit that targets a - // pre-compaction survivor recomputes against the - // unchanged standing baseline rather than - // restoring a misattributed snapshot. Measured - // against the pre-topology replacement: the - // checkpoint the topology step inserts below is - // engine-authored slot bookkeeping whose shift is - // rebased onto the entries at replace time. + // Measured against the pre-topology replacement: + // the checkpoint the topology step inserts below + // is engine-authored slot bookkeeping whose + // shift is rebased onto the entries at replace + // time. The re-anchoring itself is shared with + // the manual and emergency paths (see + // `rebase_standing_baseline_after_compaction`). let layout_preserved = standing_baseline_layout_preserved( &result.messages, &self.session.messages, @@ -1984,14 +1974,11 @@ impl Engine { let auto_messages_after = result.messages.len(); let retries_used = result.retries_used; let coverage_clause = result.coverage.receipt_clause(); - if layout_preserved { - for entry in &mut self.standing_posture_baseline_history { - entry.push_index = - topology_rebase.rebase_index(entry.push_index); - } - } else { - self.standing_posture_baseline_history.clear(); - } + self.rebase_standing_baseline_after_compaction( + layout_preserved, + result.summary_origins.as_deref(), + &topology_rebase, + ); self.session.replace_messages(result.messages); turn.clear_parent_input_tokens(); if let Some(pm) = self.session.prefix_stability.as_mut() { From fec1193f2fc277cf055c9e957ec1837fc913603a Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 16:25:17 +0800 Subject: [PATCH 26/29] fix(engine): pin the prune fixture's ambient system prompt zhuowp review 5748136802: the PruneOnly regression failed on Windows with the stale output still at full size. The prune accounting is pure byte math and platform-independent; what varies is the session system prompt the pressure estimate counts. Without a pinned prefix, the submit-turn refresh composes the default prompt from the real user environment (memory, skills, plugins, platform lines), and on that machine the ambient term pushed the post-prune estimate over the fixture's 5K threshold: the PruneOnly branch was skipped for the LLM summary pass, which the SSE-only mocks cannot serve, so the compaction failed and left the history unchanged. Pin a small fixed system prompt via SyncSession's system_prompt_override so both threshold crossings are machine-independent (the stale output alone estimates ~18K tokens, over the threshold; the pruned remainder stays ~300 tokens, far under it), and record the sizing gotcha in the fixture's docs. Signed-off-by: asto --- crates/tui/src/core/engine/tests.rs | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index aef76fa3c2..29daed79a3 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -19954,6 +19954,20 @@ async fn purge_then_edit_restores_the_standing_posture_baseline() { /// truncated B block carried away. The captured-request count also proves /// the compaction took the mechanical prune path: PruneOnly makes no /// provider call, so only the two real turns and the replacement appear. +/// +/// The fixture pins a small fixed system prompt (`system_prompt_override`): +/// without it, the submit-turn refresh composes the default prompt from the +/// real user environment (memory, skills, plugins, platform lines), whose +/// size differs per machine — zhuowp review 5748136802: on Windows the +/// ambient term pushed the post-prune pressure estimate over the compaction +/// threshold, the PruneOnly branch was skipped for the LLM summary pass +/// (which these SSE-only mocks cannot serve), and the compaction failed +/// leaving the stale output unpruned. The prune accounting itself is pure +/// byte math; only the environment-composed prompt varies. With the prompt +/// pinned, both threshold crossings are machine-independent: the stale +/// output alone estimates ~18K tokens (over the 5K threshold), and the +/// pruned remainder stays ~300 tokens — far under any threshold this +/// fixture or a reasonable estimate could set. #[tokio::test] async fn manual_prune_only_compaction_keeps_the_standing_baseline_entries() { use wiremock::matchers::{body_string_contains, method, path}; @@ -20062,8 +20076,14 @@ async fn manual_prune_only_compaction_keeps_the_standing_baseline_entries() { .send(Op::SyncSession { session_id: Some("prune-baseline-test".to_string()), messages: seeded_messages, - system_prompt: None, - system_prompt_override: false, + // Pin the stable prefix: the prune/stop checks estimate the + // session system prompt too, so an environment-composed default + // (machine-dependent size) would make the threshold crossing — + // and with it the PruneOnly branch itself — platform-dependent. + system_prompt: Some(SystemPrompt::Text( + "prune-baseline stable system prompt".to_string(), + )), + system_prompt_override: true, model: "deepseek-v4-pro".to_string(), workspace: tmp.path().to_path_buf(), workspace_roots: Vec::new(), From 93c3aa27921ff48b642ebdcff935546a657ae122 Mon Sep 17 00:00:00 2001 From: asto Date: Tue, 3 Nov 2026 18:05:00 +0800 Subject: [PATCH 27/29] fix(tui): drop the stale retained_user_messages import from last_round The summary-compaction baseline fix switched the coverage floor's production call to retained_user_messages_with_origins, but left the plain retained_user_messages import in last_round's top-level use block; its only remaining readers are the coverage-floor regression tests. Under the workspace's deny-warnings lint the lib target no longer compiles, which took down every parent-repo CI lane that builds codewhale-tui on the round-15 pin. Move the name to the test module's existing crate::compaction use block; no behavior change. Signed-off-by: asto --- crates/tui/src/compaction/last_round.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/compaction/last_round.rs b/crates/tui/src/compaction/last_round.rs index 7d15283b60..8759a8f69e 100644 --- a/crates/tui/src/compaction/last_round.rs +++ b/crates/tui/src/compaction/last_round.rs @@ -18,8 +18,8 @@ use crate::runtime_handoff::UserTurnPromptKind; use super::{ COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS, compaction_checkpoint_message, - is_compaction_checkpoint_message, retained_user_messages, retained_user_messages_with_origins, - truncate_retained_block, user_text_of, + is_compaction_checkpoint_message, retained_user_messages_with_origins, truncate_retained_block, + user_text_of, }; const LAST_ROUND_TOOL_RESULT_MAX_CHARS: usize = 8 * 1024; @@ -518,7 +518,9 @@ pub(super) fn build_replacement_history( #[cfg(test)] mod tests { use super::*; - use crate::compaction::{COMPACTION_SUMMARY_MARKER, compaction_checkpoint_message}; + use crate::compaction::{ + COMPACTION_SUMMARY_MARKER, compaction_checkpoint_message, retained_user_messages, + }; use crate::models::{ContentBlock, Role}; use serde_json::json; From eea741fcdd1d0eedeebea8cdec74e750a736cdb9 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Tue, 3 Nov 2026 18:36:46 +0800 Subject: [PATCH 28/29] fix(engine): prefer summary origins over positional layout in baseline rebase The shared standing-baseline rebase checked `layout_preserved` before `summary_origins`, so a local-builder Summary replacement that coincidentally matched the replaced history's length and role sequence was trusted as slot-preserving. Equal length plus equal roles does not mean slot-preserving: the builder can move a retained message across same-role slots (a tool round followed by an old checkpoint and the B user turn, replaced by tool round, B, and a fresh checkpoint, slides B from slot 4 to 3 while the checkpoint takes 4). The entry bound to B then stayed at slot 4, and editing B truncated from the checkpoint instead, losing the A->B transition the entry existed to restore. Reorder the branches so an available origin graph always relocates the entries; positional preservation remains only for origin-less mechanical paths (PruneOnly prune, emergency verbatim re-carry) where slot identity holds by construction. All three call sites (manual, auto, emergency) already pass both arguments. Adds a direct regression for the equal-role Summary shape and pins the PruneOnly positional behavior. Signed-off-by: asto18089 --- crates/tui/src/core/engine.rs | 26 ++-- crates/tui/src/core/engine/tests.rs | 216 ++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 53bb5368ef..ce260058ef 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -8610,11 +8610,12 @@ impl Engine { /// Re-anchor the standing-posture baseline entries onto a compaction /// replacement (see `StandingPostureBaselineEntry::push_index`; the /// manual, auto, and emergency paths all install replacements through - /// this). Three shapes: - /// - a layout-preserving replacement — the mechanical PruneOnly prune - /// rewrites ToolResult content in place, and the emergency fallback can - /// re-carry the history verbatim — keeps every slot, so each entry only - /// takes the agent-topology shift; + /// this). Branch precedence: an available origin graph always wins — + /// equal length and equal roles do not make a replacement + /// slot-preserving, because the local builder can move a retained + /// message across same-role slots, so a Summary replacement relocates + /// through its origin map even when it coincidentally matches the old + /// layout. The shapes, in precedence order: /// - a summary-path replacement is authored by the LOCAL builder /// (`CompactionResult::summary_origins`), which reports where every /// slot came from: an entry whose user message the builder retained @@ -8625,6 +8626,11 @@ impl Engine { /// kept them deterministically: editing a retained turn would pop /// nothing and silently drop the transition its truncated block /// carried away; + /// - a layout-preserving replacement on an origin-less mechanical path — + /// the PruneOnly prune rewrites ToolResult content in place, and the + /// emergency fallback can re-carry the history verbatim — keeps every + /// slot by construction, so each entry only takes the agent-topology + /// shift; /// - any other replacement (provider-authored content, or the emergency /// fallback rewritten through the agent-topology checkpoint) moves /// survivors to unknowable slots and vacates the stack — @@ -8639,11 +8645,7 @@ impl Engine { summary_origins: Option<&[Option]>, topology_rebase: &crate::runtime_handoff::AgentTopologyCheckpointRebase, ) { - if layout_preserved { - for entry in &mut self.standing_posture_baseline_history { - entry.push_index = topology_rebase.rebase_index(entry.push_index); - } - } else if let Some(origins) = summary_origins { + if let Some(origins) = summary_origins { self.standing_posture_baseline_history = self .standing_posture_baseline_history .drain(..) @@ -8657,6 +8659,10 @@ impl Engine { }) }) .collect(); + } else if layout_preserved { + for entry in &mut self.standing_posture_baseline_history { + entry.push_index = topology_rebase.rebase_index(entry.push_index); + } } else { self.standing_posture_baseline_history.clear(); } diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 29daed79a3..abc09f40b3 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -20653,6 +20653,222 @@ async fn manual_summary_compaction_keeps_the_retained_turn_linkage() { ); } +/// Build one plain user text message (the role every standing-baseline +/// entry's push_index points at). +#[cfg(test)] +fn baseline_user_text(text: &str) -> Message { + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } +} + +/// JensenChen28 review 5749580324 (PR #131): the shared rebase checked +/// `layout_preserved` before `summary_origins`, so a local-builder Summary +/// replacement that coincidentally matched the replaced history's length and +/// role sequence was trusted as slot-preserving even though the builder had +/// moved a retained message across same-role slots. The pinned shape: a +/// tool round, an old handoff checkpoint, and the B user turn are replaced +/// by the builder's layout — tool round, B, fresh checkpoint — so B slides +/// from slot 4 to 3 while the fresh checkpoint takes 4. Roles stay +/// `[user, assistant, user, user, user]` on both sides, so the positional +/// check held and (pre-fix) shadowed the builder's origin map: the entry +/// bound to B stayed at slot 4, and editing B truncated from the checkpoint +/// instead, losing the A→B transition the entry existed to restore. The +/// helper is exercised directly because the coincidental equal-role layout +/// cannot be produced deterministically through a provider mock. +#[test] +fn summary_origins_beat_an_equal_role_layout_in_the_baseline_rebase() { + let _lock = lock_test_env(); + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + + let replaced = vec![ + baseline_user_text("turn A in the original posture"), + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "tool-a".to_string(), + name: "read_file".to_string(), + input: serde_json::json!({"path": "probe.txt"}), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-a".to_string(), + content: "tool output from the A round".to_string(), + is_error: None, + content_blocks: None, + }], + }, + baseline_user_text("superseded handoff checkpoint"), + baseline_user_text("turn B that reports the transition"), + ]; + let replacement = vec![ + baseline_user_text("turn A in the original posture"), + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "tool-a".to_string(), + name: "read_file".to_string(), + input: serde_json::json!({"path": "probe.txt"}), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-a".to_string(), + content: "tool output from the A round".to_string(), + is_error: None, + content_blocks: None, + }], + }, + baseline_user_text("turn B that reports the transition"), + baseline_user_text("fresh handoff checkpoint"), + ]; + let origins = [Some(0), Some(1), Some(2), Some(4), None]; + let layout_preserved = standing_baseline_layout_preserved(&replacement, &replaced); + assert!( + layout_preserved, + "fixture must pin the coincidental equal-length equal-role shape the \ + positional check would trust" + ); + // The topology checkpoint appends after plain text history, so the + // rebase shift must be identity on these slots: any movement below comes + // from the branch under test, not from the topology bookkeeping. + let topology_rebase = { + let mut scratch = replacement.clone(); + crate::runtime_handoff::replace_agent_topology_checkpoint(&mut scratch, &[]) + }; + assert_eq!(topology_rebase.rebase_index(3), 3); + assert_eq!(topology_rebase.rebase_index(4), 4); + engine.standing_posture_baseline_history = vec![StandingPostureBaselineEntry { + prior_posture: Some(TurnMetaPosture { + mode: AppMode::Agent, + approval: crate::tui::approval::ApprovalMode::Suggest, + ask_on_guardrail: false, + }), + push_index: 4, + }]; + + engine.rebase_standing_baseline_after_compaction( + layout_preserved, + Some(&origins), + &topology_rebase, + ); + + assert_eq!( + engine.standing_posture_baseline_history.len(), + 1, + "the builder retained the B turn, so its entry must survive" + ); + let entry = &engine.standing_posture_baseline_history[0]; + assert_eq!( + entry.push_index, 3, + "the entry bound to B's old slot must follow B's origin to its NEW \ + slot, not the coincidentally role-equal slot 4 (which now holds the \ + checkpoint the builder inserted)" + ); + assert!( + entry.prior_posture.is_some(), + "the relocated entry must keep the prior posture it restores on edit" + ); +} + +/// The PruneOnly side of review 5749580324's fix must stay positional: an +/// origin-less mechanical replacement (the prune rewrites ToolResult content +/// in place, equal length, equal roles) keeps every slot by construction, so +/// an entry at slot 4 stays at 4. Pinning this proves the precedence swap +/// only reroutes builder-authored Summary replacements and leaves the +/// origin-less paths untouched. +#[test] +fn originless_layout_preserved_replacement_keeps_positional_rebase() { + let _lock = lock_test_env(); + let (mut engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); + + let tool_use = || { + vec![ContentBlock::ToolUse { + id: "tool-a".to_string(), + name: "read_file".to_string(), + input: serde_json::json!({"path": "probe.txt"}), + caller: None, + thought_signature: None, + }] + }; + let replaced = vec![ + baseline_user_text("turn A in the original posture"), + Message { + role: Role::Assistant, + content: tool_use(), + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-a".to_string(), + content: "stale pruned tool output".to_string(), + is_error: None, + content_blocks: None, + }], + }, + baseline_user_text("superseded handoff checkpoint"), + baseline_user_text("turn B that reports the transition"), + ]; + // The mechanical prune rewrites the ToolResult in place; slots and roles + // are untouched. + let replacement = vec![ + baseline_user_text("turn A in the original posture"), + Message { + role: Role::Assistant, + content: tool_use(), + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "tool-a".to_string(), + content: "pruned".to_string(), + is_error: None, + content_blocks: None, + }], + }, + baseline_user_text("superseded handoff checkpoint"), + baseline_user_text("turn B that reports the transition"), + ]; + let layout_preserved = standing_baseline_layout_preserved(&replacement, &replaced); + assert!( + layout_preserved, + "fixture must pin the prune's in-place layout" + ); + let topology_rebase = { + let mut scratch = replacement.clone(); + crate::runtime_handoff::replace_agent_topology_checkpoint(&mut scratch, &[]) + }; + assert_eq!(topology_rebase.rebase_index(4), 4); + engine.standing_posture_baseline_history = vec![StandingPostureBaselineEntry { + prior_posture: None, + push_index: 4, + }]; + + engine.rebase_standing_baseline_after_compaction(layout_preserved, None, &topology_rebase); + + assert_eq!( + engine.standing_posture_baseline_history.len(), + 1, + "the mechanical prune kept every slot, so the entry must survive" + ); + assert_eq!( + engine.standing_posture_baseline_history[0].push_index, 4, + "an origin-less layout-preserving replacement must keep positional \ + rebase: slot 4 stays slot 4" + ); +} + /// Drive the standing-posture-baseline edit scenario with non-authoritative /// runtime turns parked between the true user turns. /// From 0583e19cb79e50e757c2e6b1638207960b9057ec Mon Sep 17 00:00:00 2001 From: asto Date: Wed, 4 Nov 2026 06:19:24 +0800 Subject: [PATCH 29/29] fix(tui): answer session grants on the event-carried grouping key zhuowp review 5759493820 / JensenChen28 review 5759735821 (P2): the session-grant read side queries `approval_session_approved` with the engine-bound grouping key carried on the ApprovalRequired event, but the approval dialog recomputed the grouping identity from the parent workspace. An "approve for session" on a subagent request then stored the recomputed identity: the next identical sub-workspace request missed it and prompted again, and the grant lived under an identity the user never saw. Carry the event's grouping key into the dialog verbatim (`push_approval_request_view` -> `ApprovalRequest::new_with_intent` override) so the decision emission and the session-grant write answer under the same identity the read side queries. Exact-deny keys, the Summary origins fix, and force_prompt independence are unchanged. Regression: a real card round-trip ('2' -> ApproveForSession) on a workspace-bound subagent event asserts the emitted and stored identity equal the event key, that the parent-recomputed identity is absent, same-scope family reuse auto-approves, a different sub-workspace still prompts, and a forced hold stays on the card. Red-checked by reintroducing the parent recompute (the card answered `shell:10:cargo test` instead of the bound identity) and green on the fix; an earlier red attempt via a bare `None` was a compile error, not a test failure, and was discarded. Suites: `tui::approval` 114/0, `tui::ui::tests::approval` 7/0, `tui::widgets::tests::approval` 18/0, `core::engine` 696/0; fmt clean. Workspace clippy in the CI shape reports only the known pre-existing base-lineage `collapsible_if` at `turn_loop.rs` (eviction-notes site, present on merge-base f1eb10efc; disclosed, not fixed here). Signed-off-by: asto18089 --- crates/tui/src/tui/approval.rs | 24 ++++- crates/tui/src/tui/approval/tests.rs | 2 + crates/tui/src/tui/ui/event_loop.rs | 1 + crates/tui/src/tui/ui/overlays.rs | 7 ++ crates/tui/src/tui/ui/tests.rs | 141 +++++++++++++++++++++++++++ crates/tui/src/tui/widgets/mod.rs | 3 + 6 files changed, 175 insertions(+), 3 deletions(-) diff --git a/crates/tui/src/tui/approval.rs b/crates/tui/src/tui/approval.rs index 89f0a74eb8..bd6560c29a 100644 --- a/crates/tui/src/tui/approval.rs +++ b/crates/tui/src/tui/approval.rs @@ -165,10 +165,21 @@ impl ApprovalRequest { params, approval_key, None, + None, Path::new("/workspace"), ) } + /// `approval_grouping_key_override` carries the grouping identity the + /// engine already bound on the `ApprovalRequired` event. Subagent workers + /// mint workspace-scoped keys (`bind_workspace_scope` over the effective + /// execution root), and the read side (`resolve_ui_approval_disposition`) + /// queries session grants with that carried value — so the dialog and the + /// "approve for session" write must use the same identity. Recomputing + /// from the dialog's (parent) workspace would diverge from the read key, + /// re-prompt for the very scope the user approved and store a grant under + /// an identity the user never saw. Pass `None` only for flows that never + /// ride an engine event (render tests and the `#[cfg(test)]` helper). pub fn new_with_intent( id: &str, tool_name: &str, @@ -176,14 +187,21 @@ impl ApprovalRequest { params: &Value, approval_key: &str, intent_summary: Option<&str>, + approval_grouping_key_override: Option<&str>, workspace: &Path, ) -> Self { let semantic_tool_name = canonical_action_alias(tool_name, params); let category = get_tool_category_for_call(tool_name, params); let risk = classify_risk(tool_name, category, params); - let approval_grouping_key = - crate::tools::approval_cache::build_approval_grouping_key(tool_name, params, workspace) - .0; + let approval_grouping_key = match approval_grouping_key_override { + Some(carried) => carried.to_string(), + None => { + crate::tools::approval_cache::build_approval_grouping_key( + tool_name, params, workspace, + ) + .0 + } + }; let persistent_ask_rules = build_persistent_ask_rules(semantic_tool_name, params, workspace); let persistent_allow_rules = if classify_stakes(tool_name, category, risk, params) diff --git a/crates/tui/src/tui/approval/tests.rs b/crates/tui/src/tui/approval/tests.rs index 938cf102c8..5c0d6e4065 100644 --- a/crates/tui/src/tui/approval/tests.rs +++ b/crates/tui/src/tui/approval/tests.rs @@ -2543,6 +2543,7 @@ fn canonical_bash_keeps_original_name_but_uses_shell_approval_semantics() { &json!({"action": "run", "command": "cargo test", "cwd": "/workspace"}), "tool:Bash", None, + None, Path::new("/workspace"), ); @@ -2604,6 +2605,7 @@ fn canonical_file_mutations_get_legacy_previews_and_scoped_ask_rules() { ¶ms, "tool:File", None, + None, Path::new("/workspace"), ); assert_eq!(request.tool_name, "File", "{action}"); diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index 321903379b..9550c7bdc2 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -3504,6 +3504,7 @@ pub(crate) async fn run_event_loop( &description, &tool_input, &approval_key, + &approval_grouping_key, intent_summary.as_deref(), config.approval_default_selection(), ); diff --git a/crates/tui/src/tui/ui/overlays.rs b/crates/tui/src/tui/ui/overlays.rs index 39af396ea4..2b84e52a96 100644 --- a/crates/tui/src/tui/ui/overlays.rs +++ b/crates/tui/src/tui/ui/overlays.rs @@ -488,9 +488,15 @@ pub(crate) fn push_approval_request_view( description: &str, tool_input: &serde_json::Value, approval_key: &str, + approval_grouping_key: &str, intent_summary: Option<&str>, default_selection: crate::config::ApprovalDefaultSelection, ) { + // Carry the event's grouping identity verbatim (see + // `ApprovalRequest::new_with_intent`): the session-grant read side queries + // with the engine-bound key, so this dialog and the "approve for session" + // write it produces must answer under the same identity, not one + // recomputed from the parent workspace. let request = ApprovalRequest::new_with_intent( id, tool_name, @@ -498,6 +504,7 @@ pub(crate) fn push_approval_request_view( tool_input, approval_key, intent_summary, + Some(approval_grouping_key), &app.workspace, ); app.view_stack diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 9bd87bbfe0..9265a0963a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -20833,6 +20833,7 @@ fn approval_prompt_uses_event_input_after_message_complete_drain() { "Run cargo tests", &event_input, "approval-key", + "approval-group", None, crate::config::ApprovalDefaultSelection::Deny, ); @@ -20866,6 +20867,7 @@ fn approval_prompt_uses_configured_default_selection() { "Run a trusted command", &serde_json::json!({"command": "cargo check"}), "approval-key", + "approval-group", None, crate::config::ApprovalDefaultSelection::AllowOnce, ); @@ -20884,6 +20886,144 @@ fn approval_prompt_uses_configured_default_selection() { )); } +/// Real TUI approval round-trip for a subagent-originated request +/// (zhuowp review 5759493820 / JensenChen28 review 5759735821): the +/// session-grant read side queries with the engine-bound grouping key from +/// the `ApprovalRequired` event, so the dialog and the "approve for session" +/// write must answer under that same identity. Recomputing the grouping key +/// from the parent dialog workspace stored a grant the next identical +/// sub-workspace request could never hit (repeat prompting) and under an +/// identity the user never saw. +#[tokio::test] +async fn session_grant_round_trips_on_the_event_carried_grouping_key() { + use crate::core::authority::ApprovalRequestDisposition; + use crate::tools::approval_cache::{ + bind_workspace_scope, build_approval_grouping_key, build_approval_key, + }; + + let tmp = TempDir::new().expect("tempdir"); + let parent_ws = tmp.path().join("parent"); + let sub_ws_a = tmp.path().join("sub-a"); + let sub_ws_b = tmp.path().join("sub-b"); + for path in [&parent_ws, &sub_ws_a, &sub_ws_b] { + std::fs::create_dir_all(path).expect("workspaces"); + } + let mut app = create_test_app(); + app.mode = AppMode::Agent; + app.approval_mode = ApprovalMode::Suggest; + app.workspace = parent_ws.clone(); + + let input = serde_json::json!({"command": "cargo test -p codewhale-tui"}); + + // Mirror the subagent producer (tools/subagent): the engine binds both + // identities to the effective execution workspace before emitting the + // ApprovalRequired event. + let bound_keys = |workspace: &std::path::Path| { + let approval_key = + bind_workspace_scope(build_approval_key("exec_shell", &input), workspace).0; + let grouping = bind_workspace_scope( + build_approval_grouping_key("exec_shell", &input, workspace), + workspace, + ) + .0; + (approval_key, grouping) + }; + let (key_a, grouping_a) = bound_keys(&sub_ws_a); + let (key_b, grouping_b) = bound_keys(&sub_ws_b); + assert_ne!(grouping_a, grouping_b, "distinct scopes, distinct identity"); + + // First request from sub-a: nothing granted yet → the real card. + assert_eq!( + resolve_ui_approval_disposition(&app, "exec_shell", &grouping_a, &key_a, false), + ApprovalRequestDisposition::Prompt + ); + + push_approval_request_view( + &mut app, + "tool-a", + "exec_shell", + "Run tests in the sub workspace", + &input, + &key_a, + &grouping_a, + None, + crate::config::ApprovalDefaultSelection::Deny, + ); + let mut view = app.view_stack.pop().expect("approval view"); + let approval = view + .as_any_mut() + .downcast_mut::() + .expect("approval view"); + // '2' commits ApproveAlways → "approve for this session" (direct + // shortcut, independent of the card's option order). + let action = approval.handle_key(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE)); + let ViewAction::EmitAndClose(ViewEvent::ApprovalDecision { + decision, + approval_grouping_key, + .. + }) = action + else { + panic!("expected an approval decision from the card"); + }; + assert_eq!( + decision, + crate::tui::approval::ReviewDecision::ApprovedForSession + ); + assert_eq!( + approval_grouping_key, grouping_a, + "the card must answer under the event-carried grouping identity, not one\ + recomputed from the parent workspace" + ); + + // Feed the decision through the production writer (the same shape + // handlers.rs forwards from the view event). + let mut config = Config::default(); + let mut engine = mock_engine_handle(); + apply_approval_decision( + &mut app, + &mut engine.handle, + &mut config, + ApprovalDecisionEvent { + tool_id: "tool-a".to_string(), + tool_name: "exec_shell".to_string(), + decision: crate::tui::approval::ReviewDecision::ApprovedForSession, + timed_out: false, + approval_key: key_a.clone(), + approval_grouping_key: grouping_a.clone(), + persistent_rules: Vec::new(), + }, + ) + .await; + assert!(app.approval_session_approved.contains(&grouping_a)); + let parent_recomputed = build_approval_grouping_key("exec_shell", &input, &parent_ws).0; + assert_ne!( + grouping_a, parent_recomputed, + "the subagent binding must differ from a parent recompute for this fixture" + ); + assert!( + !app.approval_session_approved.contains(&parent_recomputed), + "the stored grant must not live under the parent-recomputed identity" + ); + + // Same sub workspace, same operation family: the grant is reused — no + // second prompt for the scope the user just approved. + assert_eq!( + resolve_ui_approval_disposition(&app, "exec_shell", &grouping_a, &key_a, false), + ApprovalRequestDisposition::AutoApprove + ); + // A different sub workspace runs the same family under a different + // identity: still prompts. + assert_eq!( + resolve_ui_approval_disposition(&app, "exec_shell", &grouping_b, &key_b, false), + ApprovalRequestDisposition::Prompt + ); + // A forced hold stays on the card even for the approved key. + assert_eq!( + resolve_ui_approval_disposition(&app, "exec_shell", &grouping_a, &key_a, true), + ApprovalRequestDisposition::Prompt + ); +} + #[test] fn patch_approval_modal_does_not_displace_the_active_file_receipt() { let mut app = create_test_app(); @@ -20901,6 +21041,7 @@ fn patch_approval_modal_does_not_displace_the_active_file_receipt() { "Apply a file patch", &input, "approval-key", + "approval-group", None, crate::config::ApprovalDefaultSelection::Deny, ); diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index a561be602a..af67d476ae 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -8280,6 +8280,7 @@ mod tests { Some( "Clearing stale build artifacts and temp files before a fresh run so the next build is reproducible.", ), + None, std::path::Path::new("/tmp/project"), ); let view = crate::tui::approval::ApprovalView::new(request.clone()); @@ -8644,6 +8645,7 @@ diff --git a/src/b.rs b/src/b.rs\n\ }), "exec_shell:cargo", Some("Need to verify the fallback build path before editing files."), + None, std::path::Path::new("/tmp/project"), ); let view = crate::tui::approval::ApprovalView::new(request.clone()); @@ -8673,6 +8675,7 @@ diff --git a/src/b.rs b/src/b.rs\n\ }), "exec_shell:cargo-clippy", Some("Confirmed - passes in isolation, so this is the documentation gate."), + None, std::path::Path::new("/Volumes/VIXinSSD/codewhale"), ); let view = crate::tui::approval::ApprovalView::new(request.clone());