Skip to main content

verbs/
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, model, or thought_level changes
8//!   (empty → set is attach, not a rotate)
9//!
10//! Process detection, registry lookups, session store I/O, and path
11//! canonicalization remain CLI-owned. Callers pass pure facts in and apply
12//! the returned decision.
13
14use std::{collections::BTreeMap, 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    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
155/// Decide the generic probe outcome from explicit harness name + argv/env.
156///
157/// An explicit harness name overrides the fingerprint harness label but does
158/// **not** invent a default provider — that matches the historical generic
159/// probe (`explicit.or(fingerprint)` for harness only).
160pub 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
182/// Treat empty / `"unknown"` attribution placeholders as absent.
183fn 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// ---------------------------------------------------------------------------
193// Session attach / create policy
194// ---------------------------------------------------------------------------
195
196/// Winning attach/create rule labels (stable machine strings).
197#[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/// Pure session policy: attach to an existing active session or create one.
225#[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/// Full attach decision including precedence trail for reports.
238#[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/// Soft lookup outcome for a single attach rule (CLI performed the I/O).
247#[derive(Debug, Clone, PartialEq, Eq, Default)]
248pub enum SessionLookupFact {
249    /// Rule not applicable (no key/id to look up).
250    #[default]
251    NotProvided,
252    /// Key was present; no active compatible match.
253    Miss { key: String },
254    /// Active session found for the key.
255    Hit { key: String, session_id: String },
256}
257
258/// Hard-bind from an explicit agent registry entry (already validated active).
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct ExplicitAgentBind {
261    pub agent_session_id: String,
262    pub heddle_session_id: String,
263}
264
265/// Current worktree session candidate after claim checks.
266#[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/// Token-claim `sid` candidate after claim checks.
279#[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/// Caller-gathered facts for pure session attach policy (no I/O).
292///
293/// Hard binds (`explicit_agent`, `explicit_heddle_session_id`) must already be
294/// validated as active sessions by the caller; invalid binds should error
295/// before calling [`decide_session_attach`].
296#[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    /// Probe attach hint: root actors may reuse the current worktree session.
304    pub root_actor: bool,
305    pub current_worktree: WorktreeSessionFact,
306    pub token_sid: TokenSidFact,
307}
308
309/// Pure attach/create decision matching harness open_session precedence.
310pub 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    // Native actor key is only consulted when no client_instance_id is present
342    // (stronger client-instance identity takes priority otherwise).
343    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    // Strong native actor key without a match → create (do not fall through to
390    // weaker native-instance / worktree reuse). Hit already returned above.
391    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// ---------------------------------------------------------------------------
507// Segment rotation
508// ---------------------------------------------------------------------------
509
510/// Whether the current session segment should rotate or attach for a new identity.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512pub enum SegmentRotation {
513    Keep,
514    /// Unpublished → published: write onto the current placeholder segment.
515    Attach,
516    Rotate,
517}
518
519/// Pure segment rotation policy: rotate when a published provider, model,
520/// or thought_level **changes**. Empty → set is [`SegmentRotation::Attach`],
521/// not a rotate.
522///
523/// - Incoming `None` / empty / `unknown` does not force rotation.
524/// - Rotation only when both sides have a published value and they differ.
525/// - Each field is evaluated independently so an unpublished provider cannot
526///   mask a published model / thought_level change.
527pub 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
543/// Same as [`segment_rotation_policy`] plus `thought_level`.
544pub 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
582/// Write newly published values onto a placeholder segment (empty → set).
583pub 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
606/// Convenience bool wrapper for [`segment_rotation_policy`].
607pub 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// ---------------------------------------------------------------------------
620// Tests
621// ---------------------------------------------------------------------------
622
623#[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        // Explicit name alone does not invent a default provider.
746        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        // Blank new values do not force rotation.
776        assert!(!should_rotate_segment(
777            Some("anthropic"),
778            Some("opus"),
779            None,
780            None,
781        ));
782        // No current segment → keep.
783        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        // Native actor is skipped when client_instance is provided.
928        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}