Skip to main content

heddle_core/
harness_policy.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Pure harness session / segment policy (no FS, registry, or process I/O).
3//!
4//! Owns:
5//! - harness kind fingerprinting from argv / env hint maps
6//! - session attach-vs-create decision given caller-gathered probe facts
7//! - segment rotation when provider or model changes
8//!
9//! Process detection, registry lookups, session store I/O, and path
10//! canonicalization remain CLI-owned. Callers pass pure facts in and apply
11//! the returned decision.
12
13use std::collections::BTreeMap;
14use std::path::Path;
15
16// ---------------------------------------------------------------------------
17// Harness kind / fingerprint
18// ---------------------------------------------------------------------------
19
20/// Known coding-agent harnesses detectable from argv/env hints.
21#[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    /// Stable harness name used in probe/report fields, when known.
33    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    /// Default provider associated with the harness when env does not override.
44    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    /// Parse a harness name string (explicit payload / config).
53    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/// Pure fingerprint of harness identity derived from argv/env maps.
65#[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/// Pure probe decision for generic (non-harness-specific) detection.
76///
77/// Full per-harness probes (hook payloads, native actor keys) stay CLI-owned;
78/// this captures the argv/env / explicit-name path used by the generic probe.
79#[derive(Debug, Clone, PartialEq)]
80pub struct HarnessProbeDecision {
81    pub fingerprint: HarnessFingerprint,
82    /// Baseline confidence for the generic path (1.0 explicit, 0.4 argv/env).
83    pub confidence: f32,
84    /// Stable probe-source label (`explicit_payload` or `argv_env`).
85    pub probe_source: &'static str,
86}
87
88/// Detect harness kind from program name + env key presence (pure map).
89///
90/// Priority matches historical `fingerprint_from_hints`: claude-code, then
91/// codex, then opencode, then aider.
92pub 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
123/// Pure argv/env fingerprint used by the generic harness probe path.
124pub 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    // HEDDLE_AGENT_PROVIDER fills only when no harness default was set
141    // (historical `or_else` order in the CLI fingerprint).
142    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
174/// Decide the generic probe outcome from explicit harness name + argv/env.
175///
176/// An explicit harness name overrides the fingerprint harness label but does
177/// **not** invent a default provider — that matches the historical generic
178/// probe (`explicit.or(fingerprint)` for harness only).
179pub 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
201/// Treat empty / `"unknown"` attribution placeholders as absent.
202fn 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// ---------------------------------------------------------------------------
212// Session attach / create policy
213// ---------------------------------------------------------------------------
214
215/// Winning attach/create rule labels (stable machine strings).
216#[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/// Pure session policy: attach to an existing active session or create one.
244#[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/// Full attach decision including precedence trail for reports.
257#[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/// Soft lookup outcome for a single attach rule (CLI performed the I/O).
266#[derive(Debug, Clone, PartialEq, Eq, Default)]
267pub enum SessionLookupFact {
268    /// Rule not applicable (no key/id to look up).
269    #[default]
270    NotProvided,
271    /// Key was present; no active compatible match.
272    Miss { key: String },
273    /// Active session found for the key.
274    Hit { key: String, session_id: String },
275}
276
277/// Hard-bind from an explicit agent registry entry (already validated active).
278#[derive(Debug, Clone, PartialEq, Eq)]
279pub struct ExplicitAgentBind {
280    pub agent_session_id: String,
281    pub heddle_session_id: String,
282}
283
284/// Current worktree session candidate after claim checks.
285#[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/// Token-claim `sid` candidate after claim checks.
298#[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/// Caller-gathered facts for pure session attach policy (no I/O).
311///
312/// Hard binds (`explicit_agent`, `explicit_heddle_session_id`) must already be
313/// validated as active sessions by the caller; invalid binds should error
314/// before calling [`decide_session_attach`].
315#[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    /// Probe attach hint: root actors may reuse the current worktree session.
323    pub root_actor: bool,
324    pub current_worktree: WorktreeSessionFact,
325    pub token_sid: TokenSidFact,
326}
327
328/// Pure attach/create decision matching harness open_session precedence.
329pub 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    // Native actor key is only consulted when no client_instance_id is present
361    // (stronger client-instance identity takes priority otherwise).
362    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    // Strong native actor key without a match → create (do not fall through to
409    // weaker native-instance / worktree reuse). Hit already returned above.
410    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// ---------------------------------------------------------------------------
526// Segment rotation
527// ---------------------------------------------------------------------------
528
529/// Whether the current session segment should rotate for a new identity.
530#[derive(Debug, Clone, Copy, PartialEq, Eq)]
531pub enum SegmentRotation {
532    Keep,
533    Rotate,
534}
535
536/// Pure segment rotation policy: rotate when provider or model changes.
537///
538/// - No current segment → keep (caller creates the first segment elsewhere).
539/// - `new_*` is `None` → does not force rotation (blank hints fall through).
540/// - Rotation only when a new value is present and differs from current.
541pub 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    // Model may be missing on a segment only if caller has no current segment;
551    // when a segment exists both provider and model are set. Treat missing
552    // current model as empty for comparison only when provider was present.
553    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
564/// Convenience bool wrapper for [`segment_rotation_policy`].
565pub 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// ---------------------------------------------------------------------------
578// Tests
579// ---------------------------------------------------------------------------
580
581#[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        // Explicit name alone does not invent a default provider.
699        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        // Blank new values do not force rotation.
729        assert!(!should_rotate_segment(
730            Some("anthropic"),
731            Some("opus"),
732            None,
733            None,
734        ));
735        // No current segment → keep.
736        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        // Native actor is skipped when client_instance is provided.
795        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}