car-server-core 0.55.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
//! Operator config for the coder, loaded from `~/.car/coder.toml`.
//!
//! A small, tolerant config the operator can drop next to the coder state dir
//! to tune a handful of knobs without recompiling:
//!
//! ```toml
//! [coder]
//! engine_preference = ["claude-code", "codex", "gemini"]  # foreman/external delegation order
//! keep_workspace_on_failure = false                        # keep the worktree for postmortem
//! default_max_iterations = 8
//! max_session_wall_secs = 3600                             # 0 = unlimited
//! max_agent_build_wall_secs = 600                          # operator ceiling; 0 = keep the card's value (unlimited only if the card has none)
//! max_check_timeout_secs = 600                             # ceiling for one contract check
//! max_replay_events = 2000                                 # per-live-session replay, 0 = unlimited
//! max_sessions = 200                                       # session-snapshot retention, 0 = unlimited
//! max_session_age_days = 30                                # 0 = unlimited
//! ```
//!
//! Loading is deliberately forgiving: a missing file, a missing `[coder]`
//! table, or any missing key falls back to the documented default. A malformed
//! file is logged and treated as absent rather than panicking — the daemon must
//! always boot. `CAR_CODER_CONFIG` overrides the path for tests and embedders,
//! mirroring `CAR_CODER_STATE_DIR`.

use std::path::PathBuf;

use serde::Deserialize;

use super::router::DEFAULT_PREFERENCE;

/// Default loop iteration cap when neither the RPC nor the config specifies one.
pub const DEFAULT_MAX_ITERATIONS: u32 = 8;

/// Default cap on retained coder session snapshots.
///
/// Nothing pruned the coder state dir before car#1310, so `<id>.json` and its
/// `<id>.events.jsonl` accumulated for the life of the installation and
/// `coder.list` read every session ever run on every call.
pub const DEFAULT_MAX_SESSIONS: usize = 200;

/// Default age cap on retained coder session snapshots, in days.
pub const DEFAULT_MAX_SESSION_AGE_DAYS: u64 = 30;

/// Default number of events retained for reconnect replay by one live session.
pub const DEFAULT_MAX_REPLAY_EVENTS: usize = 2_000;

/// Default patch budget for the merge-approval surface (512 KB).
///
/// Sixteen times the previous hardcoded 32 KB. Large enough that an ordinary
/// session is shown whole, small enough to stay a bounded payload on the wire.
pub const DEFAULT_APPROVAL_PATCH_BYTES: usize = 512 * 1024;

