Skip to main content

agentmux_srv/server/app_api/
session.rs

1use super::*;
2use crate::ambient;
3
4pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
5    register_session_activity_summary(engine, state);
6    register_session_next_prompt_suggestion(engine, state);
7    register_session_resume_preflight_handler(engine, state);
8    register_session_archive_handler(engine, state);
9    register_session_restore_handler(engine, state);
10    register_session_export_handler(engine, state);
11}
12
13/// `session:resume_preflight` — read-only, mutates nothing, spawns nothing.
14///
15/// The pane calls this on mount so it can say whether the conversation it's
16/// displaying will actually be continued, instead of the user finding out by
17/// typing and watching the transcript clear
18/// (`crate::backend::resume_preflight`'s module doc).
19fn register_session_resume_preflight_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
20    let mstore = state.mstore.clone();
21    // Needed to resolve an identity-bound pane's REAL config dir — see
22    // `preflight_input_from_meta`.
23    let id_store = state.id_store.clone();
24    let identity_store = state.identity_store.clone();
25    // The pane's own transcript, for the session its rendered history belongs to.
26    let filestore = state.filestore.clone();
27
28    engine.register_typed(
29        COMMAND_SESSION_RESUME_PREFLIGHT,
30        move |cmd: CommandSessionResumePreflightData, _ctx| {
31            let mstore = mstore.clone();
32            let id_store = id_store.clone();
33            let identity_store = identity_store.clone();
34            let filestore = filestore.clone();
35            async move {
36
37                let block = mstore
38                    .must_get::<Block>(&cmd.block_id)
39                    .map_err(|e| format!("session:resume_preflight: {e}"))?;
40
41                let bound_config_dir = crate::identity::resolver::resolve_bound_oauth_config_dir(
42                    &mstore,
43                    &id_store,
44                    &identity_store,
45                    &cmd.block_id,
46                );
47                let mut input = preflight_input_from_meta(&block.meta, bound_config_dir);
48                let session_id_field = obj::meta_get_string(&block.meta, "agent:session_id_field", "session_id");
49                let block_id = cmd.block_id.clone();
50
51                // Blocking I/O (a transcript tail read, one `is_file`, at most
52                // one `read_dir` of a single directory) off the async runtime's
53                // worker threads — small, but a pane open shouldn't be able to
54                // stall the reactor on a cold or network-backed home directory.
55                let result = tokio::task::spawn_blocking(move || {
56                    if input.session_id.is_empty() {
57                        input.history_session_id =
58                            crate::backend::blockcontroller::persistent::pane_history_session_id(
59                                Some(&filestore),
60                                Some(&mstore),
61                                &block_id,
62                                &session_id_field,
63                            )
64                            .unwrap_or_default();
65                    }
66                    // The head of the agent's chain, for the spawn's resume
67                    // gate. The UID comes from the pane's agent row, as the
68                    // spawn env's does (`persisted_agent_identity`).
69                    let uid = mstore
70                        .instance_get_active_for_block(&block_id)
71                        .ok()
72                        .flatten()
73                        .map(|instance| instance.id)
74                        .filter(|uid| !uid.trim().is_empty());
75                    let gfs = crate::backend::agent_session::global_transcript_store();
76                    if let (Some(uid), Some(gfs)) = (uid.as_deref().map(str::trim), gfs) {
77                        input.chain_head = crate::backend::continuity_segments::chain_head(gfs, uid)
78                            .map(crate::backend::continuity_segments::with_legacy_identity);
79                        // As the spawn's gate does: a fork with no chain of its
80                        // own starts from the agent it was forked from.
81                        if input.chain_head.is_none() {
82                            input.fork_source = crate::backend::continuity_segments::fork_source_head(&mstore, gfs, uid);
83                        }
84                    }
85                    input.identity_key =
86                        crate::identity::account_email::identity_key_from_oauth_dir("claude", &input.config_dir);
87                    crate::backend::resume_preflight::preflight(&input)
88                })
89                .await
90                .map_err(|e| format!("session:resume_preflight: {e}"))?;
91
92                tracing::info!(
93                    block_id = %cmd.block_id,
94                    verdict = %result.verdict.as_str(),
95                    duration_ms = result.duration_ms,
96                    "session:resume_preflight"
97                );
98
99                Ok(SessionResumePreflightResult {
100                    block_id: cmd.block_id,
101                    verdict: result.verdict.as_str().to_string(),
102                    session_id: result.session_id,
103                    recoverable_session_id: result.recoverable_session_id,
104                    steps: result
105                        .steps
106                        .into_iter()
107                        .map(|s| ResumePreflightStep {
108                            id: s.id.to_string(),
109                            label: s.label,
110                            ok: s.ok,
111                            detail: s.detail,
112                            duration_ms: s.duration_ms,
113                        })
114                        .collect(),
115                    duration_ms: result.duration_ms,
116                })
117            }
118        },
119    );
120}
121
122/// Lift a block's meta into a [`resume_preflight::PreflightInput`].
123///
124/// **Every default here must match what the real spawn path uses for the
125/// same key**, or the preflight predicts something the spawn won't do —
126/// which is worse than not predicting at all, since the pane then states a
127/// falsehood confidently.
128///
129/// `agent:resume_flag` defaulting to `"--resume"` is that rule doing real
130/// work (#2833): it was `""` here while all five real spawn-path readers
131/// default to `"--resume"` (the persistent and subprocess branches of
132/// `agent_handlers/input.rs`'s `run_agent_turn` and of
133/// `app_api/agent_io.rs`'s `register_agent_send`, plus the eager-resume path
134/// in `eager_resume.rs` — the persistent branch of `run_agent_turn` is the
135/// one that builds `PersistentSpawnConfig` for a pane's message send).
136/// `agent_open.rs` only started writing the key recently, so any Claude pane
137/// created before that and not respawned since has no `agent:resume_flag`
138/// at all: the spawn still attaches `--resume`, but the preflight was
139/// reporting `Unknown` and silently suppressing the notice for exactly the
140/// long-lived panes this feature exists for.
141/// `bound_config_dir` is `identity::resolver::resolve_bound_oauth_config_dir`'s
142/// answer for this block, and **takes precedence over `cmd:env`** whenever
143/// it's `Some` (reagent P1, second pass on PR #2833).
144///
145/// For an agent bound to an OAuth account, the `cmd:env` snapshot is
146/// simply not where the CLI will look: the real spawn resolves the isolated
147/// dir dynamically through `inject_identity_env_async` (via
148/// `agent_handlers/input.rs`'s `build_persistent_spawn_env`, which
149/// `app_api/agent_io.rs`'s `agent_send_spawn_env` also calls), and
150/// `reactive.rs` already documents that "identity-bound agents' real
151/// `CLAUDE_CONFIG_DIR` is never the stale `cmd:env` snapshot"
152/// (`SPEC_SUBAGENT_WATCHER_IDENTITY_BOUND_CONFIG_DIR_2026_08_22.md`).
153/// Checking reachability against the wrong directory is worse than not
154/// checking: it can warn "fresh" at a pane that will resume perfectly well,
155/// or stay quiet at one that's about to lose its conversation.
156///
157/// `None` — not identity-bound, or a non-OAuth provider — keeps the
158/// `cmd:env` read, which is correct for those. Same helper and same
159/// precedence order `subagent_watcher` already uses for this exact
160/// pre-spawn question.
161fn preflight_input_from_meta(
162    meta: &crate::backend::obj::MetaMapType,
163    bound_config_dir: Option<std::path::PathBuf>,
164) -> crate::backend::resume_preflight::PreflightInput {
165    // `CLAUDE_CONFIG_DIR` from the block's own `cmd:env` map — the fallback
166    // when this pane isn't identity-bound.
167    let config_dir = match bound_config_dir {
168        Some(dir) => dir.to_string_lossy().to_string(),
169        None => crate::backend::blockcontroller::cmd_env_of(meta)
170            .remove("CLAUDE_CONFIG_DIR")
171            .unwrap_or_default(),
172    };
173
174    crate::backend::resume_preflight::PreflightInput {
175        resume_flag: obj::meta_get_string(meta, "agent:resume_flag", "--resume"),
176        session_id: obj::meta_get_string(meta, "agent:sessionid", ""),
177        working_dir: obj::meta_get_string(meta, "cmd:cwd", ""),
178        config_dir,
179        history_session_id: String::new(),
180        chain_head: None,
181        identity_key: None,
182        fork_source: None,
183    }
184}
185
186fn register_session_archive_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
187    let mstore = state.mstore.clone();
188    let filestore = state.filestore.clone();
189    let broker = state.broker.clone();
190
191    engine.register_typed(
192        COMMAND_SESSION_ARCHIVE,
193        move |cmd: CommandSessionArchiveData, _ctx| {
194            let mstore = mstore.clone();
195            let filestore = filestore.clone();
196            let broker = broker.clone();
197            async move {
198
199                tracing::info!(block_id = %cmd.block_id, "session:archive");
200
201                let archive_dir = session_archive::default_archive_dir()
202                    .ok_or_else(|| "cannot determine home directory".to_string())?;
203
204                // The delete and its announcement under the block's
205                // transcript order lock, so no in-flight append's write and
206                // event can interleave with them (review of #3636).
207                let (archived_bytes, archived_at) =
208                    crate::backend::blockcontroller::shell::with_transcript_order(&cmd.block_id, || {
209                        let archived = session_archive::archive_session_output(
210                            &mstore,
211                            &filestore,
212                            &cmd.block_id,
213                            &archive_dir,
214                        )?;
215                        // The block's transcript is gone: open panes resync now.
216                        crate::backend::blockcontroller::shell::publish_transcript_changed(
217                            &broker,
218                            &cmd.block_id,
219                            crate::backend::mps::FILE_OP_DELETE,
220                            &filestore,
221                        );
222                        Ok::<_, String>(archived)
223                    })?;
224
225                Ok(SessionArchiveResult {
226                block_id: cmd.block_id,
227                archived_bytes,
228                archived_at,
229            })
230            }
231        },
232    );
233}
234
235fn register_session_restore_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
236    let mstore = state.mstore.clone();
237    let filestore = state.filestore.clone();
238    let broker = state.broker.clone();
239
240    engine.register_typed(
241        COMMAND_SESSION_RESTORE,
242        move |cmd: CommandSessionRestoreData, _ctx| {
243            let mstore = mstore.clone();
244            let filestore = filestore.clone();
245            let broker = broker.clone();
246            async move {
247
248                tracing::info!(block_id = %cmd.block_id, "session:restore");
249
250                // The replace and its announcement under the block's
251                // transcript order lock (review of #3636): no append's write
252                // and event can land between them, so a pane sees every
253                // event of the old generation, then the replace, then the
254                // new generation's.
255                let restored_bytes =
256                    crate::backend::blockcontroller::shell::with_transcript_order(&cmd.block_id, || {
257                        let restored = session_archive::restore_session_output(
258                            &mstore,
259                            &filestore,
260                            &cmd.block_id,
261                        )?;
262                        // Replaced content, a new generation: open panes resync now.
263                        crate::backend::blockcontroller::shell::publish_transcript_changed(
264                            &broker,
265                            &cmd.block_id,
266                            crate::backend::mps::FILE_OP_REPLACE,
267                            &filestore,
268                        );
269                        Ok::<_, String>(restored)
270                    })?;
271
272                Ok(SessionRestoreResult {
273                block_id: cmd.block_id,
274                restored_bytes,
275            })
276            }
277        },
278    );
279}
280
281fn register_session_export_handler(engine: &Arc<WshRpcEngine>, state: &AppState) {
282    let mstore = state.mstore.clone();
283    let filestore = state.filestore.clone();
284
285    engine.register_typed(
286        COMMAND_SESSION_EXPORT,
287        move |cmd: CommandSessionExportData, _ctx| {
288            let mstore = mstore.clone();
289            let filestore = filestore.clone();
290            async move {
291
292                tracing::info!(block_id = %cmd.block_id, "session:export");
293
294                let (raw_bytes, line_count) = session_archive::read_session_output(
295                    &mstore,
296                    &filestore,
297                    &cmd.block_id,
298                )?;
299
300                let byte_count = raw_bytes.len() as u64;
301                let content = base64::engine::general_purpose::STANDARD.encode(&raw_bytes);
302
303                Ok(SessionExportResult {
304                content,
305                line_count,
306                byte_count,
307            })
308            }
309        },
310    );
311}
312
313fn empty_summary_result() -> ActivitySummaryResult {
314    ActivitySummaryResult { summary: String::new(), tokens: None }
315}
316
317fn register_session_activity_summary(engine: &Arc<WshRpcEngine>, state: &AppState) {
318    let mstore = state.mstore.clone();
319    let filestore = state.filestore.clone();
320    let event_bus = state.event_bus.clone();
321
322    engine.register_typed(
323        COMMAND_SESSION_ACTIVITY_SUMMARY,
324        move |cmd: CommandActivitySummaryData, _ctx| {
325            let mstore = mstore.clone();
326            let filestore = filestore.clone();
327            let event_bus = event_bus.clone();
328            async move {
329                // Admit through the Ambient Model Call gateway BEFORE doing any
330                // work: a stale (superseded) request does zero FileStore reads
331                // or prompt building, not just skips the CLI spawn. `admit` queues
332                // the call in the interactive class (`ambient::limits`), raced
333                // against cancellation, so a request superseded while queued for
334                // a permit never spawns the CLI at all. See
335                // docs/specs/SPEC_AMBIENT_MODEL_CALLS_FRAMEWORK_2026_07_03.md.
336                let Some(mut slot) = ambient::call::admit(
337                    &ambient::purpose::ACTIVITY_SUMMARY,
338                    cmd.block_id.clone(),
339                    cmd.generation,
340                )
341                .await
342                else {
343                    return Ok(empty_summary_result());
344                };
345
346                let word_target = cmd.word_target.unwrap_or(7).max(3).min(20);
347
348                let block: Block = mstore
349                    .get(&cmd.block_id)
350                    .map_err(|e| format!("session:activity_summary: {e}"))?
351                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
352
353                // The user's newest message, verbatim — the frontend passes this
354                // directly from the just-submitted TurnStart content, so the
355                // common case needs no FileStore read at all. Without one, the
356                // session's recent activity is read instead, and shown to the
357                // model as activity: it used to be passed off as "The user just
358                // said". See
359                // docs/specs/SPEC_AMBIENT_PANE_TITLE_OVERALL_GOAL_TRACKING_2026_08_17.md.
360                let user_message = cmd
361                    .user_message
362                    .as_deref()
363                    .map(str::trim)
364                    .filter(|s| !s.is_empty())
365                    .map(str::to_string);
366                let activity = match user_message {
367                    Some(_) => None,
368                    None => ambient::digest::read_recent_activity_digest(&filestore, &cmd.block_id),
369                };
370
371                // A stored value that is not a real title (a placeholder an older build
372                // accepted, such as `(none yet)`) counts as NO title: it is never fed
373                // back into the prompt, so it cannot sustain itself. The first draft
374                // fed it back as the "current title" and told the model to repeat it.
375                let stored_title = obj::meta_get_string(&block.meta, ambient::title::META_TITLE, "");
376                let current_title = if ambient::validate::is_usable_title(&stored_title) {
377                    stored_title
378                } else {
379                    String::new()
380                };
381
382                // Nothing to anchor a title on AND nothing new to evaluate —
383                // matches the old digest-empty early return.
384                if user_message.is_none() && activity.is_none() && current_title.is_empty() {
385                    slot.abandon(ambient::outcome::Outcome::EmptyDigest);
386                    return Ok(empty_summary_result());
387                }
388
389                let Some(target) = ambient::call::CliTarget::from_meta(&block.meta) else {
390                    tracing::debug!(block_id = %cmd.block_id, "session:activity_summary: no CLI path in meta");
391                    return Ok(empty_summary_result());
392                };
393
394                let prompt = ambient::prompt::build_session_title_prompt(
395                    &current_title,
396                    user_message.as_deref(),
397                    activity.as_deref(),
398                    word_target,
399                );
400                let limits = ambient::validate::title_limits(word_target);
401                let reply = slot
402                    .run_held(&target, &prompt, |raw| {
403                        ambient::reply::judge_line(raw, |t| ambient::validate::accept_line(t, &limits))
404                    })
405                    .await;
406
407                // Stored here, not by the pane: one writer with the recovery sweep,
408                // in one transaction, so a rewording never replaces the title and
409                // neither writer overwrites what the other stored while its call ran
410                // (`ambient::title`). The slot is held until the write, so a newer
411                // message's request, admitted while this one ran, supersedes it and
412                // this older title is not stored after the newer one.
413                let mut stored = false;
414                if !reply.text.is_empty() {
415                    let still_current = || !slot.is_superseded();
416                    match ambient::title::store_title(&mstore, &cmd.block_id, &reply.text, ambient::title::Replace::IfNews, still_current) {
417                        Ok(true) => {
418                            stored = true;
419                            crate::backend::blockcontroller::core::broadcast_block_update(&mstore, &event_bus, &cmd.block_id);
420                        }
421                        Ok(false) => {}
422                        Err(e) => tracing::warn!(block_id = %cmd.block_id, error = %e, "session:activity_summary: could not store the title"),
423                    }
424                }
425                Ok(ActivitySummaryResult { summary: if stored { reply.text } else { String::new() }, tokens: reply.tokens })
426            }
427        },
428    );
429}
430
431fn empty_suggestion_result() -> NextPromptSuggestionResult {
432    NextPromptSuggestionResult { suggestion: String::new(), tokens: None }
433}
434
435fn register_session_next_prompt_suggestion(engine: &Arc<WshRpcEngine>, state: &AppState) {
436    let mstore = state.mstore.clone();
437    let filestore = state.filestore.clone();
438
439    engine.register_typed(
440        COMMAND_SESSION_NEXT_PROMPT_SUGGESTION,
441        move |cmd: CommandNextPromptSuggestionData, _ctx| {
442            let mstore = mstore.clone();
443            let filestore = filestore.clone();
444            async move {
445                // Same admission discipline and interactive class as activity_summary.
446                // Ghost text has a sharper failure mode than the read-only summary
447                // (a stale suggestion can put words in the user's mouth), so
448                // admitting before any work matters just as much here.
449                let Some(slot) = ambient::call::admit(
450                    &ambient::purpose::NEXT_PROMPT_SUGGESTION,
451                    cmd.block_id.clone(),
452                    cmd.generation,
453                )
454                .await
455                else {
456                    return Ok(empty_suggestion_result());
457                };
458
459                let block: Block = mstore
460                    .get(&cmd.block_id)
461                    .map_err(|e| format!("session:next_prompt_suggestion: {e}"))?
462                    .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
463
464                // The pane's own translated conversation when it sent one (any
465                // provider); otherwise the output file, which only Claude-shaped
466                // streams can be read from.
467                // Pane activity that ends at the user's message was taken before the
468                // reply reached the pane's document (the turn-end edge can lead the
469                // last nodes); the file may already hold the reply.
470                let activity = match cmd.activity.filter(|entries| !entries.is_empty()) {
471                    Some(entries) => ambient::digest::activity_from_entries(&cmd.block_id, entries)
472                        .filter(|a| a.ending != ambient::digest::TurnEnding::UserLast)
473                        .or_else(|| ambient::digest::read_recent_activity(&filestore, &cmd.block_id)),
474                    None => ambient::digest::read_recent_activity(&filestore, &cmd.block_id),
475                };
476                let Some(activity) = activity else {
477                    slot.abandon(ambient::outcome::Outcome::EmptyDigest);
478                    return Ok(empty_suggestion_result());
479                };
480                // The turn ended waiting for the user (a question, a tool that asks
481                // them) or before the assistant answered: there is no next
482                // instruction to predict, so no call. Asked to judge this itself,
483                // the model wrote prose about declining, and it reached the composer.
484                if activity.ending != ambient::digest::TurnEnding::Statement {
485                    slot.abandon(ambient::outcome::Outcome::Gated);
486                    return Ok(empty_suggestion_result());
487                }
488
489                let Some(target) = ambient::call::CliTarget::from_meta(&block.meta) else {
490                    tracing::debug!(block_id = %cmd.block_id, "session:next_prompt_suggestion: no CLI path in meta");
491                    return Ok(empty_suggestion_result());
492                };
493
494                let prompt = ambient::prompt::build_next_prompt_prompt(&activity.text);
495                let reply = slot
496                    .run(&target, &prompt, |raw| ambient::reply::judge_line(raw, ambient::validate::accept_next_prompt))
497                    .await;
498
499                // The tokens were spent either way, so they are still reported;
500                // only the text is withheld when it is not a usable next prompt.
501                Ok(NextPromptSuggestionResult { suggestion: reply.text, tokens: reply.tokens })
502            }
503        },
504    );
505}
506
507#[cfg(test)]
508mod preflight_input_tests {
509    use super::preflight_input_from_meta;
510
511    // ── resume-preflight meta extraction (reagent P1 on PR #2833) ────────
512    //
513    // The divergence these guard: the preflight must read every meta key with
514    // the SAME default the real spawn path uses, or it predicts a spawn that
515    // won't happen. Unit-testing `preflight` itself (as the rest of the suite
516    // does) can't catch that — it takes an explicit `PreflightInput`, so the
517    // extraction defaults are exactly the part those tests skip.
518
519    fn meta_of(pairs: &[(&str, serde_json::Value)]) -> crate::backend::obj::MetaMapType {
520        let mut m = crate::backend::obj::MetaMapType::new();
521        for (k, v) in pairs {
522            m.insert(k.to_string(), v.clone());
523        }
524        m
525    }
526
527    /// The regression itself: a Claude pane predating `agent_open.rs` writing
528    /// `agent:resume_flag`. The spawn attaches `--resume` via its own default,
529    /// so the preflight must not read this as "provider can't resume" and go
530    /// silent.
531    #[test]
532    fn absent_resume_flag_defaults_to_the_same_value_the_spawn_path_uses() {
533        let input = preflight_input_from_meta(&meta_of(&[]), None);
534        assert_eq!(
535            input.resume_flag, "--resume",
536            "must match agent_handlers/input.rs:400's default, or the preflight              reports Unknown for panes whose spawn really will --resume",
537        );
538    }
539
540    /// A provider that genuinely has no resume flag writes an explicit empty
541    /// string; that must survive as empty rather than being back-filled with
542    /// the default, or the preflight would claim resume support that isn't there.
543    #[test]
544    fn an_explicitly_empty_resume_flag_is_preserved_not_defaulted() {
545        let input = preflight_input_from_meta(&meta_of(&[("agent:resume_flag", serde_json::json!(""))]), None);
546        assert_eq!(input.resume_flag, "");
547    }
548
549    #[test]
550    fn an_explicit_resume_flag_is_read_verbatim() {
551        let input = preflight_input_from_meta(&meta_of(&[("agent:resume_flag", serde_json::json!("-r"))]), None);
552        assert_eq!(input.resume_flag, "-r");
553    }
554
555    #[test]
556    fn config_dir_is_read_out_of_the_cmd_env_map() {
557        let input = preflight_input_from_meta(&meta_of(&[(
558            "cmd:env",
559            serde_json::json!({ "CLAUDE_CONFIG_DIR": "/home/dev/.claude", "OTHER": "x" }),
560        )]), None);
561        assert_eq!(input.config_dir, "/home/dev/.claude");
562    }
563
564    /// No `cmd:env` at all, or no `CLAUDE_CONFIG_DIR` within it, must yield an
565    /// empty config dir — `preflight` turns that into `Unknown` and stays
566    /// silent, which is the correct posture when there's nowhere to look.
567    #[test]
568    fn a_missing_config_dir_is_empty_rather_than_a_guess() {
569        assert_eq!(preflight_input_from_meta(&meta_of(&[]), None).config_dir, "");
570        assert_eq!(
571            preflight_input_from_meta(&meta_of(&[("cmd:env", serde_json::json!({ "PATH": "/usr/bin" }))]), None)
572                .config_dir,
573            ""
574        );
575        assert_eq!(
576            preflight_input_from_meta(&meta_of(&[("cmd:env", serde_json::json!("not-an-object"))]), None).config_dir,
577            ""
578        );
579    }
580
581    #[test]
582    fn session_id_and_working_dir_default_to_empty() {
583        let input = preflight_input_from_meta(&meta_of(&[]), None);
584        assert_eq!(input.session_id, "");
585        assert_eq!(input.working_dir, "");
586
587        let input = preflight_input_from_meta(&meta_of(&[
588            ("agent:sessionid", serde_json::json!("sid-1")),
589            ("cmd:cwd", serde_json::json!("/work/dir")),
590        ]), None);
591        assert_eq!(input.session_id, "sid-1");
592        assert_eq!(input.working_dir, "/work/dir");
593    }
594
595    /// reagent P1 (second pass): an identity-bound pane's real config dir
596    /// comes from the identity resolver, never the `cmd:env` snapshot, so a
597    /// resolved dir must win outright — including when `cmd:env` disagrees.
598    #[test]
599    fn a_bound_identity_config_dir_overrides_the_cmd_env_snapshot() {
600        let meta = meta_of(&[(
601            "cmd:env",
602            serde_json::json!({ "CLAUDE_CONFIG_DIR": "/stale/from/spawn/snapshot" }),
603        )]);
604        let input = preflight_input_from_meta(
605            &meta,
606            Some(std::path::PathBuf::from("/identities/acct-7/claude")),
607        );
608        assert_eq!(
609            input.config_dir, "/identities/acct-7/claude",
610            "the identity-resolved dir is where the CLI will actually look",
611        );
612    }
613
614    /// …and a resolved dir must still win when there's no `cmd:env` at all.
615    #[test]
616    fn a_bound_identity_config_dir_is_used_with_no_cmd_env_present() {
617        let input = preflight_input_from_meta(
618            &meta_of(&[]),
619            Some(std::path::PathBuf::from("/identities/acct-9/claude")),
620        );
621        assert_eq!(input.config_dir, "/identities/acct-9/claude");
622    }
623
624    /// `None` means "not identity-bound, or not an OAuth provider" — for
625    /// those the `cmd:env` snapshot IS what the spawn uses, so it must still
626    /// be read rather than dropped.
627    #[test]
628    fn an_unbound_pane_still_falls_back_to_cmd_env() {
629        let meta = meta_of(&[("cmd:env", serde_json::json!({ "CLAUDE_CONFIG_DIR": "/home/dev/.claude" }))]);
630        assert_eq!(preflight_input_from_meta(&meta, None).config_dir, "/home/dev/.claude");
631    }
632}