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
//! Launch mode and fork context for mob member provisioning.
//!
//! These types define how a member is launched within a mob — fresh, resuming
//! an existing session, or forking from another member's conversation.
//!
//! `ForkContext` is a mob-native type defined here (not in `meerkat-core`)
//! because forking is a mob-level orchestration concept.
use crate::ids::{AgentIdentity, ProfileName};
use meerkat_core::types::SessionId;
use serde::{Deserialize, Serialize};
/// How a mob member should be launched.
///
/// Public spawn-policy enum exposed so that external consumers of
/// [`crate::runtime::SpawnMemberSpec`] can configure session adoption
/// (resume an existing bridge session, fork from a sibling member's
/// history) without reaching into `pub(crate)` internals.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
#[non_exhaustive]
pub enum MemberLaunchMode {
/// Start with a brand-new session (default).
#[default]
Fresh,
/// Resume an existing bridge session binding by ID.
///
/// `resume_from_role` is a one-request migration declaration for a
/// durable member whose role changed while its mob id and agent identity
/// stayed the same. Absence preserves the strict same-role resume
/// invariant. A populated declaration authorizes only the named
/// predecessor role; it is never a standing profile permission.
Resume {
bridge_session_id: SessionId,
#[serde(default, skip_serializing_if = "Option::is_none")]
resume_from_role: Option<ProfileName>,
},
/// Fork from another member's conversation history.
Fork {
source_member_id: AgentIdentity,
#[serde(default)]
fork_context: ForkContext,
},
}
impl MemberLaunchMode {
/// If this launch mode resumes an existing bridge session, return
/// the session id. Returns `None` for `Fresh` and `Fork` modes.
pub fn resume_bridge_session_id(&self) -> Option<&SessionId> {
match self {
Self::Resume {
bridge_session_id, ..
} => Some(bridge_session_id),
_ => None,
}
}
/// Declared predecessor role for this exact resume request.
pub fn resume_from_role(&self) -> Option<&ProfileName> {
match self {
Self::Resume {
resume_from_role, ..
} => resume_from_role.as_ref(),
_ => None,
}
}
}
/// Controls how much conversation history is included when forking.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum ForkContext {
/// Full conversation history, read from the source member's session via
/// the `SessionService` and rendered as a TEXT context block prepended to
/// the new member's initial prompt. The child gets a fresh session — no
/// `Session::fork()` copy-on-write is involved.
#[default]
FullHistory,
/// Last N messages from the source session.
LastMessages { count: u32 },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resume_launch_mode_bridge_session_accessors_stay_additive() {
let sid = SessionId::new();
let mode = MemberLaunchMode::Resume {
bridge_session_id: sid.clone(),
resume_from_role: None,
};
assert_eq!(mode.resume_bridge_session_id(), Some(&sid));
}
#[test]
fn resume_launch_mode_deserializes_bridge_session_id() {
let sid = SessionId::new();
let payload = serde_json::json!({
"mode": "resume",
"bridge_session_id": sid,
});
let mode: MemberLaunchMode =
serde_json::from_value(payload).expect("resume launch mode should deserialize");
assert_eq!(mode.resume_bridge_session_id(), Some(&sid));
assert!(mode.resume_from_role().is_none());
}
#[test]
fn resume_launch_mode_round_trips_one_request_predecessor_role() {
let sid = SessionId::new();
let mode = MemberLaunchMode::Resume {
bridge_session_id: sid.clone(),
resume_from_role: Some(ProfileName::from("domain")),
};
let encoded = serde_json::to_value(&mode).expect("migration resume serializes");
assert_eq!(encoded["resume_from_role"], "domain");
let decoded: MemberLaunchMode =
serde_json::from_value(encoded).expect("migration resume deserializes");
assert_eq!(decoded.resume_bridge_session_id(), Some(&sid));
assert_eq!(
decoded.resume_from_role(),
Some(&ProfileName::from("domain"))
);
}
/// DELETE_ME A3 + C1 regression: `MemberLaunchMode` and its variants
/// (Fresh, Resume, Fork) must be reachable from outside the crate
/// so external consumers can configure session adoption without
/// reaching into `pub(crate)` internals. This test exercises each
/// variant at the public API level — if anyone ever re-hides the
/// enum or its constructors, this fails to compile instead of
/// breaking downstream crates silently.
#[test]
fn member_launch_mode_public_seam_covers_all_variants() {
use crate::ids::AgentIdentity;
// Fresh is the default.
let fresh = MemberLaunchMode::default();
assert!(fresh.resume_bridge_session_id().is_none());
// Resume carries a SessionId; accessor exposed as pub.
let sid = SessionId::new();
let resume = MemberLaunchMode::Resume {
bridge_session_id: sid.clone(),
resume_from_role: None,
};
assert_eq!(resume.resume_bridge_session_id(), Some(&sid));
// Fork carries a source member id (+ fork context). ForkContext
// variants are both reachable — FullHistory (default) and
// LastMessages { count }.
let fork_full = MemberLaunchMode::Fork {
source_member_id: AgentIdentity::from("lead"),
fork_context: ForkContext::default(),
};
assert!(fork_full.resume_bridge_session_id().is_none());
let fork_last = MemberLaunchMode::Fork {
source_member_id: AgentIdentity::from("lead"),
fork_context: ForkContext::LastMessages { count: 5 },
};
assert!(fork_last.resume_bridge_session_id().is_none());
// Round-trip serde on all three shapes to catch accidental
// `#[serde(skip)]` or visibility regressions on constructor
// fields.
for mode in [fresh, resume, fork_full, fork_last] {
let encoded = serde_json::to_value(&mode).expect("mode serializes");
let decoded: MemberLaunchMode =
serde_json::from_value(encoded).expect("mode roundtrips");
assert_eq!(
mode.resume_bridge_session_id().is_some(),
decoded.resume_bridge_session_id().is_some(),
);
}
}
}