/// Resolved coder operator config. Every field is populated (defaults applied),
/// so callers never deal with `Option`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoderConfig {
    /// External/foreman delegation order: the first ready CLI in this list
    /// wins. Defaults to [`DEFAULT_PREFERENCE`].
    pub engine_preference: Vec<String>,
    /// Keep the throwaway worktree when a session ends `Failed`, so the operator
    /// can inspect it for a postmortem. Defaults to `false` (drop it).
    pub keep_workspace_on_failure: bool,
    /// Default loop iteration cap when `coder.start` omits `max_iterations`.
    /// Defaults to [`DEFAULT_MAX_ITERATIONS`].
    pub default_max_iterations: u32,
    /// Pin the native loop's inference model (e.g. `"parslee/reasoning"` for
    /// gpt-5.5 via the Parslee gateway). `None` = adaptive routing
    /// (`TaskHint::Code`). The seam that lets a paired A/B run the native arm on
    /// the same backbone as the external CLI arm.
    ///
    /// The pin applies to WHICHEVER engine runs the session: the native loop
    /// reasons on it, and an `external:<agent_id>` session forwards it to the
    /// CLI (`codex -m`, `claude --model`). It reached only the native loop
    /// once, which left a paired A/B's "both arms on one backbone" an
    /// unverified assumption. Foreman is the exception — its farmed workers
    /// run their own configured backbones and take no pin at all.
    ///
    /// Because the external rung hands this string to a third-party CLI's own
    /// namespace, a value here is NOT required to be a name `car models list`
    /// knows. Nothing validates it, deliberately.
    pub model: Option<String>,
    /// Byte budget for the patch shown at merge approval. Defaults to
    /// [`DEFAULT_APPROVAL_PATCH_BYTES`].
    ///
    /// This was a hardcoded 32 KB tail, which made the review surface shrink
    /// exactly as the risk grew: the gap between what a contract verifies and
    /// what a change actually does widens with session length, so the human
    /// backstop weakened precisely where the automated one did (car#706). The
    /// `--stat` is always shown in full regardless of this value.
    pub approval_patch_bytes: usize,
    /// Maximum events retained in memory for reconnect replay by one live
    /// session. Defaults to [`DEFAULT_MAX_REPLAY_EVENTS`]; `0` means unlimited.
    /// The event sequence remains monotonic when the oldest retained event is
    /// dropped, and `coder.subscribe.events_skipped` reports a stale cursor.
    pub max_replay_events: usize,
    /// Wall-clock ceiling for a whole session, across every rung of the engine
    /// fallback ladder. `0` means unlimited — for callers that impose their own
    /// bound and want the daemon out of the way. Defaults to
    /// [`car_budget::DEFAULT_SESSION_WALL_SECS`](super::budget::DEFAULT_SESSION_WALL_SECS).
    pub max_session_wall_secs: u64,
    /// Wall-clock ceiling for an Agent project's complete build: spec
    /// generation, repair attempts, and scenario runs. A confirmed contract
    /// may lower a positive ceiling, but cannot remove or raise it. `0` is the
    /// operator's explicit choice to keep the contract's value: a positive
    /// timeout is used, while a missing or zero timeout is unlimited. Defaults
    /// to 600 seconds, also shown on the synthesized `agent_scenarios_pass`
    /// contract.
    pub max_agent_build_wall_secs: u64,
    /// Wall-clock ceiling for ONE outcome-contract check command. Defaults to
    /// [`MAX_SHELL_TIMEOUT_SECS`](super::shell_tool::MAX_SHELL_TIMEOUT_SECS).
    ///
    /// A repository whose real verification gate takes longer than ten minutes
    /// could not express that gate as a contract check at all: the check was
    /// killed at the shell ceiling however much session budget remained, and no
    /// value of `max_session_wall_secs` moved it (car#1065). Raising this is an
    /// operator decision about their own test gate — it does NOT raise the
    /// ceiling on the `shell` tool the model calls, which stays at the 600s the
    /// tool description advertises.
    ///
    /// Unlike `max_session_wall_secs`, `0` is not "unlimited" here — it would
    /// floor every check at one second — so it is treated as unset.
    pub max_check_timeout_secs: u64,
    /// How many terminal session snapshots to keep in the coder state dir.
    /// Defaults to [`DEFAULT_MAX_SESSIONS`]; `0` means unlimited.
    ///
    /// Only collectable sessions are counted and evicted — see
    /// [`gc_sessions`](super::session::gc_sessions) for what is exempt. So this
    /// bounds what retention manages, not the size of the directory.
    ///
    /// `0` disables the cap, matching `config.toml`'s `[runs]`. The two used to
    /// disagree — `crate::run_store::RetentionConfig` read `0` literally, so
    /// `max_per_agent = 0` made `completed_rank >= 0` always true and
    /// `max_age_days = 0` put the cutoff at now, either one deleting every
    /// collectable run trace. Two similarly-named retention knobs in one daemon
    /// whose zero values inverted was a hazard whose failure mode was silent
    /// data loss; car#1338 settled it in this direction. Note this is not a
    /// blanket rule — `max_check_timeout_secs` two fields down reads `0` as
    /// UNSET, because zero there would floor every check at one second rather
    /// than lift a ceiling.
    pub max_sessions: usize,
    /// Age ceiling for a retained terminal session snapshot, in days. Defaults
    /// to [`DEFAULT_MAX_SESSION_AGE_DAYS`]; `0` means unlimited — see the note
    /// on [`Self::max_sessions`].
    pub max_session_age_days: u64,
}

impl CoderConfig {
    /// The retention policy [`gc_sessions`](super::session::gc_sessions) wants.
    pub fn session_retention(&self) -> super::session::SessionRetention {
        super::session::SessionRetention {
            max_sessions: self.max_sessions,
            max_age_days: self.max_session_age_days,
        }
    }
}

