1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use starweaver_context::AgentRunState;
8use starweaver_model::TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY;
9use thiserror::Error;
10
11use crate::{ApprovalStatus, ExecutionStatus, SessionResumeSnapshot};
12
13#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
15#[serde(rename_all = "snake_case")]
16pub enum ContinuationPreparationMode {
17 Ordinary,
19 WaitingHitl,
21}
22
23#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
29pub struct PreparedContinuation {
30 pub mode: ContinuationPreparationMode,
32 pub snapshot: SessionResumeSnapshot,
34}
35
36impl PreparedContinuation {
37 pub fn ordinary(snapshot: SessionResumeSnapshot) -> Result<Self, ContinuationPreparationError> {
43 validate_snapshot_identity(&snapshot)?;
44 Ok(Self {
45 mode: ContinuationPreparationMode::Ordinary,
46 snapshot,
47 })
48 }
49
50 pub fn waiting_hitl(
57 mut snapshot: SessionResumeSnapshot,
58 ) -> Result<Self, ContinuationPreparationError> {
59 validate_snapshot_identity(&snapshot)?;
60 if snapshot.run.status != crate::RunStatus::Waiting {
61 return Err(ContinuationPreparationError::SourceNotWaiting(
62 snapshot.run.run_id.as_str().to_string(),
63 ));
64 }
65 let checkpoint_state = {
66 let checkpoint = snapshot.latest_checkpoint.as_mut().ok_or_else(|| {
67 ContinuationPreparationError::MissingCheckpoint(
68 snapshot.run.run_id.as_str().to_string(),
69 )
70 })?;
71 checkpoint.state.status = starweaver_core::RunLifecycle::Waiting;
74 checkpoint.state.clone()
75 };
76 validate_waiting_evidence(&snapshot, &checkpoint_state)?;
77 Ok(Self {
78 mode: ContinuationPreparationMode::WaitingHitl,
79 snapshot,
80 })
81 }
82
83 #[must_use]
85 pub fn waiting_state(&self) -> Option<&AgentRunState> {
86 matches!(self.mode, ContinuationPreparationMode::WaitingHitl)
87 .then(|| {
88 self.snapshot
89 .latest_checkpoint
90 .as_ref()
91 .map(|checkpoint| &checkpoint.state)
92 })
93 .flatten()
94 }
95
96 #[must_use]
98 pub fn into_snapshot(self) -> SessionResumeSnapshot {
99 self.snapshot
100 }
101}
102
103#[derive(Clone, Debug, Eq, Error, PartialEq)]
105pub enum ContinuationPreparationError {
106 #[error("continuation snapshot identity mismatch: {0}")]
108 IdentityMismatch(String),
109 #[error("run {0} is not waiting")]
111 SourceNotWaiting(String),
112 #[error("waiting run {0} has no resumable checkpoint")]
114 MissingCheckpoint(String),
115 #[error("waiting run {0} has no pending HITL work")]
117 NoPendingHitl(String),
118 #[error("duplicate durable {kind} decision for {id}")]
120 DuplicateDecision {
121 kind: &'static str,
123 id: String,
125 },
126 #[error("missing terminal durable {kind} decision for {id}")]
128 MissingDecision {
129 kind: &'static str,
131 id: String,
133 },
134 #[error("unknown durable {kind} decision for {id}")]
136 UnknownDecision {
137 kind: &'static str,
139 id: String,
141 },
142 #[error("durable {kind} decision {id} references {actual}; expected {expected}")]
144 MismatchedDecisionIdentity {
145 kind: &'static str,
147 id: String,
149 actual: String,
151 expected: String,
153 },
154 #[error("durable {kind} decision {id} has mismatched {field}")]
156 MismatchedDecisionEvidence {
157 kind: &'static str,
159 id: String,
161 field: &'static str,
163 },
164 #[error("inconsistent durable approval decision for {0}")]
166 InconsistentApproval(String),
167}
168
169fn validate_snapshot_identity(
170 snapshot: &SessionResumeSnapshot,
171) -> Result<(), ContinuationPreparationError> {
172 if snapshot.session.session_id != snapshot.run.session_id {
173 return Err(ContinuationPreparationError::IdentityMismatch(format!(
174 "session {} does not own run {}",
175 snapshot.session.session_id.as_str(),
176 snapshot.run.run_id.as_str()
177 )));
178 }
179 if snapshot
180 .state
181 .run_id
182 .as_ref()
183 .is_some_and(|run_id| run_id != &snapshot.run.run_id)
184 {
185 return Err(ContinuationPreparationError::IdentityMismatch(format!(
186 "context run does not match {}",
187 snapshot.run.run_id.as_str()
188 )));
189 }
190 if snapshot
191 .state
192 .conversation_id
193 .as_ref()
194 .is_some_and(|conversation_id| conversation_id != &snapshot.run.conversation_id)
195 {
196 return Err(ContinuationPreparationError::IdentityMismatch(format!(
197 "context conversation does not match {}",
198 snapshot.run.conversation_id.as_str()
199 )));
200 }
201 validate_optional_durable_id(
202 &snapshot.state.metadata,
203 "starweaver.durable_session_id",
204 snapshot.session.session_id.as_str(),
205 )?;
206 validate_optional_durable_id(
207 &snapshot.state.metadata,
208 "starweaver.durable_run_id",
209 snapshot.run.run_id.as_str(),
210 )?;
211 if let Some(checkpoint) = &snapshot.latest_checkpoint {
212 if checkpoint.run_id != snapshot.run.run_id {
213 return Err(ContinuationPreparationError::IdentityMismatch(format!(
214 "checkpoint run does not match {}",
215 snapshot.run.run_id.as_str()
216 )));
217 }
218 if checkpoint.state.run_id != snapshot.run.run_id {
219 return Err(ContinuationPreparationError::IdentityMismatch(format!(
220 "checkpoint state run does not match {}",
221 snapshot.run.run_id.as_str()
222 )));
223 }
224 if checkpoint.conversation_id != snapshot.run.conversation_id {
225 return Err(ContinuationPreparationError::IdentityMismatch(format!(
226 "checkpoint conversation does not match {}",
227 snapshot.run.conversation_id.as_str()
228 )));
229 }
230 if checkpoint.state.conversation_id != snapshot.run.conversation_id {
231 return Err(ContinuationPreparationError::IdentityMismatch(format!(
232 "checkpoint state conversation does not match {}",
233 snapshot.run.conversation_id.as_str()
234 )));
235 }
236 if checkpoint.node != checkpoint.resume.node
237 || checkpoint.run_step != checkpoint.resume.run_step
238 || checkpoint.run_step != checkpoint.state.run_step
239 {
240 return Err(ContinuationPreparationError::IdentityMismatch(
241 "checkpoint boundary metadata does not match checkpoint state".to_string(),
242 ));
243 }
244 }
245 Ok(())
246}
247
248fn validate_optional_durable_id(
249 metadata: &serde_json::Map<String, Value>,
250 key: &'static str,
251 expected: &str,
252) -> Result<(), ContinuationPreparationError> {
253 if metadata
254 .get(key)
255 .is_some_and(|value| value.as_str() != Some(expected))
256 {
257 return Err(ContinuationPreparationError::IdentityMismatch(format!(
258 "context metadata {key} does not match {expected}"
259 )));
260 }
261 Ok(())
262}
263
264fn validate_waiting_evidence(
265 snapshot: &SessionResumeSnapshot,
266 state: &AgentRunState,
267) -> Result<(), ContinuationPreparationError> {
268 if !state.has_pending_hitl() {
269 return Err(ContinuationPreparationError::NoPendingHitl(
270 snapshot.run.run_id.as_str().to_string(),
271 ));
272 }
273 validate_approval_evidence(snapshot, state)?;
274
275 let pending_deferred = state
276 .deferred_tool_returns
277 .iter()
278 .map(|item| {
279 (
280 format!("deferred_{}_{}", state.run_id.as_str(), item.tool_call_id),
281 item.tool_call_id.clone(),
282 )
283 })
284 .collect::<BTreeMap<_, _>>();
285 let mut deferred = BTreeMap::new();
286 for record in &snapshot.deferred_tools {
287 if record.session_id != snapshot.run.session_id || record.run_id != snapshot.run.run_id {
288 return Err(ContinuationPreparationError::IdentityMismatch(format!(
289 "deferred record {} does not belong to the source run",
290 record.deferred_id
291 )));
292 }
293 if deferred
294 .insert(record.deferred_id.clone(), record)
295 .is_some()
296 {
297 return Err(ContinuationPreparationError::DuplicateDecision {
298 kind: "deferred",
299 id: record.deferred_id.clone(),
300 });
301 }
302 }
303 validate_deferred_set(&pending_deferred, &deferred)
304}
305
306fn validate_approval_evidence(
307 snapshot: &SessionResumeSnapshot,
308 state: &AgentRunState,
309) -> Result<(), ContinuationPreparationError> {
310 let pending_tool_calls = state
311 .latest_response
312 .iter()
313 .flat_map(starweaver_model::ModelResponse::tool_calls)
314 .chain(state.pending_tool_calls.iter().cloned())
315 .map(|call| (call.id.clone(), call))
316 .collect::<BTreeMap<_, _>>();
317 let mut pending_approvals = BTreeMap::new();
318 for item in &state.pending_approval_tool_returns {
319 let action_id = item.tool_call_id.clone();
320 let approval_id = format!("approval_{}_{}", state.run_id.as_str(), action_id);
321 let tool_call = pending_tool_calls.get(&action_id).ok_or_else(|| {
322 ContinuationPreparationError::IdentityMismatch(format!(
323 "pending approval {approval_id} has no matching checkpoint tool call"
324 ))
325 })?;
326 if tool_call.name != item.name {
327 return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
328 kind: "approval",
329 id: approval_id,
330 field: "checkpoint tool name",
331 });
332 }
333 let request = item
334 .metadata
335 .get("approval")
336 .cloned()
337 .unwrap_or(Value::Null);
338 let reviewed_arguments = item
339 .metadata
340 .get(TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY)
341 .ok_or_else(
342 || ContinuationPreparationError::MismatchedDecisionEvidence {
343 kind: "approval",
344 id: approval_id.clone(),
345 field: "reviewed tool arguments",
346 },
347 )?;
348 if reviewed_arguments != &tool_call.arguments.execution_value() {
349 return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
350 kind: "approval",
351 id: approval_id,
352 field: "checkpoint tool arguments",
353 });
354 }
355 if pending_approvals
356 .insert(
357 action_id.clone(),
358 PendingApprovalEvidence {
359 approval_id,
360 action_name: item.name.clone(),
361 request,
362 reviewed_arguments: reviewed_arguments.clone(),
363 },
364 )
365 .is_some()
366 {
367 return Err(ContinuationPreparationError::DuplicateDecision {
368 kind: "pending approval",
369 id: action_id,
370 });
371 }
372 }
373 let mut approvals = BTreeMap::new();
374 for record in &snapshot.approvals {
375 if record.session_id != snapshot.run.session_id || record.run_id != snapshot.run.run_id {
376 return Err(ContinuationPreparationError::IdentityMismatch(format!(
377 "approval {} does not belong to the source run",
378 record.approval_id
379 )));
380 }
381 let Some(expected) = pending_approvals.get(&record.action_id) else {
382 return Err(ContinuationPreparationError::UnknownDecision {
383 kind: "approval",
384 id: record.action_id.clone(),
385 });
386 };
387 if record.approval_id != expected.approval_id {
388 return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
389 kind: "approval",
390 id: record.action_id.clone(),
391 field: "approval id",
392 });
393 }
394 if approvals.insert(record.action_id.clone(), record).is_some() {
395 return Err(ContinuationPreparationError::DuplicateDecision {
396 kind: "approval",
397 id: record.action_id.clone(),
398 });
399 }
400 }
401 validate_approval_set(&pending_approvals, &approvals)
402}
403
404#[derive(Clone, Debug, Eq, PartialEq)]
405struct PendingApprovalEvidence {
406 approval_id: String,
407 action_name: String,
408 request: Value,
409 reviewed_arguments: Value,
410}
411
412fn validate_approval_set(
413 pending: &BTreeMap<String, PendingApprovalEvidence>,
414 records: &BTreeMap<String, &crate::ApprovalRecord>,
415) -> Result<(), ContinuationPreparationError> {
416 for (id, expected) in pending {
417 let record =
418 records
419 .get(id)
420 .ok_or_else(|| ContinuationPreparationError::MissingDecision {
421 kind: "approval",
422 id: id.clone(),
423 })?;
424 if record.action_name != expected.action_name {
425 return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
426 kind: "approval",
427 id: record.approval_id.clone(),
428 field: "action name",
429 });
430 }
431 if record.request != expected.request {
432 return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
433 kind: "approval",
434 id: record.approval_id.clone(),
435 field: "request",
436 });
437 }
438 if record.reviewed_arguments.as_ref() != Some(&expected.reviewed_arguments) {
439 return Err(ContinuationPreparationError::MismatchedDecisionEvidence {
440 kind: "approval",
441 id: record.approval_id.clone(),
442 field: "durable reviewed tool arguments",
443 });
444 }
445 if record.status == ApprovalStatus::Pending {
446 return Err(ContinuationPreparationError::MissingDecision {
447 kind: "approval",
448 id: id.clone(),
449 });
450 }
451 let Some(decision) = record.decision.as_ref() else {
452 return Err(ContinuationPreparationError::MissingDecision {
453 kind: "approval",
454 id: id.clone(),
455 });
456 };
457 let consistent = decision.status == record.status
458 || (matches!(
459 record.status,
460 ApprovalStatus::Expired | ApprovalStatus::Cancelled
461 ) && matches!(decision.status, ApprovalStatus::Denied));
462 if !consistent {
463 return Err(ContinuationPreparationError::InconsistentApproval(
464 id.clone(),
465 ));
466 }
467 }
468 if let Some(id) = records.keys().find(|id| !pending.contains_key(*id)) {
469 return Err(ContinuationPreparationError::UnknownDecision {
470 kind: "approval",
471 id: id.clone(),
472 });
473 }
474 Ok(())
475}
476
477fn validate_deferred_set(
478 pending: &BTreeMap<String, String>,
479 records: &BTreeMap<String, &crate::DeferredToolRecord>,
480) -> Result<(), ContinuationPreparationError> {
481 for (id, expected_tool_call_id) in pending {
482 let record =
483 records
484 .get(id)
485 .ok_or_else(|| ContinuationPreparationError::MissingDecision {
486 kind: "deferred",
487 id: id.clone(),
488 })?;
489 if record.tool_call_id != *expected_tool_call_id {
490 return Err(ContinuationPreparationError::MismatchedDecisionIdentity {
491 kind: "deferred",
492 id: id.clone(),
493 actual: record.tool_call_id.clone(),
494 expected: expected_tool_call_id.clone(),
495 });
496 }
497 if matches!(
498 record.status,
499 ExecutionStatus::Pending | ExecutionStatus::Running | ExecutionStatus::Waiting
500 ) {
501 return Err(ContinuationPreparationError::MissingDecision {
502 kind: "deferred",
503 id: id.clone(),
504 });
505 }
506 }
507 if let Some(id) = records.keys().find(|id| !pending.contains_key(*id)) {
508 return Err(ContinuationPreparationError::UnknownDecision {
509 kind: "deferred",
510 id: id.clone(),
511 });
512 }
513 Ok(())
514}
515
516#[cfg(test)]
517mod tests {
518 use std::collections::BTreeMap;
519
520 use serde_json::json;
521 use starweaver_context::{AgentCheckpoint, AgentRunState, ResumableState};
522 use starweaver_core::{AgentExecutionNode, ConversationId, RunId, SessionId};
523 use starweaver_model::{
524 TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY, ToolArguments, ToolCallPart, ToolReturnPart,
525 };
526
527 use super::{ContinuationPreparationError, PreparedContinuation, validate_deferred_set};
528 use crate::{
529 ApprovalRecord, ApprovalStatus, DeferredToolRecord, ExecutionStatus, RunRecord, RunStatus,
530 SessionRecord, SessionResumeSnapshot, ToolApprovalDecision, ToolReturnRecordInput,
531 };
532
533 fn waiting_approval_snapshot() -> SessionResumeSnapshot {
534 let session_id = SessionId::from_string("session_waiting");
535 let run_id = RunId::from_string("run_waiting");
536 let conversation_id = ConversationId::from_string("conversation_waiting");
537 let arguments = json!({"command": "rm -rf target/tmp"});
538 let approval_request = json!({
539 "reviewer": "shell_command_reviewer",
540 "reason": "destructive command needs review",
541 "risk_level": "high",
542 "command": "rm -rf target/tmp",
543 });
544 let mut metadata = serde_json::Map::new();
545 metadata.insert("control_flow".to_string(), json!("approval_required"));
546 metadata.insert("approval".to_string(), approval_request);
547 metadata.insert(
548 TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY.to_string(),
549 arguments.clone(),
550 );
551 let pending_return =
552 ToolReturnPart::new("call_approval", "shell", json!("approval required"))
553 .with_error(true)
554 .with_metadata(metadata);
555 let mut run_state = AgentRunState::new(run_id.clone(), conversation_id.clone());
556 run_state.pending_tool_calls.push(ToolCallPart {
557 id: "call_approval".to_string(),
558 name: "shell".to_string(),
559 arguments: ToolArguments::parsed(arguments),
560 });
561 run_state
562 .pending_approval_tool_returns
563 .push(pending_return.clone());
564 let checkpoint = AgentCheckpoint::new(AgentExecutionNode::ToolReturn, &run_state);
565
566 let input = ToolReturnRecordInput::new(
567 &session_id,
568 &run_id,
569 &pending_return.tool_call_id,
570 &pending_return.name,
571 &pending_return.metadata,
572 );
573 let Some(mut approval) = ApprovalRecord::from_tool_return(&input) else {
574 panic!("approval-required tool return must produce durable evidence");
575 };
576 approval.status = ApprovalStatus::Approved;
577 approval.decision = Some(ToolApprovalDecision::approved().into_approval_decision());
578
579 let mut state = ResumableState {
580 run_id: Some(run_id.clone()),
581 session_id: Some(SessionId::from_string("provider_routing_affinity")),
583 conversation_id: Some(conversation_id.clone()),
584 ..ResumableState::default()
585 };
586 state.metadata.insert(
587 "starweaver.durable_session_id".to_string(),
588 json!(session_id.as_str()),
589 );
590 state.metadata.insert(
591 "starweaver.durable_run_id".to_string(),
592 json!(run_id.as_str()),
593 );
594 let mut run = RunRecord::new(session_id.clone(), run_id, conversation_id);
595 run.status = RunStatus::Waiting;
596 SessionResumeSnapshot {
597 session: SessionRecord::new(session_id),
598 run,
599 state,
600 environment_state: None,
601 latest_checkpoint: Some(checkpoint),
602 stream_records: Vec::new(),
603 approvals: vec![approval],
604 deferred_tools: Vec::new(),
605 stream_cursors: Vec::new(),
606 }
607 }
608
609 #[test]
610 fn waiting_approval_binds_durable_identity_name_request_and_tool_arguments() {
611 let snapshot = waiting_approval_snapshot();
612 assert!(PreparedContinuation::waiting_hitl(snapshot.clone()).is_ok());
613
614 let mut missing_arguments_binding = snapshot.clone();
615 let Some(checkpoint) = missing_arguments_binding.latest_checkpoint.as_mut() else {
616 panic!("test snapshot must include a checkpoint");
617 };
618 checkpoint.state.pending_approval_tool_returns[0]
619 .metadata
620 .remove(TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY);
621 assert!(matches!(
622 PreparedContinuation::waiting_hitl(missing_arguments_binding),
623 Err(ContinuationPreparationError::MismatchedDecisionEvidence {
624 field: "reviewed tool arguments",
625 ..
626 })
627 ));
628
629 let mut jointly_tampered_arguments = snapshot.clone();
630 let Some(checkpoint) = jointly_tampered_arguments.latest_checkpoint.as_mut() else {
631 panic!("test snapshot must include a checkpoint");
632 };
633 let tampered = json!({"command": "other", "environment": {"TOKEN": "changed"}});
634 checkpoint.state.pending_tool_calls[0].arguments = ToolArguments::parsed(tampered.clone());
635 checkpoint.state.pending_approval_tool_returns[0]
636 .metadata
637 .insert(
638 TOOL_RETURN_APPROVAL_ARGUMENTS_METADATA_KEY.to_string(),
639 tampered,
640 );
641 assert!(matches!(
642 PreparedContinuation::waiting_hitl(jointly_tampered_arguments),
643 Err(ContinuationPreparationError::MismatchedDecisionEvidence {
644 field: "durable reviewed tool arguments",
645 ..
646 })
647 ));
648
649 let mut wrong_id = snapshot.clone();
650 wrong_id.approvals[0].approval_id = "approval_other".to_string();
651 assert!(matches!(
652 PreparedContinuation::waiting_hitl(wrong_id),
653 Err(ContinuationPreparationError::MismatchedDecisionEvidence {
654 field: "approval id",
655 ..
656 })
657 ));
658
659 let mut wrong_name = snapshot.clone();
660 wrong_name.approvals[0].action_name = "other_tool".to_string();
661 assert!(matches!(
662 PreparedContinuation::waiting_hitl(wrong_name),
663 Err(ContinuationPreparationError::MismatchedDecisionEvidence {
664 field: "action name",
665 ..
666 })
667 ));
668
669 let mut wrong_request = snapshot.clone();
670 wrong_request.approvals[0].request = json!({"arguments": {"command": "other"}});
671 assert!(matches!(
672 PreparedContinuation::waiting_hitl(wrong_request),
673 Err(ContinuationPreparationError::MismatchedDecisionEvidence {
674 field: "request",
675 ..
676 })
677 ));
678
679 let mut wrong_arguments = snapshot;
680 let Some(checkpoint) = wrong_arguments.latest_checkpoint.as_mut() else {
681 panic!("test snapshot must include a checkpoint");
682 };
683 checkpoint.state.pending_tool_calls[0].arguments =
684 ToolArguments::parsed(json!({"command": "other"}));
685 assert!(matches!(
686 PreparedContinuation::waiting_hitl(wrong_arguments),
687 Err(ContinuationPreparationError::MismatchedDecisionEvidence {
688 field: "checkpoint tool arguments",
689 ..
690 })
691 ));
692 }
693
694 fn assert_identity_rejected(snapshot: SessionResumeSnapshot) {
695 assert!(matches!(
696 PreparedContinuation::ordinary(snapshot.clone()),
697 Err(ContinuationPreparationError::IdentityMismatch(_))
698 ));
699 assert!(matches!(
700 PreparedContinuation::waiting_hitl(snapshot),
701 Err(ContinuationPreparationError::IdentityMismatch(_))
702 ));
703 }
704
705 #[test]
706 fn continuation_rejects_context_and_checkpoint_identity_tampering() {
707 let snapshot = waiting_approval_snapshot();
708
709 let mut context_conversation = snapshot.clone();
710 context_conversation.state.conversation_id =
711 Some(ConversationId::from_string("conversation_other"));
712 assert_identity_rejected(context_conversation);
713
714 let mut durable_session = snapshot.clone();
715 durable_session.state.metadata.insert(
716 "starweaver.durable_session_id".to_string(),
717 json!("session_other"),
718 );
719 assert_identity_rejected(durable_session);
720
721 let mut checkpoint_run = snapshot.clone();
722 let Some(checkpoint) = checkpoint_run.latest_checkpoint.as_mut() else {
723 panic!("test snapshot must include a checkpoint");
724 };
725 checkpoint.run_id = RunId::from_string("run_other");
726 assert_identity_rejected(checkpoint_run);
727
728 let mut checkpoint_conversation = snapshot.clone();
729 let Some(checkpoint) = checkpoint_conversation.latest_checkpoint.as_mut() else {
730 panic!("test snapshot must include a checkpoint");
731 };
732 checkpoint.conversation_id = ConversationId::from_string("conversation_other");
733 assert_identity_rejected(checkpoint_conversation);
734
735 let mut checkpoint_state_conversation = snapshot;
736 let Some(checkpoint) = checkpoint_state_conversation.latest_checkpoint.as_mut() else {
737 panic!("test snapshot must include a checkpoint");
738 };
739 checkpoint.state.conversation_id = ConversationId::from_string("conversation_other");
740 assert_identity_rejected(checkpoint_state_conversation);
741 }
742
743 #[test]
744 fn deferred_decision_rejects_mismatched_tool_call_identity() {
745 let deferred_id = "deferred_run_waiting_call_expected".to_string();
746 let pending = BTreeMap::from([(deferred_id.clone(), "call_expected".to_string())]);
747 let mut record = DeferredToolRecord::new(
748 deferred_id.clone(),
749 SessionId::from_string("session_waiting"),
750 RunId::from_string("run_waiting"),
751 "call_other",
752 "shell",
753 );
754 record.status = ExecutionStatus::Completed;
755 let records = BTreeMap::from([(deferred_id.clone(), &record)]);
756
757 assert_eq!(
758 validate_deferred_set(&pending, &records),
759 Err(ContinuationPreparationError::MismatchedDecisionIdentity {
760 kind: "deferred",
761 id: deferred_id,
762 actual: "call_other".to_string(),
763 expected: "call_expected".to_string(),
764 })
765 );
766 }
767
768 #[test]
769 fn deferred_decision_accepts_matching_terminal_identity() {
770 let deferred_id = "deferred_run_waiting_call_expected".to_string();
771 let pending = BTreeMap::from([(deferred_id.clone(), "call_expected".to_string())]);
772 let mut record = DeferredToolRecord::new(
773 deferred_id.clone(),
774 SessionId::from_string("session_waiting"),
775 RunId::from_string("run_waiting"),
776 "call_expected",
777 "shell",
778 );
779 record.status = ExecutionStatus::Completed;
780 let records = BTreeMap::from([(deferred_id, &record)]);
781
782 assert_eq!(validate_deferred_set(&pending, &records), Ok(()));
783 }
784}