1use forge_agent::client::SessionLaunchSettings;
9use forge_primitives::runtime::EffortLevel;
10use serde_json::{Map, Value, json};
11
12pub struct LaunchSettingsDocuments<'a> {
14 pub user: &'a Value,
16 pub local: &'a Value,
18 pub preferences: &'a Value,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum DefaultPermissionMode {
25 #[default]
26 Default,
27 Auto,
28 AcceptEdits,
29 Plan,
30 DontAsk,
31 BypassPermissions,
32}
33
34impl DefaultPermissionMode {
35 pub const fn as_stored(self) -> &'static str {
36 match self {
37 Self::Default => "default",
38 Self::Auto => "auto",
39 Self::AcceptEdits => "acceptEdits",
40 Self::Plan => "plan",
41 Self::DontAsk => "dontAsk",
42 Self::BypassPermissions => "bypassPermissions",
43 }
44 }
45
46 pub fn from_stored(value: &str) -> Option<Self> {
47 match value {
48 "default" => Some(Self::Default),
49 "auto" => Some(Self::Auto),
50 "acceptEdits" => Some(Self::AcceptEdits),
51 "plan" => Some(Self::Plan),
52 "dontAsk" => Some(Self::DontAsk),
53 "bypassPermissions" => Some(Self::BypassPermissions),
54 _ => None,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum OutputStyle {
62 #[default]
63 Default,
64 Explanatory,
65 Learning,
66}
67
68impl OutputStyle {
69 pub const fn as_stored(self) -> &'static str {
70 match self {
71 Self::Default => "Default",
72 Self::Explanatory => "Explanatory",
73 Self::Learning => "Learning",
74 }
75 }
76
77 pub fn from_stored(value: &str) -> Option<Self> {
78 match value {
79 "Default" => Some(Self::Default),
80 "Explanatory" => Some(Self::Explanatory),
81 "Learning" => Some(Self::Learning),
82 _ => None,
83 }
84 }
85}
86
87const LANGUAGE_MIN_CHARS: usize = 2;
88const LANGUAGE_MAX_CHARS: usize = 30;
89
90pub fn language_input_validation_message(value: &str) -> Option<&'static str> {
94 let trimmed = value.trim();
95 if trimmed.is_empty() {
96 return None;
97 }
98 let length = trimmed.chars().count();
99 if length < LANGUAGE_MIN_CHARS {
100 Some("Language must be at least 2 characters.")
101 } else if length > LANGUAGE_MAX_CHARS {
102 Some("Language must be at most 30 characters.")
103 } else {
104 None
105 }
106}
107
108pub fn language(document: &Value) -> Option<String> {
110 read_string(document, &["language"])
111 .ok()
112 .flatten()
113 .map(|value| value.trim().to_owned())
114 .filter(|value| !value.is_empty())
115 .filter(|value| language_input_validation_message(value).is_none())
116}
117
118pub fn always_thinking_enabled(document: &Value) -> bool {
119 read_bool(document, &["alwaysThinkingEnabled"]).ok().flatten().unwrap_or(false)
120}
121
122pub fn model(document: &Value) -> Option<String> {
126 read_string(document, &["model"]).ok().flatten().or_else(|| Some("opus".to_owned()))
127}
128
129pub fn thinking_effort(document: &Value) -> EffortLevel {
131 read_string(document, &["effortLevel"])
132 .ok()
133 .flatten()
134 .and_then(|value| EffortLevel::from_stored(&value))
135 .unwrap_or(EffortLevel::Max)
136}
137
138pub fn default_permission_mode(document: &Value) -> DefaultPermissionMode {
140 read_string(document, &["permissions", "defaultMode"])
141 .ok()
142 .flatten()
143 .and_then(|value| DefaultPermissionMode::from_stored(&value))
144 .unwrap_or(DefaultPermissionMode::Auto)
145}
146
147pub fn output_style(document: &Value) -> OutputStyle {
148 read_string(document, &["outputStyle"])
149 .ok()
150 .flatten()
151 .and_then(|value| OutputStyle::from_stored(&value))
152 .unwrap_or_default()
153}
154
155pub fn spinner_tips_enabled(document: &Value) -> bool {
156 read_bool(document, &["spinnerTipsEnabled"]).ok().flatten().unwrap_or(true)
157}
158
159pub fn terminal_progress_bar_enabled(document: &Value) -> bool {
160 read_bool(document, &["terminalProgressBarEnabled"]).ok().flatten().unwrap_or(true)
161}
162
163pub fn session_launch_settings(documents: &LaunchSettingsDocuments<'_>) -> SessionLaunchSettings {
166 SessionLaunchSettings {
167 language: language(documents.user),
168 settings: Some(build_session_settings_object(documents)),
169 agent_progress_summaries: Some(true),
170 charter: None,
171 ..SessionLaunchSettings::default()
172 }
173}
174
175fn build_session_settings_object(documents: &LaunchSettingsDocuments<'_>) -> Value {
176 let mut settings = Map::new();
177
178 settings.insert(
179 "alwaysThinkingEnabled".to_owned(),
180 Value::Bool(always_thinking_enabled(documents.user)),
181 );
182
183 if let Some(model) = model(documents.user) {
184 settings.insert("model".to_owned(), Value::String(model));
185 }
186
187 settings.insert(
188 "permissions".to_owned(),
189 json!({ "defaultMode": default_permission_mode(documents.user).as_stored() }),
190 );
191 settings.insert(
192 "effortLevel".to_owned(),
193 Value::String(thinking_effort(documents.user).as_stored().to_owned()),
194 );
195 settings.insert(
196 "outputStyle".to_owned(),
197 Value::String(output_style(documents.local).as_stored().to_owned()),
198 );
199 settings.insert(
200 "spinnerTipsEnabled".to_owned(),
201 Value::Bool(spinner_tips_enabled(documents.local)),
202 );
203 settings.insert(
204 "terminalProgressBarEnabled".to_owned(),
205 Value::Bool(terminal_progress_bar_enabled(documents.preferences)),
206 );
207 if let Some(mut sandbox) = documents.user.get("sandbox").and_then(Value::as_object).cloned() {
208 if sandbox.get("enabled").and_then(Value::as_bool) == Some(true)
209 && !sandbox.contains_key("failIfUnavailable")
210 {
211 sandbox.insert("failIfUnavailable".to_owned(), Value::Bool(false));
212 }
213 settings.insert("sandbox".to_owned(), Value::Object(sandbox));
214 }
215
216 Value::Object(settings)
217}
218
219fn read_json_path<'a>(document: &'a Value, path: &[&str]) -> Option<&'a Value> {
220 let mut current = document;
221 for key in path {
222 current = current.get(key)?;
223 }
224 Some(current)
225}
226
227fn read_bool(document: &Value, path: &[&str]) -> Result<Option<bool>, ()> {
228 match read_json_path(document, path) {
229 None => Ok(None),
230 Some(Value::Bool(value)) => Ok(Some(*value)),
231 Some(_) => Err(()),
232 }
233}
234
235fn read_string(document: &Value, path: &[&str]) -> Result<Option<String>, ()> {
236 match read_json_path(document, path) {
237 None => Ok(None),
238 Some(Value::String(value)) => Ok(Some(value.clone())),
239 Some(_) => Err(()),
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 fn documents<'a>(
248 user: &'a Value,
249 local: &'a Value,
250 preferences: &'a Value,
251 ) -> LaunchSettingsDocuments<'a> {
252 LaunchSettingsDocuments { user, local, preferences }
253 }
254
255 fn empty() -> Value {
256 Value::Object(Map::new())
257 }
258
259 fn settings_of(launch: &SessionLaunchSettings) -> &Value {
260 launch.settings.as_ref().expect("a launch always carries the settings object")
261 }
262
263 #[test]
264 fn the_settings_carry_the_stored_model_permissions_and_effort() {
265 let user = json!({
266 "alwaysThinkingEnabled": true,
267 "model": "haiku",
268 "permissions": { "defaultMode": "plan" },
269 "effortLevel": "high",
270 "language": "German",
271 });
272 let local = json!({ "outputStyle": "Learning", "spinnerTipsEnabled": false });
276 let preferences = json!({ "terminalProgressBarEnabled": false });
279
280 let launch = session_launch_settings(&documents(&user, &local, &preferences));
281
282 assert_eq!(launch.language.as_deref(), Some("German"));
283 assert_eq!(launch.agent_progress_summaries, Some(true));
284 let settings = settings_of(&launch);
285 assert_eq!(settings["alwaysThinkingEnabled"], json!(true));
286 assert_eq!(settings["model"], json!("haiku"));
287 assert_eq!(settings["permissions"]["defaultMode"], json!("plan"));
288 assert_eq!(settings["effortLevel"], json!("high"));
289 assert_eq!(settings["outputStyle"], json!("Learning"));
290 assert_eq!(settings["spinnerTipsEnabled"], json!(false));
291 assert_eq!(settings["terminalProgressBarEnabled"], json!(false));
292 }
293
294 #[test]
297 fn nothing_stored_still_carries_forges_defaults() {
298 let (user, local, preferences) = (empty(), empty(), empty());
299
300 let launch = session_launch_settings(&documents(&user, &local, &preferences));
301
302 assert_eq!(launch.language, None);
303 let settings = settings_of(&launch);
304 assert_eq!(settings["model"], json!("opus"));
305 assert_eq!(settings["permissions"]["defaultMode"], json!("auto"));
306 assert_eq!(settings["effortLevel"], json!("max"));
307 }
308
309 #[test]
312 fn an_unreadable_value_reads_as_the_default() {
313 let user = json!({
314 "model": true,
315 "effortLevel": "brisk",
316 "permissions": { "defaultMode": "not-a-mode" },
317 });
318 let local = json!({ "outputStyle": "Verbose" });
319
320 let launch = session_launch_settings(&documents(&user, &local, &empty()));
321
322 let settings = settings_of(&launch);
323 assert_eq!(settings["model"], json!("opus"));
324 assert_eq!(settings["effortLevel"], json!("max"));
325 assert_eq!(settings["permissions"]["defaultMode"], json!("auto"));
326 assert_eq!(settings["outputStyle"], json!("Default"));
327 }
328
329 #[test]
333 fn a_stored_sandbox_keeps_its_own_fallback_answer() {
334 let user = json!({ "sandbox": { "enabled": true, "allowUnsandboxedCommands": false } });
335 let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
336 assert_eq!(
337 settings_of(&launch)["sandbox"],
338 json!({ "enabled": true, "allowUnsandboxedCommands": false, "failIfUnavailable": false }),
339 "an enabled sandbox with no answer gets the explicit opt-out, and its other keys ride along",
340 );
341
342 let user = json!({ "sandbox": { "enabled": true, "failIfUnavailable": true } });
343 let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
344 assert_eq!(
345 settings_of(&launch)["sandbox"]["failIfUnavailable"],
346 json!(true),
347 "an explicit answer is never overwritten",
348 );
349 }
350
351 #[test]
355 fn an_unusable_language_never_reaches_the_payload() {
356 let user = json!({ "language": " German " });
357 let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
358 assert_eq!(launch.language.as_deref(), Some("German"), "trimmed");
359
360 let user = json!({ "language": "E" });
361 let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
362 assert_eq!(launch.language, None, "too short");
363
364 let user = json!({ "language": " " });
365 let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
366 assert_eq!(launch.language, None, "blank");
367
368 let too_long = "L".repeat(31);
371 let user = json!({ "language": too_long });
372 let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
373 assert_eq!(launch.language, None, "longer than the ceiling");
374
375 let at_the_ceiling = "L".repeat(30);
376 let user = json!({ "language": at_the_ceiling });
377 let launch = session_launch_settings(&documents(&user, &empty(), &empty()));
378 assert_eq!(launch.language.as_deref(), Some(&*at_the_ceiling), "the ceiling itself passes");
379 }
380}