Skip to main content

bamboo_server/connect/
approvals.rs

1//! NeedsHuman/QuestionDialog → buttons/text replies → respond path (epic
2//! #447's planned `approvals.rs`, issue #458).
3//!
4//! Three concerns live here:
5//! - Rendering a [`PendingAsk`][render_pending_ask_type] as an outbound
6//!   message (buttons when the platform supports them, always ALSO a
7//!   numbered text list — text replies are first-class on every platform).
8//! - Matching an inbound text reply or button `callback_data` against a
9//!   [`ParkedAsk`], including the binary-ask keyword mapping
10//!   (允许/yes/allow vs deny/no).
11//! - The [`Responder`] seam: the ONLY resolution path is
12//!   `bamboo_engine::session_app::respond::submit_pending_response` followed
13//!   by `resume::resume_session_execution` — exactly what
14//!   `POST /sessions/{id}/respond` does. [`EngineResponder`] is the
15//!   production implementation (in-proc, via [`super::bridge::ConnectContext`]);
16//!   tests inject a fake instead of standing up a full `AppState`.
17//!
18//! [render_pending_ask_type]: crate::connect::render::PendingAsk
19
20use std::sync::Arc;
21
22use tokio::sync::broadcast;
23
24use bamboo_agent_core::tools::{ToolCall, ToolExecutionContext};
25use bamboo_agent_core::{AgentEvent, Session};
26use bamboo_engine::execution::{
27    create_event_forwarder, get_or_create_event_sender, reserve_session_execution,
28    SessionExecutionReserveOutcome,
29};
30use bamboo_engine::runtime::execution::agent_spawn::{
31    spawn_session_execution, SessionExecutionArgs,
32};
33use bamboo_engine::session_app::approval_replay::{
34    refresh_approval_replay_posture, ApprovalReplayDecision,
35};
36use bamboo_engine::session_app::resolution::resolve_resume_config_snapshot;
37use bamboo_engine::session_app::respond::{
38    submit_pending_response, PERMISSION_REEXECUTE_METADATA_KEY,
39};
40use bamboo_engine::session_app::resume::{
41    resume_session_execution, ResumeExecutionPort, ResumeSpawnRequest,
42};
43use bamboo_engine::session_app::types::RespondInput;
44use bamboo_engine::{ModelRoster, RoleModel};
45
46use super::bridge::ConnectContext;
47use super::platform::{Button, OutboundMessage, Platform, PlatformResult, ReplyCtx};
48use super::render::PendingAsk;
49
50/// Longest a button's visible label is allowed to be — Telegram (and most IM
51/// platforms) truncate/reject very long inline-button text, so keep it well
52/// under any known limit.
53const BUTTON_LABEL_MAX_CHARS: usize = 48;
54
55// ---------------------------------------------------------------------------
56// ParkedAsk — the bridge's one-ask-per-chat state
57// ---------------------------------------------------------------------------
58
59/// A pending question rendered to a chat and awaiting resolution (button
60/// press or text reply). One per chat at a time (issue #458: "one parked ask
61/// per chat — session serializes asks").
62#[derive(Debug, Clone)]
63pub struct ParkedAsk {
64    /// Short nonce embedded in every button's `callback_data`
65    /// (`"{nonce}:{option_index}"`). Validated on every callback so
66    /// forged/stale data is ignored.
67    pub nonce: String,
68    pub session_id: String,
69    pub tool_call_id: String,
70    pub tool_name: String,
71    pub question: String,
72    pub options: Vec<String>,
73    pub allow_custom: bool,
74}
75
76impl ParkedAsk {
77    pub fn new(nonce: String, session_id: String, ask: &PendingAsk) -> Self {
78        Self {
79            nonce,
80            session_id,
81            tool_call_id: ask.tool_call_id.clone(),
82            tool_name: ask.tool_name.clone(),
83            question: ask.question.clone(),
84            options: ask.options.clone(),
85            allow_custom: ask.allow_custom,
86        }
87    }
88}
89
90/// A short, hard-to-guess nonce for one parked ask. Not cryptographically
91/// load-bearing on its own (it's paired with per-chat scoping + a single
92/// live ask at a time) — just enough entropy that a stale/forged
93/// `callback_data` from a different ask/session won't collide by accident.
94pub fn new_nonce() -> String {
95    let raw = uuid::Uuid::new_v4().to_string();
96    raw.split('-').next().unwrap_or(&raw).to_string()
97}
98
99// ---------------------------------------------------------------------------
100// Rendering an ask
101// ---------------------------------------------------------------------------
102
103fn truncate_label(text: &str) -> String {
104    if text.chars().count() <= BUTTON_LABEL_MAX_CHARS {
105        return text.to_string();
106    }
107    let mut out: String = text.chars().take(BUTTON_LABEL_MAX_CHARS - 1).collect();
108    out.push('…');
109    out
110}
111
112/// Format the ask's question + a numbered option list (text replies remain
113/// first-class even when buttons are ALSO rendered).
114fn format_ask_text(ask: &ParkedAsk) -> String {
115    let mut text = ask.question.clone();
116    if !ask.options.is_empty() {
117        text.push_str("\n\n");
118        for (index, option) in ask.options.iter().enumerate() {
119            text.push_str(&format!("{}. {}\n", index + 1, option));
120        }
121    }
122    if ask.allow_custom {
123        text.push_str("\n(or reply with your own answer)");
124    }
125    text
126}
127
128/// Render `ask` to the chat: inline buttons (one per option, `callback_data =
129/// "{nonce}:{index}"`) when `buttons_capable`, always alongside the numbered
130/// text list — per issue #458, buttons are an enhancement, never a
131/// requirement. Returns the platform error (if the send failed) so the
132/// caller can log it; rendering failure does not itself invalidate the
133/// parked ask (a text reply can still resolve it).
134pub async fn render_ask(
135    platform: &Arc<dyn Platform>,
136    reply_ctx: &ReplyCtx,
137    ask: &ParkedAsk,
138    buttons_capable: bool,
139) -> PlatformResult<()> {
140    let text = format_ask_text(ask);
141    let outbound = if buttons_capable && !ask.options.is_empty() {
142        let rows: Vec<Vec<Button>> = ask
143            .options
144            .iter()
145            .enumerate()
146            .map(|(index, option)| {
147                vec![Button::new(
148                    truncate_label(option),
149                    format!("{}:{index}", ask.nonce),
150                )]
151            })
152            .collect();
153        OutboundMessage::text(text).with_buttons(rows)
154    } else {
155        OutboundMessage::text(text)
156    };
157    platform.reply(reply_ctx, outbound).await.map(|_| ())
158}
159
160// ---------------------------------------------------------------------------
161// Matching a text reply / callback against a ParkedAsk
162// ---------------------------------------------------------------------------
163
164const AFFIRMATIVE_KEYWORDS: &[&str] = &[
165    "允许", "同意", "确定", "是", "yes", "allow", "approve", "ok",
166];
167/// "stay" comes from plan-mode's decline phrasing — ExitPlanMode's negative
168/// option is literally "Stay in plan mode" (see
169/// `session_app::respond::is_exit_plan_mode_approved`), so a user typing
170/// "stay" declines the plan approval. Safe to keep in this fallback list
171/// because [`match_text_answer`] tries EXACT (case-insensitive) option-text
172/// matching BEFORE the keyword fallback: an ask whose positive option is
173/// literally titled "Stay" resolves on the exact match and never reaches
174/// here.
175const NEGATIVE_KEYWORDS: &[&str] = &["拒绝", "不", "否", "no", "deny", "reject", "stay"];
176
177fn classify_intent(text: &str) -> Option<bool> {
178    let lower = text.trim().to_lowercase();
179    if AFFIRMATIVE_KEYWORDS.iter().any(|keyword| lower == *keyword) {
180        return Some(true);
181    }
182    if NEGATIVE_KEYWORDS.iter().any(|keyword| lower == *keyword) {
183        return Some(false);
184    }
185    None
186}
187
188/// "First-affirmative mapping": prefer an option whose OWN text already
189/// reads as affirmative/negative (e.g. "Approve" / "Deny"); for a plain
190/// 2-option ask with no such wording, fall back to treating the first option
191/// as the affirmative one.
192fn pick_option_by_intent(options: &[String], affirmative: bool) -> Option<String> {
193    let keywords: &[&str] = if affirmative {
194        AFFIRMATIVE_KEYWORDS
195    } else {
196        NEGATIVE_KEYWORDS
197    };
198    if let Some(option) = options.iter().find(|option| {
199        let lower = option.to_lowercase();
200        keywords.iter().any(|keyword| lower.contains(keyword))
201    }) {
202        return Some(option.clone());
203    }
204    if options.len() == 2 {
205        return Some(if affirmative {
206            options[0].clone()
207        } else {
208            options[1].clone()
209        });
210    }
211    None
212}
213
214/// Match a text reply against `ask`, returning the answer to submit, or
215/// `None` when it doesn't resolve the ask at all (issue #458: a non-matching
216/// text on a CLOSED ask — no free text allowed — falls through to the
217/// caller's normal busy-queue handling instead of being submitted as a
218/// doomed-to-fail answer).
219///
220/// Tried in order: 1-based numeric option index, exact (case-insensitive)
221/// option text, then — for a closed (non-`allow_custom`) ask — the
222/// affirmative/negative keyword mapping. An OPEN ask (`allow_custom`) always
223/// matches: any non-empty text IS the answer, verbatim (matching
224/// `validate_pending_response`'s server-side rule).
225pub fn match_text_answer(ask: &ParkedAsk, text: &str) -> Option<String> {
226    let trimmed = text.trim();
227    if trimmed.is_empty() {
228        return None;
229    }
230    if let Ok(index) = trimmed.parse::<usize>() {
231        if index >= 1 && index <= ask.options.len() {
232            return Some(ask.options[index - 1].clone());
233        }
234    }
235    if let Some(option) = ask
236        .options
237        .iter()
238        .find(|option| option.eq_ignore_ascii_case(trimmed))
239    {
240        return Some(option.clone());
241    }
242    if !ask.allow_custom {
243        if let Some(intent) = classify_intent(trimmed) {
244            if let Some(option) = pick_option_by_intent(&ask.options, intent) {
245                return Some(option);
246            }
247        }
248    }
249    if ask.allow_custom {
250        return Some(trimmed.to_string());
251    }
252    None
253}
254
255/// Match a button press's `callback_data` (`"{nonce}:{index}"`) against
256/// `ask`. Returns `None` for anything that doesn't EXACTLY match the parked
257/// nonce and a valid option index — forged/stale data (issue #458: "always
258/// answerCallbackQuery, even stale" — the caller acks regardless, but never
259/// forwards a non-match as an answer).
260pub fn match_callback_data(ask: &ParkedAsk, data: &str) -> Option<String> {
261    let (nonce, index_str) = data.split_once(':')?;
262    if nonce != ask.nonce {
263        return None;
264    }
265    let index: usize = index_str.parse().ok()?;
266    ask.options.get(index).cloned()
267}
268
269// ---------------------------------------------------------------------------
270// Responder — the resolution seam
271// ---------------------------------------------------------------------------
272
273/// What happened after submitting an answer and attempting to resume.
274pub enum RespondAndResumeOutcome {
275    /// Execution resumed; `receiver` was subscribed BEFORE the resume was
276    /// triggered, so the caller can keep rendering without missing events.
277    Resumed(broadcast::Receiver<AgentEvent>),
278    /// The answer was recorded, but nothing (more) is running — e.g. the
279    /// runner slot was already taken by a concurrent run, or the session
280    /// vanished between answering and resuming. `reason` is a short,
281    /// user-facing explanation.
282    NotResumed(String),
283}
284
285/// Error submitting an answer (mirrors `bamboo_engine::session_app::errors::RespondError`,
286/// decoupled so `connect` doesn't leak that error type through its public
287/// surface).
288#[derive(Debug, thiserror::Error)]
289pub enum ResponderError {
290    #[error("session not found")]
291    NotFound,
292    #[error("no pending question waiting for a response")]
293    NoPendingQuestion,
294    #[error("invalid response: {0}")]
295    InvalidResponse(String),
296    #[error("{0}")]
297    Other(String),
298}
299
300/// The bridge's resolution seam (issue #458: "Design a small Responder seam
301/// on the bridge so tests inject a fake instead of full AppState"). The ONLY
302/// production implementation ([`EngineResponder`]) routes through
303/// `submit_pending_response` + `resume_session_execution` — the exact same
304/// use-case functions `POST /sessions/{id}/respond` calls — never a parallel
305/// path.
306#[async_trait::async_trait]
307pub trait Responder: Send + Sync {
308    async fn respond_and_resume(
309        &self,
310        session_id: &str,
311        answer: String,
312    ) -> Result<RespondAndResumeOutcome, ResponderError>;
313}
314
315fn map_respond_error(error: bamboo_engine::session_app::errors::RespondError) -> ResponderError {
316    use bamboo_engine::session_app::errors::RespondError;
317    match error {
318        RespondError::NotFound(_) => ResponderError::NotFound,
319        RespondError::NoPendingQuestion => ResponderError::NoPendingQuestion,
320        RespondError::InvalidResponse(message) => ResponderError::InvalidResponse(message),
321        other => ResponderError::Other(other.to_string()),
322    }
323}
324
325/// Production [`Responder`]: submits through `submit_pending_response`
326/// (`SessionAccess` is implemented directly by `SessionRepository`, no
327/// `AppState` wrapper needed), applies any permission grants the answer
328/// implied (mirrors `handlers::agent::respond::handlers::submit`), then
329/// resumes via [`ConnectResumePort`] — the connect-scoped
330/// `ResumeExecutionPort` implementation that spawns through the same
331/// crate-agnostic `spawn_session_execution` the bridge already uses for a
332/// fresh prompt (`bridge::ConnectBridge::run_prompt`), including re-running a
333/// gated tool call that was only a placeholder while awaiting approval.
334pub struct EngineResponder {
335    ctx: ConnectContext,
336}
337
338impl EngineResponder {
339    pub fn new(ctx: ConnectContext) -> Self {
340        Self { ctx }
341    }
342}
343
344#[async_trait::async_trait]
345impl Responder for EngineResponder {
346    async fn respond_and_resume(
347        &self,
348        session_id: &str,
349        answer: String,
350    ) -> Result<RespondAndResumeOutcome, ResponderError> {
351        let input = RespondInput {
352            session_id: session_id.to_string(),
353            user_response: answer,
354            model: None,
355            model_ref: None,
356            provider: None,
357            reasoning_effort: None,
358        };
359
360        let (session, _submitted_answer, plan_mode_transition, permission_grants) =
361            submit_pending_response(&self.ctx.session_repo, input)
362                .await
363                .map_err(map_respond_error)?;
364
365        // Mirrors `handlers::agent::respond::handlers::submit`: record any
366        // permission grant so the resumed re-execution of the gated tool
367        // passes the check without re-prompting.
368        for (perm_type, resource) in &permission_grants {
369            if let Some(request_id) = session.metadata.get(PERMISSION_REEXECUTE_METADATA_KEY) {
370                self.ctx.permission_checker.grant_once(
371                    session_id,
372                    request_id,
373                    *perm_type,
374                    resource.clone(),
375                );
376            }
377        }
378
379        // Subscribe BEFORE triggering resume so the `ExecutionStarted` event
380        // (and everything after) is never missed — same ordering
381        // `bridge::ConnectBridge::run_prompt` uses for a fresh prompt.
382        let session_tx =
383            get_or_create_event_sender(&self.ctx.session_event_senders, session_id).await;
384        let receiver = session_tx.subscribe();
385
386        if let Some(event) = plan_mode_transition_event(session_id, plan_mode_transition.as_ref()) {
387            let _ = session_tx.send(event);
388        }
389
390        let config_snapshot = self.ctx.config.read().await.clone();
391        let resume_config = resolve_resume_config_snapshot(
392            &config_snapshot,
393            &self.ctx.provider_registry,
394            &session,
395            None,
396        );
397
398        let port = ConnectResumePort {
399            ctx: self.ctx.clone(),
400        };
401        let outcome = resume_session_execution(&port, session_id, resume_config).await;
402
403        match outcome {
404            bamboo_engine::session_app::types::ResumeOutcome::Started { .. } => {
405                Ok(RespondAndResumeOutcome::Resumed(receiver))
406            }
407            bamboo_engine::session_app::types::ResumeOutcome::AlreadyRunning { .. } => Ok(
408                RespondAndResumeOutcome::NotResumed("this session is already running".to_string()),
409            ),
410            bamboo_engine::session_app::types::ResumeOutcome::Completed => Ok(
411                RespondAndResumeOutcome::NotResumed("nothing left to resume".to_string()),
412            ),
413            bamboo_engine::session_app::types::ResumeOutcome::NotFound => Ok(
414                RespondAndResumeOutcome::NotResumed("session no longer exists".to_string()),
415            ),
416        }
417    }
418}
419
420fn plan_mode_transition_event(
421    session_id: &str,
422    transition: Option<&bamboo_engine::session_app::respond::PlanModeTransition>,
423) -> Option<AgentEvent> {
424    use bamboo_engine::session_app::respond::PlanModeTransition;
425    transition.map(|transition| match transition {
426        PlanModeTransition::Entered {
427            reason,
428            pre_permission_mode,
429            entered_at,
430            status,
431            plan_file_path,
432        } => AgentEvent::PlanModeEntered {
433            session_id: session_id.to_string(),
434            reason: reason.clone(),
435            pre_permission_mode: pre_permission_mode.clone(),
436            entered_at: *entered_at,
437            status: *status,
438            plan_file_path: plan_file_path.clone(),
439        },
440        PlanModeTransition::Exited {
441            approved,
442            restored_mode,
443            plan,
444        } => AgentEvent::PlanModeExited {
445            session_id: session_id.to_string(),
446            approved: *approved,
447            restored_mode: restored_mode.clone(),
448            plan: plan.clone(),
449        },
450    })
451}
452
453// ---------------------------------------------------------------------------
454// ConnectResumePort — ResumeExecutionPort for connect-bridged sessions
455// ---------------------------------------------------------------------------
456
457/// [`ResumeExecutionPort`] backed by [`ConnectContext`] instead of `AppState`
458/// — the connect-scoped counterpart of the server's
459/// `app_state::resume_adapter::AppStateResumeRef`. Spawns through the
460/// crate-agnostic `spawn_session_execution` (matching
461/// `bridge::ConnectBridge::run_prompt`'s fresh-prompt spawn exactly: same
462/// tools/agent/model-roster resolution, no guardian/bash-resume-hook — those
463/// remain a later phase, same as the fresh-prompt path), rather than the
464/// server handler layer's `spawn_agent_execution` (which pulls in
465/// `AppState`-specific wiring connect deliberately doesn't use).
466struct ConnectResumePort {
467    ctx: ConnectContext,
468}
469
470#[async_trait::async_trait]
471impl ResumeExecutionPort for ConnectResumePort {
472    async fn load_session(&self, session_id: &str) -> Option<Session> {
473        self.ctx.session_repo.load_merged(session_id).await
474    }
475
476    async fn save_and_cache_session(&self, session: &mut Session) {
477        self.ctx.session_repo.save_and_cache(session).await;
478    }
479
480    async fn reserve_session_execution(
481        &self,
482        session_id: &str,
483        event_sender: &broadcast::Sender<AgentEvent>,
484    ) -> SessionExecutionReserveOutcome {
485        reserve_session_execution(
486            &self.ctx.agent,
487            &self.ctx.agent_runners,
488            &self.ctx.session_event_senders,
489            session_id,
490            event_sender,
491        )
492        .await
493    }
494
495    async fn get_or_create_event_sender(&self, session_id: &str) -> broadcast::Sender<AgentEvent> {
496        get_or_create_event_sender(&self.ctx.session_event_senders, session_id).await
497    }
498
499    async fn spawn_resume_execution(&self, request: ResumeSpawnRequest) {
500        let ResumeSpawnRequest {
501            session_id,
502            session,
503            mut execution_reservation,
504            event_sender,
505            config,
506        } = request;
507        if let Err(error) = execution_reservation.ensure_registered().await {
508            tracing::warn!(
509                %session_id,
510                run_id = %execution_reservation.run_id(),
511                %error,
512                "cannot resume connect session without exact router ownership"
513            );
514            return;
515        }
516
517        let model = session.model.clone();
518        let reasoning_effort = session.reasoning_effort;
519        let model_roster = ModelRoster {
520            model: Some(model),
521            provider_name: Some(config.provider_name.clone()),
522            provider_type: config.provider_type.clone(),
523            fast: RoleModel::from_parts(config.fast_model.clone(), None),
524            background: RoleModel::from_parts(
525                config.background_model.clone(),
526                config.background_model_provider.clone(),
527            ),
528            summarization: RoleModel::from_parts(
529                config.summarization_model.clone(),
530                config.summarization_model_provider.clone(),
531            ),
532        };
533
534        let (mpsc_tx, _forwarder_handle) = create_event_forwarder(
535            session_id.clone(),
536            event_sender,
537            self.ctx.agent_runners.clone(),
538            self.ctx.account_feed_inbox.clone(),
539        );
540
541        // If the user just approved a permission prompt, the gated tool call
542        // was intercepted before it ran — its recorded result is only a
543        // placeholder. Re-execute it for real now (the grant was already
544        // applied to `ctx.permission_checker` in `EngineResponder`), write
545        // the output back, then start the loop — mirrors
546        // `app_state::resume_adapter::AppStateResumeRef::spawn_resume_execution`
547        // exactly, minus the `AppState`-specific plumbing.
548        let reexecute_tool_call_id = session
549            .metadata
550            .get(PERMISSION_REEXECUTE_METADATA_KEY)
551            .cloned();
552
553        let Some(reexecute_tool_call_id) = reexecute_tool_call_id else {
554            spawn_session_execution(SessionExecutionArgs {
555                agent: self.ctx.agent.clone(),
556                session_id,
557                session,
558                execution_reservation,
559                tools_override: Some(self.ctx.tools.clone()),
560                provider_override: None,
561                model_roster,
562                reasoning_effort,
563                reasoning_effort_source: "connect_resume".to_string(),
564                auxiliary_model_resolver: None,
565                disabled_filter_resolver: None,
566                disabled_tools: Some(config.disabled_tools.clone()),
567                disabled_skill_ids: Some(config.disabled_skill_ids.clone()),
568                selected_skill_ids: None,
569                selected_skill_mode: None,
570                mpsc_tx,
571                image_fallback: config.image_fallback.clone(),
572                gold_config: config.gold_config.clone(),
573                guardian_config: None,
574                guardian_spawner: None,
575                bash_resume_hook: None,
576                bash_completion_sink: None,
577                app_data_dir: self.ctx.app_data_dir.clone(),
578                // No per-request override on this path; the config-level
579                // default (issue #221) still applies.
580                run_budget: None,
581                runners: self.ctx.agent_runners.clone(),
582                sessions_cache: self.ctx.session_repo.cache().clone(),
583                on_complete: None,
584                // Connect drives root sessions; a child finishing on this
585                // path is backstopped by the child-wait watchdog (#546).
586                child_completion_handler: None,
587            });
588            return;
589        };
590
591        let ctx = self.ctx.clone();
592        tokio::spawn(async move {
593            let mut session = session;
594
595            if let Some(tool_call) = find_pending_tool_call(&session, &reexecute_tool_call_id) {
596                let tool_name = tool_call.function.name.clone();
597                let configured_mode = ctx
598                    .permission_checker
599                    .permission_config()
600                    .map(|config| config.mode())
601                    .unwrap_or_default();
602                let decision = match refresh_approval_replay_posture(
603                    ctx.session_repo.storage().as_ref(),
604                    &mut session,
605                    configured_mode,
606                    &tool_name,
607                )
608                .await
609                {
610                    Ok(decision) => decision,
611                    Err(error) => {
612                        tracing::error!(
613                            %session_id,
614                            tool_call_id = %reexecute_tool_call_id,
615                            %error,
616                            "connect approval replay posture refresh failed closed"
617                        );
618                        return;
619                    }
620                };
621                session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
622
623                let (content, success) = match decision {
624                    ApprovalReplayDecision::BlockedByPlan(_) => (
625                        format!(
626                            "Plan mode blocked approved mutating tool '{tool_name}'; the stale approval was not executed"
627                        ),
628                        false,
629                    ),
630                    ApprovalReplayDecision::Execute(flags) => {
631                        let executor = ctx.tools.clone();
632                        let is_mutating = bamboo_tools::orchestrator::classify_tool(&tool_name)
633                            == bamboo_tools::orchestrator::ToolMutability::Mutating;
634                        let mut emitter = bamboo_tools::ToolEmitter::new(
635                            &tool_call.id,
636                            &tool_name,
637                            is_mutating,
638                        );
639                        emitter.set_auto_approved(true);
640                        let _ = mpsc_tx
641                            .send(emitter.begin().clone().into_agent_event())
642                            .await;
643                        let exec_result = executor
644                            .execute_with_context(
645                                &tool_call,
646                                ToolExecutionContext {
647                                    session_id: Some(session.id.as_str()),
648                                    tool_call_id: reexecute_tool_call_id.as_str(),
649                                    event_tx: Some(&mpsc_tx),
650                                    available_tool_schemas: None,
651                                    bypass_permissions: flags.bypass_permissions,
652                                    auto_approve_permissions: flags.auto_approve_permissions,
653                                    plan_read_only: flags.plan_read_only,
654                                    can_async_resume: false,
655                                    bash_completion_sink: None,
656                                    pre_parsed_args: None,
657                                },
658                            )
659                            .await;
660
661                        match exec_result {
662                            Ok(tool_result) => {
663                                let _ = mpsc_tx
664                                    .send(
665                                        emitter
666                                            .finish(Some(
667                                                "Re-executed after approval".to_string(),
668                                            ))
669                                            .clone()
670                                            .into_agent_event(),
671                                    )
672                                    .await;
673                                let _ = mpsc_tx
674                                    .send(AgentEvent::ToolComplete {
675                                        tool_call_id: tool_call.id.clone(),
676                                        result: tool_result.clone(),
677                                    })
678                                    .await;
679                                (tool_result.result, tool_result.success)
680                            }
681                            Err(error) => {
682                                let message =
683                                    format!("Tool re-execution after approval failed: {error}");
684                                let _ = mpsc_tx
685                                    .send(
686                                        emitter.error(message.clone()).clone().into_agent_event(),
687                                    )
688                                    .await;
689                                (message, false)
690                            }
691                        }
692                    }
693                };
694
695                tracing::info!(
696                    "[{}] connect: resolved approved tool replay '{}' ({}) -> success={}",
697                    session_id,
698                    tool_name,
699                    reexecute_tool_call_id,
700                    success
701                );
702                apply_tool_result(&mut session, &reexecute_tool_call_id, content, success);
703                ctx.session_repo.save_and_cache(&mut session).await;
704            } else {
705                session.metadata.remove(PERMISSION_REEXECUTE_METADATA_KEY);
706                tracing::warn!(
707                    "[{}] connect: permission re-exec marker set but tool call '{}' not found in history",
708                    session_id,
709                    reexecute_tool_call_id
710                );
711            }
712
713            spawn_session_execution(SessionExecutionArgs {
714                agent: ctx.agent.clone(),
715                session_id,
716                session,
717                execution_reservation,
718                tools_override: Some(ctx.tools.clone()),
719                provider_override: None,
720                model_roster,
721                reasoning_effort,
722                reasoning_effort_source: "connect_resume".to_string(),
723                auxiliary_model_resolver: None,
724                disabled_filter_resolver: None,
725                disabled_tools: Some(config.disabled_tools.clone()),
726                disabled_skill_ids: Some(config.disabled_skill_ids.clone()),
727                selected_skill_ids: None,
728                selected_skill_mode: None,
729                mpsc_tx,
730                image_fallback: config.image_fallback.clone(),
731                gold_config: config.gold_config.clone(),
732                guardian_config: None,
733                guardian_spawner: None,
734                bash_resume_hook: None,
735                bash_completion_sink: None,
736                app_data_dir: ctx.app_data_dir.clone(),
737                // No per-request override on this path; the config-level
738                // default (issue #221) still applies.
739                run_budget: None,
740                runners: ctx.agent_runners.clone(),
741                sessions_cache: ctx.session_repo.cache().clone(),
742                on_complete: None,
743                // Connect drives root sessions; a child finishing on this
744                // path is backstopped by the child-wait watchdog (#546).
745                child_completion_handler: None,
746            });
747        });
748    }
749}
750
751/// Find the original tool call (with its arguments) by id in the session
752/// history. Mirrors `app_state::resume_adapter::find_pending_tool_call`.
753fn find_pending_tool_call(session: &Session, tool_call_id: &str) -> Option<ToolCall> {
754    session.messages.iter().find_map(|message| {
755        message
756            .tool_calls
757            .as_ref()
758            .and_then(|calls| calls.iter().find(|call| call.id == tool_call_id).cloned())
759    })
760}
761
762/// Overwrite the tool-result message for `tool_call_id` with the real tool
763/// output. Mirrors `app_state::resume_adapter::apply_tool_result`.
764fn apply_tool_result(session: &mut Session, tool_call_id: &str, content: String, success: bool) {
765    for message in &mut session.messages {
766        if message.tool_call_id.as_deref() == Some(tool_call_id) {
767            message.content = content;
768            message.tool_success = Some(success);
769            return;
770        }
771    }
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777
778    fn ask(options: Vec<&str>, allow_custom: bool) -> ParkedAsk {
779        ParkedAsk {
780            nonce: "abc12345".to_string(),
781            session_id: "sess-1".to_string(),
782            tool_call_id: "call-1".to_string(),
783            tool_name: "conclusion_with_options".to_string(),
784            question: "Approve?".to_string(),
785            options: options.into_iter().map(str::to_string).collect(),
786            allow_custom,
787        }
788    }
789
790    #[test]
791    fn new_nonce_is_short_and_hex_like() {
792        let nonce = new_nonce();
793        assert!(!nonce.is_empty());
794        assert!(nonce.len() <= 16);
795        assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
796    }
797
798    #[test]
799    fn match_text_answer_numeric_index_selects_option() {
800        let pending = ask(vec!["Approve", "Deny"], false);
801        assert_eq!(
802            match_text_answer(&pending, "1"),
803            Some("Approve".to_string())
804        );
805        assert_eq!(match_text_answer(&pending, "2"), Some("Deny".to_string()));
806        assert_eq!(match_text_answer(&pending, "3"), None);
807    }
808
809    #[test]
810    fn match_text_answer_exact_text_is_case_insensitive() {
811        let pending = ask(vec!["Approve", "Deny"], false);
812        assert_eq!(
813            match_text_answer(&pending, "approve"),
814            Some("Approve".to_string())
815        );
816    }
817
818    #[test]
819    fn match_text_answer_binary_keyword_mapping() {
820        let pending = ask(vec!["Approve", "Deny"], false);
821        assert_eq!(
822            match_text_answer(&pending, "允许"),
823            Some("Approve".to_string())
824        );
825        assert_eq!(
826            match_text_answer(&pending, "yes"),
827            Some("Approve".to_string())
828        );
829        assert_eq!(
830            match_text_answer(&pending, "deny"),
831            Some("Deny".to_string())
832        );
833        assert_eq!(match_text_answer(&pending, "no"), Some("Deny".to_string()));
834    }
835
836    /// Ordering guarantee documented on [`NEGATIVE_KEYWORDS`]: an option
837    /// literally titled "Stay" — even as the POSITIVE first choice — resolves
838    /// via exact option-text matching BEFORE the keyword fallback, so the
839    /// "stay"-is-negative heuristic can never misroute it.
840    #[test]
841    fn match_text_answer_exact_option_named_stay_beats_negative_keyword_fallback() {
842        let pending = ask(vec!["Stay", "Leave"], false);
843        assert_eq!(
844            match_text_answer(&pending, "stay"),
845            Some("Stay".to_string())
846        );
847        // And the fallback still works as intended for plan-mode phrasing,
848        // where "stay" appears INSIDE the negative option's text.
849        let plan_pending = ask(vec!["Approve", "Stay in plan mode"], false);
850        assert_eq!(
851            match_text_answer(&plan_pending, "stay"),
852            Some("Stay in plan mode".to_string())
853        );
854    }
855
856    #[test]
857    fn match_text_answer_closed_ask_non_matching_text_falls_through() {
858        let pending = ask(vec!["Approve", "Deny"], false);
859        assert_eq!(match_text_answer(&pending, "banana"), None);
860    }
861
862    #[test]
863    fn match_text_answer_open_question_accepts_any_free_text() {
864        let pending = ask(vec!["OK", "Need changes"], true);
865        assert_eq!(
866            match_text_answer(&pending, "please add tests too"),
867            Some("please add tests too".to_string())
868        );
869    }
870
871    #[test]
872    fn match_text_answer_empty_text_never_matches() {
873        let pending = ask(vec!["OK", "Need changes"], true);
874        assert_eq!(match_text_answer(&pending, "   "), None);
875    }
876
877    #[test]
878    fn match_callback_data_requires_the_exact_nonce() {
879        let pending = ask(vec!["Approve", "Deny"], false);
880        assert_eq!(
881            match_callback_data(&pending, "abc12345:0"),
882            Some("Approve".to_string())
883        );
884        assert_eq!(match_callback_data(&pending, "stale-nonce:0"), None);
885    }
886
887    #[test]
888    fn match_callback_data_rejects_out_of_range_index() {
889        let pending = ask(vec!["Approve", "Deny"], false);
890        assert_eq!(match_callback_data(&pending, "abc12345:9"), None);
891    }
892
893    #[test]
894    fn match_callback_data_rejects_malformed_data() {
895        let pending = ask(vec!["Approve", "Deny"], false);
896        assert_eq!(match_callback_data(&pending, "not-a-valid-shape"), None);
897        assert_eq!(match_callback_data(&pending, "abc12345:not-a-number"), None);
898    }
899
900    #[test]
901    fn format_ask_text_numbers_every_option() {
902        let pending = ask(vec!["Approve", "Deny"], false);
903        let text = format_ask_text(&pending);
904        assert!(text.contains("1. Approve"));
905        assert!(text.contains("2. Deny"));
906        assert!(!text.contains("reply with your own answer"));
907    }
908
909    #[test]
910    fn format_ask_text_open_question_mentions_free_text() {
911        let pending = ask(vec!["OK", "Need changes"], true);
912        assert!(format_ask_text(&pending).contains("reply with your own answer"));
913    }
914}