1use crate::WorkContext;
33use std::fmt;
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum Explicit {
38 Org(String),
40 Context(WorkContext),
42 Personal,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum RepositoryOrg {
49 Undeclared,
51 Declared { org: String, declared_in: String },
53 Unreadable { location: String, error: String },
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
60pub enum Permitted {
61 Orgs(Vec<String>),
63 AnyHeld,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct AgentOrganizations {
72 pub agent: String,
74 pub permitted: Permitted,
75 pub default: Option<String>,
77 pub personal: bool,
79}
80
81impl AgentOrganizations {
82 pub fn personal_only(agent: impl Into<String>) -> Self {
85 Self {
86 agent: agent.into(),
87 permitted: Permitted::Orgs(Vec::new()),
88 default: None,
89 personal: true,
90 }
91 }
92}
93
94#[derive(Debug, Clone, Copy)]
97pub struct Inputs<'a> {
98 pub inbound: Option<&'a WorkContext>,
102 pub explicit: Option<&'a Explicit>,
103 pub repository: &'a RepositoryOrg,
104 pub agent: &'a AgentOrganizations,
105 pub user_default: Option<&'a WorkContext>,
107 pub held: &'a [WorkContext],
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub enum Source {
114 Inbound,
115 Explicit,
116 Repository,
117 AgentDefault,
118 UserDefault,
119 Personal,
120}
121
122impl Source {
123 pub fn as_str(&self) -> &'static str {
125 match self {
126 Self::Inbound => "inbound",
127 Self::Explicit => "explicit",
128 Self::Repository => "repository",
129 Self::AgentDefault => "agent_default",
130 Self::UserDefault => "user_default",
131 Self::Personal => "personal",
132 }
133 }
134}
135
136impl fmt::Display for Source {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.write_str(match self {
139 Self::Inbound => "the inbound request",
140 Self::Explicit => "the explicit choice",
141 Self::Repository => "the repository",
142 Self::AgentDefault => "the agent's default",
143 Self::UserDefault => "your default org",
144 Self::Personal => "personal work",
145 })
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct Resolved {
152 pub context: Option<WorkContext>,
154 pub source: Source,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum Refusal {
160 RepositoryUnreadable {
161 location: String,
162 error: String,
163 },
164 RepositoryMismatch {
166 repository_org: String,
167 declared_in: String,
168 requested: Option<String>,
169 source: Source,
170 },
171 NotPermitted {
172 agent: String,
173 org: String,
174 source: Source,
175 },
176 PersonalNotAllowed {
177 agent: String,
178 },
179 NotHeld {
180 org: String,
181 source: Source,
182 },
183 Ambiguous {
184 org: String,
185 accounts: Vec<String>,
186 },
187 ContextNotHeld {
188 context: WorkContext,
189 },
190 DefaultUnavailable {
191 org: String,
192 },
193 Unresolved {
194 agent: String,
195 },
196}
197
198impl Refusal {
199 pub fn code(&self) -> &'static str {
202 match self {
203 Self::RepositoryUnreadable { .. } => "repository_unreadable",
204 Self::RepositoryMismatch { .. } => "repository_mismatch",
205 Self::NotPermitted { .. } => "not_permitted",
206 Self::PersonalNotAllowed { .. } => "personal_not_allowed",
207 Self::NotHeld { .. } => "not_held",
208 Self::Ambiguous { .. } => "ambiguous",
209 Self::ContextNotHeld { .. } => "context_not_held",
210 Self::DefaultUnavailable { .. } => "default_unavailable",
211 Self::Unresolved { .. } => "unresolved",
212 }
213 }
214}
215
216impl fmt::Display for Refusal {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 match self {
219 Self::RepositoryUnreadable { location, error } => write!(
220 f,
221 "cannot read the repository's organization from {location}: {error}. \
222 Fix the file before starting work here"
223 ),
224 Self::RepositoryMismatch {
225 repository_org,
226 declared_in,
227 requested,
228 source,
229 } => {
230 let requested = requested
231 .as_deref()
232 .map(|org| format!("org `{org}`"))
233 .unwrap_or_else(|| "personal work".to_string());
234 write!(
235 f,
236 "this repository belongs to org `{repository_org}` (declared in \
237 {declared_in}); a session here cannot bind {requested} from {source}. \
238 Work in `{repository_org}`, or start the session outside this repository"
239 )
240 }
241 Self::NotPermitted { agent, org, source } => write!(
242 f,
243 "agent `{agent}` is not permitted in org `{org}` (from {source}). \
244 Add `{org}` to the agent's permitted organizations, or choose another org"
245 ),
246 Self::PersonalNotAllowed { agent } => write!(
247 f,
248 "agent `{agent}` does not run personal work. Choose one of its organizations"
249 ),
250 Self::NotHeld { org, source } => write!(
251 f,
252 "no signed-in Parslee login belongs to org `{org}` (from {source}). \
253 Sign in to it with `car auth login --add`"
254 ),
255 Self::Ambiguous { org, accounts } => write!(
256 f,
257 "more than one signed-in login belongs to org `{org}` ({}). \
258 Choose one with `car auth default-org {org} --account <id>`",
259 accounts.join(", ")
260 ),
261 Self::ContextNotHeld { context } => write!(
262 f,
263 "no signed-in login holds the requested context (account `{}`, org `{}`). \
264 Sign in to it with `car auth login --add`",
265 context.account_id,
266 context.organization_id.as_deref().unwrap_or("personal")
267 ),
268 Self::DefaultUnavailable { org } => write!(
269 f,
270 "your default org `{org}` is no longer held by any signed-in login. \
271 Choose a new one with `car auth default-org <org>`"
272 ),
273 Self::Unresolved { agent } => write!(
274 f,
275 "no organization applies to this session and agent `{agent}` does not run \
276 personal work. Name an org, or set a default with `car auth default-org <org>`"
277 ),
278 }
279 }
280}
281
282impl std::error::Error for Refusal {}
283
284enum Candidate<'a> {
286 Exact(&'a WorkContext),
287 Org(&'a str),
288 Personal,
289}
290
291impl Candidate<'_> {
292 fn org(&self) -> Option<&str> {
293 match self {
294 Self::Exact(context) => context.organization_id.as_deref(),
295 Self::Org(org) => Some(org),
296 Self::Personal => None,
297 }
298 }
299}
300
301pub fn resolve(inputs: Inputs<'_>) -> Result<Resolved, Refusal> {
303 if let RepositoryOrg::Unreadable { location, error } = inputs.repository {
304 return Err(Refusal::RepositoryUnreadable {
305 location: location.clone(),
306 error: error.clone(),
307 });
308 }
309
310 let (candidate, source) = pick(&inputs)?;
311
312 if let RepositoryOrg::Declared { org, declared_in } = inputs.repository {
313 if candidate.org() != Some(org.as_str()) {
314 return Err(Refusal::RepositoryMismatch {
315 repository_org: org.clone(),
316 declared_in: declared_in.clone(),
317 requested: candidate.org().map(str::to_string),
318 source,
319 });
320 }
321 }
322
323 let agent = inputs.agent;
324 if !permits(agent, candidate.org()) {
325 return Err(match candidate.org() {
326 None => Refusal::PersonalNotAllowed {
327 agent: agent.agent.clone(),
328 },
329 Some(org) => Refusal::NotPermitted {
330 agent: agent.agent.clone(),
331 org: org.to_string(),
332 source,
333 },
334 });
335 }
336
337 let context = match candidate {
338 Candidate::Personal => None,
339 Candidate::Exact(context) if context.organization_id.is_none() => None,
340 Candidate::Exact(context) => {
341 if !inputs.held.contains(context) {
342 return Err(if source == Source::UserDefault {
343 Refusal::DefaultUnavailable {
344 org: context.organization_id.clone().unwrap_or_default(),
345 }
346 } else {
347 Refusal::ContextNotHeld {
348 context: context.clone(),
349 }
350 });
351 }
352 Some(context.clone())
353 }
354 Candidate::Org(org) => Some(
355 held_by_one(inputs.held, org, source)
356 .or_else(|refusal| default_settles(refusal, &inputs, org))?
357 .clone(),
358 ),
359 };
360 Ok(Resolved { context, source })
361}
362
363fn pick<'a>(inputs: &Inputs<'a>) -> Result<(Candidate<'a>, Source), Refusal> {
364 if let Some(context) = inputs.inbound {
365 return Ok((Candidate::Exact(context), Source::Inbound));
366 }
367 if let Some(explicit) = inputs.explicit {
368 let candidate = match explicit {
369 Explicit::Org(org) => Candidate::Org(org),
370 Explicit::Context(context) => Candidate::Exact(context),
371 Explicit::Personal => Candidate::Personal,
372 };
373 return Ok((candidate, Source::Explicit));
374 }
375 if let RepositoryOrg::Declared { org, .. } = inputs.repository {
376 return Ok((Candidate::Org(org), Source::Repository));
377 }
378 if let Some(org) = inputs.agent.default.as_deref() {
379 return Ok((Candidate::Org(org), Source::AgentDefault));
380 }
381 if let Some(context) = inputs
382 .user_default
383 .filter(|context| permits(inputs.agent, context.organization_id.as_deref()))
384 {
385 return Ok((Candidate::Exact(context), Source::UserDefault));
386 }
387 if inputs.agent.personal {
388 return Ok((Candidate::Personal, Source::Personal));
389 }
390 Err(Refusal::Unresolved {
391 agent: inputs.agent.agent.clone(),
392 })
393}
394
395fn permits(agent: &AgentOrganizations, org: Option<&str>) -> bool {
397 match (org, &agent.permitted) {
398 (None, _) => agent.personal,
399 (Some(_), Permitted::AnyHeld) => true,
400 (Some(org), Permitted::Orgs(orgs)) => orgs.iter().any(|o| o == org),
401 }
402}
403
404fn default_settles<'a>(
410 refusal: Refusal,
411 inputs: &Inputs<'a>,
412 org: &str,
413) -> Result<&'a WorkContext, Refusal> {
414 let Refusal::Ambiguous { .. } = refusal else {
415 return Err(refusal);
416 };
417 inputs
418 .user_default
419 .filter(|default| default.organization_id.as_deref() == Some(org))
420 .and_then(|default| inputs.held.iter().find(|held| *held == default))
421 .ok_or(refusal)
422}
423
424fn held_by_one<'h>(
425 held: &'h [WorkContext],
426 org: &str,
427 source: Source,
428) -> Result<&'h WorkContext, Refusal> {
429 let mut matches = held
430 .iter()
431 .filter(|context| context.organization_id.as_deref() == Some(org));
432 let Some(first) = matches.next() else {
433 return Err(Refusal::NotHeld {
434 org: org.to_string(),
435 source,
436 });
437 };
438 let others: Vec<&WorkContext> = matches.filter(|context| *context != first).collect();
439 if others.is_empty() {
440 return Ok(first);
441 }
442 let mut accounts: Vec<String> = std::iter::once(first)
443 .chain(others)
444 .map(|context| context.account_id.clone())
445 .collect();
446 accounts.sort();
447 accounts.dedup();
448 Err(Refusal::Ambiguous {
449 org: org.to_string(),
450 accounts,
451 })
452}
453
454#[cfg(test)]
455mod tests {
456 use super::*;
457
458 const BASE: &str = "https://api.parslee.ai";
459
460 fn ctx(account: &str, org: &str) -> WorkContext {
461 WorkContext::new(BASE, account, Some(org.into())).unwrap()
462 }
463
464 fn held() -> Vec<WorkContext> {
467 vec![
468 ctx("acc_parslee", "org_parslee"),
469 ctx("acc_parslee", "org_parslee_steelpath"),
470 ctx("acc_fly", "org_fly_exclusive"),
471 ctx("acc_volato", "org_volato"),
472 ]
473 }
474
475 fn agent(permitted: &[&str], default: Option<&str>, personal: bool) -> AgentOrganizations {
476 AgentOrganizations {
477 agent: "parslee-core".into(),
478 permitted: Permitted::Orgs(permitted.iter().map(|s| s.to_string()).collect()),
479 default: default.map(str::to_string),
480 personal,
481 }
482 }
483
484 fn declared(org: &str) -> RepositoryOrg {
485 RepositoryOrg::Declared {
486 org: org.into(),
487 declared_in: "/repo/.car/config.toml".into(),
488 }
489 }
490
491 struct Case<'a> {
492 inbound: Option<WorkContext>,
493 explicit: Option<Explicit>,
494 repository: RepositoryOrg,
495 agent: AgentOrganizations,
496 user_default: Option<WorkContext>,
497 held: &'a [WorkContext],
498 }
499
500 impl Case<'_> {
501 fn resolve(&self) -> Result<Resolved, Refusal> {
502 resolve(Inputs {
503 inbound: self.inbound.as_ref(),
504 explicit: self.explicit.as_ref(),
505 repository: &self.repository,
506 agent: &self.agent,
507 user_default: self.user_default.as_ref(),
508 held: self.held,
509 })
510 }
511 }
512
513 fn base(held: &[WorkContext]) -> Case<'_> {
514 Case {
515 inbound: None,
516 explicit: None,
517 repository: RepositoryOrg::Undeclared,
518 agent: agent(
519 &["org_parslee", "org_fly_exclusive", "org_volato"],
520 None,
521 true,
522 ),
523 user_default: Some(ctx("acc_parslee", "org_parslee")),
524 held,
525 }
526 }
527
528 fn bound(result: Result<Resolved, Refusal>) -> (Option<String>, Option<String>, Source) {
529 let resolved = result.expect("expected a binding");
530 let context = resolved.context;
531 (
532 context.as_ref().map(|c| c.account_id.clone()),
533 context.and_then(|c| c.organization_id),
534 resolved.source,
535 )
536 }
537
538 fn some(s: &str) -> Option<String> {
539 Some(s.to_string())
540 }
541
542 #[test]
543 fn the_user_default_fills_in_when_nothing_else_applies() {
544 let held = held();
545 assert_eq!(
546 bound(base(&held).resolve()),
547 (
548 some("acc_parslee"),
549 some("org_parslee"),
550 Source::UserDefault
551 )
552 );
553 }
554
555 #[test]
556 fn sources_apply_in_order() {
557 let held = held();
558 let mut case = base(&held);
560 case.agent.default = some("org_volato");
561 assert_eq!(
562 bound(case.resolve()),
563 (some("acc_volato"), some("org_volato"), Source::AgentDefault)
564 );
565 case.repository = declared("org_fly_exclusive");
567 assert_eq!(
568 bound(case.resolve()),
569 (
570 some("acc_fly"),
571 some("org_fly_exclusive"),
572 Source::Repository
573 )
574 );
575 case.explicit = Some(Explicit::Org("org_fly_exclusive".into()));
577 assert_eq!(bound(case.resolve()).2, Source::Explicit);
578 case.inbound = Some(ctx("acc_fly", "org_fly_exclusive"));
580 assert_eq!(bound(case.resolve()).2, Source::Inbound);
581 }
582
583 #[test]
584 fn the_org_supplies_the_account() {
585 let held = held();
586 let mut case = base(&held);
587 case.explicit = Some(Explicit::Org("org_parslee_steelpath".into()));
588 case.agent.permitted = Permitted::AnyHeld;
589 assert_eq!(
590 bound(case.resolve()),
591 (
592 some("acc_parslee"),
593 some("org_parslee_steelpath"),
594 Source::Explicit
595 )
596 );
597 }
598
599 #[test]
600 fn a_repository_org_constrains_every_source() {
601 let held = held();
602 let mismatch = |case: &Case<'_>, source: Source| match case.resolve() {
603 Err(Refusal::RepositoryMismatch {
604 repository_org,
605 source: got,
606 ..
607 }) => {
608 assert_eq!(repository_org, "org_fly_exclusive");
609 assert_eq!(got, source);
610 }
611 other => panic!("expected a repository mismatch, got {other:?}"),
612 };
613 let mut case = base(&held);
614 case.repository = declared("org_fly_exclusive");
615
616 case.explicit = Some(Explicit::Org("org_volato".into()));
617 mismatch(&case, Source::Explicit);
618
619 case.explicit = Some(Explicit::Personal);
620 mismatch(&case, Source::Explicit);
621
622 case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
623 mismatch(&case, Source::Explicit);
624
625 case.explicit = None;
628 case.inbound = Some(ctx("acc_volato", "org_volato"));
629 mismatch(&case, Source::Inbound);
630 }
631
632 #[test]
633 fn an_unreadable_repository_refuses_instead_of_falling_back() {
634 let held = held();
635 let mut case = base(&held);
636 case.repository = RepositoryOrg::Unreadable {
637 location: "/repo/.car/config.toml".into(),
638 error: "invalid TOML".into(),
639 };
640 case.explicit = Some(Explicit::Org("org_parslee".into()));
642 assert!(matches!(
643 case.resolve(),
644 Err(Refusal::RepositoryUnreadable { .. })
645 ));
646 }
647
648 #[test]
649 fn the_permitted_set_binds_every_source() {
650 let held = held();
651 let mut case = base(&held);
652 case.agent = agent(&["org_parslee"], None, true);
653
654 case.explicit = Some(Explicit::Org("org_volato".into()));
655 assert!(matches!(
656 case.resolve(),
657 Err(Refusal::NotPermitted {
658 source: Source::Explicit,
659 ..
660 })
661 ));
662
663 case.explicit = None;
665 case.repository = declared("org_volato");
666 assert!(matches!(
667 case.resolve(),
668 Err(Refusal::NotPermitted {
669 source: Source::Repository,
670 ..
671 })
672 ));
673
674 case.repository = RepositoryOrg::Undeclared;
676 case.inbound = Some(ctx("acc_volato", "org_volato"));
677 assert!(matches!(
678 case.resolve(),
679 Err(Refusal::NotPermitted {
680 source: Source::Inbound,
681 ..
682 })
683 ));
684 }
685
686 #[test]
687 fn the_user_default_applies_only_where_the_agent_is_permitted() {
688 let held = held();
689 let mut case = base(&held);
690 case.agent = agent(&["org_parslee"], None, true);
691 case.user_default = Some(ctx("acc_volato", "org_volato"));
692 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
694
695 case.agent.personal = false;
696 assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
697 }
698
699 #[test]
700 fn an_explicit_refusal_never_falls_back_to_a_default() {
701 let held = held();
702 let mut case = base(&held);
703 case.agent = agent(&["org_parslee"], Some("org_parslee"), true);
704 case.explicit = Some(Explicit::Org("org_volato".into()));
705 assert!(matches!(
708 case.resolve(),
709 Err(Refusal::NotPermitted {
710 source: Source::Explicit,
711 ..
712 })
713 ));
714 }
715
716 #[test]
717 fn personal_work_needs_the_agent_to_allow_it() {
718 let held = held();
719 let mut case = base(&held);
720 case.user_default = None;
721 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
722
723 case.agent.personal = false;
724 assert!(matches!(case.resolve(), Err(Refusal::Unresolved { .. })));
725
726 case.explicit = Some(Explicit::Personal);
727 assert!(matches!(
728 case.resolve(),
729 Err(Refusal::PersonalNotAllowed { .. })
730 ));
731 }
732
733 #[test]
734 fn an_agent_with_no_org_settings_stays_personal() {
735 let held = held();
736 let mut case = base(&held);
737 case.agent = AgentOrganizations::personal_only("milo");
738 case.user_default = None;
741 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
742 case.user_default = Some(ctx("acc_parslee", "org_parslee"));
743 assert_eq!(bound(case.resolve()), (None, None, Source::Personal));
744 case.explicit = Some(Explicit::Org("org_parslee".into()));
746 assert!(matches!(case.resolve(), Err(Refusal::NotPermitted { .. })));
747 }
748
749 #[test]
750 fn an_org_no_login_holds_is_refused() {
751 let held = held();
752 let mut case = base(&held);
753 case.agent.permitted = Permitted::AnyHeld;
754 case.explicit = Some(Explicit::Org("org_acme".into()));
755 assert!(matches!(
756 case.resolve(),
757 Err(Refusal::NotHeld {
758 source: Source::Explicit,
759 ..
760 })
761 ));
762 }
763
764 #[test]
765 fn a_stale_user_default_is_refused_by_name() {
766 let held: Vec<WorkContext> = held()
768 .into_iter()
769 .filter(|c| c.account_id != "acc_volato")
770 .collect();
771 let mut case = base(&held);
772 case.user_default = Some(ctx("acc_volato", "org_volato"));
773 match case.resolve() {
774 Err(Refusal::DefaultUnavailable { org }) => assert_eq!(org, "org_volato"),
775 other => panic!("expected DefaultUnavailable, got {other:?}"),
776 }
777 }
778
779 #[test]
780 fn an_explicit_context_must_be_held_exactly() {
781 let held = held();
782 let mut case = base(&held);
783 case.explicit = Some(Explicit::Context(ctx("acc_parslee", "org_volato")));
785 assert!(matches!(
786 case.resolve(),
787 Err(Refusal::ContextNotHeld { .. })
788 ));
789 case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
790 assert_eq!(
791 bound(case.resolve()),
792 (some("acc_volato"), some("org_volato"), Source::Explicit)
793 );
794 }
795
796 #[test]
797 fn two_logins_in_one_org_is_ambiguous_not_a_guess() {
798 let mut held = held();
799 held.push(ctx("acc_parslee", "org_volato"));
800 let mut case = base(&held);
801 case.explicit = Some(Explicit::Org("org_volato".into()));
802 match case.resolve() {
803 Err(Refusal::Ambiguous { org, accounts }) => {
804 assert_eq!(org, "org_volato");
805 assert_eq!(accounts, vec!["acc_parslee", "acc_volato"]);
806 }
807 other => panic!("expected Ambiguous, got {other:?}"),
808 }
809 case.explicit = Some(Explicit::Context(ctx("acc_volato", "org_volato")));
811 assert_eq!(bound(case.resolve()).0, some("acc_volato"));
812 }
813
814 #[test]
818 fn a_repository_org_two_logins_hold_takes_the_default_login() {
819 let mut held = held();
820 held.push(ctx("acc_parslee", "org_volato"));
821 let mut case = base(&held);
822 case.repository = declared("org_volato");
823 case.user_default = Some(ctx("acc_parslee", "org_volato"));
824 assert_eq!(
825 bound(case.resolve()),
826 (some("acc_parslee"), some("org_volato"), Source::Repository)
827 );
828 case.user_default = Some(ctx("acc_parslee", "org_parslee"));
829 let refused = case.resolve().expect_err("no default for the org");
830 assert!(matches!(refused, Refusal::Ambiguous { .. }));
831 assert!(
832 refused
833 .to_string()
834 .contains("car auth default-org org_volato --account"),
835 "{refused}"
836 );
837 }
838
839 #[test]
840 fn duplicate_index_entries_are_not_ambiguous() {
841 let mut held = held();
842 held.push(ctx("acc_volato", "org_volato"));
843 let mut case = base(&held);
844 case.explicit = Some(Explicit::Org("org_volato".into()));
845 assert_eq!(bound(case.resolve()).0, some("acc_volato"));
846 }
847
848 #[test]
849 fn every_refusal_names_a_next_step() {
850 let refusals = [
851 Refusal::RepositoryUnreadable {
852 location: "x".into(),
853 error: "y".into(),
854 },
855 Refusal::RepositoryMismatch {
856 repository_org: "a".into(),
857 declared_in: "x".into(),
858 requested: None,
859 source: Source::Explicit,
860 },
861 Refusal::NotPermitted {
862 agent: "a".into(),
863 org: "o".into(),
864 source: Source::Explicit,
865 },
866 Refusal::PersonalNotAllowed { agent: "a".into() },
867 Refusal::NotHeld {
868 org: "o".into(),
869 source: Source::Explicit,
870 },
871 Refusal::Ambiguous {
872 org: "o".into(),
873 accounts: vec!["a".into(), "b".into()],
874 },
875 Refusal::ContextNotHeld {
876 context: ctx("a", "o"),
877 },
878 Refusal::DefaultUnavailable { org: "o".into() },
879 Refusal::Unresolved { agent: "a".into() },
880 ];
881 for refusal in refusals {
882 let message = refusal.to_string();
883 let actionable = ["Fix", "Work in", "Add", "Choose", "Sign in", "Name"]
884 .iter()
885 .any(|verb| message.contains(verb));
886 assert!(actionable, "refusal without a next step: {message}");
887 }
888 }
889}