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        // A session that never bound a launch owner never ran, so starting a
209        // fresh conversation cannot lose one; an owned-but-uncaptured session
210        // with reported identity stays guarded, because its conversation may
211        // exist under an id muster never learned.
212        let never_launched = self.owner_process_id.is_none();
213        (self.state == AgentSessionState::Pending
214            || never_launched
215            || self.state == AgentSessionState::Open
216                && self.tool.identity_source() == AgentIdentitySource::Assigned)
217            .then(|| {
218                self.tool
219                    .new_session_command(&self.launch_command, &self.id)
220            })
221            .flatten()
222    }
223
224    /// Whether a custom resume template can accept a provider identity without
225    /// embedding it in a quoted or compound shell word. The complete template
226    /// must also end outside quotes or escape syntax.
227    pub fn resume_template_is_valid(template: &CommandLine) -> bool {
228        let template = template.as_ref();
229        Self::shell_context(template) == ShellContext::Unquoted
230            && !Self::contains_here_document(template)
231            && (!template.contains(SESSION_ID_PLACEHOLDER)
232                && Self::command_text_accepts_provider_arguments(template)
233                || template.contains(SESSION_ID_PLACEHOLDER)
234                    && template
235                        .match_indices(SESSION_ID_PLACEHOLDER)
236                        .all(|(index, _)| {
237                            Self::placeholder_is_unquoted_shell_word(
238                                template,
239                                index,
240                                SESSION_ID_PLACEHOLDER,
241                            )
242                        }))
243    }
244
245    /// Whether built-in provider arguments can be safely appended to `command`.
246    /// Shell compositions require an explicit resume template so arguments are
247    /// never attached to a different command in a pipeline or sequence.
248    pub fn launch_command_accepts_provider_arguments(command: &CommandLine) -> bool {
249        Self::command_text_accepts_provider_arguments(command.as_ref())
250    }
251
252    /// Whether provider arguments can be appended to shell command text.
253    fn command_text_accepts_provider_arguments(command: &str) -> bool {
254        let mut context = ShellContext::Unquoted;
255        let mut chars = command.chars();
256        while let Some(character) = chars.next() {
257            if context == ShellContext::Unquoted
258                && matches!(
259                    character,
260                    '#' | '|' | ';' | '&' | '(' | ')' | '`' | '\n' | '\r'
261                )
262            {
263                return false;
264            }
265            context = match (context, character) {
266                (ShellContext::Unquoted, '\\')
267                | (ShellContext::Double, '\\')
268                | (ShellContext::Backtick, '\\') => {
269                    if chars.next().is_some() {
270                        context
271                    } else {
272                        ShellContext::TrailingEscape
273                    }
274                },
275                (ShellContext::Unquoted, '\'') => ShellContext::Single,
276                (ShellContext::Single, '\'') => ShellContext::Unquoted,
277                (ShellContext::Unquoted, '"') => ShellContext::Double,
278                (ShellContext::Double, '"') => ShellContext::Unquoted,
279                (ShellContext::Unquoted, '`') => ShellContext::Backtick,
280                (ShellContext::Backtick, '`') => ShellContext::Unquoted,
281                _ => context,
282            };
283        }
284        context == ShellContext::Unquoted
285    }
286
287    /// Returns whether unquoted text introduces a here-document body, where
288    /// shell quoting cannot safely protect a substituted provider identity.
289    fn contains_here_document(command: &str) -> bool {
290        let mut context = ShellContext::Unquoted;
291        let mut chars = command.chars().peekable();
292        while let Some(character) = chars.next() {
293            if context == ShellContext::Unquoted && character == '<' && chars.peek() == Some(&'<') {
294                return true;
295            }
296            context = match (context, character) {
297                (ShellContext::Unquoted, '\\')
298                | (ShellContext::Double, '\\')
299                | (ShellContext::Backtick, '\\') => {
300                    if chars.next().is_some() {
301                        context
302                    } else {
303                        ShellContext::TrailingEscape
304                    }
305                },
306                (ShellContext::Unquoted, '\'') => ShellContext::Single,
307                (ShellContext::Single, '\'') => ShellContext::Unquoted,
308                (ShellContext::Unquoted, '"') => ShellContext::Double,
309                (ShellContext::Double, '"') => ShellContext::Unquoted,
310                (ShellContext::Unquoted, '`') => ShellContext::Backtick,
311                (ShellContext::Backtick, '`') => ShellContext::Unquoted,
312                _ => context,
313            };
314        }
315        false
316    }
317
318    /// Expands a custom resume command, accepting the placeholder only as an
319    /// unquoted standalone shell word, or appending a safely quoted ID.
320    fn expand_resume_template(
321        template: &CommandLine,
322        native_id: &NativeSessionId,
323    ) -> Option<CommandLine> {
324        let quoted = Self::quote_for_command_shell(native_id.as_ref())?;
325        Self::resume_template_is_valid(template).then_some(())?;
326        let template = template.as_ref();
327        let command = if template.contains(SESSION_ID_PLACEHOLDER) {
328            template.replace(SESSION_ID_PLACEHOLDER, &quoted)
329        } else {
330            format!("{template} {quoted}")
331        };
332        CommandLine::try_new(command).ok()
333    }
334
335    /// Quotes one opaque argument for the command shell that launches agents.
336    pub(crate) fn quote_for_command_shell(value: &str) -> Option<String> {
337        #[cfg(windows)]
338        {
339            // cmd.exe expands percent variables even inside double quotes. Its
340            // ordinary metacharacters are inert there, so double percent signs
341            // preserve an opaque provider identity as one command argument.
342            Some(format!(
343                "\"{}\"",
344                value.replace('%', "%%").replace('"', "^\"")
345            ))
346        }
347        #[cfg(not(windows))]
348        {
349            shlex::try_quote(value).ok().map(Into::into)
350        }
351    }
352
353    /// Whether the placeholder is an unquoted shell word delimited by command
354    /// boundaries or unescaped whitespace.
355    fn placeholder_is_unquoted_shell_word(template: &str, index: usize, placeholder: &str) -> bool {
356        let before = &template[..index];
357        let after = &template[index + placeholder.len()..];
358        Self::shell_context(before) == ShellContext::Unquoted
359            && Self::prefix_ends_at_shell_boundary(before)
360            && after.chars().next().is_none_or(char::is_whitespace)
361    }
362
363    /// Whether `prefix` ends at a command boundary or whitespace that is not
364    /// escaped by an odd-length backslash run.
365    fn prefix_ends_at_shell_boundary(prefix: &str) -> bool {
366        let Some(last) = prefix.chars().next_back() else {
367            return true;
368        };
369        if !last.is_whitespace() {
370            return false;
371        }
372        prefix
373            .chars()
374            .rev()
375            .skip(1)
376            .take_while(|character| *character == '\\')
377            .count()
378            % 2
379            == 0
380    }
381
382    /// Tracks POSIX shell lexical state through `prefix`, treating complete
383    /// escapes as literals and retaining an incomplete terminal escape.
384    fn shell_context(prefix: &str) -> ShellContext {
385        let mut context = ShellContext::Unquoted;
386        let mut chars = prefix.chars();
387        while let Some(character) = chars.next() {
388            context = match (context, character) {
389                (ShellContext::Unquoted, '\\')
390                | (ShellContext::Double, '\\')
391                | (ShellContext::Backtick, '\\') => {
392                    if chars.next().is_some() {
393                        context
394                    } else {
395                        ShellContext::TrailingEscape
396                    }
397                },
398                (ShellContext::Unquoted, '\'') => ShellContext::Single,
399                (ShellContext::Single, '\'') => ShellContext::Unquoted,
400                (ShellContext::Unquoted, '"') => ShellContext::Double,
401                (ShellContext::Double, '"') => ShellContext::Unquoted,
402                (ShellContext::Unquoted, '`') => ShellContext::Backtick,
403                (ShellContext::Backtick, '`') => ShellContext::Unquoted,
404                _ => context,
405            };
406        }
407        context
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    /// A reported-identity session that never bound an owner never ran, so it
416    /// restores as a fresh conversation instead of being stuck forever.
417    #[test]
418    fn a_never_launched_session_restores_as_a_new_conversation() {
419        let session = AgentSession::builder()
420            .id(AgentSessionId::try_new("48e80ed9-1d5b-410a-9c20-1023d1cae5f4").unwrap())
421            .name(ProcessName::try_new("Rahul").unwrap())
422            .tool(AgentTool::Codex)
423            .project(PathBuf::from("/p/muster.yml"))
424            .launch_command(CommandLine::try_new("codex").unwrap())
425            .state(AgentSessionState::Open)
426            .build();
427
428        assert!(
429            session.restore_command().is_some(),
430            "no owner was ever bound, so nothing can be lost"
431        );
432    }
433
434    /// An owned but uncaptured reported-identity session stays guarded: its
435    /// conversation may exist under an id muster never learned.
436    #[test]
437    fn an_owned_uncaptured_session_stays_unrestorable() {
438        let session = AgentSession::builder()
439            .id(AgentSessionId::try_new("48e80ed9-1d5b-410a-9c20-1023d1cae5f4").unwrap())
440            .name(ProcessName::try_new("Rahul").unwrap())
441            .tool(AgentTool::Codex)
442            .project(PathBuf::from("/p/muster.yml"))
443            .launch_command(CommandLine::try_new("codex").unwrap())
444            .state(AgentSessionState::Open)
445            .build()
446            .with_owner_process_id(AgentProcessId::try_new(4242).unwrap());
447
448        assert!(session.restore_command().is_none());
449    }
450
451    /// A custom resume template substitutes its provider identity safely.
452    #[test]
453    fn expands_custom_resume_templates() {
454        let session = AgentSession::builder()
455            .id(AgentSessionId::generate().unwrap())
456            .name(ProcessName::try_new("Ada").unwrap())
457            .tool(AgentTool::Custom)
458            .project(PathBuf::from("/repo/muster.yml"))
459            .launch_command(CommandLine::try_new("agent").unwrap())
460            .resume_command(Some(
461                CommandLine::try_new("agent --resume {session_id}").unwrap(),
462            ))
463            .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
464            .state(AgentSessionState::Closed)
465            .build();
466
467        assert_eq!(
468            session.resume().unwrap().as_ref(),
469            "agent --resume 'thread one'"
470        );
471    }
472
473    /// A placeholder-free template appends the provider identity as one safely
474    /// quoted shell word when the template ends in ordinary shell context.
475    #[test]
476    fn appends_ids_to_complete_placeholder_free_templates() {
477        let session = AgentSession::builder()
478            .id(AgentSessionId::generate().unwrap())
479            .name(ProcessName::try_new("Ada").unwrap())
480            .tool(AgentTool::Custom)
481            .project(PathBuf::from("/repo/muster.yml"))
482            .launch_command(CommandLine::try_new("agent").unwrap())
483            .resume_command(Some(CommandLine::try_new("agent --resume").unwrap()))
484            .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
485            .state(AgentSessionState::Closed)
486            .build();
487
488        assert_eq!(
489            session.resume().unwrap().as_ref(),
490            "agent --resume 'thread one'"
491        );
492    }
493
494    /// Placeholder-free templates with an unmatched quote or dangling escape
495    /// are rejected before provider-controlled identity text can be appended.
496    #[test]
497    fn rejects_incomplete_placeholder_free_templates() {
498        for template in ["agent --resume \"", "agent --resume \\"] {
499            let template = CommandLine::try_new(template).unwrap();
500            assert!(!AgentSession::resume_template_is_valid(&template));
501        }
502        let session = AgentSession::builder()
503            .id(AgentSessionId::generate().unwrap())
504            .name(ProcessName::try_new("Ada").unwrap())
505            .tool(AgentTool::Custom)
506            .project(PathBuf::from("/repo/muster.yml"))
507            .launch_command(CommandLine::try_new("agent").unwrap())
508            .resume_command(Some(CommandLine::try_new("agent --resume \"").unwrap()))
509            .native_id(Some(
510                NativeSessionId::try_new("\"; some-command; #").unwrap(),
511            ))
512            .state(AgentSessionState::Closed)
513            .build();
514
515        assert!(session.resume().is_none());
516    }
517
518    /// Built-in provider flags are never appended to a pipeline or sequence.
519    #[test]
520    fn rejects_shell_compositions_for_provider_arguments() {
521        let pipeline = CommandLine::try_new("codex | tee agent.log").unwrap();
522        let sequence = CommandLine::try_new("codex; echo done").unwrap();
523        let newline = CommandLine::try_new("codex\ntee agent.log").unwrap();
524        let simple = CommandLine::try_new("FOO=bar codex").unwrap();
525
526        assert!(!AgentSession::launch_command_accepts_provider_arguments(
527            &pipeline
528        ));
529        assert!(!AgentSession::launch_command_accepts_provider_arguments(
530            &sequence
531        ));
532        assert!(!AgentSession::launch_command_accepts_provider_arguments(
533            &newline
534        ));
535        assert!(AgentSession::launch_command_accepts_provider_arguments(
536            &simple
537        ));
538    }
539
540    /// Placeholder-free templates cannot append an ID after a composition or
541    /// comment, while an explicit placeholder remains safe before a pipeline.
542    #[test]
543    fn rejects_placeholder_free_composed_resume_templates() {
544        for template in [
545            "agent --resume | tee agent.log",
546            "agent --resume # local",
547            "agent --resume\ntee agent.log",
548        ] {
549            let template = CommandLine::try_new(template).unwrap();
550            assert!(!AgentSession::resume_template_is_valid(&template));
551        }
552        let explicit = CommandLine::try_new("agent --resume {session_id} | tee agent.log").unwrap();
553        assert!(AgentSession::resume_template_is_valid(&explicit));
554    }
555
556    /// Here-document bodies do not apply shell quoting to substituted values.
557    #[test]
558    fn rejects_resume_placeholders_inside_here_documents() {
559        let template = CommandLine::try_new("cat <<EOF\n{session_id}\nEOF").unwrap();
560
561        assert!(!AgentSession::resume_template_is_valid(&template));
562    }
563
564    /// A placeholder nested inside user-provided quotes is rejected because a
565    /// pre-quoted shell word cannot be inserted safely into that context.
566    #[test]
567    fn rejects_a_quoted_resume_placeholder() {
568        let session = AgentSession::builder()
569            .id(AgentSessionId::generate().unwrap())
570            .name(ProcessName::try_new("Ada").unwrap())
571            .tool(AgentTool::Custom)
572            .project(PathBuf::from("/repo/muster.yml"))
573            .launch_command(CommandLine::try_new("agent").unwrap())
574            .resume_command(Some(
575                CommandLine::try_new("agent --resume \"{session_id}\"").unwrap(),
576            ))
577            .native_id(Some(NativeSessionId::try_new("thread one").unwrap()))
578            .state(AgentSessionState::Closed)
579            .build();
580
581        assert!(session.resume().is_none());
582    }
583
584    /// Whitespace around a placeholder does not make it safe when the whole
585    /// placeholder still appears inside a double-quoted shell word.
586    #[test]
587    fn rejects_a_spaced_placeholder_inside_shell_quotes() {
588        let session = AgentSession::builder()
589            .id(AgentSessionId::generate().unwrap())
590            .name(ProcessName::try_new("Ada").unwrap())
591            .tool(AgentTool::Custom)
592            .project(PathBuf::from("/repo/muster.yml"))
593            .launch_command(CommandLine::try_new("agent").unwrap())
594            .resume_command(Some(
595                CommandLine::try_new("agent --resume \"prefix {session_id} suffix\"").unwrap(),
596            ))
597            .native_id(Some(
598                NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
599            ))
600            .state(AgentSessionState::Closed)
601            .build();
602
603        assert!(session.resume().is_none());
604    }
605
606    /// Shell metacharacters in provider-owned IDs remain inside one quoted word.
607    #[test]
608    fn quotes_shell_metacharacters_in_resume_ids() {
609        let session = AgentSession::builder()
610            .id(AgentSessionId::generate().unwrap())
611            .name(ProcessName::try_new("Ada").unwrap())
612            .tool(AgentTool::Custom)
613            .project(PathBuf::from("/repo/muster.yml"))
614            .launch_command(CommandLine::try_new("agent").unwrap())
615            .resume_command(Some(
616                CommandLine::try_new("agent --resume {session_id}").unwrap(),
617            ))
618            .native_id(Some(
619                NativeSessionId::try_new("$(touch /tmp/muster-owned)").unwrap(),
620            ))
621            .state(AgentSessionState::Closed)
622            .build();
623
624        assert_eq!(
625            session.resume().unwrap().as_ref(),
626            "agent --resume '$(touch /tmp/muster-owned)'"
627        );
628    }
629
630    /// Windows must use cmd.exe quoting rather than POSIX single quotes.
631    #[cfg(windows)]
632    #[test]
633    fn quotes_resume_ids_for_the_windows_command_shell() {
634        assert_eq!(
635            AgentSession::quote_for_command_shell("thread & command"),
636            Some("\"thread & command\"".to_string())
637        );
638    }
639
640    /// A caller-assigned provider retries its stable launch identity until the
641    /// provider confirms that conversation through a lifecycle hook.
642    #[test]
643    fn restores_an_unconfirmed_assigned_identity_with_a_new_session_command() {
644        let session = AgentSession::builder()
645            .id(AgentSessionId::try_new("assigned-session").unwrap())
646            .name(ProcessName::try_new("Ada").unwrap())
647            .tool(AgentTool::Claude)
648            .project(PathBuf::from("/repo/muster.yml"))
649            .launch_command(CommandLine::try_new("claude").unwrap())
650            .state(AgentSessionState::Open)
651            .build();
652
653        assert!(session.resume().is_none());
654        assert_eq!(
655            session.restore_command().unwrap().as_ref(),
656            "claude --session-id assigned-session"
657        );
658    }
659
660    /// A closed unconfirmed conversation never becomes a fresh launch from history.
661    #[test]
662    fn does_not_reopen_a_closed_unconfirmed_assigned_identity() {
663        // The session ran (an owner was bound) but the assigned identity was
664        // never confirmed back by a capture hook.
665        let session = AgentSession::builder()
666            .id(AgentSessionId::try_new("closed-session").unwrap())
667            .name(ProcessName::try_new("Ada").unwrap())
668            .tool(AgentTool::Claude)
669            .project(PathBuf::from("/repo/muster.yml"))
670            .launch_command(CommandLine::try_new("claude").unwrap())
671            .state(AgentSessionState::Closed)
672            .build()
673            .with_owner_process_id(AgentProcessId::try_new(4242).unwrap());
674
675        assert!(session.restore_command().is_none());
676    }
677
678    /// A reported-ID session retries safely when its initial PTY launch never attached.
679    #[test]
680    fn restores_a_pending_reported_identity_with_a_new_session_command() {
681        let session = AgentSession::builder()
682            .id(AgentSessionId::try_new("pending-session").unwrap())
683            .name(ProcessName::try_new("Ada").unwrap())
684            .tool(AgentTool::Codex)
685            .project(PathBuf::from("/repo/muster.yml"))
686            .launch_command(CommandLine::try_new("codex").unwrap())
687            .state(AgentSessionState::Pending)
688            .build();
689
690        assert_eq!(session.restore_command().unwrap().as_ref(), "codex");
691    }
692
693    /// Once a caller-assigned provider confirms its native conversation, all
694    /// restoration paths use the provider's resume command.
695    #[test]
696    fn restores_a_confirmed_assigned_identity_with_resume() {
697        let session = AgentSession::builder()
698            .id(AgentSessionId::try_new("assigned-session").unwrap())
699            .name(ProcessName::try_new("Ada").unwrap())
700            .tool(AgentTool::Claude)
701            .project(PathBuf::from("/repo/muster.yml"))
702            .launch_command(CommandLine::try_new("claude").unwrap())
703            .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
704            .state(AgentSessionState::Open)
705            .build();
706
707        assert_eq!(
708            session.restore_command().unwrap().as_ref(),
709            "claude --resume confirmed-session"
710        );
711    }
712
713    /// A confirmed identity with invalid durable resume behavior never falls
714    /// back to a fresh conversation under the same Muster session.
715    #[test]
716    fn does_not_replace_a_confirmed_conversation_when_resume_is_invalid() {
717        let session = AgentSession::builder()
718            .id(AgentSessionId::try_new("assigned-session").unwrap())
719            .name(ProcessName::try_new("Ada").unwrap())
720            .tool(AgentTool::Claude)
721            .project(PathBuf::from("/repo/muster.yml"))
722            .launch_command(CommandLine::try_new("claude").unwrap())
723            .resume_command(Some(CommandLine::try_new("claude --resume \"").unwrap()))
724            .native_id(Some(NativeSessionId::try_new("confirmed-session").unwrap()))
725            .state(AgentSessionState::Open)
726            .build();
727
728        assert!(session.restore_command().is_none());
729    }
730}