impl Default for CoderConfig {
    fn default() -> Self {
        Self {
            engine_preference: DEFAULT_PREFERENCE.iter().map(|s| s.to_string()).collect(),
            keep_workspace_on_failure: false,
            default_max_iterations: DEFAULT_MAX_ITERATIONS,
            model: None,
            approval_patch_bytes: DEFAULT_APPROVAL_PATCH_BYTES,
            max_replay_events: DEFAULT_MAX_REPLAY_EVENTS,
            max_session_wall_secs: super::budget::DEFAULT_SESSION_WALL_SECS,
            max_agent_build_wall_secs: super::shell_tool::MAX_SHELL_TIMEOUT_SECS,
            max_check_timeout_secs: super::shell_tool::MAX_SHELL_TIMEOUT_SECS,
            max_sessions: DEFAULT_MAX_SESSIONS,
            max_session_age_days: DEFAULT_MAX_SESSION_AGE_DAYS,
        }
    }
}

/// The raw on-disk shape. Every field is optional so a partial file is valid;
/// missing fields fall back to [`CoderConfig::default`].
#[derive(Debug, Default, Deserialize)]
struct RawConfigFile {
    #[serde(default)]
    coder: RawCoderTable,
}

#[derive(Debug, Default, Deserialize)]
struct RawCoderTable {
    #[serde(default)]
    engine_preference: Option<Vec<String>>,
    #[serde(default)]
    keep_workspace_on_failure: Option<bool>,
    #[serde(default)]
    default_max_iterations: Option<u32>,
    #[serde(default)]
    model: Option<String>,
    #[serde(default)]
    approval_patch_bytes: Option<usize>,
    #[serde(default)]
    max_replay_events: Option<usize>,
    #[serde(default)]
    max_session_wall_secs: Option<u64>,
    #[serde(default)]
    max_agent_build_wall_secs: Option<u64>,
    #[serde(default)]
    max_check_timeout_secs: Option<u64>,
    #[serde(default)]
    max_sessions: Option<usize>,
    #[serde(default)]
    max_session_age_days: Option<u64>,
}

impl CoderConfig {
    /// Resolve `engine_preference` to the `&str` slice [`resolve_engine`] wants.
    ///
    /// [`resolve_engine`]: super::router::resolve_engine
    pub fn preference_refs(&self) -> Vec<&str> {
        self.engine_preference.iter().map(|s| s.as_str()).collect()
    }

    fn from_raw(raw: RawConfigFile) -> Self {
        let defaults = Self::default();
        let coder = raw.coder;
        Self {
            // An explicitly-empty list would mean "no preference at all"; treat
            // it as unset and fall back to the default order so the operator
            // can't accidentally disable all delegation by writing `[]`.
            engine_preference: coder
                .engine_preference
                .filter(|p| !p.is_empty())
                .unwrap_or(defaults.engine_preference),
            keep_workspace_on_failure: coder
                .keep_workspace_on_failure
                .unwrap_or(defaults.keep_workspace_on_failure),
            default_max_iterations: coder
                .default_max_iterations
                // 0 iterations is a footgun (the loop never runs); ignore it.
                .filter(|n| *n > 0)
                .unwrap_or(defaults.default_max_iterations),
            // A blank string is "unset", not "pin the empty model".
            model: coder.model.filter(|m| !m.trim().is_empty()),
            // 0 would show no patch at all — treat it as unset rather than
            // silently blinding the approval surface.
            approval_patch_bytes: coder
                .approval_patch_bytes
                .filter(|n| *n > 0)
                .unwrap_or(defaults.approval_patch_bytes),
            // 0 explicitly disables the in-memory replay cap. The durable
            // event journal is independent of this reconnect-speed window.
            max_replay_events: coder
                .max_replay_events
                .unwrap_or(defaults.max_replay_events),
            // 0 is meaningful here (unlimited), unlike `approval_patch_bytes`
            // where it would blind the approval surface — so it is NOT filtered.
            max_session_wall_secs: coder
                .max_session_wall_secs
                .unwrap_or(defaults.max_session_wall_secs),
            // The dedicated agent-build clock uses the same 0 == unlimited
            // convention as the general session clock.
            max_agent_build_wall_secs: coder
                .max_agent_build_wall_secs
                .unwrap_or(defaults.max_agent_build_wall_secs),
            // And here 0 is NOT unlimited — it would clamp every check to the
            // shell's 1s floor and turn a whole contract red. Treat it as unset,
            // like `approval_patch_bytes`.
            max_check_timeout_secs: coder
                .max_check_timeout_secs
                .filter(|n| *n > 0)
                .unwrap_or(defaults.max_check_timeout_secs),
            // 0 IS meaningful for both retention caps: it disables that cap,
            // which is the operator saying "keep everything" rather than
            // "keep nothing". Not filtered, unlike `approval_patch_bytes`.
            max_sessions: coder.max_sessions.unwrap_or(defaults.max_sessions),
            max_session_age_days: coder
                .max_session_age_days
                .unwrap_or(defaults.max_session_age_days),
        }
    }

