Skip to main content

muster/domain/
agent_session.rs

1use std::path::PathBuf;
2
3use getset::Getters;
4use nutype::nutype;
5use serde::{Deserialize, Serialize};
6use typed_builder::TypedBuilder;
7
8use crate::domain::{
9    process::{AgentIdentitySource, AgentProtocol, AgentTool},
10    value::{CommandLine, ProcessName},
11};
12
13/// Stable Muster-owned identity for an agent session.
14#[nutype(
15    sanitize(trim),
16    validate(not_empty),
17    derive(
18        Debug,
19        Clone,
20        PartialEq,
21        Eq,
22        Hash,
23        AsRef,
24        Display,
25        Serialize,
26        Deserialize
27    )
28)]
29pub struct AgentSessionId(String);
30
31impl AgentSessionId {
32    /// Creates a globally unique identity for a new session.
33    ///
34    /// # Errors
35    /// Returns the generated newtype's validation error if its invariant ever
36    /// diverges from UUID's non-empty textual representation.
37    pub fn generate() -> Result<Self, AgentSessionIdError> {
38        Self::try_new(uuid::Uuid::new_v4().to_string())
39    }
40}
41
42/// Provider-owned identity used to resume an existing conversation.
43#[nutype(
44    sanitize(trim),
45    validate(not_empty),
46    derive(
47        Debug,
48        Clone,
49        PartialEq,
50        Eq,
51        Hash,
52        AsRef,
53        Display,
54        Serialize,
55        Deserialize
56    )
57)]
58pub struct NativeSessionId(String);
59
60/// Operating-system process identity of the provider instance owned by Muster.
61#[nutype(
62    validate(greater = 0),
63    derive(
64        Debug,
65        Clone,
66        Copy,
67        PartialEq,
68        Eq,
69        Hash,
70        Display,
71        Serialize,
72        Deserialize
73    )
74)]
75pub struct AgentProcessId(u32);
76
77/// Non-reusable creation marker paired with an operating-system process ID.
78#[nutype(
79    validate(greater = 0),
80    derive(
81        Debug,
82        Clone,
83        Copy,
84        PartialEq,
85        Eq,
86        Hash,
87        Display,
88        Serialize,
89        Deserialize
90    )
91)]
92pub struct AgentProcessStartToken(u64);
93
94/// Whether a persisted session should be restored with its workspace.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "lowercase")]
97pub enum AgentSessionState {
98    /// A fresh launch was reserved but has not attached to a PTY successfully.
99    Pending,
100    /// The pane was open when Muster last owned the workspace.
101    Open,
102    /// The user closed the pane, leaving it available in history.
103    Closed,
104}
105
106/// POSIX shell lexical context at a resume-template byte offset.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum ShellContext {
109    /// The placeholder is parsed as ordinary shell syntax.
110    Unquoted,
111    /// The placeholder appears inside single quotes.
112    Single,
113    /// The placeholder appears inside double quotes.
114    Double,
115    /// The placeholder appears inside legacy backtick command substitution.
116    Backtick,
117    /// A terminal backslash has no following character to escape.
118    TrailingEscape,
119}
120
121/// Placeholder accepted by custom provider resume templates.
122const SESSION_ID_PLACEHOLDER: &str = "{session_id}";
123
124/// Durable metadata for one provider-backed agent conversation.
125#[derive(Debug, Clone, Serialize, Deserialize, Getters, TypedBuilder)]
126#[getset(get = "pub")]
127pub struct AgentSession {
128    /// Stable identity owned by Muster rather than the provider.
129    id: AgentSessionId,
130    /// Generated or user-supplied display name.
131    name: ProcessName,
132    /// Provider preset controlling launch, activity, and resume behavior.
133    tool: AgentTool,
134    /// Exact workspace config location that owns the session.
135    project: PathBuf,
136    /// Original launch command, including user customizations.
137    launch_command: CommandLine,
138    /// Optional working directory relative to the owning workspace.
139    #[builder(default)]
140    working_dir: Option<PathBuf>,
141    /// Optional custom resume template. `{session_id}` is replaced when present.
142    #[builder(default)]
143    resume_command: Option<CommandLine>,
144    /// Provider identity captured by a lifecycle integration.
145    #[builder(default)]
146    native_id: Option<NativeSessionId>,
147    /// Process identity allowed to update the provider conversation.
148    #[builder(default)]
149    owner_process_id: Option<AgentProcessId>,
150    /// Creation marker paired with the owner PID to reject PID reuse.
151    #[builder(default)]
152    owner_process_start_token: Option<AgentProcessStartToken>,
153    /// Shell wrapper identity allowed to hand off to its direct provider child.
154    #[builder(default)]
155    wrapper_process_id: Option<AgentProcessId>,
156    /// Open sessions restore automatically; closed sessions remain in history.
157    state: AgentSessionState,
158}
159
160impl AgentSession {
161    /// Returns a copy with the provider identity captured by a hook or plugin.
162    pub fn with_native_id(mut self, native_id: NativeSessionId) -> Self {
163        self.native_id = Some(native_id);
164        self
165    }
166
167    /// Returns a copy bound to the current managed provider process.
168    pub fn with_owner_process_id(mut self, process_id: AgentProcessId) -> Self {
169        self.owner_process_id = Some(process_id);
170        self
171    }
172
173    /// Returns a copy bound to a launch owner and its optional shell wrapper.
174    pub fn with_launch_processes(
175        mut self,
176        owner_process_id: AgentProcessId,
177        owner_process_start_token: Option<AgentProcessStartToken>,
178        wrapper_process_id: Option<AgentProcessId>,
179    ) -> Self {
180        self.owner_process_id = Some(owner_process_id);
181        self.owner_process_start_token = owner_process_start_token;
182        self.wrapper_process_id = wrapper_process_id;
183        self
184    }
185
186    /// Returns a copy with the requested persisted lifecycle state.
187    pub fn with_state(mut self, state: AgentSessionState) -> Self {
188        self.state = state;
189        self
190    }
191
192    /// Builds the provider-specific command that resumes this conversation.
193    pub fn resume(&self) -> Option<CommandLine> {
194        let native_id = self.native_id.as_ref()?;
195        if let Some(template) = &self.resume_command {
196            return Self::expand_resume_template(template, native_id);
197        }
198        self.tool.resume_command(&self.launch_command, native_id)
199    }
200
201    /// Builds the safest command available after a process or Muster restart.
202    /// Pending launches and open caller-assigned identities retry their original
203    /// new-session command until a lifecycle hook confirms them.
204    pub fn restore_command(&self) -> Option<CommandLine> {
205        if self.native_id.is_some() {
206            return self.resume();
207        }
208        (self.state == AgentSessionState::Pending
209            || self.state == AgentSessionState::Open
210                && self.tool.identity_source() == AgentIdentitySource::Assigned)
211            .then(|| {
212                self.tool
213                    .new_session_command(&self.launch_command, &self.id)
214            })
215            .flatten()
216    }
217
218    /// Whether a custom resume template can accept a provider identity without
219    /// embedding it in a quoted or compound shell word. The complete template
220    /// must also end outside quotes or escape syntax.
221    pub fn resume_template_is_valid(template: &CommandLine) -> bool {
222        let template = template.as_ref();
223        Self::shell_context(template) == ShellContext::Unquoted
224            && !Self::contains_here_document(template)
225            && (!template.contains(SESSION_ID_PLACEHOLDER)
226                && Self::command_text_accepts_provider_arguments(template)
227                || template.contains(SESSION_ID_PLACEHOLDER)
228                    && template
229                        .match_indices(SESSION_ID_PLACEHOLDER)
230                        .all(|(index, _)| {
231                            Self::placeholder_is_unquoted_shell_word(
232                                template,
233                                index,
234                                SESSION_ID_PLACEHOLDER,
235                            )
236                        }))
237    }
238
239    /// Whether built-in provider arguments can be safely appended to `command`.
240    /// Shell compositions require an explicit resume template so arguments are
241    /// never attached to a different command in a pipeline or sequence.
242    pub fn launch_command_accepts_provider_arguments(command: &CommandLine) -> bool {
243        Self::command_text_accepts_provider_arguments(command.as_ref())
244    }
245
246    /// Whether provider arguments can be appended to shell command text.
247    fn command_text_accepts_provider_arguments(command: &str) -> bool {
248        let mut context = ShellContext::Unquoted;
249        let mut chars = command.chars();
250        while let Some(character) = chars.next() {
251            if context == ShellContext::Unquoted
252                && matches!(
253                    character,
254                    '#' | '|' | ';' | '&' | '(' | ')' | '`' | '\n' | '\r'
255                )
256            {
257                return false;
258            }
259            context = match (context, character) {
260                (ShellContext::Unquoted, '\\')
261                | (ShellContext::Double, '\\')
262                | (ShellContext::Backtick, '\\') => {
263                    if chars.next().is_some() {
264                        context
265                    } else {
266                        ShellContext::TrailingEscape
267                    }
268                },
269                (ShellContext::Unquoted, '\'') => ShellContext::Single,
270                (ShellContext::Single, '\'') => ShellContext::Unquoted,
271                (ShellContext::Unquoted, '"') => ShellContext::Double,
272                (ShellContext::Double, '"') => ShellContext::Unquoted,
273                (ShellContext::Unquoted, '`') => ShellContext::Backtick,
274                (ShellContext::Backtick, '`') => ShellContext::Unquoted,
275                _ => context,
276            };
277        }
278        context == ShellContext::Unquoted
279    }
280
281    /// Returns whether unquoted text introduces a here-document body, where
282    /// shell quoting cannot safely protect a substituted provider identity.
283    fn contains_here_document(command: &str) -> bool {
284        let mut context = ShellContext::Unquoted;
285        let mut chars = command.chars().peekable();
286        while let Some(character) = chars.next() {
287            if context == ShellContext::Unquoted && character == '<' && chars.peek() == Some(&'<') {
288                return true;
289            }
290            context = match (context, character) {
291                (ShellContext::Unquoted, '\\')
292                | (ShellContext::Double, '\\')
293                | (ShellContext::Backtick, '\\') => {
294                    if chars.next().is_some() {
295                        context
296                    } else {
297                        ShellContext::TrailingEscape
298                    }
299                },
300                (ShellContext::Unquoted, '\'') => ShellContext::Single,
301                (ShellContext::Single, '\'') => ShellContext::Unquoted,
302                (ShellContext::Unquoted, '"') => ShellContext::Double,
303                (ShellContext::Double, '"') => ShellContext::Unquoted,
304                (ShellContext::Unquoted, '`') => ShellContext::Backtick,
305                (ShellContext::Backtick, '`') => ShellContext::Unquoted,
306                _ => context,
307            };
308        }
309        false
310    }
311
312    /// Expands a custom resume command, accepting the placeholder only as an
313    /// unquoted standalone shell word, or appending a safely quoted ID.
314    fn expand_resume_template(
315        template: &CommandLine,
316        native_id: &NativeSessionId,
317    ) -> Option<CommandLine> {
318        let quoted = Self::quote_for_command_shell(native_id.as_ref())?;
319        Self::resume_template_is_valid(template).then_some(())?;
320        let template = template.as_ref();
321        let command = if template.contains(SESSION_ID_PLACEHOLDER) {
322            template.replace(SESSION_ID_PLACEHOLDER, &quoted)
323        } else {
324            format!("{template} {quoted}")
325        };
326        CommandLine::try_new(command).ok()
327    }
328
329    /// Quotes one opaque argument for the command shell that launches agents.
330    pub(crate) fn quote_for_command_shell(value: &str) -> Option<String> {
331        #[cfg(windows)]
332        {
333            // cmd.exe expands percent variables even inside double quotes. Its
334            // ordinary metacharacters are inert there, so double percent signs
335            // preserve an opaque provider identity as one command argument.
336            Some(format!(
337                "\"{}\"",
338                value.replace('%', "%%").replace('"', "^\"")
339            ))
340        }
341        #[cfg(not(windows))]
342        {
343            shlex::try_quote(value).ok().map(Into::into)
344        }
345    }
346
347    /// Whether the placeholder is an unquoted shell word delimited by command
348    /// boundaries or unescaped whitespace.
349    fn placeholder_is_unquoted_shell_word(template: &str, index: usize, placeholder: &str) -> bool {
350        let before = &template[..index];
351        let after = &template[index + placeholder.len()..];
352        Self::shell_context(before) == ShellContext::Unquoted
353            && Self::prefix_ends_at_shell_boundary(before)
354            && after.chars().next().is_none_or(char::is_whitespace)
355    }
356
357    /// Whether `prefix` ends at a command boundary or whitespace that is not
358    /// escaped by an odd-length backslash run.
359    fn prefix_ends_at_shell_boundary(prefix: &str) -> bool {
360        let Some(last) = prefix.chars().next_back() else {
361            return true;
362        };
363        if !last.is_whitespace() {
364            return false;
365        }
366        prefix
367            .chars()
368            .rev()
369            .skip(1)
370            .take_while(|character| *character == '\\')
371            .count()
372            % 2
373            == 0
374    }
375
376    /// Tracks POSIX shell lexical state through `prefix`, treating complete
377    /// escapes as literals and retaining an incomplete terminal escape.
378    fn shell_context(prefix: &str) -> ShellContext {
379        let mut context = ShellContext::Unquoted;
380        let mut chars = prefix.chars();
381        while let Some(character) = chars.next() {
382            context = match (context, character) {
383                (ShellContext::Unquoted, '\\')
384                | (ShellContext::Double, '\\')
385                | (ShellContext::Backtick, '\\') => {
386                    if chars.next().is_some() {
387                        context
388                    } else {
389                        ShellContext::TrailingEscape
390                    }
391                },
392                (ShellContext::Unquoted, '\'') => ShellContext::Single,
393                (ShellContext::Single, '\'') => ShellContext::Unquoted,
394                (ShellContext::Unquoted, '"') => ShellContext::Double,
395                (ShellContext::Double, '"') => ShellContext::Unquoted,
396                (ShellContext::Unquoted, '`') => ShellContext::Backtick,
397                (ShellContext::Backtick, '`') => ShellContext::Unquoted,
398                _ => context,
399            };
400        }
401        context
402    }
403}
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    /// A custom resume template substitutes its provider identity safely.
410    #[test]
411    fn expands_custom_resume_templates() {
412        let session = AgentSession::builder()
413            .id(AgentSessionId::generate().unwrap())
414            .name(ProcessName::try_new("Ada").unwrap())
415            .tool(AgentTool::Custom)
416            .project(PathBuf::from("/repo/muster.yml"))
417            .launch_command(CommandLine::try_new("agent").unwrap())
418            .resume_command(Some(
419                CommandLine::try_new("agent --resume {session_id}").unwrap(),
420            ))
421            .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
422            .state(AgentSessionState::Closed)
423            .build();
424
425        assert_eq!(
426            session.resume().unwrap().as_ref(),
427            "agent --resume 'thread one'"
428        );
429    }
430
431    /// A placeholder-free template appends the provider identity as one safely
432    /// quoted shell word when the template ends in ordinary shell context.
433    #[test]
434    fn appends_ids_to_complete_placeholder_free_templates() {
435        let session = AgentSession::builder()
436            .id(AgentSessionId::generate().unwrap())
437            .name(ProcessName::try_new("Ada").unwrap())
438            .tool(AgentTool::Custom)
439            .project(PathBuf::from("/repo/muster.yml"))
440            .launch_command(CommandLine::try_new("agent").unwrap())
441            .resume_command(Some(CommandLine::try_new("agent --resume").unwrap()))
442            .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
443            .state(AgentSessionState::Closed)
444            .build();
445
446        assert_eq!(
447            session.resume().unwrap().as_ref(),
448            "agent --resume 'thread one'"
449        );
450    }
451
452    /// Placeholder-free templates with an unmatched quote or dangling escape
453    /// are rejected before provider-controlled identity text can be appended.
454    #[test]
455    fn rejects_incomplete_placeholder_free_templates() {
456        for template in ["agent --resume \"", "agent --resume \\"] {
457            let template = CommandLine::try_new(template).unwrap();
458            assert!(!AgentSession::resume_template_is_valid(&template));
459        }
460        let session = AgentSession::builder()
461            .id(AgentSessionId::generate().unwrap())
462            .name(ProcessName::try_new("Ada").unwrap())
463            .tool(AgentTool::Custom)
464            .project(PathBuf::from("/repo/muster.yml"))
465            .launch_command(CommandLine::try_new("agent").unwrap())
466            .resume_command(Some(CommandLine::try_new("agent --resume \"").unwrap()))
467            .native_id(Some(
468                NativeSessionId::try_new("\"; some-command; #").unwrap(),
469            ))
470            .state(AgentSessionState::Closed)
471            .build();
472
473        assert!(session.resume().is_none());
474    }
475
476    /// Built-in provider flags are never appended to a pipeline or sequence.
477    #[test]
478    fn rejects_shell_compositions_for_provider_arguments() {
479        let pipeline = CommandLine::try_new("codex | tee agent.log").unwrap();
480        let sequence = CommandLine::try_new("codex; echo done").unwrap();
481        let newline = CommandLine::try_new("codex\ntee agent.log").unwrap();
482        let simple = CommandLine::try_new("FOO=bar codex").unwrap();
483
484        assert!(!AgentSession::launch_command_accepts_provider_arguments(
485            &pipeline
486        ));
487        assert!(!AgentSession::launch_command_accepts_provider_arguments(
488            &sequence
489        ));
490        assert!(!AgentSession::launch_command_accepts_provider_arguments(
491            &newline
492        ));
493        assert!(AgentSession::launch_command_accepts_provider_arguments(
494            &simple
495        ));
496    }
497
498    /// Placeholder-free templates cannot append an ID after a composition or
499    /// comment, while an explicit placeholder remains safe before a pipeline.
500    #[test]
501    fn rejects_placeholder_free_composed_resume_templates() {
502        for template in [
503            "agent --resume | tee agent.log",
504            "agent --resume # local",
505            "agent --resume\ntee agent.log",
506        ] {
507            let template = CommandLine::try_new(template).unwrap();
508            assert!(!AgentSession::resume_template_is_valid(&template));
509        }
510        let explicit = CommandLine::try_new("agent --resume {session_id} | tee agent.log").unwrap();
511        assert!(AgentSession::resume_template_is_valid(&explicit));
512    }
513
514    /// Here-document bodies do not apply shell quoting to substituted values.
515    #[test]
516    fn rejects_resume_placeholders_inside_here_documents() {
517        let template = CommandLine::try_new("cat <<EOF\n{session_id}\nEOF").unwrap();
518
519        assert!(!AgentSession::resume_template_is_valid(&template));
520    }
521
522    /// A placeholder nested inside user-provided quotes is rejected because a
523    /// pre-quoted shell word cannot be inserted safely into that context.
524    #[test]
525    fn rejects_a_quoted_resume_placeholder() {
526        let session = AgentSession::builder()
527            .id(AgentSessionId::generate().unwrap())
528            .name(ProcessName::try_new("Ada").unwrap())
529            .tool(AgentTool::Custom)
530            .project(PathBuf::from("/repo/muster.yml"))
531            .launch_command(CommandLine::try_new("agent").unwrap())
532            .resume_command(Some(
533                CommandLine::try_new("agent --resume \"{session_id}\"").unwrap(),
534            ))
535            .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
536            .state(AgentSessionState::Closed)
537            .build();
538
539        assert!(session.resume().is_none());
540    }
541
542    /// Whitespace around a placeholder does not make it safe when the whole
543    /// placeholder still appears inside a double-quoted shell word.
544    #[test]
545    fn rejects_a_spaced_placeholder_inside_shell_quotes() {
546        let session = AgentSession::builder()
547            .id(AgentSessionId::generate().unwrap())
548            .name(ProcessName::try_new("Ada").unwrap())
549            .tool(AgentTool::Custom)
550            .project(PathBuf::from("/repo/muster.yml"))
551            .launch_command(CommandLine::try_new("agent").unwrap())
552            .resume_command(Some(
553                CommandLine::try_new("agent --resume \"prefix {session_id} suffix\"").unwrap(),
554            ))
555            .native_id(Some(
556                NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
557            ))
558            .state(AgentSessionState::Closed)
559            .build();
560
561        assert!(session.resume().is_none());
562    }
563
564    /// Shell metacharacters in provider-owned IDs remain inside one quoted word.
565    #[test]
566    fn quotes_shell_metacharacters_in_resume_ids() {
567        let session = AgentSession::builder()
568            .id(AgentSessionId::generate().unwrap())
569            .name(ProcessName::try_new("Ada").unwrap())
570            .tool(AgentTool::Custom)
571            .project(PathBuf::from("/repo/muster.yml"))
572            .launch_command(CommandLine::try_new("agent").unwrap())
573            .resume_command(Some(
574                CommandLine::try_new("agent --resume {session_id}").unwrap(),
575            ))
576            .native_id(Some(
577                NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
578            ))
579            .state(AgentSessionState::Closed)
580            .build();
581
582        assert_eq!(
583            session.resume().unwrap().as_ref(),
584            "agent --resume '$(touch /tmp/muster-owned)'"
585        );
586    }
587
588    /// Windows must use cmd.exe quoting rather than POSIX single quotes.
589    #[cfg(windows)]
590    #[test]
591    fn quotes_resume_ids_for_the_windows_command_shell() {
592        assert_eq!(
593            AgentSession::quote_for_command_shell("thread & command"),
594            Some("\"thread & command\"".to_string())
595        );
596    }
597
598    /// A caller-assigned provider retries its stable launch identity until the
599    /// provider confirms that conversation through a lifecycle hook.
600    #[test]
601    fn restores_an_unconfirmed_assigned_identity_with_a_new_session_command() {
602        let session = AgentSession::builder()
603            .id(AgentSessionId::try_new("assigned-session").unwrap())
604            .name(ProcessName::try_new("Ada").unwrap())
605            .tool(AgentTool::Claude)
606            .project(PathBuf::from("/repo/muster.yml"))
607            .launch_command(CommandLine::try_new("claude").unwrap())
608            .state(AgentSessionState::Open)
609            .build();
610
611        assert!(session.resume().is_none());
612        assert_eq!(
613            session.restore_command().unwrap().as_ref(),
614            "claude --session-id assigned-session"
615        );
616    }
617
618    /// A closed unconfirmed conversation never becomes a fresh launch from history.
619    #[test]
620    fn does_not_reopen_a_closed_unconfirmed_assigned_identity() {
621        let session = AgentSession::builder()
622            .id(AgentSessionId::try_new("closed-session").unwrap())
623            .name(ProcessName::try_new("Ada").unwrap())
624            .tool(AgentTool::Claude)
625            .project(PathBuf::from("/repo/muster.yml"))
626            .launch_command(CommandLine::try_new("claude").unwrap())
627            .state(AgentSessionState::Closed)
628            .build();
629
630        assert!(session.restore_command().is_none());
631    }
632
633    /// A reported-ID session retries safely when its initial PTY launch never attached.
634    #[test]
635    fn restores_a_pending_reported_identity_with_a_new_session_command() {
636        let session = AgentSession::builder()
637            .id(AgentSessionId::try_new("pending-session").unwrap())
638            .name(ProcessName::try_new("Ada").unwrap())
639            .tool(AgentTool::Codex)
640            .project(PathBuf::from("/repo/muster.yml"))
641            .launch_command(CommandLine::try_new("codex").unwrap())
642            .state(AgentSessionState::Pending)
643            .build();
644
645        assert_eq!(session.restore_command().unwrap().as_ref(), "codex");
646    }
647
648    /// Once a caller-assigned provider confirms its native conversation, all
649    /// restoration paths use the provider's resume command.
650    #[test]
651    fn restores_a_confirmed_assigned_identity_with_resume() {
652        let session = AgentSession::builder()
653            .id(AgentSessionId::try_new("assigned-session").unwrap())
654            .name(ProcessName::try_new("Ada").unwrap())
655            .tool(AgentTool::Claude)
656            .project(PathBuf::from("/repo/muster.yml"))
657            .launch_command(CommandLine::try_new("claude").unwrap())
658            .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
659            .state(AgentSessionState::Open)
660            .build();
661
662        assert_eq!(
663            session.restore_command().unwrap().as_ref(),
664            "claude --resume confirmed-session"
665        );
666    }
667
668    /// A confirmed identity with invalid durable resume behavior never falls
669    /// back to a fresh conversation under the same Muster session.
670    #[test]
671    fn does_not_replace_a_confirmed_conversation_when_resume_is_invalid() {
672        let session = AgentSession::builder()
673            .id(AgentSessionId::try_new("assigned-session").unwrap())
674            .name(ProcessName::try_new("Ada").unwrap())
675            .tool(AgentTool::Claude)
676            .project(PathBuf::from("/repo/muster.yml"))
677            .launch_command(CommandLine::try_new("claude").unwrap())
678            .resume_command(Some(CommandLine::try_new("claude --resume \"").unwrap()))
679            .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
680            .state(AgentSessionState::Open)
681            .build();
682
683        assert!(session.restore_command().is_none());
684    }
685}