1use std::collections::BTreeMap;
14use std::path::Path;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum HarnessKind {
23 ClaudeCode,
24 Codex,
25 OpenCode,
26 Aider,
27 #[default]
28 Unknown,
29}
30
31impl HarnessKind {
32 pub fn as_str(self) -> Option<&'static str> {
34 match self {
35 Self::ClaudeCode => Some("claude-code"),
36 Self::Codex => Some("codex"),
37 Self::OpenCode => Some("opencode"),
38 Self::Aider => Some("aider"),
39 Self::Unknown => None,
40 }
41 }
42
43 pub fn default_provider(self) -> Option<&'static str> {
45 match self {
46 Self::ClaudeCode => Some("anthropic"),
47 Self::Codex => Some("openai"),
48 Self::OpenCode | Self::Aider | Self::Unknown => None,
49 }
50 }
51
52 pub fn parse_name(name: &str) -> Self {
54 match name {
55 "claude-code" => Self::ClaudeCode,
56 "codex" => Self::Codex,
57 "opencode" => Self::OpenCode,
58 "aider" => Self::Aider,
59 _ => Self::Unknown,
60 }
61 }
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Eq)]
66pub struct HarnessFingerprint {
67 pub kind: HarnessKind,
68 pub harness: Option<String>,
69 pub provider: Option<String>,
70 pub model: Option<String>,
71 pub thinking_level: Option<String>,
72 pub policy: Option<String>,
73}
74
75#[derive(Debug, Clone, PartialEq)]
80pub struct HarnessProbeDecision {
81 pub fingerprint: HarnessFingerprint,
82 pub confidence: f32,
84 pub probe_source: &'static str,
86}
87
88pub fn detect_harness_kind(
93 program: Option<&str>,
94 env_hints: &BTreeMap<String, String>,
95) -> HarnessKind {
96 let program = program
97 .and_then(|program| Path::new(program).file_name())
98 .and_then(|name| name.to_str())
99 .map(str::to_ascii_lowercase)
100 .unwrap_or_default();
101 let program = program.strip_suffix(".exe").unwrap_or(&program);
102
103 if matches!(program, "claude" | "claude-code")
104 || env_hints.contains_key("CLAUDECODE")
105 || env_hints.contains_key("CLAUDE_CODE")
106 {
107 HarnessKind::ClaudeCode
108 } else if program == "codex"
109 || env_hints.contains_key("CODEX_SANDBOX")
110 || env_hints.contains_key("CODEX_THREAD_ID")
111 || env_hints.contains_key("CODEX_CI")
112 {
113 HarnessKind::Codex
114 } else if program == "opencode" || env_hints.contains_key("OPENCODE_CLIENT") {
115 HarnessKind::OpenCode
116 } else if program == "aider" {
117 HarnessKind::Aider
118 } else {
119 HarnessKind::Unknown
120 }
121}
122
123pub fn fingerprint_harness_from_hints(
125 argv: Option<&[String]>,
126 env_hints: &BTreeMap<String, String>,
127) -> HarnessFingerprint {
128 let program = argv.and_then(|args| args.first()).map(String::as_str);
129 let kind = detect_harness_kind(program, env_hints);
130
131 let mut fingerprint = HarnessFingerprint {
132 kind,
133 harness: kind.as_str().map(str::to_string),
134 provider: kind.default_provider().map(str::to_string),
135 model: None,
136 thinking_level: None,
137 policy: None,
138 };
139
140 fingerprint.provider = fingerprint.provider.or_else(|| {
143 env_hints
144 .get("HEDDLE_AGENT_PROVIDER")
145 .cloned()
146 .and_then(clean_attribution_value)
147 });
148 fingerprint.model = env_hints
149 .get("HEDDLE_AGENT_MODEL")
150 .cloned()
151 .and_then(clean_attribution_value)
152 .or_else(|| env_hints.get("CODEX_MODEL").cloned())
153 .or_else(|| env_hints.get("CLAUDE_MODEL").cloned())
154 .or_else(|| env_hints.get("ANTHROPIC_MODEL").cloned())
155 .or_else(|| env_hints.get("OPENAI_MODEL").cloned())
156 .or_else(|| env_hints.get("OPENCODE_MODEL").cloned())
157 .or_else(|| env_hints.get("AIDER_MODEL").cloned())
158 .or_else(|| env_hints.get("MODEL").cloned());
159 fingerprint.thinking_level = env_hints
160 .get("THINKING_LEVEL")
161 .cloned()
162 .or_else(|| env_hints.get("CODEX_REASONING_EFFORT").cloned())
163 .or_else(|| env_hints.get("REASONING_EFFORT").cloned())
164 .or_else(|| env_hints.get("OPENAI_REASONING_EFFORT").cloned());
165 fingerprint.policy = env_hints
166 .get("HEDDLE_AGENT_POLICY")
167 .cloned()
168 .and_then(clean_attribution_value)
169 .or_else(|| env_hints.get("PROMPT_POLICY").cloned());
170
171 fingerprint
172}
173
174pub fn decide_harness_probe(
180 explicit_harness: Option<&str>,
181 argv: Option<&[String]>,
182 env_hints: &BTreeMap<String, String>,
183) -> HarnessProbeDecision {
184 let mut fingerprint = fingerprint_harness_from_hints(argv, env_hints);
185 if let Some(name) = explicit_harness {
186 fingerprint.kind = HarnessKind::parse_name(name);
187 fingerprint.harness = Some(name.to_string());
188 }
189 let explicit = explicit_harness.is_some();
190 HarnessProbeDecision {
191 fingerprint,
192 confidence: if explicit { 1.0 } else { 0.4 },
193 probe_source: if explicit {
194 "explicit_payload"
195 } else {
196 "argv_env"
197 },
198 }
199}
200
201fn clean_attribution_value(value: String) -> Option<String> {
203 let trimmed = value.trim();
204 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
205 None
206 } else {
207 Some(value)
208 }
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum SessionAttachRule {
218 ExplicitAgentSession,
219 ExplicitHeddleSession,
220 NativeActorKey,
221 ClientInstanceId,
222 NativeInstanceKey,
223 CurrentWorktreeSession,
224 TokenSid,
225 CreateNewSession,
226}
227
228impl SessionAttachRule {
229 pub fn as_str(self) -> &'static str {
230 match self {
231 Self::ExplicitAgentSession => "explicit-agent-session",
232 Self::ExplicitHeddleSession => "explicit-heddle-session",
233 Self::NativeActorKey => "native-actor-key",
234 Self::ClientInstanceId => "client-instance-id",
235 Self::NativeInstanceKey => "native-instance-key",
236 Self::CurrentWorktreeSession => "current-worktree-session",
237 Self::TokenSid => "token-sid",
238 Self::CreateNewSession => "create-new-session",
239 }
240 }
241}
242
243#[derive(Debug, Clone, PartialEq, Eq)]
245pub enum SessionPolicy {
246 AttachExisting {
247 session_id: String,
248 rule: SessionAttachRule,
249 },
250 CreateNew {
251 because_claimed: bool,
252 rule: SessionAttachRule,
253 },
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
258pub struct SessionAttachDecision {
259 pub policy: SessionPolicy,
260 pub winning_rule: &'static str,
261 pub attach_reason: String,
262 pub precedence: Vec<String>,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Default)]
267pub enum SessionLookupFact {
268 #[default]
270 NotProvided,
271 Miss { key: String },
273 Hit { key: String, session_id: String },
275}
276
277#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct ExplicitAgentBind {
280 pub agent_session_id: String,
281 pub heddle_session_id: String,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Default)]
286pub enum WorktreeSessionFact {
287 #[default]
288 None,
289 Available {
290 session_id: String,
291 },
292 Claimed {
293 session_id: String,
294 },
295}
296
297#[derive(Debug, Clone, PartialEq, Eq, Default)]
299pub enum TokenSidFact {
300 #[default]
301 None,
302 Available {
303 session_id: String,
304 },
305 Claimed {
306 session_id: String,
307 },
308}
309
310#[derive(Debug, Clone, Default, PartialEq, Eq)]
316pub struct SessionAttachFacts {
317 pub explicit_agent: Option<ExplicitAgentBind>,
318 pub explicit_heddle_session_id: Option<String>,
319 pub native_actor: SessionLookupFact,
320 pub client_instance: SessionLookupFact,
321 pub native_instance: SessionLookupFact,
322 pub root_actor: bool,
324 pub current_worktree: WorktreeSessionFact,
325 pub token_sid: TokenSidFact,
326}
327
328pub fn decide_session_attach(facts: &SessionAttachFacts) -> SessionAttachDecision {
330 let mut precedence = Vec::new();
331
332 if let Some(bind) = &facts.explicit_agent {
333 precedence.push(format!(
334 "explicit-agent-session:{}:matched",
335 bind.agent_session_id
336 ));
337 return attach_decision(
338 &bind.heddle_session_id,
339 SessionAttachRule::ExplicitAgentSession,
340 format!(
341 "reattached actor {} to existing Heddle session {}",
342 bind.agent_session_id, bind.heddle_session_id
343 ),
344 precedence,
345 );
346 }
347 precedence.push("explicit-agent-session:miss".to_string());
348
349 if let Some(session_id) = facts.explicit_heddle_session_id.as_deref() {
350 precedence.push(format!("explicit-heddle-session:{session_id}:matched"));
351 return attach_decision(
352 session_id,
353 SessionAttachRule::ExplicitHeddleSession,
354 format!("attached to explicit Heddle session {session_id}"),
355 precedence,
356 );
357 }
358 precedence.push("explicit-heddle-session:miss".to_string());
359
360 let client_instance_provided = !matches!(facts.client_instance, SessionLookupFact::NotProvided);
363 if !client_instance_provided {
364 match &facts.native_actor {
365 SessionLookupFact::Hit { key, session_id } => {
366 precedence.push(format!("native-actor-key:{key}:matched"));
367 return attach_decision(
368 session_id,
369 SessionAttachRule::NativeActorKey,
370 format!("reattached native actor {key} to Heddle session {session_id}"),
371 precedence,
372 );
373 }
374 SessionLookupFact::Miss { key } => {
375 precedence.push(format!("native-actor-key:{key}:miss"));
376 }
377 SessionLookupFact::NotProvided => {
378 precedence.push("native-actor-key:miss".to_string());
379 }
380 }
381 } else {
382 precedence.push("native-actor-key:miss".to_string());
383 }
384
385 match &facts.client_instance {
386 SessionLookupFact::Hit { key, session_id } => {
387 precedence.push(format!("client-instance-id:{key}:matched"));
388 return attach_decision(
389 session_id,
390 SessionAttachRule::ClientInstanceId,
391 format!("reattached client instance {key} to Heddle session {session_id}"),
392 precedence,
393 );
394 }
395 SessionLookupFact::Miss { key } => {
396 precedence.push(format!("client-instance-id:{key}:miss"));
397 return create_decision(
398 false,
399 format!("started new Heddle session for distinct client instance {key}"),
400 precedence,
401 );
402 }
403 SessionLookupFact::NotProvided => {
404 precedence.push("client-instance-id:miss".to_string());
405 }
406 }
407
408 if !client_instance_provided && matches!(facts.native_actor, SessionLookupFact::Miss { .. }) {
411 precedence.push("native-instance-key:skipped-strong-native-key".to_string());
412 return create_decision(
413 false,
414 "started new Heddle session because no compatible native actor match was found"
415 .to_string(),
416 precedence,
417 );
418 }
419
420 match &facts.native_instance {
421 SessionLookupFact::Hit { key, session_id } => {
422 precedence.push(format!("native-instance-key:{key}:matched"));
423 return attach_decision(
424 session_id,
425 SessionAttachRule::NativeInstanceKey,
426 format!("reattached native instance {key} to Heddle session {session_id}"),
427 precedence,
428 );
429 }
430 SessionLookupFact::Miss { key } => {
431 precedence.push(format!("native-instance-key:{key}:miss"));
432 }
433 SessionLookupFact::NotProvided => {
434 precedence.push("native-instance-key:miss".to_string());
435 }
436 }
437
438 if facts.root_actor {
439 match &facts.current_worktree {
440 WorktreeSessionFact::Available { session_id } => {
441 precedence.push(format!("current-worktree-session:{session_id}:matched"));
442 return attach_decision(
443 session_id,
444 SessionAttachRule::CurrentWorktreeSession,
445 format!("attached to active worktree Heddle session {session_id}"),
446 precedence,
447 );
448 }
449 WorktreeSessionFact::Claimed { session_id } => {
450 precedence.push(format!("current-worktree-session:{session_id}:claimed"));
451 return create_decision(
452 true,
453 "started a new Heddle session because the current session was already claimed by another active actor".to_string(),
454 precedence,
455 );
456 }
457 WorktreeSessionFact::None => {
458 precedence.push("current-worktree-session:miss".to_string());
459 }
460 }
461 } else {
462 precedence.push("current-worktree-session:miss".to_string());
463 }
464
465 match &facts.token_sid {
466 TokenSidFact::Available { session_id } => {
467 precedence.push(format!("token-sid:{session_id}:matched"));
468 return attach_decision(
469 session_id,
470 SessionAttachRule::TokenSid,
471 format!("attached to Heddle session {session_id} from auth token sid"),
472 precedence,
473 );
474 }
475 TokenSidFact::Claimed { session_id } => {
476 precedence.push(format!("token-sid:{session_id}:claimed"));
477 return create_decision(
478 true,
479 "started a new Heddle session because the current session was already claimed by another active actor".to_string(),
480 precedence,
481 );
482 }
483 TokenSidFact::None => {
484 precedence.push("token-sid:miss".to_string());
485 }
486 }
487
488 create_decision(false, "started new Heddle session".to_string(), precedence)
489}
490
491fn attach_decision(
492 session_id: &str,
493 rule: SessionAttachRule,
494 attach_reason: String,
495 precedence: Vec<String>,
496) -> SessionAttachDecision {
497 SessionAttachDecision {
498 policy: SessionPolicy::AttachExisting {
499 session_id: session_id.to_string(),
500 rule,
501 },
502 winning_rule: rule.as_str(),
503 attach_reason,
504 precedence,
505 }
506}
507
508fn create_decision(
509 because_claimed: bool,
510 attach_reason: String,
511 precedence: Vec<String>,
512) -> SessionAttachDecision {
513 let rule = SessionAttachRule::CreateNewSession;
514 SessionAttachDecision {
515 policy: SessionPolicy::CreateNew {
516 because_claimed,
517 rule,
518 },
519 winning_rule: rule.as_str(),
520 attach_reason,
521 precedence,
522 }
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub enum SegmentRotation {
532 Keep,
533 Rotate,
534}
535
536pub fn segment_rotation_policy(
542 current_provider: Option<&str>,
543 current_model: Option<&str>,
544 new_provider: Option<&str>,
545 new_model: Option<&str>,
546) -> SegmentRotation {
547 let Some(current_provider) = current_provider else {
548 return SegmentRotation::Keep;
549 };
550 let current_model = current_model.unwrap_or("");
554
555 let provider_changed = new_provider.is_some_and(|p| p != current_provider);
556 let model_changed = new_model.is_some_and(|m| m != current_model);
557 if provider_changed || model_changed {
558 SegmentRotation::Rotate
559 } else {
560 SegmentRotation::Keep
561 }
562}
563
564pub fn should_rotate_segment(
566 current_provider: Option<&str>,
567 current_model: Option<&str>,
568 new_provider: Option<&str>,
569 new_model: Option<&str>,
570) -> bool {
571 matches!(
572 segment_rotation_policy(current_provider, current_model, new_provider, new_model),
573 SegmentRotation::Rotate
574 )
575}
576
577#[cfg(test)]
582mod tests {
583 use super::*;
584
585 fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
586 pairs
587 .iter()
588 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
589 .collect()
590 }
591
592 #[test]
593 fn detect_harness_kind_from_env_and_program() {
594 assert_eq!(
595 detect_harness_kind(None, &env(&[("CLAUDECODE", "1")])),
596 HarnessKind::ClaudeCode
597 );
598 assert_eq!(
599 detect_harness_kind(Some("/usr/bin/codex"), &BTreeMap::new()),
600 HarnessKind::Codex
601 );
602 assert_eq!(
603 detect_harness_kind(None, &env(&[("OPENCODE_CLIENT", "desktop")])),
604 HarnessKind::OpenCode
605 );
606 assert_eq!(
607 detect_harness_kind(Some("aider"), &BTreeMap::new()),
608 HarnessKind::Aider
609 );
610 assert_eq!(
611 detect_harness_kind(Some("bash"), &BTreeMap::new()),
612 HarnessKind::Unknown
613 );
614 }
615
616 #[test]
617 fn detect_harness_kind_matches_program_file_name_only() {
618 let no_env = BTreeMap::new();
619
620 assert_eq!(
621 detect_harness_kind(
622 Some("/home/u/dev/.claude/worktrees/x/target/debug/heddle"),
623 &no_env,
624 ),
625 HarnessKind::Unknown
626 );
627 assert_eq!(
628 detect_harness_kind(Some("/usr/bin/claude"), &no_env),
629 HarnessKind::ClaudeCode
630 );
631 assert_eq!(
632 detect_harness_kind(Some("claude-code"), &no_env),
633 HarnessKind::ClaudeCode
634 );
635 assert_eq!(
636 detect_harness_kind(Some("/home/u/dev/codex/target/debug/heddle"), &no_env),
637 HarnessKind::Unknown
638 );
639 assert_eq!(
640 detect_harness_kind(Some("/usr/bin/codex"), &no_env),
641 HarnessKind::Codex
642 );
643 assert_eq!(
644 detect_harness_kind(Some("CODEX.EXE"), &no_env),
645 HarnessKind::Codex
646 );
647 }
648
649 #[test]
650 fn fingerprint_prefers_claude_over_codex_env_when_program_is_claude() {
651 let fp = fingerprint_harness_from_hints(
652 Some(&["claude".to_string()]),
653 &env(&[("CODEX_THREAD_ID", "t1"), ("CLAUDECODE", "1")]),
654 );
655 assert_eq!(fp.kind, HarnessKind::ClaudeCode);
656 assert_eq!(fp.harness.as_deref(), Some("claude-code"));
657 assert_eq!(fp.provider.as_deref(), Some("anthropic"));
658 }
659
660 #[test]
661 fn fingerprint_reads_model_and_thinking_env() {
662 let fp = fingerprint_harness_from_hints(
663 None,
664 &env(&[
665 ("CODEX_THREAD_ID", "t1"),
666 ("CODEX_MODEL", "gpt-5.5"),
667 ("CODEX_REASONING_EFFORT", "xhigh"),
668 ]),
669 );
670 assert_eq!(fp.kind, HarnessKind::Codex);
671 assert_eq!(fp.model.as_deref(), Some("gpt-5.5"));
672 assert_eq!(fp.thinking_level.as_deref(), Some("xhigh"));
673 assert_eq!(fp.provider.as_deref(), Some("openai"));
674 }
675
676 #[test]
677 fn fingerprint_strips_blank_heddle_agent_env() {
678 let fp = fingerprint_harness_from_hints(
679 None,
680 &env(&[
681 ("HEDDLE_AGENT_PROVIDER", "custom"),
682 ("HEDDLE_AGENT_MODEL", ""),
683 ("HEDDLE_AGENT_POLICY", "unknown"),
684 ("MODEL", "fallback-model"),
685 ]),
686 );
687 assert_eq!(fp.provider.as_deref(), Some("custom"));
688 assert_eq!(fp.model.as_deref(), Some("fallback-model"));
689 assert_eq!(fp.policy, None);
690 }
691
692 #[test]
693 fn decide_harness_probe_explicit_vs_argv() {
694 let explicit = decide_harness_probe(Some("codex"), None, &BTreeMap::new());
695 assert_eq!(explicit.confidence, 1.0);
696 assert_eq!(explicit.probe_source, "explicit_payload");
697 assert_eq!(explicit.fingerprint.harness.as_deref(), Some("codex"));
698 assert_eq!(explicit.fingerprint.provider, None);
700
701 let argv = decide_harness_probe(None, Some(&["/bin/claude".to_string()]), &BTreeMap::new());
702 assert_eq!(argv.confidence, 0.4);
703 assert_eq!(argv.probe_source, "argv_env");
704 assert_eq!(argv.fingerprint.kind, HarnessKind::ClaudeCode);
705 assert_eq!(argv.fingerprint.provider.as_deref(), Some("anthropic"));
706 }
707
708 #[test]
709 fn segment_rotates_on_provider_or_model_change_only() {
710 assert!(!should_rotate_segment(
711 Some("anthropic"),
712 Some("opus"),
713 Some("anthropic"),
714 Some("opus"),
715 ));
716 assert!(should_rotate_segment(
717 Some("anthropic"),
718 Some("opus"),
719 Some("openai"),
720 Some("opus"),
721 ));
722 assert!(should_rotate_segment(
723 Some("anthropic"),
724 Some("opus"),
725 Some("anthropic"),
726 Some("sonnet"),
727 ));
728 assert!(!should_rotate_segment(
730 Some("anthropic"),
731 Some("opus"),
732 None,
733 None,
734 ));
735 assert!(!should_rotate_segment(
737 None,
738 None,
739 Some("anthropic"),
740 Some("opus")
741 ));
742 assert_eq!(
743 segment_rotation_policy(
744 Some("anthropic"),
745 Some("opus"),
746 Some("anthropic"),
747 Some("sonnet"),
748 ),
749 SegmentRotation::Rotate
750 );
751 }
752
753 #[test]
754 fn session_attach_explicit_agent_wins() {
755 let decision = decide_session_attach(&SessionAttachFacts {
756 explicit_agent: Some(ExplicitAgentBind {
757 agent_session_id: "agent-1".into(),
758 heddle_session_id: "sess-1".into(),
759 }),
760 explicit_heddle_session_id: Some("sess-other".into()),
761 ..SessionAttachFacts::default()
762 });
763 assert_eq!(
764 decision.policy,
765 SessionPolicy::AttachExisting {
766 session_id: "sess-1".into(),
767 rule: SessionAttachRule::ExplicitAgentSession,
768 }
769 );
770 assert_eq!(decision.winning_rule, "explicit-agent-session");
771 assert!(decision.precedence[0].contains("matched"));
772 }
773
774 #[test]
775 fn session_attach_client_instance_miss_creates() {
776 let decision = decide_session_attach(&SessionAttachFacts {
777 client_instance: SessionLookupFact::Miss {
778 key: "cli-2".into(),
779 },
780 native_actor: SessionLookupFact::Hit {
781 key: "codex:thread:t".into(),
782 session_id: "should-not-use".into(),
783 },
784 ..SessionAttachFacts::default()
785 });
786 assert_eq!(
787 decision.policy,
788 SessionPolicy::CreateNew {
789 because_claimed: false,
790 rule: SessionAttachRule::CreateNewSession,
791 }
792 );
793 assert!(decision.attach_reason.contains("cli-2"));
794 assert!(
796 decision
797 .precedence
798 .iter()
799 .any(|p| p == "native-actor-key:miss")
800 );
801 }
802
803 #[test]
804 fn session_attach_native_actor_hit_and_miss() {
805 let hit = decide_session_attach(&SessionAttachFacts {
806 native_actor: SessionLookupFact::Hit {
807 key: "codex:thread:t1".into(),
808 session_id: "sess-a".into(),
809 },
810 ..SessionAttachFacts::default()
811 });
812 assert_eq!(
813 hit.policy,
814 SessionPolicy::AttachExisting {
815 session_id: "sess-a".into(),
816 rule: SessionAttachRule::NativeActorKey,
817 }
818 );
819
820 let miss = decide_session_attach(&SessionAttachFacts {
821 native_actor: SessionLookupFact::Miss {
822 key: "codex:thread:t2".into(),
823 },
824 ..SessionAttachFacts::default()
825 });
826 assert!(matches!(miss.policy, SessionPolicy::CreateNew { .. }));
827 assert!(
828 miss.precedence
829 .iter()
830 .any(|p| p == "native-instance-key:skipped-strong-native-key")
831 );
832 }
833
834 #[test]
835 fn session_attach_worktree_and_token_claim_paths() {
836 let available = decide_session_attach(&SessionAttachFacts {
837 root_actor: true,
838 current_worktree: WorktreeSessionFact::Available {
839 session_id: "wt-1".into(),
840 },
841 ..SessionAttachFacts::default()
842 });
843 assert_eq!(
844 available.policy,
845 SessionPolicy::AttachExisting {
846 session_id: "wt-1".into(),
847 rule: SessionAttachRule::CurrentWorktreeSession,
848 }
849 );
850
851 let claimed = decide_session_attach(&SessionAttachFacts {
852 root_actor: true,
853 current_worktree: WorktreeSessionFact::Claimed {
854 session_id: "wt-2".into(),
855 },
856 ..SessionAttachFacts::default()
857 });
858 assert_eq!(
859 claimed.policy,
860 SessionPolicy::CreateNew {
861 because_claimed: true,
862 rule: SessionAttachRule::CreateNewSession,
863 }
864 );
865
866 let token = decide_session_attach(&SessionAttachFacts {
867 token_sid: TokenSidFact::Available {
868 session_id: "tok-1".into(),
869 },
870 ..SessionAttachFacts::default()
871 });
872 assert_eq!(
873 token.policy,
874 SessionPolicy::AttachExisting {
875 session_id: "tok-1".into(),
876 rule: SessionAttachRule::TokenSid,
877 }
878 );
879 }
880
881 #[test]
882 fn session_attach_default_creates_new() {
883 let decision = decide_session_attach(&SessionAttachFacts::default());
884 assert_eq!(
885 decision.policy,
886 SessionPolicy::CreateNew {
887 because_claimed: false,
888 rule: SessionAttachRule::CreateNewSession,
889 }
890 );
891 assert_eq!(decision.attach_reason, "started new Heddle session");
892 assert_eq!(decision.winning_rule, "create-new-session");
893 }
894}