    /// Parse a TOML string. Tolerant: a malformed document yields defaults
    /// rather than an error.
    pub fn parse_toml(text: &str) -> Self {
        match toml::from_str::<RawConfigFile>(text) {
            Ok(raw) => Self::from_raw(raw),
            Err(e) => {
                tracing::warn!("ignoring malformed ~/.car/coder.toml: {e}");
                Self::default()
            }
        }
    }

    /// Load from `path`. A missing file → defaults; an unreadable or malformed
    /// file → defaults (logged). Never panics.
    pub fn load_from(path: &std::path::Path) -> Self {
        match std::fs::read_to_string(path) {
            Ok(text) => Self::parse_toml(&text),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Self::default(),
            Err(e) => {
                tracing::warn!("ignoring unreadable {}: {e}", path.display());
                Self::default()
            }
        }
    }

    /// Load from the resolved config path ([`config_path`]).
    pub fn load() -> Self {
        match config_path() {
            Ok(path) => Self::load_from(&path),
            Err(_) => Self::default(),
        }
    }
}

/// Which file a resolved session model pin came from.
///
/// Carried out of [`session_model`] rather than re-derived by the caller: the
/// precedence rule and "which one won" are one decision, and a caller that
/// answers the second question with its own copy of the first is how the two
/// come to disagree — the defect car#1360 was.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PinSource {
    /// The per-session pin — `coder.start`'s `model`, `car code --model`, or
    /// `heal.toml`'s `coder_model`.
    Request,
    /// This file's `[coder] model`.
    Config,
}

/// The model a coder session actually runs on, and which file said so.
///
/// The per-session pin wins; a blank one is "unset" rather than "pin the empty
/// model". `None` from both means unpinned — adaptive routing picks per
/// request, and nothing before the run can say what it will pick.
///
/// The config arm is NOT trimmed, only blank-filtered ([`CoderConfig`]'s
/// `model` is normalized that way at parse). Documented rather than fixed
/// because this is now the single copy and a padded value reaches the engine
/// padded — the callers have always behaved this way.
///
/// One copy on purpose. Three callers need this answer — `coder.start`,
/// `car code-task`, and the self-heal loop's "the coder may not sit on the
/// review panel" check — and heal passes `heal.toml`'s `coder_model` as the
/// request pin, so an ABSENT `coder_model` does not mean "unpinned", it means
/// "whatever this file says". Re-deriving the rule in the check read only the
/// first source and let a coder that was also a review seat through (car#1360).
pub fn session_model<'a>(
    request: Option<&'a str>,
    config: Option<&'a str>,
) -> Option<(&'a str, PinSource)> {
    match request.map(str::trim).filter(|m| !m.is_empty()) {
        Some(m) => Some((m, PinSource::Request)),
        None => config.map(|m| (m, PinSource::Config)),
    }
}

/// Process-wide lock serializing tests that mutate the `CAR_CODER_CONFIG`
/// global env var, across every module that does.
///
/// ONE lock, here, next to the thing it guards. Two modules each with their own
/// `OnceLock<Mutex>` over the same global is not mutual exclusion — all of
/// `car-server-core`'s unit tests link into one binary and libtest runs them on
/// a thread pool, so `rpc`'s and `heal_service`'s tests would interleave and
/// read each other's `coder.toml`.
#[cfg(test)]
pub(crate) fn config_env_lock() -> &'static std::sync::Mutex<()> {
    static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
    LOCK.get_or_init(|| std::sync::Mutex::new(()))
}

