Skip to main content

vv_agent/runner/
resume.rs

1use std::sync::{Arc, Mutex};
2
3use serde_json::Value;
4
5use crate::budget::{BudgetEnforcementBoundary, BudgetEvaluator};
6use crate::events::{RunEvent, RunEventPayload, ToolStatus};
7use crate::result::{PendingToolApproval, RunResult, RunResumeContext, RunState};
8use crate::run_config::INITIAL_BUDGET_USAGE_METADATA_KEY;
9use crate::tools::ToolLifecycleEvent;
10use crate::types::{
11    last_assistant_output, AgentResult, AgentStatus, CompletionReason, ToolDirective,
12};
13
14use super::helpers::terminal_event;
15use super::session_blocking::block_on_session;
16use super::support::{
17    apply_cancellation_precedence, apply_optional_output_validation, apply_output_guardrails,
18    capture_event, effective_event_store, extract_handoff, output_type_validation_error,
19    SingleRunOutcome,
20};
21use super::{effective_session_id, NormalizedInput, Runner};
22
23impl Runner {
24    pub async fn resume(&self, state: RunState) -> Result<RunResult, String> {
25        Box::pin(self.resume_with_optional_input(state, None)).await
26    }
27
28    pub async fn resume_with_input(
29        &self,
30        state: RunState,
31        input: impl Into<NormalizedInput>,
32    ) -> Result<RunResult, String> {
33        Box::pin(self.resume_with_optional_input(state, Some(input.into()))).await
34    }
35
36    async fn resume_with_optional_input(
37        &self,
38        state: RunState,
39        input: Option<NormalizedInput>,
40    ) -> Result<RunResult, String> {
41        let (source, approved_ids, approval_consumption) = state.into_inner();
42        let Some(resume_context) = source.resume_context().cloned() else {
43            return Err("run state does not include resume context".to_string());
44        };
45        let origin_runner = resume_context.runner.clone();
46        if let Some(result) = Box::pin(origin_runner.resume_approved_tool_call(
47            &source,
48            &resume_context,
49            &approved_ids,
50            &approval_consumption,
51            input.as_ref(),
52        ))
53        .await
54        {
55            return result;
56        }
57        let mut config = resume_context.config;
58        config.initial_messages = Some(source.result().messages.clone());
59        config.initial_shared_state = source.result().shared_state.clone();
60        set_initial_budget_usage(&mut config, source.budget_usage())?;
61        let result = origin_runner
62            .run_with_config(
63                &resume_context.agent,
64                input.unwrap_or(resume_context.input),
65                config,
66            )
67            .await
68            .map_err(|error| error.to_string())?;
69        Ok(result)
70    }
71
72    async fn resume_approved_tool_call(
73        &self,
74        source: &RunResult,
75        resume_context: &RunResumeContext,
76        approved_ids: &[String],
77        approval_consumption: &Arc<Mutex<std::collections::BTreeSet<String>>>,
78        resume_input: Option<&NormalizedInput>,
79    ) -> Option<Result<RunResult, String>> {
80        let approval = match select_approved_tool_context(
81            resume_context.pending_tool_approval.as_ref(),
82            approved_ids,
83        ) {
84            Ok(Some(approval)) => approval.clone(),
85            Ok(None) => return None,
86            Err(error) => return Some(Err(error)),
87        };
88        if !approval_snapshot_matches_result(source.result(), &approval) {
89            return Some(Err(
90                "approved tool call does not match the captured interruption".to_string(),
91            ));
92        }
93        if resume_input.is_some() {
94            return Some(Err(
95                "input cannot be provided when resuming an approved tool call".to_string(),
96            ));
97        }
98        let cancellation_token = resume_context
99            .config
100            .cancellation_token
101            .as_ref()
102            .or(self.default_run_config.cancellation_token.as_ref());
103        if cancellation_token.is_some_and(crate::runtime::CancellationToken::is_cancelled) {
104            let mut cancelled = source.result().clone();
105            cancelled.status = AgentStatus::Failed;
106            cancelled.completion_reason = Some(CompletionReason::Cancelled);
107            cancelled.completion_tool_name = None;
108            cancelled.partial_output = cancelled
109                .partial_output
110                .or_else(|| last_assistant_output(&cancelled.cycles));
111            cancelled.error = Some(
112                cancellation_token
113                    .and_then(crate::runtime::CancellationToken::reason)
114                    .unwrap_or_else(|| "run cancelled".to_string()),
115            );
116            cancelled.budget_exhaustion = None;
117            cancelled.final_answer = None;
118            cancelled.wait_reason = None;
119            return Some(self.finalize_approval_terminal(
120                source,
121                resume_context,
122                &approval.interruption_id,
123                cancelled,
124                source.new_items().to_vec(),
125                cancellation_token,
126                None,
127                Vec::new(),
128            ));
129        }
130        {
131            let mut consumed = approval_consumption
132                .lock()
133                .unwrap_or_else(std::sync::PoisonError::into_inner);
134            if !consumed.insert(approval.interruption_id.clone()) {
135                return Some(Err("approval_already_consumed".to_string()));
136            }
137        }
138        let resumed_run_id = format!("run_{}", uuid::Uuid::new_v4().simple());
139        let budget_limits = resume_context
140            .config
141            .budget_limits
142            .clone()
143            .or_else(|| self.default_run_config.budget_limits.clone());
144        let host_cost_meter = resume_context
145            .config
146            .host_cost_meter
147            .clone()
148            .or_else(|| self.default_run_config.host_cost_meter.clone());
149        let mut budget_evaluator = match budget_limits.filter(|limits| limits.has_limits()) {
150            Some(limits) => {
151                match BudgetEvaluator::new(limits, host_cost_meter, source.budget_usage().cloned())
152                {
153                    Ok(evaluator) => Some(Box::new(evaluator)),
154                    Err(error) => return Some(Err(error)),
155                }
156            }
157            None => None,
158        };
159        let mut context = approval.context.clone();
160        context.shared_state = source.result().shared_state.clone();
161        let call = approval.call.clone();
162        let lifecycle_observations = Arc::new(Mutex::new(Vec::<ToolLifecycleEvent>::new()));
163        let lifecycle_observations_for_callback = lifecycle_observations.clone();
164        let options = approval
165            .options
166            .clone()
167            .lifecycle_callback(Arc::new(move |event| {
168                lifecycle_observations_for_callback
169                    .lock()
170                    .unwrap_or_else(std::sync::PoisonError::into_inner)
171                    .push(event);
172            }));
173        let execution = approval
174            .orchestrator
175            .run_one_with_approval_and_metadata_deferred(
176                call.clone(),
177                &mut context,
178                options,
179                |_call, _requirement, _context, _metadata| None,
180            )
181            .await
182            .map_err(|error| error.to_string());
183        let mut execution = match execution {
184            Ok(execution) => execution,
185            Err(error) => {
186                let persistence = persist_approval_lifecycle_events(
187                    self,
188                    source,
189                    resume_context,
190                    &resumed_run_id,
191                    approval.cycle_index,
192                    &lifecycle_observations,
193                );
194                return Some(persistence.and(Err(error)));
195            }
196        };
197        let tool_result = execution.result().clone();
198        let mut tool_result = approval.hook_manager.apply_after_tool_call(
199            &approval.task,
200            approval.cycle_index,
201            &call,
202            &context,
203            tool_result,
204        );
205        let behavior_reason = crate::runtime::tool_call_runner::apply_tool_use_behavior(
206            &approval.task,
207            &call,
208            &mut tool_result,
209        );
210        execution.replace_result(tool_result);
211        let tool_result = execution.complete();
212        let resume_tool_events = match persist_approval_lifecycle_events(
213            self,
214            source,
215            resume_context,
216            &resumed_run_id,
217            approval.cycle_index,
218            &lifecycle_observations,
219        ) {
220            Ok(events) => events,
221            Err(error) => return Some(Err(error)),
222        };
223        let mut agent_result = source.result().clone();
224        agent_result.shared_state = context.shared_state.clone();
225        if let Some(cycle) = agent_result
226            .cycles
227            .iter_mut()
228            .find(|cycle| cycle.index == approval.cycle_index)
229        {
230            if let Some(existing) = cycle.tool_results.iter_mut().find(|existing| {
231                existing.tool_call_id == call.id
232                    && existing
233                        .metadata
234                        .get("approval_interruption_id")
235                        .and_then(Value::as_str)
236                        == Some(approval.interruption_id.as_str())
237            }) {
238                *existing = tool_result.clone();
239            } else {
240                cycle.tool_results.push(tool_result.clone());
241            }
242        }
243        let tool_message = tool_result.to_message();
244        agent_result.messages.retain(|message| {
245            !(message.role == crate::types::MessageRole::Tool
246                && message.tool_call_id.as_deref() == Some(call.id.as_str()))
247        });
248        agent_result.messages.push(tool_message.clone());
249        if let Some(session) = resume_context.config.session.as_ref() {
250            let session_items =
251                crate::sessions::SessionItem::from_message(&tool_message).map(|item| vec![item]);
252            let Some(session_items) = session_items else {
253                return Some(Err(
254                    "approved resume messages cannot be persisted to session".to_string(),
255                ));
256            };
257            if let Err(error) = block_on_session(session.add_items(session_items)) {
258                return Some(Err(error));
259            }
260        }
261        let mut new_items = source
262            .new_items()
263            .iter()
264            .filter(|message| {
265                !(message.role == crate::types::MessageRole::Tool
266                    && message.tool_call_id.as_deref() == Some(call.id.as_str()))
267            })
268            .cloned()
269            .collect::<Vec<_>>();
270        new_items.push(tool_message);
271
272        let mut resume_budget_events = Vec::new();
273        if let Some(evaluator) = &mut budget_evaluator {
274            let observed_exhaustion = evaluator.tool_batch_complete(false);
275            let snapshot = evaluator.snapshot();
276            let cancelled =
277                cancellation_token.is_some_and(crate::runtime::CancellationToken::is_cancelled);
278            let exhaustion = (!cancelled).then_some(observed_exhaustion).flatten();
279            agent_result.budget_usage = Some(snapshot.clone());
280            agent_result.budget_exhaustion = exhaustion.clone();
281            if exhaustion.is_some() {
282                agent_result.status = AgentStatus::Failed;
283                agent_result.completion_reason = Some(CompletionReason::BudgetExhausted);
284                agent_result.completion_tool_name = None;
285                agent_result.partial_output = last_assistant_output(&agent_result.cycles);
286                agent_result.final_answer = None;
287                agent_result.wait_reason = None;
288                agent_result.error = Some("Run budget exhausted.".to_string());
289            }
290            let payload = match exhaustion.clone() {
291                Some(budget_exhaustion) => RunEventPayload::BudgetExhausted {
292                    enforcement_boundary: BudgetEnforcementBoundary::ToolBatchComplete,
293                    budget_usage: snapshot,
294                    budget_exhaustion,
295                },
296                None => RunEventPayload::BudgetSnapshot {
297                    enforcement_boundary: BudgetEnforcementBoundary::ToolBatchComplete,
298                    budget_usage: snapshot,
299                },
300            };
301            let mut budget_event = RunEvent::new(
302                &resumed_run_id,
303                source.trace_id(),
304                source.agent_name(),
305                Some(approval.cycle_index),
306                payload,
307            );
308            let session_id = effective_session_id(&self.default_run_config, &resume_context.config);
309            if let Some(session_id) = session_id.as_deref() {
310                budget_event = budget_event.with_session_id(session_id);
311            }
312            let (event_store, event_store_fail_closed) =
313                effective_event_store(&self.default_run_config, &resume_context.config);
314            if let Err(error) = capture_event(
315                None,
316                None,
317                event_store.as_ref(),
318                event_store_fail_closed,
319                budget_event.clone(),
320            ) {
321                return Some(Err(error));
322            }
323            resume_budget_events.push(budget_event);
324        }
325
326        if agent_result.completion_reason != Some(CompletionReason::BudgetExhausted)
327            && tool_result.directive == ToolDirective::Continue
328        {
329            let mut config = resume_context.config.clone();
330            config.initial_messages = Some(agent_result.messages.clone());
331            config.initial_shared_state = agent_result.shared_state.clone();
332            config.trace_id = Some(source.trace_id().to_string());
333            if let Err(error) =
334                set_initial_budget_usage(&mut config, agent_result.budget_usage.as_ref())
335            {
336                return Some(Err(error));
337            }
338            let mut prior_events = Vec::new();
339            if !resume_budget_events.is_empty() {
340                prior_events.extend_from_slice(source.events());
341            }
342            prior_events.extend(resume_tool_events);
343            prior_events.extend(resume_budget_events);
344            let result = self
345                .run_with_config_and_run_id(
346                    &resume_context.agent,
347                    NormalizedInput::from(source.input().to_string()),
348                    config,
349                    resumed_run_id,
350                )
351                .await
352                .map(move |result| {
353                    let mut events = prior_events;
354                    events.extend_from_slice(result.events());
355                    let mut metadata = result.metadata().clone();
356                    metadata.insert("resumed".to_string(), Value::Bool(true));
357                    metadata.insert(
358                        "approved_interruption_id".to_string(),
359                        Value::String(approval.interruption_id.clone()),
360                    );
361                    result.with_events(events).with_metadata(metadata)
362                });
363            return Some(result);
364        }
365
366        if agent_result.completion_reason != Some(CompletionReason::BudgetExhausted) {
367            let completion_reason = behavior_reason.unwrap_or(match tool_result.directive {
368                ToolDirective::Finish => CompletionReason::ToolFinish,
369                ToolDirective::WaitUser => CompletionReason::WaitUser,
370                ToolDirective::Continue => unreachable!(),
371            });
372            agent_result.completion_reason = Some(completion_reason);
373            agent_result.completion_tool_name = Some(call.name.clone());
374            agent_result.error = None;
375            match tool_result.directive {
376                ToolDirective::Finish => {
377                    agent_result.status = AgentStatus::Completed;
378                    agent_result.partial_output = None;
379                    agent_result.final_answer =
380                        Some(crate::runtime::extract_final_message(&tool_result));
381                    agent_result.wait_reason = None;
382                }
383                ToolDirective::WaitUser => {
384                    agent_result.status = AgentStatus::WaitUser;
385                    agent_result.partial_output = last_assistant_output(&agent_result.cycles);
386                    agent_result.final_answer = None;
387                    agent_result.wait_reason =
388                        Some(crate::runtime::extract_wait_reason(&tool_result));
389                }
390                ToolDirective::Continue => unreachable!(),
391            }
392        }
393        let guardrail_context = context
394            .run_context
395            .clone()
396            .unwrap_or_else(|| crate::RunContext {
397                run_id: source.run_id().to_string(),
398                agent_name: resume_context.agent.name().to_string(),
399                metadata: source.metadata().clone(),
400                ..crate::RunContext::default()
401            });
402        agent_result =
403            apply_output_guardrails(&resume_context.agent, &guardrail_context, agent_result);
404        agent_result = apply_cancellation_precedence(agent_result, cancellation_token);
405        let output_type_validation_error =
406            output_type_validation_error(&resume_context.agent, &agent_result);
407        let (validated_result, output_validation_error) = apply_optional_output_validation(
408            &resume_context.agent,
409            &guardrail_context,
410            agent_result,
411            output_type_validation_error,
412        );
413        agent_result = validated_result;
414        let resumed = match self.finalize_approval_terminal(
415            source,
416            resume_context,
417            &approval.interruption_id,
418            agent_result,
419            new_items,
420            cancellation_token,
421            Some(resumed_run_id),
422            resume_tool_events
423                .into_iter()
424                .chain(resume_budget_events)
425                .collect(),
426        ) {
427            Ok(resumed) => resumed,
428            Err(error) => return Some(Err(error)),
429        };
430        if let Some(error) = output_validation_error {
431            return Some(Err(error));
432        }
433        let Some(handoff) = extract_handoff(resumed.result()) else {
434            return Some(Ok(resumed));
435        };
436        let event_collector = Arc::new(Mutex::new(resumed.events().to_vec()));
437        let initial_outcome = SingleRunOutcome {
438            result: resumed,
439            handoff: Some(handoff),
440        };
441        let runner = self.clone();
442        let agent = resume_context.agent.clone();
443        let input = resume_context.input.clone();
444        let config = resume_context.config.clone();
445        Some(
446            tokio::task::spawn_blocking(move || {
447                runner.run_agent_chain_with_initial(
448                    &agent,
449                    input,
450                    config,
451                    Some(event_collector),
452                    None,
453                    None,
454                    Some(initial_outcome),
455                    None,
456                )
457            })
458            .await
459            .map_err(|error| format!("resume handoff task failed: {error}"))
460            .and_then(|result| result),
461        )
462    }
463
464    #[allow(clippy::too_many_arguments)] // Keep the terminal commit context explicit and atomic.
465    fn finalize_approval_terminal(
466        &self,
467        source: &RunResult,
468        resume_context: &RunResumeContext,
469        interruption_id: &str,
470        agent_result: AgentResult,
471        new_items: Vec<crate::types::Message>,
472        cancellation_token: Option<&crate::runtime::CancellationToken>,
473        resumed_run_id: Option<String>,
474        additional_events: Vec<RunEvent>,
475    ) -> Result<RunResult, String> {
476        let resumed_run_id =
477            resumed_run_id.unwrap_or_else(|| format!("run_{}", uuid::Uuid::new_v4().simple()));
478        let mut events = source.events().to_vec();
479        events.extend(additional_events);
480        let mut resumed = RunResult::new(
481            resume_context.agent.name().to_string(),
482            agent_result,
483            source
484                .resolved_model()
485                .cloned()
486                .expect("interrupted runs have a resolved model"),
487        )
488        .with_ids(&resumed_run_id, source.trace_id())
489        .with_input(source.input())
490        .with_new_items(new_items)
491        .with_events(events)
492        .with_metadata({
493            let mut metadata = source.metadata().clone();
494            metadata.insert("resumed".to_string(), Value::Bool(true));
495            metadata.insert(
496                "approved_interruption_id".to_string(),
497                Value::String(interruption_id.to_string()),
498            );
499            metadata
500        })
501        .with_resume_context(resume_context.clone());
502        let event_collector = Arc::new(Mutex::new(resumed.events().to_vec()));
503        let session_id = effective_session_id(&self.default_run_config, &resume_context.config);
504        let (event_store, event_store_fail_closed) =
505            effective_event_store(&self.default_run_config, &resume_context.config);
506        capture_event(
507            Some(&event_collector),
508            None,
509            event_store.as_ref(),
510            event_store_fail_closed,
511            terminal_event(
512                resumed.result(),
513                resumed.run_id(),
514                resumed.trace_id(),
515                resume_context.agent.name(),
516                session_id.as_deref(),
517                cancellation_token,
518            ),
519        )?;
520        let events = event_collector
521            .lock()
522            .map(|events| events.clone())
523            .unwrap_or_default();
524        resumed = resumed.with_events(events);
525        Ok(resumed)
526    }
527}
528
529fn persist_approval_lifecycle_events(
530    runner: &Runner,
531    source: &RunResult,
532    resume_context: &RunResumeContext,
533    resumed_run_id: &str,
534    cycle_index: u32,
535    observations: &Arc<Mutex<Vec<ToolLifecycleEvent>>>,
536) -> Result<Vec<RunEvent>, String> {
537    let observations = std::mem::take(
538        &mut *observations
539            .lock()
540            .unwrap_or_else(std::sync::PoisonError::into_inner),
541    );
542    let session_id = effective_session_id(&runner.default_run_config, &resume_context.config);
543    let (event_store, event_store_fail_closed) =
544        effective_event_store(&runner.default_run_config, &resume_context.config);
545    let mut events = Vec::with_capacity(observations.len());
546    for observation in observations {
547        let mut event = approval_lifecycle_run_event(
548            observation,
549            resumed_run_id,
550            source.trace_id(),
551            source.agent_name(),
552            cycle_index,
553        );
554        if let Some(session_id) = session_id.as_deref() {
555            event = event.with_session_id(session_id);
556        }
557        capture_event(
558            None,
559            None,
560            event_store.as_ref(),
561            event_store_fail_closed,
562            event.clone(),
563        )?;
564        events.push(event);
565    }
566    Ok(events)
567}
568
569fn approval_lifecycle_run_event(
570    observation: ToolLifecycleEvent,
571    run_id: &str,
572    trace_id: &str,
573    agent_name: &str,
574    cycle_index: u32,
575) -> RunEvent {
576    match observation {
577        ToolLifecycleEvent::Planned {
578            call,
579            tool_metadata,
580        } => RunEvent::tool_call_planned(
581            run_id,
582            trace_id,
583            agent_name,
584            cycle_index,
585            call.id,
586            call.name,
587            Value::Object(call.arguments.into_iter().collect()),
588        )
589        .with_tool_metadata(tool_metadata.as_ref()),
590        ToolLifecycleEvent::Started {
591            call,
592            tool_metadata,
593        } => RunEvent::tool_call_started(
594            run_id,
595            trace_id,
596            agent_name,
597            cycle_index,
598            call.id,
599            call.name,
600            Value::Object(call.arguments.into_iter().collect()),
601        )
602        .with_tool_metadata(tool_metadata.as_ref()),
603        ToolLifecycleEvent::Completed {
604            call,
605            result,
606            execution_started,
607            duration_ms,
608            tool_metadata,
609        } => {
610            let status = match result.status {
611                crate::types::ToolResultStatus::Success => ToolStatus::Success,
612                crate::types::ToolResultStatus::Error => ToolStatus::Error,
613                crate::types::ToolResultStatus::WaitResponse => ToolStatus::WaitResponse,
614                crate::types::ToolResultStatus::Running => ToolStatus::Running,
615                crate::types::ToolResultStatus::PendingCompress => ToolStatus::PendingCompress,
616            };
617            RunEvent::new(
618                run_id,
619                trace_id,
620                agent_name,
621                Some(cycle_index),
622                RunEventPayload::ToolCallCompleted {
623                    tool_call_id: result.tool_call_id.clone(),
624                    tool_name: call.name,
625                    status,
626                    directive: result.directive,
627                    error_code: result.error_code.clone(),
628                    execution_started,
629                    duration_ms,
630                },
631            )
632            .with_tool_metadata(tool_metadata.as_ref())
633            .with_metadata(
634                "tool_arguments",
635                Value::Object(call.arguments.into_iter().collect()),
636            )
637            .with_metadata(
638                "metadata",
639                Value::Object(result.metadata.into_iter().collect()),
640            )
641            .with_metadata("content", Value::String(result.content))
642        }
643    }
644}
645
646fn set_initial_budget_usage(
647    config: &mut crate::run_config::RunConfig,
648    usage: Option<&crate::budget::BudgetUsageSnapshot>,
649) -> Result<(), String> {
650    match usage {
651        Some(usage) => {
652            let value = serde_json::to_value(usage)
653                .map_err(|error| format!("failed to serialize resumed budget usage: {error}"))?;
654            config
655                .metadata
656                .insert(INITIAL_BUDGET_USAGE_METADATA_KEY.to_string(), value);
657        }
658        None => {
659            config.metadata.remove(INITIAL_BUDGET_USAGE_METADATA_KEY);
660        }
661    }
662    Ok(())
663}
664
665fn select_approved_tool_context<'a>(
666    pending: Option<&'a PendingToolApproval>,
667    approved_ids: &[String],
668) -> Result<Option<&'a PendingToolApproval>, String> {
669    if approved_ids.is_empty() {
670        return Ok(None);
671    }
672    let pending = pending.ok_or_else(|| {
673        "approved tool call is missing its captured interruption context".to_string()
674    })?;
675    if !approved_ids.iter().any(|id| id == &pending.interruption_id) {
676        return Err("approved tool call is missing its captured interruption context".to_string());
677    }
678    Ok(Some(pending))
679}
680
681fn approval_snapshot_matches_result(result: &AgentResult, approval: &PendingToolApproval) -> bool {
682    result.cycles.iter().any(|cycle| {
683        cycle.index == approval.cycle_index
684            && cycle.tool_calls.iter().any(|call| call == &approval.call)
685            && cycle.tool_results.iter().any(|tool_result| {
686                tool_result.tool_call_id == approval.call.id
687                    && tool_result
688                        .metadata
689                        .get("approval_interruption_id")
690                        .and_then(Value::as_str)
691                        == Some(approval.interruption_id.as_str())
692                    && tool_result
693                        .metadata
694                        .get("tool_name")
695                        .and_then(Value::as_str)
696                        == Some(approval.call.name.as_str())
697                    && tool_result.metadata.get("arguments")
698                        == Some(&Value::Object(
699                            approval.call.arguments.clone().into_iter().collect(),
700                        ))
701            })
702    })
703}
704
705#[cfg(test)]
706mod tests {
707    use super::select_approved_tool_context;
708
709    #[test]
710    fn approved_id_without_captured_context_fails_closed() {
711        let error = match select_approved_tool_context(None, &["approval_1".to_string()]) {
712            Ok(_) => panic!("missing context must fail"),
713            Err(error) => error,
714        };
715        assert_eq!(
716            error,
717            "approved tool call is missing its captured interruption context"
718        );
719    }
720
721    #[test]
722    fn conversational_resume_without_approved_id_needs_no_approval_context() {
723        assert!(select_approved_tool_context(None, &[])
724            .expect("conversational resume")
725            .is_none());
726    }
727}