1use std::{collections::BTreeMap, 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.thinking_level = env_hints
141 .get("THINKING_LEVEL")
142 .cloned()
143 .or_else(|| env_hints.get("CODEX_REASONING_EFFORT").cloned())
144 .or_else(|| env_hints.get("REASONING_EFFORT").cloned())
145 .or_else(|| env_hints.get("OPENAI_REASONING_EFFORT").cloned());
146 fingerprint.policy = env_hints
147 .get("HEDDLE_AGENT_POLICY")
148 .cloned()
149 .and_then(clean_attribution_value)
150 .or_else(|| env_hints.get("PROMPT_POLICY").cloned());
151
152 fingerprint
153}
154
155pub fn decide_harness_probe(
161 explicit_harness: Option<&str>,
162 argv: Option<&[String]>,
163 env_hints: &BTreeMap<String, String>,
164) -> HarnessProbeDecision {
165 let mut fingerprint = fingerprint_harness_from_hints(argv, env_hints);
166 if let Some(name) = explicit_harness {
167 fingerprint.kind = HarnessKind::parse_name(name);
168 fingerprint.harness = Some(name.to_string());
169 }
170 let explicit = explicit_harness.is_some();
171 HarnessProbeDecision {
172 fingerprint,
173 confidence: if explicit { 1.0 } else { 0.4 },
174 probe_source: if explicit {
175 "explicit_payload"
176 } else {
177 "argv_env"
178 },
179 }
180}
181
182fn clean_attribution_value(value: String) -> Option<String> {
184 let trimmed = value.trim();
185 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("unknown") {
186 None
187 } else {
188 Some(value)
189 }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
198pub enum SessionAttachRule {
199 ExplicitAgentSession,
200 ExplicitHeddleSession,
201 NativeActorKey,
202 ClientInstanceId,
203 NativeInstanceKey,
204 CurrentWorktreeSession,
205 TokenSid,
206 CreateNewSession,
207}
208
209impl SessionAttachRule {
210 pub fn as_str(self) -> &'static str {
211 match self {
212 Self::ExplicitAgentSession => "explicit-agent-session",
213 Self::ExplicitHeddleSession => "explicit-heddle-session",
214 Self::NativeActorKey => "native-actor-key",
215 Self::ClientInstanceId => "client-instance-id",
216 Self::NativeInstanceKey => "native-instance-key",
217 Self::CurrentWorktreeSession => "current-worktree-session",
218 Self::TokenSid => "token-sid",
219 Self::CreateNewSession => "create-new-session",
220 }
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
226pub enum SessionPolicy {
227 AttachExisting {
228 session_id: String,
229 rule: SessionAttachRule,
230 },
231 CreateNew {
232 because_claimed: bool,
233 rule: SessionAttachRule,
234 },
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct SessionAttachDecision {
240 pub policy: SessionPolicy,
241 pub winning_rule: &'static str,
242 pub attach_reason: String,
243 pub precedence: Vec<String>,
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Default)]
248pub enum SessionLookupFact {
249 #[default]
251 NotProvided,
252 Miss { key: String },
254 Hit { key: String, session_id: String },
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct ExplicitAgentBind {
261 pub agent_session_id: String,
262 pub heddle_session_id: String,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Default)]
267pub enum WorktreeSessionFact {
268 #[default]
269 None,
270 Available {
271 session_id: String,
272 },
273 Claimed {
274 session_id: String,
275 },
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, Default)]
280pub enum TokenSidFact {
281 #[default]
282 None,
283 Available {
284 session_id: String,
285 },
286 Claimed {
287 session_id: String,
288 },
289}
290
291#[derive(Debug, Clone, Default, PartialEq, Eq)]
297pub struct SessionAttachFacts {
298 pub explicit_agent: Option<ExplicitAgentBind>,
299 pub explicit_heddle_session_id: Option<String>,
300 pub native_actor: SessionLookupFact,
301 pub client_instance: SessionLookupFact,
302 pub native_instance: SessionLookupFact,
303 pub root_actor: bool,
305 pub current_worktree: WorktreeSessionFact,
306 pub token_sid: TokenSidFact,
307}
308
309pub fn decide_session_attach(facts: &SessionAttachFacts) -> SessionAttachDecision {
311 let mut precedence = Vec::new();
312
313 if let Some(bind) = &facts.explicit_agent {
314 precedence.push(format!(
315 "explicit-agent-session:{}:matched",
316 bind.agent_session_id
317 ));
318 return attach_decision(
319 &bind.heddle_session_id,
320 SessionAttachRule::ExplicitAgentSession,
321 format!(
322 "reattached actor {} to existing Heddle session {}",
323 bind.agent_session_id, bind.heddle_session_id
324 ),
325 precedence,
326 );
327 }
328 precedence.push("explicit-agent-session:miss".to_string());
329
330 if let Some(session_id) = facts.explicit_heddle_session_id.as_deref() {
331 precedence.push(format!("explicit-heddle-session:{session_id}:matched"));
332 return attach_decision(
333 session_id,
334 SessionAttachRule::ExplicitHeddleSession,
335 format!("attached to explicit Heddle session {session_id}"),
336 precedence,
337 );
338 }
339 precedence.push("explicit-heddle-session:miss".to_string());
340
341 let client_instance_provided = !matches!(facts.client_instance, SessionLookupFact::NotProvided);
344 if !client_instance_provided {
345 match &facts.native_actor {
346 SessionLookupFact::Hit { key, session_id } => {
347 precedence.push(format!("native-actor-key:{key}:matched"));
348 return attach_decision(
349 session_id,
350 SessionAttachRule::NativeActorKey,
351 format!("reattached native actor {key} to Heddle session {session_id}"),
352 precedence,
353 );
354 }
355 SessionLookupFact::Miss { key } => {
356 precedence.push(format!("native-actor-key:{key}:miss"));
357 }
358 SessionLookupFact::NotProvided => {
359 precedence.push("native-actor-key:miss".to_string());
360 }
361 }
362 } else {
363 precedence.push("native-actor-key:miss".to_string());
364 }
365
366 match &facts.client_instance {
367 SessionLookupFact::Hit { key, session_id } => {
368 precedence.push(format!("client-instance-id:{key}:matched"));
369 return attach_decision(
370 session_id,
371 SessionAttachRule::ClientInstanceId,
372 format!("reattached client instance {key} to Heddle session {session_id}"),
373 precedence,
374 );
375 }
376 SessionLookupFact::Miss { key } => {
377 precedence.push(format!("client-instance-id:{key}:miss"));
378 return create_decision(
379 false,
380 format!("started new Heddle session for distinct client instance {key}"),
381 precedence,
382 );
383 }
384 SessionLookupFact::NotProvided => {
385 precedence.push("client-instance-id:miss".to_string());
386 }
387 }
388
389 if !client_instance_provided && matches!(facts.native_actor, SessionLookupFact::Miss { .. }) {
392 precedence.push("native-instance-key:skipped-strong-native-key".to_string());
393 return create_decision(
394 false,
395 "started new Heddle session because no compatible native actor match was found"
396 .to_string(),
397 precedence,
398 );
399 }
400
401 match &facts.native_instance {
402 SessionLookupFact::Hit { key, session_id } => {
403 precedence.push(format!("native-instance-key:{key}:matched"));
404 return attach_decision(
405 session_id,
406 SessionAttachRule::NativeInstanceKey,
407 format!("reattached native instance {key} to Heddle session {session_id}"),
408 precedence,
409 );
410 }
411 SessionLookupFact::Miss { key } => {
412 precedence.push(format!("native-instance-key:{key}:miss"));
413 }
414 SessionLookupFact::NotProvided => {
415 precedence.push("native-instance-key:miss".to_string());
416 }
417 }
418
419 if facts.root_actor {
420 match &facts.current_worktree {
421 WorktreeSessionFact::Available { session_id } => {
422 precedence.push(format!("current-worktree-session:{session_id}:matched"));
423 return attach_decision(
424 session_id,
425 SessionAttachRule::CurrentWorktreeSession,
426 format!("attached to active worktree Heddle session {session_id}"),
427 precedence,
428 );
429 }
430 WorktreeSessionFact::Claimed { session_id } => {
431 precedence.push(format!("current-worktree-session:{session_id}:claimed"));
432 return create_decision(
433 true,
434 "started a new Heddle session because the current session was already claimed by another active actor".to_string(),
435 precedence,
436 );
437 }
438 WorktreeSessionFact::None => {
439 precedence.push("current-worktree-session:miss".to_string());
440 }
441 }
442 } else {
443 precedence.push("current-worktree-session:miss".to_string());
444 }
445
446 match &facts.token_sid {
447 TokenSidFact::Available { session_id } => {
448 precedence.push(format!("token-sid:{session_id}:matched"));
449 return attach_decision(
450 session_id,
451 SessionAttachRule::TokenSid,
452 format!("attached to Heddle session {session_id} from auth token sid"),
453 precedence,
454 );
455 }
456 TokenSidFact::Claimed { session_id } => {
457 precedence.push(format!("token-sid:{session_id}:claimed"));
458 return create_decision(
459 true,
460 "started a new Heddle session because the current session was already claimed by another active actor".to_string(),
461 precedence,
462 );
463 }
464 TokenSidFact::None => {
465 precedence.push("token-sid:miss".to_string());
466 }
467 }
468
469 create_decision(false, "started new Heddle session".to_string(), precedence)
470}
471
472fn attach_decision(
473 session_id: &str,
474 rule: SessionAttachRule,
475 attach_reason: String,
476 precedence: Vec<String>,
477) -> SessionAttachDecision {
478 SessionAttachDecision {
479 policy: SessionPolicy::AttachExisting {
480 session_id: session_id.to_string(),
481 rule,
482 },
483 winning_rule: rule.as_str(),
484 attach_reason,
485 precedence,
486 }
487}
488
489fn create_decision(
490 because_claimed: bool,
491 attach_reason: String,
492 precedence: Vec<String>,
493) -> SessionAttachDecision {
494 let rule = SessionAttachRule::CreateNewSession;
495 SessionAttachDecision {
496 policy: SessionPolicy::CreateNew {
497 because_claimed,
498 rule,
499 },
500 winning_rule: rule.as_str(),
501 attach_reason,
502 precedence,
503 }
504}
505
506#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum SegmentRotation {
513 Keep,
514 Attach,
516 Rotate,
517}
518
519pub fn segment_rotation_policy(
528 current_provider: Option<&str>,
529 current_model: Option<&str>,
530 new_provider: Option<&str>,
531 new_model: Option<&str>,
532) -> SegmentRotation {
533 cursor_segment_rotation(
534 current_provider,
535 current_model,
536 None,
537 new_provider,
538 new_model,
539 None,
540 )
541}
542
543pub fn cursor_segment_rotation(
545 current_provider: Option<&str>,
546 current_model: Option<&str>,
547 current_thought_level: Option<&str>,
548 new_provider: Option<&str>,
549 new_model: Option<&str>,
550 new_thought_level: Option<&str>,
551) -> SegmentRotation {
552 if field_rotates(current_provider, new_provider)
553 || field_rotates(current_model, new_model)
554 || field_rotates(current_thought_level, new_thought_level)
555 {
556 SegmentRotation::Rotate
557 } else if field_attaches(current_provider, new_provider)
558 || field_attaches(current_model, new_model)
559 || field_attaches(current_thought_level, new_thought_level)
560 {
561 SegmentRotation::Attach
562 } else {
563 SegmentRotation::Keep
564 }
565}
566
567fn field_rotates(current: Option<&str>, incoming: Option<&str>) -> bool {
568 match (
569 crate::identity_cursor::published_field(current),
570 crate::identity_cursor::published_field(incoming),
571 ) {
572 (Some(current), Some(incoming)) => current != incoming,
573 _ => false,
574 }
575}
576
577fn field_attaches(current: Option<&str>, incoming: Option<&str>) -> bool {
578 crate::identity_cursor::published_field(current).is_none()
579 && crate::identity_cursor::published_field(incoming).is_some()
580}
581
582pub fn attach_published_segment_fields(
584 segment: &mut objects::object::SessionSegment,
585 provider: Option<&str>,
586 model: Option<&str>,
587 thought_level: Option<&str>,
588) {
589 if crate::identity_cursor::published_field(Some(segment.provider.as_str())).is_none()
590 && let Some(provider) = crate::identity_cursor::published_field(provider)
591 {
592 segment.provider = provider.to_string();
593 }
594 if crate::identity_cursor::published_field(Some(segment.model.as_str())).is_none()
595 && let Some(model) = crate::identity_cursor::published_field(model)
596 {
597 segment.model = model.to_string();
598 }
599 if crate::identity_cursor::published_field(segment.thought_level.as_deref()).is_none()
600 && let Some(thought_level) = crate::identity_cursor::published_field(thought_level)
601 {
602 segment.thought_level = Some(thought_level.to_string());
603 }
604}
605
606pub fn should_rotate_segment(
608 current_provider: Option<&str>,
609 current_model: Option<&str>,
610 new_provider: Option<&str>,
611 new_model: Option<&str>,
612) -> bool {
613 matches!(
614 segment_rotation_policy(current_provider, current_model, new_provider, new_model),
615 SegmentRotation::Rotate
616 )
617}
618
619#[cfg(test)]
624mod tests {
625 use super::*;
626
627 fn env(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
628 pairs
629 .iter()
630 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
631 .collect()
632 }
633
634 #[test]
635 fn detect_harness_kind_from_env_and_program() {
636 assert_eq!(
637 detect_harness_kind(None, &env(&[("CLAUDECODE", "1")])),
638 HarnessKind::ClaudeCode
639 );
640 assert_eq!(
641 detect_harness_kind(Some("/usr/bin/codex"), &BTreeMap::new()),
642 HarnessKind::Codex
643 );
644 assert_eq!(
645 detect_harness_kind(None, &env(&[("OPENCODE_CLIENT", "desktop")])),
646 HarnessKind::OpenCode
647 );
648 assert_eq!(
649 detect_harness_kind(Some("aider"), &BTreeMap::new()),
650 HarnessKind::Aider
651 );
652 assert_eq!(
653 detect_harness_kind(Some("bash"), &BTreeMap::new()),
654 HarnessKind::Unknown
655 );
656 }
657
658 #[test]
659 fn detect_harness_kind_matches_program_file_name_only() {
660 let no_env = BTreeMap::new();
661
662 assert_eq!(
663 detect_harness_kind(
664 Some("/home/u/dev/.claude/worktrees/x/target/debug/heddle"),
665 &no_env,
666 ),
667 HarnessKind::Unknown
668 );
669 assert_eq!(
670 detect_harness_kind(Some("/usr/bin/claude"), &no_env),
671 HarnessKind::ClaudeCode
672 );
673 assert_eq!(
674 detect_harness_kind(Some("claude-code"), &no_env),
675 HarnessKind::ClaudeCode
676 );
677 assert_eq!(
678 detect_harness_kind(Some("/home/u/dev/codex/target/debug/heddle"), &no_env),
679 HarnessKind::Unknown
680 );
681 assert_eq!(
682 detect_harness_kind(Some("/usr/bin/codex"), &no_env),
683 HarnessKind::Codex
684 );
685 assert_eq!(
686 detect_harness_kind(Some("CODEX.EXE"), &no_env),
687 HarnessKind::Codex
688 );
689 }
690
691 #[test]
692 fn fingerprint_prefers_claude_over_codex_env_when_program_is_claude() {
693 let fp = fingerprint_harness_from_hints(
694 Some(&["claude".to_string()]),
695 &env(&[("CODEX_THREAD_ID", "t1"), ("CLAUDECODE", "1")]),
696 );
697 assert_eq!(fp.kind, HarnessKind::ClaudeCode);
698 assert_eq!(fp.harness.as_deref(), Some("claude-code"));
699 assert_eq!(fp.provider.as_deref(), Some("anthropic"));
700 }
701
702 #[test]
703 fn fingerprint_does_not_invent_model_from_hoped_for_env() {
704 let fp = fingerprint_harness_from_hints(
705 None,
706 &env(&[
707 ("CODEX_THREAD_ID", "t1"),
708 ("CODEX_MODEL", "gpt-5.5"),
709 ("HEDDLE_AGENT_MODEL", "claude-opus-4-7"),
710 ("MODEL", "fallback-model"),
711 ("CODEX_REASONING_EFFORT", "xhigh"),
712 ]),
713 );
714 assert_eq!(fp.kind, HarnessKind::Codex);
715 assert!(
716 fp.model.is_none(),
717 "kind-only fingerprint must not hunt a model"
718 );
719 assert_eq!(fp.thinking_level.as_deref(), Some("xhigh"));
720 assert_eq!(fp.provider.as_deref(), Some("openai"));
721 }
722
723 #[test]
724 fn fingerprint_strips_blank_heddle_agent_policy() {
725 let fp = fingerprint_harness_from_hints(
726 None,
727 &env(&[
728 ("HEDDLE_AGENT_PROVIDER", "custom"),
729 ("HEDDLE_AGENT_MODEL", ""),
730 ("HEDDLE_AGENT_POLICY", "unknown"),
731 ("MODEL", "fallback-model"),
732 ]),
733 );
734 assert!(fp.provider.is_none());
735 assert!(fp.model.is_none());
736 assert_eq!(fp.policy, None);
737 }
738
739 #[test]
740 fn decide_harness_probe_explicit_vs_argv() {
741 let explicit = decide_harness_probe(Some("codex"), None, &BTreeMap::new());
742 assert_eq!(explicit.confidence, 1.0);
743 assert_eq!(explicit.probe_source, "explicit_payload");
744 assert_eq!(explicit.fingerprint.harness.as_deref(), Some("codex"));
745 assert_eq!(explicit.fingerprint.provider, None);
747
748 let argv = decide_harness_probe(None, Some(&["/bin/claude".to_string()]), &BTreeMap::new());
749 assert_eq!(argv.confidence, 0.4);
750 assert_eq!(argv.probe_source, "argv_env");
751 assert_eq!(argv.fingerprint.kind, HarnessKind::ClaudeCode);
752 assert_eq!(argv.fingerprint.provider.as_deref(), Some("anthropic"));
753 }
754
755 #[test]
756 fn segment_rotates_on_provider_or_model_change_only() {
757 assert!(!should_rotate_segment(
758 Some("anthropic"),
759 Some("opus"),
760 Some("anthropic"),
761 Some("opus"),
762 ));
763 assert!(should_rotate_segment(
764 Some("anthropic"),
765 Some("opus"),
766 Some("openai"),
767 Some("opus"),
768 ));
769 assert!(should_rotate_segment(
770 Some("anthropic"),
771 Some("opus"),
772 Some("anthropic"),
773 Some("sonnet"),
774 ));
775 assert!(!should_rotate_segment(
777 Some("anthropic"),
778 Some("opus"),
779 None,
780 None,
781 ));
782 assert!(!should_rotate_segment(
784 None,
785 None,
786 Some("anthropic"),
787 Some("opus")
788 ));
789 assert_eq!(
790 segment_rotation_policy(
791 Some("anthropic"),
792 Some("opus"),
793 Some("anthropic"),
794 Some("sonnet"),
795 ),
796 SegmentRotation::Rotate
797 );
798 }
799
800 #[test]
801 fn segment_rotates_on_thought_level_change_empty_set_is_attach() {
802 assert_eq!(
803 cursor_segment_rotation(
804 Some("anthropic"),
805 Some("opus"),
806 None,
807 Some("anthropic"),
808 Some("opus"),
809 Some("high"),
810 ),
811 SegmentRotation::Attach
812 );
813 assert_eq!(
814 cursor_segment_rotation(
815 Some("anthropic"),
816 Some("opus"),
817 Some("high"),
818 Some("anthropic"),
819 Some("opus"),
820 Some("low"),
821 ),
822 SegmentRotation::Rotate
823 );
824 assert_eq!(
825 cursor_segment_rotation(
826 Some("anthropic"),
827 Some(""),
828 None,
829 Some("anthropic"),
830 Some("opus"),
831 None,
832 ),
833 SegmentRotation::Attach
834 );
835 }
836
837 #[test]
838 fn unpublished_to_published_attaches_placeholder_segment() {
839 assert_eq!(
840 cursor_segment_rotation(
841 Some("unknown"),
842 Some("unknown"),
843 None,
844 Some("anthropic"),
845 Some("opus"),
846 Some("high"),
847 ),
848 SegmentRotation::Attach
849 );
850 assert!(!should_rotate_segment(
851 Some("unknown"),
852 Some("unknown"),
853 Some("anthropic"),
854 Some("opus"),
855 ));
856 let mut segment = objects::object::SessionSegment {
857 id: "sess-1-seg-1".into(),
858 provider: "unknown".into(),
859 model: "unknown".into(),
860 started_at: chrono::Utc::now(),
861 policy_id: None,
862 thought_level: None,
863 };
864 attach_published_segment_fields(
865 &mut segment,
866 Some("anthropic"),
867 Some("opus"),
868 Some("high"),
869 );
870 assert_eq!(segment.provider, "anthropic");
871 assert_eq!(segment.model, "opus");
872 assert_eq!(segment.thought_level.as_deref(), Some("high"));
873 assert_eq!(
874 cursor_segment_rotation(
875 Some("anthropic"),
876 Some("opus"),
877 Some("high"),
878 Some("anthropic"),
879 Some("sonnet"),
880 Some("high"),
881 ),
882 SegmentRotation::Rotate
883 );
884 }
885
886 #[test]
887 fn session_attach_explicit_agent_wins() {
888 let decision = decide_session_attach(&SessionAttachFacts {
889 explicit_agent: Some(ExplicitAgentBind {
890 agent_session_id: "agent-1".into(),
891 heddle_session_id: "sess-1".into(),
892 }),
893 explicit_heddle_session_id: Some("sess-other".into()),
894 ..SessionAttachFacts::default()
895 });
896 assert_eq!(
897 decision.policy,
898 SessionPolicy::AttachExisting {
899 session_id: "sess-1".into(),
900 rule: SessionAttachRule::ExplicitAgentSession,
901 }
902 );
903 assert_eq!(decision.winning_rule, "explicit-agent-session");
904 assert!(decision.precedence[0].contains("matched"));
905 }
906
907 #[test]
908 fn session_attach_client_instance_miss_creates() {
909 let decision = decide_session_attach(&SessionAttachFacts {
910 client_instance: SessionLookupFact::Miss {
911 key: "cli-2".into(),
912 },
913 native_actor: SessionLookupFact::Hit {
914 key: "codex:thread:t".into(),
915 session_id: "should-not-use".into(),
916 },
917 ..SessionAttachFacts::default()
918 });
919 assert_eq!(
920 decision.policy,
921 SessionPolicy::CreateNew {
922 because_claimed: false,
923 rule: SessionAttachRule::CreateNewSession,
924 }
925 );
926 assert!(decision.attach_reason.contains("cli-2"));
927 assert!(
929 decision
930 .precedence
931 .iter()
932 .any(|p| p == "native-actor-key:miss")
933 );
934 }
935
936 #[test]
937 fn session_attach_native_actor_hit_and_miss() {
938 let hit = decide_session_attach(&SessionAttachFacts {
939 native_actor: SessionLookupFact::Hit {
940 key: "codex:thread:t1".into(),
941 session_id: "sess-a".into(),
942 },
943 ..SessionAttachFacts::default()
944 });
945 assert_eq!(
946 hit.policy,
947 SessionPolicy::AttachExisting {
948 session_id: "sess-a".into(),
949 rule: SessionAttachRule::NativeActorKey,
950 }
951 );
952
953 let miss = decide_session_attach(&SessionAttachFacts {
954 native_actor: SessionLookupFact::Miss {
955 key: "codex:thread:t2".into(),
956 },
957 ..SessionAttachFacts::default()
958 });
959 assert!(matches!(miss.policy, SessionPolicy::CreateNew { .. }));
960 assert!(
961 miss.precedence
962 .iter()
963 .any(|p| p == "native-instance-key:skipped-strong-native-key")
964 );
965 }
966
967 #[test]
968 fn session_attach_worktree_and_token_claim_paths() {
969 let available = decide_session_attach(&SessionAttachFacts {
970 root_actor: true,
971 current_worktree: WorktreeSessionFact::Available {
972 session_id: "wt-1".into(),
973 },
974 ..SessionAttachFacts::default()
975 });
976 assert_eq!(
977 available.policy,
978 SessionPolicy::AttachExisting {
979 session_id: "wt-1".into(),
980 rule: SessionAttachRule::CurrentWorktreeSession,
981 }
982 );
983
984 let claimed = decide_session_attach(&SessionAttachFacts {
985 root_actor: true,
986 current_worktree: WorktreeSessionFact::Claimed {
987 session_id: "wt-2".into(),
988 },
989 ..SessionAttachFacts::default()
990 });
991 assert_eq!(
992 claimed.policy,
993 SessionPolicy::CreateNew {
994 because_claimed: true,
995 rule: SessionAttachRule::CreateNewSession,
996 }
997 );
998
999 let token = decide_session_attach(&SessionAttachFacts {
1000 token_sid: TokenSidFact::Available {
1001 session_id: "tok-1".into(),
1002 },
1003 ..SessionAttachFacts::default()
1004 });
1005 assert_eq!(
1006 token.policy,
1007 SessionPolicy::AttachExisting {
1008 session_id: "tok-1".into(),
1009 rule: SessionAttachRule::TokenSid,
1010 }
1011 );
1012 }
1013
1014 #[test]
1015 fn session_attach_default_creates_new() {
1016 let decision = decide_session_attach(&SessionAttachFacts::default());
1017 assert_eq!(
1018 decision.policy,
1019 SessionPolicy::CreateNew {
1020 because_claimed: false,
1021 rule: SessionAttachRule::CreateNewSession,
1022 }
1023 );
1024 assert_eq!(decision.attach_reason, "started new Heddle session");
1025 assert_eq!(decision.winning_rule, "create-new-session");
1026 }
1027}