/// Where the operator config lives. `CAR_CODER_CONFIG` overrides for tests and
/// embedders (mirroring `CAR_CODER_STATE_DIR`); otherwise `coder.toml` under
/// the CAR state root, which is `~/.car/coder.toml` unless `CAR_HOME` moves it.
pub fn config_path() -> Result<PathBuf, String> {
    if let Some(path) = std::env::var_os("CAR_CODER_CONFIG") {
        // Don't let an env var silently redirect coder config. It stays a
        // test/embedder path override (no longer the model-pinning mechanism —
        // that's the `coder.start` `model` param), but a native app shouldn't
        // hide behavior-changing env, so surface it.
        tracing::info!(
            path = ?path,
            "coder config path overridden by CAR_CODER_CONFIG env"
        );
        return Ok(PathBuf::from(path));
    }
    let root = car_home::root()
        .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
    Ok(root.join("coder.toml"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn missing_keys_fall_back_to_documented_defaults() {
        // Empty file → every default.
        let c = CoderConfig::parse_toml("");
        assert_eq!(c, CoderConfig::default());
        assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
        assert!(!c.keep_workspace_on_failure);
        assert_eq!(c.default_max_iterations, 8);

        // Empty [coder] table → still defaults.
        let c = CoderConfig::parse_toml("[coder]\n");
        assert_eq!(c, CoderConfig::default());
    }

    #[test]
    fn replay_cap_is_read_and_zero_means_unlimited() {
        assert_eq!(
            CoderConfig::parse_toml("").max_replay_events,
            DEFAULT_MAX_REPLAY_EVENTS
        );
        assert_eq!(
            CoderConfig::parse_toml("[coder]\nmax_replay_events = 37\n").max_replay_events,
            37
        );
        assert_eq!(
            CoderConfig::parse_toml("[coder]\nmax_replay_events = 0\n").max_replay_events,
            0
        );
    }

    #[test]
    fn retention_knobs_are_read_and_zero_means_unlimited() {
        let c = CoderConfig::parse_toml("");
        assert_eq!(c.max_sessions, DEFAULT_MAX_SESSIONS);
        assert_eq!(c.max_session_age_days, DEFAULT_MAX_SESSION_AGE_DAYS);

        let c = CoderConfig::parse_toml(
            r#"
            [coder]
            max_sessions = 25
            max_session_age_days = 7
            "#,
        );
        assert_eq!(c.session_retention().max_sessions, 25);
        assert_eq!(c.session_retention().max_age_days, 7);

        // 0 disables the cap rather than collecting everything — the operator
        // saying "keep it all", which is why neither field is filtered the way
        // `approval_patch_bytes` is.
        let c = CoderConfig::parse_toml(
            r#"
            [coder]
            max_sessions = 0
            max_session_age_days = 0
            "#,
        );
        assert_eq!(c.max_sessions, 0);
        assert_eq!(c.max_session_age_days, 0);
    }

    #[test]
    fn full_config_is_honored() {
        let c = CoderConfig::parse_toml(
            r#"
            [coder]
            engine_preference = ["codex", "claude-code"]
            keep_workspace_on_failure = true
            default_max_iterations = 3
            "#,
        );
        assert_eq!(c.engine_preference, ["codex", "claude-code"]);
        assert!(c.keep_workspace_on_failure);
        assert_eq!(c.default_max_iterations, 3);
        assert_eq!(c.preference_refs(), vec!["codex", "claude-code"]);
    }

    #[test]
    fn the_check_timeout_ceiling_defaults_to_the_shell_ceiling_and_zero_is_unset() {
        use super::super::shell_tool::MAX_SHELL_TIMEOUT_SECS;
        assert_eq!(
            CoderConfig::default().max_check_timeout_secs,
            MAX_SHELL_TIMEOUT_SECS
        );
        assert_eq!(
            CoderConfig::parse_toml("").max_check_timeout_secs,
            MAX_SHELL_TIMEOUT_SECS
        );

        // The whole point of the knob (car#1065): a suite that needs more than
        // ten minutes.
        let c = CoderConfig::parse_toml("[coder]\nmax_check_timeout_secs = 1800\n");
        assert_eq!(c.max_check_timeout_secs, 1800);

        // 0 is "unset", not "unlimited" — unlike max_session_wall_secs, where it
        // is. Honoring it would floor every check at one second.
        let c = CoderConfig::parse_toml("[coder]\nmax_check_timeout_secs = 0\n");
        assert_eq!(c.max_check_timeout_secs, MAX_SHELL_TIMEOUT_SECS);
        assert_eq!(
            CoderConfig::parse_toml("[coder]\nmax_session_wall_secs = 0\n").max_session_wall_secs,
            0,
            "the sibling knob keeps 0 == unlimited"
        );
        let agent = CoderConfig::parse_toml("[coder]\nmax_agent_build_wall_secs = 45\n");
        assert_eq!(agent.max_agent_build_wall_secs, 45);
        assert_eq!(
            CoderConfig::parse_toml("[coder]\nmax_agent_build_wall_secs = 0\n")
                .max_agent_build_wall_secs,
            0,
            "the agent-build knob keeps 0 == unlimited"
        );
    }

    /// The precedence rule, pinned. `heal_service` reads it to decide whether
    /// its coder is also a review seat, so a change here silently changes what
    /// that check is checking.
    #[test]
    fn the_request_pin_wins_and_a_blank_one_falls_through_to_this_file() {
        // Request wins, trimmed.
        assert_eq!(
            session_model(Some("  gpt-5.6  "), Some("parslee/reasoning")),
            Some(("gpt-5.6", PinSource::Request))
        );
        // A blank request is "unset", not "pin blank" — this file still wins.
        assert_eq!(
            session_model(Some("   "), Some("parslee/reasoning")),
            Some(("parslee/reasoning", PinSource::Config))
        );
        // The case car#1360 turned on: no request pin does NOT mean unpinned.
        assert_eq!(
            session_model(None, Some("parslee/reasoning")),
            Some(("parslee/reasoning", PinSource::Config))
        );
        // Neither source pins: the router picks per request, and only what
        // actually authored the change can say what it picked.
        assert_eq!(session_model(None, None), None);
        assert_eq!(session_model(Some(""), None), None);
        // The documented asymmetry: the config arm is blank-filtered at parse
        // but never trimmed, so a padded value reaches the engine padded.
        assert_eq!(
            session_model(None, Some("  gpt-5.6  ")),
            Some(("  gpt-5.6  ", PinSource::Config))
        );
    }

    #[test]
    fn model_pin_parses_and_blank_is_unset() {
        assert_eq!(CoderConfig::default().model, None);
        let c = CoderConfig::parse_toml("[coder]\nmodel = \"parslee/reasoning\"\n");
        assert_eq!(c.model.as_deref(), Some("parslee/reasoning"));
        let c = CoderConfig::parse_toml("[coder]\nmodel = \"   \"\n");
        assert_eq!(c.model, None);
    }

    #[test]
    fn partial_config_mixes_explicit_and_default() {
        // Only one key set; the rest default.
        let c = CoderConfig::parse_toml("[coder]\nkeep_workspace_on_failure = true\n");
        assert!(c.keep_workspace_on_failure);
        assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
        assert_eq!(c.default_max_iterations, 8);
    }

    #[test]
    fn empty_preference_and_zero_iterations_are_ignored() {
        let c = CoderConfig::parse_toml(
            "[coder]\nengine_preference = []\ndefault_max_iterations = 0\n",
        );
        // Both footguns fall back to defaults rather than disabling the feature.
        assert_eq!(c.engine_preference, ["claude-code", "codex", "gemini"]);
        assert_eq!(c.default_max_iterations, 8);
    }

    #[test]
    fn malformed_toml_yields_defaults_not_panic() {
        let c = CoderConfig::parse_toml("this is not = = toml [[[");
        assert_eq!(c, CoderConfig::default());
    }

    #[test]
    fn missing_file_yields_defaults() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("does-not-exist.toml");
        assert_eq!(CoderConfig::load_from(&path), CoderConfig::default());
    }

    #[test]
    fn load_from_real_file_round_trips() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("coder.toml");
        std::fs::write(
            &path,
            "[coder]\nengine_preference = [\"gemini\"]\nkeep_workspace_on_failure = true\ndefault_max_iterations = 5\n",
        )
        .unwrap();
        let c = CoderConfig::load_from(&path);
        assert_eq!(c.engine_preference, ["gemini"]);
        assert!(c.keep_workspace_on_failure);
        assert_eq!(c.default_max_iterations, 5);
    }
}