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