1use toml_edit::{Array, DocumentMut, Item, Table, Value};
11
12static CANONICAL_ORDER: &[&str] = &[
14 "agent",
15 "llm",
16 "skills",
17 "memory",
18 "index",
19 "tools",
20 "mcp",
21 "telegram",
22 "discord",
23 "slack",
24 "a2a",
25 "acp",
26 "gateway",
27 "metrics",
28 "daemon",
29 "scheduler",
30 "orchestration",
31 "classifiers",
32 "security",
33 "vault",
34 "timeouts",
35 "cost",
36 "debug",
37 "logging",
38 "notifications",
39 "tui",
40 "agents",
41 "experiments",
42 "lsp",
43 "telemetry",
44 "session",
45];
46
47#[derive(Debug, thiserror::Error)]
49pub enum MigrateError {
50 #[error("failed to parse input config: {0}")]
52 Parse(#[from] toml_edit::TomlError),
53 #[error("failed to parse reference config: {0}")]
55 Reference(toml_edit::TomlError),
56 #[error("migration failed: invalid TOML structure — {0}")]
59 InvalidStructure(&'static str),
60}
61
62#[derive(Debug)]
64pub struct MigrationResult {
65 pub output: String,
67 pub changed_count: usize,
69 pub sections_changed: Vec<String>,
71}
72
73pub struct ConfigMigrator {
78 reference_src: &'static str,
79}
80
81impl Default for ConfigMigrator {
82 fn default() -> Self {
83 Self::new()
84 }
85}
86
87impl ConfigMigrator {
88 #[must_use]
90 pub fn new() -> Self {
91 Self {
92 reference_src: include_str!("../../config/default.toml"),
93 }
94 }
95
96 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
108 let reference_doc = self
109 .reference_src
110 .parse::<DocumentMut>()
111 .map_err(MigrateError::Reference)?;
112 let mut user_doc = user_toml.parse::<DocumentMut>()?;
113
114 let mut changed_count = 0usize;
115 let mut sections_changed: Vec<String> = Vec::new();
116 let mut pending_comments: Vec<(String, String)> = Vec::new();
119
120 for (key, ref_item) in reference_doc.as_table() {
122 if ref_item.is_table() {
123 let ref_table = ref_item.as_table().expect("is_table checked above");
124 if user_doc.contains_key(key) {
125 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
127 let (n, comments) =
128 merge_table_commented(user_table, ref_table, key, user_toml);
129 changed_count += n;
130 pending_comments.extend(comments);
131 }
132 } else {
133 if user_toml.contains(&format!("# [{key}]")) {
136 continue;
137 }
138 let commented = commented_table_block(key, ref_table);
139 if !commented.is_empty() {
140 sections_changed.push(key.to_owned());
141 }
142 changed_count += 1;
143 }
144 } else {
145 if !user_doc.contains_key(key) {
147 let raw = format_commented_item(key, ref_item);
148 if !raw.is_empty() {
149 sections_changed.push(format!("__scalar__{key}"));
150 changed_count += 1;
151 }
152 }
153 }
154 }
155
156 let user_str = user_doc.to_string();
158
159 let mut output = user_str;
162 for (section_key, comment_line) in &pending_comments {
163 if !section_body(&output, section_key).contains(comment_line.trim()) {
164 output = insert_after_section(&output, section_key, comment_line);
165 }
166 }
167
168 for key in §ions_changed {
170 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
171 if let Some(ref_item) = reference_doc.get(scalar_key) {
172 let raw = format_commented_item(scalar_key, ref_item);
173 if !raw.is_empty() {
174 output.push('\n');
175 output.push_str(&raw);
176 output.push('\n');
177 }
178 }
179 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
180 {
181 let block = commented_table_block(key, ref_table);
182 if !block.is_empty() {
183 output.push('\n');
184 output.push_str(&block);
185 }
186 }
187 }
188
189 output = reorder_sections(&output, CANONICAL_ORDER);
191
192 let sections_changed_clean: Vec<String> = sections_changed
194 .into_iter()
195 .filter(|k| !k.starts_with("__scalar__"))
196 .collect();
197
198 Ok(MigrationResult {
199 output,
200 changed_count,
201 sections_changed: sections_changed_clean,
202 })
203 }
204}
205
206fn merge_table_commented(
212 user_table: &mut Table,
213 ref_table: &Table,
214 section_key: &str,
215 user_toml: &str,
216) -> (usize, Vec<(String, String)>) {
217 let mut count = 0usize;
218 let mut comments: Vec<(String, String)> = Vec::new();
219 for (key, ref_item) in ref_table {
220 if ref_item.is_table() {
221 if user_table.contains_key(key) {
222 let pair = (
223 user_table.get_mut(key).and_then(Item::as_table_mut),
224 ref_item.as_table(),
225 );
226 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
227 let sub_key = format!("{section_key}.{key}");
228 let (n, c) =
229 merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
230 count += n;
231 comments.extend(c);
232 }
233 } else if let Some(ref_sub_table) = ref_item.as_table() {
234 let dotted = format!("{section_key}.{key}");
236 let marker = format!("# [{dotted}]");
237 if !user_toml.contains(&marker) {
238 let block = commented_table_block(&dotted, ref_sub_table);
239 if !block.is_empty() {
240 comments.push((section_key.to_owned(), format!("\n{block}")));
241 count += 1;
242 }
243 }
244 }
245 } else if ref_item.is_array_of_tables() {
246 } else {
248 if !user_table.contains_key(key) {
250 let raw_value = ref_item
251 .as_value()
252 .map(value_to_toml_string)
253 .unwrap_or_default();
254 if !raw_value.is_empty() {
255 let comment_line = format!("# {key} = {raw_value}\n");
256 if !section_body(user_toml, section_key).contains(comment_line.trim()) {
259 comments.push((section_key.to_owned(), comment_line));
260 count += 1;
261 }
262 }
263 }
264 }
265 }
266 (count, comments)
267}
268
269fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
275 let header = format!("[{section}]");
276 let Some(section_start) = doc.find(&header) else {
277 return "";
278 };
279 let body_start = section_start + header.len();
280 let body_end = doc[body_start..]
281 .find("\n[")
282 .map_or(doc.len(), |r| body_start + r);
283 &doc[body_start..body_end]
284}
285
286fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
292 let header = format!("[{section_name}]");
293 let Some(section_start) = raw.find(&header) else {
294 return format!("{raw}{text}");
295 };
296 let search_from = section_start + header.len();
298 let insert_pos = raw[search_from..]
300 .find("\n[")
301 .map_or(raw.len(), |rel| search_from + rel + 1);
302 let mut out = String::with_capacity(raw.len() + text.len());
303 out.push_str(&raw[..insert_pos]);
304 out.push_str(text);
305 out.push_str(&raw[insert_pos..]);
306 out
307}
308
309fn format_commented_item(key: &str, item: &Item) -> String {
311 if let Some(val) = item.as_value() {
312 let raw = value_to_toml_string(val);
313 if !raw.is_empty() {
314 return format!("# {key} = {raw}\n");
315 }
316 }
317 String::new()
318}
319
320fn commented_table_block(section_name: &str, table: &Table) -> String {
325 use std::fmt::Write as _;
326
327 let mut lines = format!("# [{section_name}]\n");
328
329 for (key, item) in table {
330 if item.is_table() {
331 if let Some(sub_table) = item.as_table() {
332 let sub_name = format!("{section_name}.{key}");
333 let sub_block = commented_table_block(&sub_name, sub_table);
334 if !sub_block.is_empty() {
335 lines.push('\n');
336 lines.push_str(&sub_block);
337 }
338 }
339 } else if item.is_array_of_tables() {
340 } else if let Some(val) = item.as_value() {
342 let raw = value_to_toml_string(val);
343 if !raw.is_empty() {
344 let _ = writeln!(lines, "# {key} = {raw}");
345 }
346 }
347 }
348
349 if lines.trim() == format!("[{section_name}]") {
351 return String::new();
352 }
353 lines
354}
355
356fn value_to_toml_string(val: &Value) -> String {
358 match val {
359 Value::String(s) => {
360 let inner = s.value();
361 format!("\"{inner}\"")
362 }
363 Value::Integer(i) => i.value().to_string(),
364 Value::Float(f) => {
365 let v = f.value();
366 if v.fract() == 0.0 {
368 format!("{v:.1}")
369 } else {
370 format!("{v}")
371 }
372 }
373 Value::Boolean(b) => b.value().to_string(),
374 Value::Array(arr) => format_array(arr),
375 Value::InlineTable(t) => {
376 let pairs: Vec<String> = t
377 .iter()
378 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
379 .collect();
380 format!("{{ {} }}", pairs.join(", "))
381 }
382 Value::Datetime(dt) => dt.value().to_string(),
383 }
384}
385
386fn format_array(arr: &Array) -> String {
387 if arr.is_empty() {
388 return "[]".to_owned();
389 }
390 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
391 format!("[{}]", items.join(", "))
392}
393
394fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
400 let sections = split_into_sections(toml_str);
401 if sections.is_empty() {
402 return toml_str.to_owned();
403 }
404
405 let preamble_block = sections
407 .iter()
408 .find(|(h, _)| h.is_empty())
409 .map_or("", |(_, c)| c.as_str());
410
411 let section_map: Vec<(&str, &str)> = sections
412 .iter()
413 .filter(|(h, _)| !h.is_empty())
414 .map(|(h, c)| (h.as_str(), c.as_str()))
415 .collect();
416
417 let mut out = String::new();
418 if !preamble_block.is_empty() {
419 out.push_str(preamble_block);
420 }
421
422 let mut emitted: Vec<bool> = vec![false; section_map.len()];
423
424 for &canon in canonical_order {
425 for (idx, &(header, content)) in section_map.iter().enumerate() {
426 let section_name = extract_section_name(header);
427 let top_level = section_name
428 .split('.')
429 .next()
430 .unwrap_or("")
431 .trim_start_matches('#')
432 .trim();
433 if top_level == canon && !emitted[idx] {
434 out.push_str(content);
435 emitted[idx] = true;
436 }
437 }
438 }
439
440 for (idx, &(_, content)) in section_map.iter().enumerate() {
442 if !emitted[idx] {
443 out.push_str(content);
444 }
445 }
446
447 out
448}
449
450fn extract_section_name(header: &str) -> &str {
452 let trimmed = header.trim().trim_start_matches("# ");
454 if trimmed.starts_with('[') && trimmed.contains(']') {
456 let inner = &trimmed[1..];
457 if let Some(end) = inner.find(']') {
458 return &inner[..end];
459 }
460 }
461 trimmed
462}
463
464fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
468 let mut sections: Vec<(String, String)> = Vec::new();
469 let mut current_header = String::new();
470 let mut current_content = String::new();
471
472 for line in toml_str.lines() {
473 let trimmed = line.trim();
474 if is_top_level_section_header(trimmed) {
475 sections.push((current_header.clone(), current_content.clone()));
476 trimmed.clone_into(&mut current_header);
477 line.clone_into(&mut current_content);
478 current_content.push('\n');
479 } else {
480 current_content.push_str(line);
481 current_content.push('\n');
482 }
483 }
484
485 if !current_header.is_empty() || !current_content.is_empty() {
487 sections.push((current_header, current_content));
488 }
489
490 sections
491}
492
493fn is_top_level_section_header(line: &str) -> bool {
498 if line.starts_with('[')
499 && !line.starts_with("[[")
500 && let Some(end) = line.find(']')
501 {
502 return !line[1..end].contains('.');
503 }
504 false
505}
506
507#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
508fn migrate_ollama_provider(
509 llm: &toml_edit::Table,
510 model: &Option<String>,
511 base_url: &Option<String>,
512 embedding_model: &Option<String>,
513) -> Vec<String> {
514 let mut block = "[[llm.providers]]\ntype = \"ollama\"\n".to_owned();
515 if let Some(m) = model {
516 block.push_str(&format!("model = \"{m}\"\n"));
517 }
518 if let Some(em) = embedding_model {
519 block.push_str(&format!("embedding_model = \"{em}\"\n"));
520 }
521 if let Some(u) = base_url {
522 block.push_str(&format!("base_url = \"{u}\"\n"));
523 }
524 let _ = llm; vec![block]
526}
527
528#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
529fn migrate_claude_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
530 let mut block = "[[llm.providers]]\ntype = \"claude\"\n".to_owned();
531 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
532 if let Some(m) = cloud.get("model").and_then(toml_edit::Item::as_str) {
533 block.push_str(&format!("model = \"{m}\"\n"));
534 }
535 if let Some(t) = cloud
536 .get("max_tokens")
537 .and_then(toml_edit::Item::as_integer)
538 {
539 block.push_str(&format!("max_tokens = {t}\n"));
540 }
541 if cloud
542 .get("server_compaction")
543 .and_then(toml_edit::Item::as_bool)
544 == Some(true)
545 {
546 block.push_str("server_compaction = true\n");
547 }
548 if cloud
549 .get("enable_extended_context")
550 .and_then(toml_edit::Item::as_bool)
551 == Some(true)
552 {
553 block.push_str("enable_extended_context = true\n");
554 }
555 if let Some(thinking) = cloud.get("thinking").and_then(toml_edit::Item::as_table) {
556 let pairs: Vec<String> = thinking.iter().map(|(k, v)| format!("{k} = {v}")).collect();
557 block.push_str(&format!("thinking = {{ {} }}\n", pairs.join(", ")));
558 }
559 if let Some(v) = cloud
560 .get("prompt_cache_ttl")
561 .and_then(toml_edit::Item::as_str)
562 {
563 if v != "ephemeral" {
564 block.push_str(&format!("prompt_cache_ttl = \"{v}\"\n"));
565 }
566 }
567 } else if let Some(m) = model {
568 block.push_str(&format!("model = \"{m}\"\n"));
569 }
570 vec![block]
571}
572
573#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
574fn migrate_openai_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
575 let mut block = "[[llm.providers]]\ntype = \"openai\"\n".to_owned();
576 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
577 copy_str_field(openai, "model", &mut block);
578 copy_str_field(openai, "base_url", &mut block);
579 copy_int_field(openai, "max_tokens", &mut block);
580 copy_str_field(openai, "embedding_model", &mut block);
581 copy_str_field(openai, "reasoning_effort", &mut block);
582 } else if let Some(m) = model {
583 block.push_str(&format!("model = \"{m}\"\n"));
584 }
585 vec![block]
586}
587
588#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
589fn migrate_gemini_provider(llm: &toml_edit::Table, model: &Option<String>) -> Vec<String> {
590 let mut block = "[[llm.providers]]\ntype = \"gemini\"\n".to_owned();
591 if let Some(gemini) = llm.get("gemini").and_then(toml_edit::Item::as_table) {
592 copy_str_field(gemini, "model", &mut block);
593 copy_int_field(gemini, "max_tokens", &mut block);
594 copy_str_field(gemini, "base_url", &mut block);
595 copy_str_field(gemini, "embedding_model", &mut block);
596 copy_str_field(gemini, "thinking_level", &mut block);
597 copy_int_field(gemini, "thinking_budget", &mut block);
598 if let Some(v) = gemini
599 .get("include_thoughts")
600 .and_then(toml_edit::Item::as_bool)
601 {
602 block.push_str(&format!("include_thoughts = {v}\n"));
603 }
604 } else if let Some(m) = model {
605 block.push_str(&format!("model = \"{m}\"\n"));
606 }
607 vec![block]
608}
609
610#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
611fn migrate_compatible_provider(llm: &toml_edit::Table) -> Vec<String> {
612 let mut blocks = Vec::new();
613 if let Some(compat_arr) = llm
614 .get("compatible")
615 .and_then(toml_edit::Item::as_array_of_tables)
616 {
617 for entry in compat_arr {
618 let mut block = "[[llm.providers]]\ntype = \"compatible\"\n".to_owned();
619 copy_str_field(entry, "name", &mut block);
620 copy_str_field(entry, "base_url", &mut block);
621 copy_str_field(entry, "model", &mut block);
622 copy_int_field(entry, "max_tokens", &mut block);
623 copy_str_field(entry, "embedding_model", &mut block);
624 blocks.push(block);
625 }
626 }
627 blocks
628}
629
630#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
632fn migrate_orchestrator_provider(
633 llm: &toml_edit::Table,
634 model: &Option<String>,
635 base_url: &Option<String>,
636 embedding_model: &Option<String>,
637) -> (Vec<String>, Option<String>) {
638 let mut blocks = Vec::new();
639 let routing = None;
640 if let Some(orch) = llm.get("orchestrator").and_then(toml_edit::Item::as_table) {
641 let default_name = orch
642 .get("default")
643 .and_then(toml_edit::Item::as_str)
644 .unwrap_or("")
645 .to_owned();
646 let embed_name = orch
647 .get("embed")
648 .and_then(toml_edit::Item::as_str)
649 .unwrap_or("")
650 .to_owned();
651 if let Some(providers) = orch.get("providers").and_then(toml_edit::Item::as_table) {
652 for (name, pcfg_item) in providers {
653 let Some(pcfg) = pcfg_item.as_table() else {
654 continue;
655 };
656 let ptype = pcfg
657 .get("type")
658 .and_then(toml_edit::Item::as_str)
659 .unwrap_or("ollama");
660 let mut block =
661 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
662 if name == default_name {
663 block.push_str("default = true\n");
664 }
665 if name == embed_name {
666 block.push_str("embed = true\n");
667 }
668 copy_str_field(pcfg, "model", &mut block);
669 copy_str_field(pcfg, "base_url", &mut block);
670 copy_str_field(pcfg, "embedding_model", &mut block);
671 if ptype == "claude" && !pcfg.contains_key("model") {
672 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
673 copy_str_field(cloud, "model", &mut block);
674 copy_int_field(cloud, "max_tokens", &mut block);
675 }
676 }
677 if ptype == "openai" && !pcfg.contains_key("model") {
678 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table) {
679 copy_str_field(openai, "model", &mut block);
680 copy_str_field(openai, "base_url", &mut block);
681 copy_int_field(openai, "max_tokens", &mut block);
682 copy_str_field(openai, "embedding_model", &mut block);
683 }
684 }
685 if ptype == "ollama" && !pcfg.contains_key("base_url") {
686 if let Some(u) = base_url {
687 block.push_str(&format!("base_url = \"{u}\"\n"));
688 }
689 }
690 if ptype == "ollama" && !pcfg.contains_key("model") {
691 if let Some(m) = model {
692 block.push_str(&format!("model = \"{m}\"\n"));
693 }
694 }
695 if ptype == "ollama" && !pcfg.contains_key("embedding_model") {
696 if let Some(em) = embedding_model {
697 block.push_str(&format!("embedding_model = \"{em}\"\n"));
698 }
699 }
700 blocks.push(block);
701 }
702 }
703 }
704 (blocks, routing)
705}
706
707#[allow(clippy::format_push_string, clippy::collapsible_if, clippy::ref_option)]
709fn migrate_router_provider(
710 llm: &toml_edit::Table,
711 model: &Option<String>,
712 base_url: &Option<String>,
713 embedding_model: &Option<String>,
714) -> (Vec<String>, Option<String>) {
715 let mut blocks = Vec::new();
716 let mut routing = None;
717 if let Some(router) = llm.get("router").and_then(toml_edit::Item::as_table) {
718 let strategy = router
719 .get("strategy")
720 .and_then(toml_edit::Item::as_str)
721 .unwrap_or("ema");
722 routing = Some(strategy.to_owned());
723 if let Some(chain) = router.get("chain").and_then(toml_edit::Item::as_array) {
724 for item in chain {
725 let name = item.as_str().unwrap_or_default();
726 let ptype = infer_provider_type(name, llm);
727 let mut block =
728 format!("[[llm.providers]]\nname = \"{name}\"\ntype = \"{ptype}\"\n");
729 match ptype {
730 "claude" => {
731 if let Some(cloud) = llm.get("cloud").and_then(toml_edit::Item::as_table) {
732 copy_str_field(cloud, "model", &mut block);
733 copy_int_field(cloud, "max_tokens", &mut block);
734 }
735 }
736 "openai" => {
737 if let Some(openai) = llm.get("openai").and_then(toml_edit::Item::as_table)
738 {
739 copy_str_field(openai, "model", &mut block);
740 copy_str_field(openai, "base_url", &mut block);
741 copy_int_field(openai, "max_tokens", &mut block);
742 copy_str_field(openai, "embedding_model", &mut block);
743 } else {
744 if let Some(m) = model {
745 block.push_str(&format!("model = \"{m}\"\n"));
746 }
747 if let Some(u) = base_url {
748 block.push_str(&format!("base_url = \"{u}\"\n"));
749 }
750 }
751 }
752 "ollama" => {
753 if let Some(m) = model {
754 block.push_str(&format!("model = \"{m}\"\n"));
755 }
756 if let Some(em) = embedding_model {
757 block.push_str(&format!("embedding_model = \"{em}\"\n"));
758 }
759 if let Some(u) = base_url {
760 block.push_str(&format!("base_url = \"{u}\"\n"));
761 }
762 }
763 _ => {
764 if let Some(m) = model {
765 block.push_str(&format!("model = \"{m}\"\n"));
766 }
767 }
768 }
769 blocks.push(block);
770 }
771 }
772 }
773 (blocks, routing)
774}
775
776fn strip_task_routing_keys(toml_src: &str) -> String {
785 let mut in_routes_block = false;
786 let mut out = Vec::new();
787 for line in toml_src.lines() {
788 let trimmed = line.trim();
789 if trimmed == "[llm.routes]" {
790 in_routes_block = true;
791 continue;
792 }
793 if in_routes_block {
794 if trimmed.starts_with('[') {
796 in_routes_block = false;
797 } else {
798 continue;
799 }
800 }
801 if trimmed.starts_with("routing") && trimmed.contains("\"task\"") {
803 continue;
804 }
805 out.push(line);
806 }
807 out.join("\n")
808}
809
810#[allow(
816 clippy::too_many_lines,
817 clippy::format_push_string,
818 clippy::manual_let_else,
819 clippy::op_ref,
820 clippy::collapsible_if
821)]
822pub fn migrate_llm_to_providers(toml_src: &str) -> Result<MigrationResult, MigrateError> {
823 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
824
825 let llm = match doc.get("llm").and_then(toml_edit::Item::as_table) {
827 Some(t) => t,
828 None => {
829 return Ok(MigrationResult {
831 output: toml_src.to_owned(),
832 changed_count: 0,
833 sections_changed: Vec::new(),
834 });
835 }
836 };
837
838 if llm.get("routing").and_then(toml_edit::Item::as_str) == Some("task") {
841 let routes_count = llm
842 .get("routes")
843 .and_then(toml_edit::Item::as_table)
844 .map_or(0, toml_edit::Table::len);
845 let msg = format!(
846 "routing = \"task\" is no longer supported and has been removed (#3248). \
847 {routes_count} route(s) in [llm.routes] will be dropped. \
848 Falling back to default single-provider routing."
849 );
850 tracing::warn!("{msg}");
851 eprintln!("WARNING: {msg}");
852 let cleaned = strip_task_routing_keys(toml_src);
854 return migrate_llm_to_providers(&cleaned);
855 }
856
857 let has_provider_field = llm.contains_key("provider");
858 let has_cloud = llm.contains_key("cloud");
859 let has_openai = llm.contains_key("openai");
860 let has_gemini = llm.contains_key("gemini");
861 let has_orchestrator = llm.contains_key("orchestrator");
862 let has_router = llm.contains_key("router");
863 let has_providers = llm.contains_key("providers");
864
865 if !has_provider_field
866 && !has_cloud
867 && !has_openai
868 && !has_orchestrator
869 && !has_router
870 && !has_gemini
871 {
872 return Ok(MigrationResult {
874 output: toml_src.to_owned(),
875 changed_count: 0,
876 sections_changed: Vec::new(),
877 });
878 }
879
880 if has_providers {
881 return Err(MigrateError::Parse(
883 "cannot migrate: [[llm.providers]] already exists alongside legacy keys"
884 .parse::<toml_edit::DocumentMut>()
885 .unwrap_err(),
886 ));
887 }
888
889 let provider_str = llm
891 .get("provider")
892 .and_then(toml_edit::Item::as_str)
893 .unwrap_or("ollama");
894 let base_url = llm
895 .get("base_url")
896 .and_then(toml_edit::Item::as_str)
897 .map(str::to_owned);
898 let model = llm
899 .get("model")
900 .and_then(toml_edit::Item::as_str)
901 .map(str::to_owned);
902 let embedding_model = llm
903 .get("embedding_model")
904 .and_then(toml_edit::Item::as_str)
905 .map(str::to_owned);
906
907 let mut provider_blocks: Vec<String> = Vec::new();
909 let mut routing: Option<String> = None;
910
911 match provider_str {
912 "ollama" => {
913 provider_blocks.extend(migrate_ollama_provider(
914 llm,
915 &model,
916 &base_url,
917 &embedding_model,
918 ));
919 }
920 "claude" => {
921 provider_blocks.extend(migrate_claude_provider(llm, &model));
922 }
923 "openai" => {
924 provider_blocks.extend(migrate_openai_provider(llm, &model));
925 }
926 "gemini" => {
927 provider_blocks.extend(migrate_gemini_provider(llm, &model));
928 }
929 "compatible" => {
930 provider_blocks.extend(migrate_compatible_provider(llm));
931 }
932 "orchestrator" => {
933 let (blocks, r) =
934 migrate_orchestrator_provider(llm, &model, &base_url, &embedding_model);
935 provider_blocks.extend(blocks);
936 routing = r;
937 }
938 "router" => {
939 let (blocks, r) = migrate_router_provider(llm, &model, &base_url, &embedding_model);
940 provider_blocks.extend(blocks);
941 routing = r;
942 }
943 other => {
944 let mut block = format!("[[llm.providers]]\ntype = \"{other}\"\n");
945 if let Some(ref m) = model {
946 block.push_str(&format!("model = \"{m}\"\n"));
947 }
948 provider_blocks.push(block);
949 }
950 }
951
952 if provider_blocks.is_empty() {
953 return Ok(MigrationResult {
955 output: toml_src.to_owned(),
956 changed_count: 0,
957 sections_changed: Vec::new(),
958 });
959 }
960
961 let mut new_llm = "[llm]\n".to_owned();
963 if let Some(ref r) = routing {
964 new_llm.push_str(&format!("routing = \"{r}\"\n"));
965 }
966 for key in &[
968 "response_cache_enabled",
969 "response_cache_ttl_secs",
970 "semantic_cache_enabled",
971 "semantic_cache_threshold",
972 "semantic_cache_max_candidates",
973 "summary_model",
974 "instruction_file",
975 ] {
976 if let Some(val) = llm.get(key) {
977 if let Some(v) = val.as_value() {
978 let raw = value_to_toml_string(v);
979 if !raw.is_empty() {
980 new_llm.push_str(&format!("{key} = {raw}\n"));
981 }
982 }
983 }
984 }
985 new_llm.push('\n');
986
987 for block in &provider_blocks {
988 new_llm.push_str(block);
989 new_llm.push('\n');
990 }
991
992 let output = replace_llm_section(toml_src, &new_llm);
995
996 Ok(MigrationResult {
997 output,
998 changed_count: provider_blocks.len(),
999 sections_changed: vec!["llm.providers".to_owned()],
1000 })
1001}
1002
1003fn infer_provider_type<'a>(name: &str, llm: &'a toml_edit::Table) -> &'a str {
1005 match name {
1006 "claude" => "claude",
1007 "openai" => "openai",
1008 "gemini" => "gemini",
1009 "ollama" => "ollama",
1010 "candle" => "candle",
1011 _ => {
1012 if llm.contains_key("compatible") {
1014 "compatible"
1015 } else if llm.contains_key("openai") {
1016 "openai"
1017 } else {
1018 "ollama"
1019 }
1020 }
1021 }
1022}
1023
1024fn copy_str_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1025 use std::fmt::Write as _;
1026 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_str) {
1027 let _ = writeln!(out, "{key} = \"{v}\"");
1028 }
1029}
1030
1031fn copy_int_field(table: &toml_edit::Table, key: &str, out: &mut String) {
1032 use std::fmt::Write as _;
1033 if let Some(v) = table.get(key).and_then(toml_edit::Item::as_integer) {
1034 let _ = writeln!(out, "{key} = {v}");
1035 }
1036}
1037
1038fn replace_llm_section(toml_str: &str, new_llm_section: &str) -> String {
1041 let mut out = String::new();
1042 let mut in_llm = false;
1043 let mut skip_until_next_top = false;
1044
1045 for line in toml_str.lines() {
1046 let trimmed = line.trim();
1047
1048 let is_top_section = (trimmed.starts_with('[') && !trimmed.starts_with("[["))
1050 && trimmed.ends_with(']')
1051 && !trimmed[1..trimmed.len() - 1].contains('.');
1052 let is_top_aot = trimmed.starts_with("[[")
1053 && trimmed.ends_with("]]")
1054 && !trimmed[2..trimmed.len() - 2].contains('.');
1055 let is_llm_sub = (trimmed.starts_with("[llm") || trimmed.starts_with("[[llm"))
1056 && (trimmed.contains(']'));
1057
1058 if is_llm_sub || (in_llm && !is_top_section && !is_top_aot) {
1059 in_llm = true;
1060 skip_until_next_top = true;
1061 continue;
1062 }
1063
1064 if is_top_section || is_top_aot {
1065 if skip_until_next_top {
1066 out.push_str(new_llm_section);
1068 skip_until_next_top = false;
1069 }
1070 in_llm = false;
1071 }
1072
1073 if !skip_until_next_top {
1074 out.push_str(line);
1075 out.push('\n');
1076 }
1077 }
1078
1079 if skip_until_next_top {
1081 out.push_str(new_llm_section);
1082 }
1083
1084 out
1085}
1086
1087struct SttFields {
1089 model: Option<String>,
1090 base_url: Option<String>,
1091 provider_hint: String,
1092}
1093
1094fn extract_stt_fields(doc: &toml_edit::DocumentMut) -> SttFields {
1096 let stt_table = doc
1097 .get("llm")
1098 .and_then(toml_edit::Item::as_table)
1099 .and_then(|llm| llm.get("stt"))
1100 .and_then(toml_edit::Item::as_table);
1101
1102 let model = stt_table
1103 .and_then(|stt| stt.get("model"))
1104 .and_then(toml_edit::Item::as_str)
1105 .map(ToOwned::to_owned);
1106
1107 let base_url = stt_table
1108 .and_then(|stt| stt.get("base_url"))
1109 .and_then(toml_edit::Item::as_str)
1110 .map(ToOwned::to_owned);
1111
1112 let provider_hint = stt_table
1113 .and_then(|stt| stt.get("provider"))
1114 .and_then(toml_edit::Item::as_str)
1115 .map(ToOwned::to_owned)
1116 .unwrap_or_default();
1117
1118 SttFields {
1119 model,
1120 base_url,
1121 provider_hint,
1122 }
1123}
1124
1125fn find_matching_provider_index(
1128 doc: &toml_edit::DocumentMut,
1129 target_type: &str,
1130 provider_hint: &str,
1131) -> Option<usize> {
1132 let providers = doc
1133 .get("llm")
1134 .and_then(toml_edit::Item::as_table)
1135 .and_then(|llm| llm.get("providers"))
1136 .and_then(toml_edit::Item::as_array_of_tables)?;
1137
1138 providers.iter().enumerate().find_map(|(i, t)| {
1139 let name = t
1140 .get("name")
1141 .and_then(toml_edit::Item::as_str)
1142 .unwrap_or("");
1143 let ptype = t
1144 .get("type")
1145 .and_then(toml_edit::Item::as_str)
1146 .unwrap_or("");
1147 let name_match =
1149 !provider_hint.is_empty() && (name == provider_hint || ptype == provider_hint);
1150 let type_match = ptype == target_type;
1151 if name_match || type_match {
1152 Some(i)
1153 } else {
1154 None
1155 }
1156 })
1157}
1158
1159fn attach_stt_to_existing_provider(
1162 doc: &mut toml_edit::DocumentMut,
1163 idx: usize,
1164 stt_model: &str,
1165 stt_base_url: Option<&str>,
1166) -> Result<String, MigrateError> {
1167 let llm_mut = doc
1168 .get_mut("llm")
1169 .and_then(toml_edit::Item::as_table_mut)
1170 .ok_or(MigrateError::InvalidStructure(
1171 "[llm] table not accessible for mutation",
1172 ))?;
1173 let providers_mut = llm_mut
1174 .get_mut("providers")
1175 .and_then(toml_edit::Item::as_array_of_tables_mut)
1176 .ok_or(MigrateError::InvalidStructure(
1177 "[[llm.providers]] array not accessible for mutation",
1178 ))?;
1179 let entry = providers_mut
1180 .iter_mut()
1181 .nth(idx)
1182 .ok_or(MigrateError::InvalidStructure(
1183 "[[llm.providers]] entry index out of range during mutation",
1184 ))?;
1185
1186 let existing_name = entry
1188 .get("name")
1189 .and_then(toml_edit::Item::as_str)
1190 .map(ToOwned::to_owned);
1191 let entry_name = existing_name.unwrap_or_else(|| {
1192 let t = entry
1193 .get("type")
1194 .and_then(toml_edit::Item::as_str)
1195 .unwrap_or("openai");
1196 format!("{t}-stt")
1197 });
1198 entry.insert("name", toml_edit::value(entry_name.clone()));
1199 entry.insert("stt_model", toml_edit::value(stt_model));
1200 if let Some(url) = stt_base_url
1201 && entry.get("base_url").is_none()
1202 {
1203 entry.insert("base_url", toml_edit::value(url));
1204 }
1205 Ok(entry_name)
1206}
1207
1208fn append_new_stt_provider(
1211 doc: &mut toml_edit::DocumentMut,
1212 target_type: &str,
1213 stt_model: &str,
1214 stt_base_url: Option<&str>,
1215) -> Result<String, MigrateError> {
1216 let new_name = if target_type == "candle" {
1217 "local-whisper".to_owned()
1218 } else {
1219 "openai-stt".to_owned()
1220 };
1221 let mut new_entry = toml_edit::Table::new();
1222 new_entry.insert("name", toml_edit::value(new_name.clone()));
1223 new_entry.insert("type", toml_edit::value(target_type));
1224 new_entry.insert("stt_model", toml_edit::value(stt_model));
1225 if let Some(url) = stt_base_url {
1226 new_entry.insert("base_url", toml_edit::value(url));
1227 }
1228 let llm_mut = doc
1229 .get_mut("llm")
1230 .and_then(toml_edit::Item::as_table_mut)
1231 .ok_or(MigrateError::InvalidStructure(
1232 "[llm] table not accessible for mutation",
1233 ))?;
1234 if let Some(item) = llm_mut.get_mut("providers") {
1235 if let Some(arr) = item.as_array_of_tables_mut() {
1236 arr.push(new_entry);
1237 }
1238 } else {
1239 let mut arr = toml_edit::ArrayOfTables::new();
1240 arr.push(new_entry);
1241 llm_mut.insert("providers", toml_edit::Item::ArrayOfTables(arr));
1242 }
1243 Ok(new_name)
1244}
1245
1246fn rewrite_stt_section(doc: &mut toml_edit::DocumentMut, resolved_provider_name: &str) {
1248 if let Some(stt_table) = doc
1249 .get_mut("llm")
1250 .and_then(toml_edit::Item::as_table_mut)
1251 .and_then(|llm| llm.get_mut("stt"))
1252 .and_then(toml_edit::Item::as_table_mut)
1253 {
1254 stt_table.insert("provider", toml_edit::value(resolved_provider_name));
1255 stt_table.remove("model");
1256 stt_table.remove("base_url");
1257 }
1258}
1259
1260pub fn migrate_stt_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1279 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1280 let stt = extract_stt_fields(&doc);
1281
1282 if stt.model.is_none() && stt.base_url.is_none() {
1284 return Ok(MigrationResult {
1285 output: toml_src.to_owned(),
1286 changed_count: 0,
1287 sections_changed: Vec::new(),
1288 });
1289 }
1290
1291 let stt_model = stt.model.unwrap_or_else(|| "whisper-1".to_owned());
1292
1293 let target_type = match stt.provider_hint.as_str() {
1295 "candle-whisper" | "candle" => "candle",
1296 _ => "openai",
1297 };
1298
1299 let resolved_name = match find_matching_provider_index(&doc, target_type, &stt.provider_hint) {
1300 Some(idx) => {
1301 attach_stt_to_existing_provider(&mut doc, idx, &stt_model, stt.base_url.as_deref())?
1302 }
1303 None => {
1304 append_new_stt_provider(&mut doc, target_type, &stt_model, stt.base_url.as_deref())?
1305 }
1306 };
1307
1308 rewrite_stt_section(&mut doc, &resolved_name);
1309
1310 Ok(MigrationResult {
1311 output: doc.to_string(),
1312 changed_count: 1,
1313 sections_changed: vec!["llm.providers.stt_model".to_owned()],
1314 })
1315}
1316
1317pub fn migrate_planner_model_to_provider(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1330 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1331
1332 let old_value = doc
1333 .get("orchestration")
1334 .and_then(toml_edit::Item::as_table)
1335 .and_then(|t| t.get("planner_model"))
1336 .and_then(toml_edit::Item::as_value)
1337 .and_then(toml_edit::Value::as_str)
1338 .map(ToOwned::to_owned);
1339
1340 let Some(old_model) = old_value else {
1341 return Ok(MigrationResult {
1342 output: toml_src.to_owned(),
1343 changed_count: 0,
1344 sections_changed: Vec::new(),
1345 });
1346 };
1347
1348 let commented_out = format!(
1352 "# planner_provider = \"{old_model}\" \
1353 # MIGRATED: was planner_model; update to a [[llm.providers]] name"
1354 );
1355
1356 let orch_table = doc
1357 .get_mut("orchestration")
1358 .and_then(toml_edit::Item::as_table_mut)
1359 .ok_or(MigrateError::InvalidStructure(
1360 "[orchestration] is not a table",
1361 ))?;
1362 orch_table.remove("planner_model");
1363 let decor = orch_table.decor_mut();
1364 let existing_suffix = decor.suffix().and_then(|s| s.as_str()).unwrap_or("");
1365 let new_suffix = if existing_suffix.trim().is_empty() {
1367 format!("\n{commented_out}\n")
1368 } else {
1369 format!("{existing_suffix}\n{commented_out}\n")
1370 };
1371 decor.set_suffix(new_suffix);
1372
1373 eprintln!(
1374 "Migration warning: [orchestration].planner_model has been renamed to planner_provider \
1375 and its value commented out. `planner_provider` must reference a [[llm.providers]] \
1376 `name` field, not a raw model name. Update or remove the commented line."
1377 );
1378
1379 Ok(MigrationResult {
1380 output: doc.to_string(),
1381 changed_count: 1,
1382 sections_changed: vec!["orchestration.planner_provider".to_owned()],
1383 })
1384}
1385
1386pub fn migrate_mcp_trust_levels(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1400 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1401 let mut added = 0usize;
1402
1403 let Some(mcp) = doc.get_mut("mcp").and_then(toml_edit::Item::as_table_mut) else {
1404 return Ok(MigrationResult {
1405 output: toml_src.to_owned(),
1406 changed_count: 0,
1407 sections_changed: Vec::new(),
1408 });
1409 };
1410
1411 let Some(servers) = mcp
1412 .get_mut("servers")
1413 .and_then(toml_edit::Item::as_array_of_tables_mut)
1414 else {
1415 return Ok(MigrationResult {
1416 output: toml_src.to_owned(),
1417 changed_count: 0,
1418 sections_changed: Vec::new(),
1419 });
1420 };
1421
1422 for entry in servers.iter_mut() {
1423 if !entry.contains_key("trust_level") {
1424 entry.insert(
1425 "trust_level",
1426 toml_edit::value(toml_edit::Value::from("trusted")),
1427 );
1428 added += 1;
1429 }
1430 }
1431
1432 if added > 0 {
1433 eprintln!(
1434 "Migration: added trust_level = \"trusted\" to {added} [[mcp.servers]] \
1435 entr{} (preserving previous SSRF-skip behavior). \
1436 Review and adjust trust levels as needed.",
1437 if added == 1 { "y" } else { "ies" }
1438 );
1439 }
1440
1441 Ok(MigrationResult {
1442 output: doc.to_string(),
1443 changed_count: added,
1444 sections_changed: if added > 0 {
1445 vec!["mcp.servers.trust_level".to_owned()]
1446 } else {
1447 Vec::new()
1448 },
1449 })
1450}
1451
1452pub fn migrate_agent_retry_to_tools_retry(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1463 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1464
1465 let max_retries = doc
1466 .get("agent")
1467 .and_then(toml_edit::Item::as_table)
1468 .and_then(|t| t.get("max_tool_retries"))
1469 .and_then(toml_edit::Item::as_value)
1470 .and_then(toml_edit::Value::as_integer)
1471 .map(i64::cast_unsigned);
1472
1473 let budget_secs = doc
1474 .get("agent")
1475 .and_then(toml_edit::Item::as_table)
1476 .and_then(|t| t.get("max_retry_duration_secs"))
1477 .and_then(toml_edit::Item::as_value)
1478 .and_then(toml_edit::Value::as_integer)
1479 .map(i64::cast_unsigned);
1480
1481 if max_retries.is_none() && budget_secs.is_none() {
1482 return Ok(MigrationResult {
1483 output: toml_src.to_owned(),
1484 changed_count: 0,
1485 sections_changed: Vec::new(),
1486 });
1487 }
1488
1489 if !doc.contains_key("tools") {
1491 doc.insert("tools", toml_edit::Item::Table(toml_edit::Table::new()));
1492 }
1493 let tools_table = doc
1494 .get_mut("tools")
1495 .and_then(toml_edit::Item::as_table_mut)
1496 .ok_or(MigrateError::InvalidStructure("[tools] is not a table"))?;
1497
1498 if !tools_table.contains_key("retry") {
1499 tools_table.insert("retry", toml_edit::Item::Table(toml_edit::Table::new()));
1500 }
1501 let retry_table = tools_table
1502 .get_mut("retry")
1503 .and_then(toml_edit::Item::as_table_mut)
1504 .ok_or(MigrateError::InvalidStructure(
1505 "[tools.retry] is not a table",
1506 ))?;
1507
1508 let mut changed_count = 0usize;
1509
1510 if let Some(retries) = max_retries
1511 && !retry_table.contains_key("max_attempts")
1512 {
1513 retry_table.insert(
1514 "max_attempts",
1515 toml_edit::value(i64::try_from(retries).unwrap_or(2)),
1516 );
1517 changed_count += 1;
1518 }
1519
1520 if let Some(secs) = budget_secs
1521 && !retry_table.contains_key("budget_secs")
1522 {
1523 retry_table.insert(
1524 "budget_secs",
1525 toml_edit::value(i64::try_from(secs).unwrap_or(30)),
1526 );
1527 changed_count += 1;
1528 }
1529
1530 if changed_count > 0 {
1531 eprintln!(
1532 "Migration: [agent].max_tool_retries / max_retry_duration_secs migrated to \
1533 [tools.retry].max_attempts / budget_secs. Old fields preserved for compatibility."
1534 );
1535 }
1536
1537 Ok(MigrationResult {
1538 output: doc.to_string(),
1539 changed_count,
1540 sections_changed: if changed_count > 0 {
1541 vec!["tools.retry".to_owned()]
1542 } else {
1543 Vec::new()
1544 },
1545 })
1546}
1547
1548pub fn migrate_database_url(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1557 if toml_src.contains("database_url") {
1559 return Ok(MigrationResult {
1560 output: toml_src.to_owned(),
1561 changed_count: 0,
1562 sections_changed: Vec::new(),
1563 });
1564 }
1565
1566 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1567
1568 if !doc.contains_key("memory") {
1570 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
1571 }
1572
1573 let comment = "\n# PostgreSQL connection URL (used when binary is compiled with --features postgres).\n\
1574 # Leave empty and store the actual URL in the vault:\n\
1575 # zeph vault set ZEPH_DATABASE_URL \"postgres://user:pass@localhost:5432/zeph\"\n\
1576 # database_url = \"\"\n";
1577 let raw = doc.to_string();
1578 let output = format!("{raw}{comment}");
1579
1580 Ok(MigrationResult {
1581 output,
1582 changed_count: 1,
1583 sections_changed: vec!["memory.database_url".to_owned()],
1584 })
1585}
1586
1587pub fn migrate_shell_transactional(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1596 if toml_src.contains("transactional") {
1598 return Ok(MigrationResult {
1599 output: toml_src.to_owned(),
1600 changed_count: 0,
1601 sections_changed: Vec::new(),
1602 });
1603 }
1604
1605 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1606
1607 let tools_shell_exists = doc
1608 .get("tools")
1609 .and_then(toml_edit::Item::as_table)
1610 .is_some_and(|t| t.contains_key("shell"));
1611 if !tools_shell_exists {
1612 return Ok(MigrationResult {
1614 output: toml_src.to_owned(),
1615 changed_count: 0,
1616 sections_changed: Vec::new(),
1617 });
1618 }
1619
1620 let comment = "\n# Transactional shell: snapshot files before write commands, rollback on failure.\n\
1621 # transactional = false\n\
1622 # transaction_scope = [] # glob patterns; empty = all extracted paths\n\
1623 # auto_rollback = false # rollback when exit code >= 2\n\
1624 # auto_rollback_exit_codes = [] # explicit exit codes; overrides >= 2 heuristic\n\
1625 # snapshot_required = false # abort if snapshot fails (default: warn and proceed)\n";
1626 let raw = doc.to_string();
1627 let output = format!("{raw}{comment}");
1628
1629 Ok(MigrationResult {
1630 output,
1631 changed_count: 1,
1632 sections_changed: vec!["tools.shell.transactional".to_owned()],
1633 })
1634}
1635
1636pub fn migrate_agent_budget_hint(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1642 if toml_src.contains("budget_hint_enabled") {
1644 return Ok(MigrationResult {
1645 output: toml_src.to_owned(),
1646 changed_count: 0,
1647 sections_changed: Vec::new(),
1648 });
1649 }
1650
1651 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1652 if !doc.contains_key("agent") {
1653 return Ok(MigrationResult {
1654 output: toml_src.to_owned(),
1655 changed_count: 0,
1656 sections_changed: Vec::new(),
1657 });
1658 }
1659
1660 let comment = "\n# Inject <budget> XML into the system prompt so the LLM can self-regulate (#2267).\n\
1661 # budget_hint_enabled = true\n";
1662 let raw = doc.to_string();
1663 let output = format!("{raw}{comment}");
1664
1665 Ok(MigrationResult {
1666 output,
1667 changed_count: 1,
1668 sections_changed: vec!["agent.budget_hint_enabled".to_owned()],
1669 })
1670}
1671
1672pub fn migrate_forgetting_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1681 if toml_src.contains("[memory.forgetting]") || toml_src.contains("# [memory.forgetting]") {
1683 return Ok(MigrationResult {
1684 output: toml_src.to_owned(),
1685 changed_count: 0,
1686 sections_changed: Vec::new(),
1687 });
1688 }
1689
1690 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1691 if !doc.contains_key("memory") {
1692 return Ok(MigrationResult {
1693 output: toml_src.to_owned(),
1694 changed_count: 0,
1695 sections_changed: Vec::new(),
1696 });
1697 }
1698
1699 let comment = "\n# SleepGate forgetting sweep (#2397). Disabled by default.\n\
1700 # [memory.forgetting]\n\
1701 # enabled = false\n\
1702 # decay_rate = 0.1 # per-sweep importance decay\n\
1703 # forgetting_floor = 0.05 # prune below this score\n\
1704 # sweep_interval_secs = 7200 # run every 2 hours\n\
1705 # sweep_batch_size = 500\n\
1706 # protect_recent_hours = 24\n\
1707 # protect_min_access_count = 3\n";
1708 let raw = doc.to_string();
1709 let output = format!("{raw}{comment}");
1710
1711 Ok(MigrationResult {
1712 output,
1713 changed_count: 1,
1714 sections_changed: vec!["memory.forgetting".to_owned()],
1715 })
1716}
1717
1718pub fn migrate_compression_predictor_config(
1727 toml_src: &str,
1728) -> Result<MigrationResult, MigrateError> {
1729 let has_active = toml_src.contains("[memory.compression.predictor]");
1732 let has_commented = toml_src.contains("# [memory.compression.predictor]");
1733 if !has_active && !has_commented {
1734 return Ok(MigrationResult {
1735 output: toml_src.to_owned(),
1736 changed_count: 0,
1737 sections_changed: Vec::new(),
1738 });
1739 }
1740
1741 let mut output_lines: Vec<&str> = Vec::new();
1745 let mut in_predictor = false;
1746 for line in toml_src.lines() {
1747 let trimmed = line.trim();
1748 if trimmed == "[memory.compression.predictor]"
1750 || trimmed == "# [memory.compression.predictor]"
1751 {
1752 in_predictor = true;
1753 continue;
1754 }
1755 if in_predictor && trimmed.starts_with('[') && !trimmed.starts_with("# [") {
1757 in_predictor = false;
1758 }
1759 if !in_predictor {
1760 output_lines.push(line);
1761 }
1762 }
1763 let mut output = output_lines.join("\n");
1765 if toml_src.ends_with('\n') {
1766 output.push('\n');
1767 }
1768
1769 Ok(MigrationResult {
1770 output,
1771 changed_count: 1,
1772 sections_changed: vec!["memory.compression.predictor".to_owned()],
1773 })
1774}
1775
1776pub fn migrate_microcompact_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1782 if toml_src.contains("[memory.microcompact]") || toml_src.contains("# [memory.microcompact]") {
1784 return Ok(MigrationResult {
1785 output: toml_src.to_owned(),
1786 changed_count: 0,
1787 sections_changed: Vec::new(),
1788 });
1789 }
1790
1791 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1792 if !doc.contains_key("memory") {
1793 return Ok(MigrationResult {
1794 output: toml_src.to_owned(),
1795 changed_count: 0,
1796 sections_changed: Vec::new(),
1797 });
1798 }
1799
1800 let comment = "\n# Time-based microcompact (#2699). Strips stale low-value tool outputs after idle.\n\
1801 # [memory.microcompact]\n\
1802 # enabled = false\n\
1803 # gap_threshold_minutes = 60 # idle gap before clearing stale outputs\n\
1804 # keep_recent = 3 # always keep this many recent outputs intact\n";
1805 let raw = doc.to_string();
1806 let output = format!("{raw}{comment}");
1807
1808 Ok(MigrationResult {
1809 output,
1810 changed_count: 1,
1811 sections_changed: vec!["memory.microcompact".to_owned()],
1812 })
1813}
1814
1815pub fn migrate_autodream_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1821 if toml_src.contains("[memory.autodream]") || toml_src.contains("# [memory.autodream]") {
1823 return Ok(MigrationResult {
1824 output: toml_src.to_owned(),
1825 changed_count: 0,
1826 sections_changed: Vec::new(),
1827 });
1828 }
1829
1830 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1831 if !doc.contains_key("memory") {
1832 return Ok(MigrationResult {
1833 output: toml_src.to_owned(),
1834 changed_count: 0,
1835 sections_changed: Vec::new(),
1836 });
1837 }
1838
1839 let comment = "\n# autoDream background memory consolidation (#2697). Disabled by default.\n\
1840 # [memory.autodream]\n\
1841 # enabled = false\n\
1842 # min_sessions = 5 # sessions since last consolidation\n\
1843 # min_hours = 8 # hours since last consolidation\n\
1844 # consolidation_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1845 # max_iterations = 5\n";
1846 let raw = doc.to_string();
1847 let output = format!("{raw}{comment}");
1848
1849 Ok(MigrationResult {
1850 output,
1851 changed_count: 1,
1852 sections_changed: vec!["memory.autodream".to_owned()],
1853 })
1854}
1855
1856pub fn migrate_magic_docs_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1862 use toml_edit::{Item, Table};
1863
1864 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1865
1866 if doc.contains_key("magic_docs") {
1867 return Ok(MigrationResult {
1868 output: toml_src.to_owned(),
1869 changed_count: 0,
1870 sections_changed: Vec::new(),
1871 });
1872 }
1873
1874 doc.insert("magic_docs", Item::Table(Table::new()));
1875 let comment = "# MagicDocs auto-maintained markdown (#2702). Disabled by default.\n\
1876 # [magic_docs]\n\
1877 # enabled = false\n\
1878 # min_turns_between_updates = 10\n\
1879 # update_provider = \"\" # provider name from [[llm.providers]]; empty = primary\n\
1880 # max_iterations = 3\n";
1881 doc.remove("magic_docs");
1883 let raw = doc.to_string();
1885 let output = format!("{raw}\n{comment}");
1886
1887 Ok(MigrationResult {
1888 output,
1889 changed_count: 1,
1890 sections_changed: vec!["magic_docs".to_owned()],
1891 })
1892}
1893
1894pub fn migrate_telemetry_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1903 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1904
1905 if doc.contains_key("telemetry") || toml_src.contains("# [telemetry]") {
1906 return Ok(MigrationResult {
1907 output: toml_src.to_owned(),
1908 changed_count: 0,
1909 sections_changed: Vec::new(),
1910 });
1911 }
1912
1913 let comment = "\n\
1914 # Profiling and distributed tracing (requires --features profiling). All\n\
1915 # instrumentation points are zero-overhead when the feature is absent.\n\
1916 # [telemetry]\n\
1917 # enabled = false\n\
1918 # backend = \"local\" # \"local\" (Chrome JSON), \"otlp\", or \"pyroscope\"\n\
1919 # trace_dir = \".local/traces\"\n\
1920 # include_args = false\n\
1921 # service_name = \"zeph-agent\"\n\
1922 # sample_rate = 1.0\n\
1923 # otel_filter = \"info\" # base EnvFilter for OTLP layer; noisy-crate exclusions always appended\n";
1924
1925 let raw = doc.to_string();
1926 let output = format!("{raw}{comment}");
1927
1928 Ok(MigrationResult {
1929 output,
1930 changed_count: 1,
1931 sections_changed: vec!["telemetry".to_owned()],
1932 })
1933}
1934
1935pub fn migrate_supervisor_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1944 if toml_src.contains("[agent.supervisor]") || toml_src.contains("# [agent.supervisor]") {
1946 return Ok(MigrationResult {
1947 output: toml_src.to_owned(),
1948 changed_count: 0,
1949 sections_changed: Vec::new(),
1950 });
1951 }
1952
1953 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
1954
1955 if !doc.contains_key("agent") {
1958 return Ok(MigrationResult {
1959 output: toml_src.to_owned(),
1960 changed_count: 0,
1961 sections_changed: Vec::new(),
1962 });
1963 }
1964
1965 let comment = "\n\
1966 # Background task supervisor tuning (optional — defaults shown, #2883).\n\
1967 # [agent.supervisor]\n\
1968 # enrichment_limit = 4\n\
1969 # telemetry_limit = 8\n\
1970 # abort_enrichment_on_turn = false\n";
1971
1972 let raw = doc.to_string();
1973 let output = format!("{raw}{comment}");
1974
1975 Ok(MigrationResult {
1976 output,
1977 changed_count: 1,
1978 sections_changed: vec!["agent.supervisor".to_owned()],
1979 })
1980}
1981
1982pub fn migrate_otel_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
1992 if toml_src.contains("otel_filter") {
1994 return Ok(MigrationResult {
1995 output: toml_src.to_owned(),
1996 changed_count: 0,
1997 sections_changed: Vec::new(),
1998 });
1999 }
2000
2001 let doc = toml_src.parse::<toml_edit::DocumentMut>()?;
2002
2003 if !doc.contains_key("telemetry") {
2006 return Ok(MigrationResult {
2007 output: toml_src.to_owned(),
2008 changed_count: 0,
2009 sections_changed: Vec::new(),
2010 });
2011 }
2012
2013 let comment = "\n# Base EnvFilter for the OTLP tracing layer. Noisy-crate exclusions \
2014 (tonic=warn etc.) are always appended (#2997).\n\
2015 # otel_filter = \"info\"\n";
2016 let raw = doc.to_string();
2017 let output = insert_after_section(&raw, "telemetry", comment);
2019
2020 Ok(MigrationResult {
2021 output,
2022 changed_count: 1,
2023 sections_changed: vec!["telemetry.otel_filter".to_owned()],
2024 })
2025}
2026
2027pub fn migrate_egress_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2033 if toml_src.contains("[tools.egress]") || toml_src.contains("tools.egress") {
2034 return Ok(MigrationResult {
2035 output: toml_src.to_owned(),
2036 changed_count: 0,
2037 sections_changed: Vec::new(),
2038 });
2039 }
2040
2041 let comment = "\n# Egress network logging — records outbound HTTP requests to the audit log\n\
2042 # with per-hop correlation IDs, response metadata, and block reasons (#3058).\n\
2043 # [tools.egress]\n\
2044 # enabled = true # set to false to disable all egress event recording\n\
2045 # log_blocked = true # record scheme/domain/SSRF-blocked requests\n\
2046 # log_response_bytes = true\n\
2047 # log_hosts_to_tui = true\n";
2048
2049 let mut output = toml_src.to_owned();
2050 output.push_str(comment);
2051 Ok(MigrationResult {
2052 output,
2053 changed_count: 1,
2054 sections_changed: vec!["tools.egress".to_owned()],
2055 })
2056}
2057
2058pub fn migrate_vigil_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2064 if toml_src.contains("[security.vigil]") || toml_src.contains("security.vigil") {
2065 return Ok(MigrationResult {
2066 output: toml_src.to_owned(),
2067 changed_count: 0,
2068 sections_changed: Vec::new(),
2069 });
2070 }
2071
2072 let comment = "\n# VIGIL verify-before-commit intent-anchoring gate (#3058).\n\
2073 # Runs a regex tripwire on every tool output before it enters LLM context.\n\
2074 # [security.vigil]\n\
2075 # enabled = true # master switch; false bypasses VIGIL entirely\n\
2076 # strict_mode = false # true: block (replace with sentinel); false: truncate+annotate\n\
2077 # sanitize_max_chars = 2048\n\
2078 # extra_patterns = [] # operator-supplied additional injection patterns (max 64)\n\
2079 # exempt_tools = [\"memory_search\", \"read_overflow\", \"load_skill\", \"schedule_deferred\"]\n";
2080
2081 let mut output = toml_src.to_owned();
2082 output.push_str(comment);
2083 Ok(MigrationResult {
2084 output,
2085 changed_count: 1,
2086 sections_changed: vec!["security.vigil".to_owned()],
2087 })
2088}
2089
2090pub fn migrate_sandbox_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2102 let doc: DocumentMut = toml_src.parse()?;
2103 let already_present = doc
2104 .get("tools")
2105 .and_then(|t| t.as_table())
2106 .and_then(|t| t.get("sandbox"))
2107 .is_some();
2108 if already_present || toml_src.contains("# [tools.sandbox]") {
2111 return Ok(MigrationResult {
2112 output: toml_src.to_owned(),
2113 changed_count: 0,
2114 sections_changed: Vec::new(),
2115 });
2116 }
2117
2118 let comment = "\n# OS-level subprocess sandbox for shell commands (#3070).\n\
2119 # macOS: sandbox-exec (Seatbelt); Linux: bwrap + Landlock + seccomp (requires `sandbox` feature).\n\
2120 # Applies ONLY to subprocess executors — in-process tools are unaffected.\n\
2121 # [tools.sandbox]\n\
2122 # enabled = false # set to true to wrap shell commands\n\
2123 # profile = \"workspace\" # \"workspace\" | \"read-only\" | \"network-allow-all\" | \"off\"\n\
2124 # backend = \"auto\" # \"auto\" | \"seatbelt\" | \"landlock-bwrap\" | \"noop\"\n\
2125 # strict = true # fail startup if sandbox init fails (fail-closed)\n\
2126 # allow_read = [] # additional read-allowed absolute paths\n\
2127 # allow_write = [] # additional write-allowed absolute paths\n";
2128
2129 let mut output = toml_src.to_owned();
2130 output.push_str(comment);
2131 Ok(MigrationResult {
2132 output,
2133 changed_count: 1,
2134 sections_changed: vec!["tools.sandbox".to_owned()],
2135 })
2136}
2137
2138pub fn migrate_sandbox_egress_filter(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2147 if !toml_src.contains("[tools.sandbox]") {
2149 return Ok(MigrationResult {
2150 output: toml_src.to_owned(),
2151 changed_count: 0,
2152 sections_changed: Vec::new(),
2153 });
2154 }
2155
2156 let already_has_denied =
2157 toml_src.contains("denied_domains") || toml_src.contains("# denied_domains");
2158 let already_has_fail =
2159 toml_src.contains("fail_if_unavailable") || toml_src.contains("# fail_if_unavailable");
2160
2161 if already_has_denied && already_has_fail {
2162 return Ok(MigrationResult {
2163 output: toml_src.to_owned(),
2164 changed_count: 0,
2165 sections_changed: Vec::new(),
2166 });
2167 }
2168
2169 let mut comment = String::new();
2170 if !already_has_denied {
2171 comment.push_str(
2172 "# denied_domains = [] \
2173 # hostnames denied egress from sandboxed processes (\"pastebin.com\", \"*.evil.com\")\n",
2174 );
2175 }
2176 if !already_has_fail {
2177 comment.push_str(
2178 "# fail_if_unavailable = false \
2179 # abort startup when no effective OS sandbox is available\n",
2180 );
2181 }
2182
2183 let output = toml_src.replacen(
2184 "[tools.sandbox]\n",
2185 &format!("[tools.sandbox]\n{comment}"),
2186 1,
2187 );
2188 Ok(MigrationResult {
2189 output,
2190 changed_count: 1,
2191 sections_changed: vec!["tools.sandbox.denied_domains".to_owned()],
2192 })
2193}
2194
2195pub fn migrate_orchestration_persistence(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2204 if toml_src.contains("persistence_enabled") || toml_src.contains("# persistence_enabled") {
2206 return Ok(MigrationResult {
2207 output: toml_src.to_owned(),
2208 changed_count: 0,
2209 sections_changed: Vec::new(),
2210 });
2211 }
2212
2213 if !toml_src.contains("[orchestration]") {
2215 return Ok(MigrationResult {
2216 output: toml_src.to_owned(),
2217 changed_count: 0,
2218 sections_changed: Vec::new(),
2219 });
2220 }
2221
2222 let comment = "# persistence_enabled = true \
2224 # persist task graphs to SQLite after each tick; enables `/plan resume <id>` (#3107)\n";
2225 let output = toml_src.replacen(
2226 "[orchestration]\n",
2227 &format!("[orchestration]\n{comment}"),
2228 1,
2229 );
2230 Ok(MigrationResult {
2231 output,
2232 changed_count: 1,
2233 sections_changed: vec!["orchestration.persistence_enabled".to_owned()],
2234 })
2235}
2236
2237pub fn migrate_session_recap_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2245 if toml_src.contains("[session.recap]") || toml_src.contains("# [session.recap]") {
2247 return Ok(MigrationResult {
2248 output: toml_src.to_owned(),
2249 changed_count: 0,
2250 sections_changed: Vec::new(),
2251 });
2252 }
2253
2254 let comment = "\n# [session.recap] — show a recap when resuming a conversation (#3064).\n\
2255 # [session.recap]\n\
2256 # on_resume = true\n\
2257 # max_tokens = 200\n\
2258 # provider = \"\"\n\
2259 # max_input_messages = 20\n";
2260 let raw = toml_src.parse::<toml_edit::DocumentMut>()?.to_string();
2261 let output = format!("{raw}{comment}");
2262
2263 Ok(MigrationResult {
2264 output,
2265 changed_count: 1,
2266 sections_changed: vec!["session.recap".to_owned()],
2267 })
2268}
2269
2270pub fn migrate_mcp_elicitation_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2278 if toml_src.contains("elicitation_enabled") || toml_src.contains("# elicitation_enabled") {
2280 return Ok(MigrationResult {
2281 output: toml_src.to_owned(),
2282 changed_count: 0,
2283 sections_changed: Vec::new(),
2284 });
2285 }
2286
2287 if !toml_src.contains("[mcp]") {
2289 return Ok(MigrationResult {
2290 output: toml_src.to_owned(),
2291 changed_count: 0,
2292 sections_changed: Vec::new(),
2293 });
2294 }
2295
2296 if !toml_src.contains("[mcp]\n") {
2298 return Ok(MigrationResult {
2299 output: toml_src.to_owned(),
2300 changed_count: 0,
2301 sections_changed: Vec::new(),
2302 });
2303 }
2304
2305 let comment = "# elicitation_enabled = false \
2306 # opt-in: servers may request user input mid-task (#3141)\n\
2307 # elicitation_timeout = 120 # seconds to wait for user response\n\
2308 # elicitation_queue_capacity = 16 # beyond this limit requests are auto-declined\n\
2309 # elicitation_warn_sensitive_fields = true # warn before prompting for password/token/etc.\n";
2310 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2311
2312 Ok(MigrationResult {
2313 output,
2314 changed_count: 1,
2315 sections_changed: vec!["mcp.elicitation".to_owned()],
2316 })
2317}
2318
2319pub fn migrate_mcp_max_connect_attempts(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2329 if toml_src.contains("max_connect_attempts") {
2330 return Ok(MigrationResult {
2331 output: toml_src.to_owned(),
2332 changed_count: 0,
2333 sections_changed: Vec::new(),
2334 });
2335 }
2336
2337 if !toml_src.contains("[mcp]\n") {
2338 return Ok(MigrationResult {
2339 output: toml_src.to_owned(),
2340 changed_count: 0,
2341 sections_changed: Vec::new(),
2342 });
2343 }
2344
2345 let comment = "# max_connect_attempts = 3 \
2346 # startup retry count per server (1 = no retry, 1..=10, backoff: 500ms/1s/2s/...)\n";
2347 let output = toml_src.replacen("[mcp]\n", &format!("[mcp]\n{comment}"), 1);
2348
2349 Ok(MigrationResult {
2350 output,
2351 changed_count: 1,
2352 sections_changed: vec!["mcp".to_owned()],
2353 })
2354}
2355
2356pub fn migrate_quality_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2367 if toml_src
2369 .lines()
2370 .any(|l| l.trim() == "[quality]" || l.trim() == "# [quality]")
2371 {
2372 return Ok(MigrationResult {
2373 output: toml_src.to_owned(),
2374 changed_count: 0,
2375 sections_changed: Vec::new(),
2376 });
2377 }
2378
2379 let comment = "\n# [quality] — MARCH Proposer+Checker self-check pipeline (#3226, #3228).\n\
2380 # [quality]\n\
2381 # self_check = false # enable post-response self-check\n\
2382 # trigger = \"has_retrieval\" # has_retrieval | always | manual\n\
2383 # latency_budget_ms = 4000 # hard ceiling for the whole pipeline\n\
2384 # proposer_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2385 # checker_provider = \"\" # optional: provider name from [[llm.providers]]\n\
2386 # min_evidence = 0.6 # 0.0..1.0; below → flag assertion\n\
2387 # async_run = false # true = fire-and-forget (non-blocking)\n\
2388 # per_call_timeout_ms = 2000 # per-LLM-call timeout\n\
2389 # max_assertions = 12 # maximum assertions extracted from one response\n\
2390 # max_response_chars = 8000 # skip pipeline when response exceeds this\n\
2391 # cache_disabled_for_checker = true # suppress prompt-cache on Checker provider\n\
2392 # flag_marker = \"[verify]\" # marker appended when assertions are flagged\n";
2393 let output = format!("{toml_src}{comment}");
2394
2395 Ok(MigrationResult {
2396 output,
2397 changed_count: 1,
2398 sections_changed: vec!["quality".to_owned()],
2399 })
2400}
2401
2402pub fn migrate_acp_subagents_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2413 if toml_src
2414 .lines()
2415 .any(|l| l.trim() == "[acp.subagents]" || l.trim() == "# [acp.subagents]")
2416 {
2417 return Ok(MigrationResult {
2418 output: toml_src.to_owned(),
2419 changed_count: 0,
2420 sections_changed: Vec::new(),
2421 });
2422 }
2423
2424 let comment = "\n# [acp.subagents] — sub-agent delegation via ACP protocol (#3289).\n\
2425 # [acp.subagents]\n\
2426 # enabled = false\n\
2427 #\n\
2428 # [[acp.subagents.presets]]\n\
2429 # name = \"inner\" # identifier used in /subagent commands\n\
2430 # command = \"cargo run --quiet -- --acp\" # shell command to spawn the sub-agent\n\
2431 # # cwd = \"/path/to/agent\" # optional working directory\n\
2432 # # handshake_timeout_secs = 30 # initialize+session/new timeout\n\
2433 # # prompt_timeout_secs = 600 # single round-trip timeout\n";
2434 let output = format!("{toml_src}{comment}");
2435
2436 Ok(MigrationResult {
2437 output,
2438 changed_count: 1,
2439 sections_changed: vec!["acp.subagents".to_owned()],
2440 })
2441}
2442
2443pub fn migrate_hooks_permission_denied_config(
2454 toml_src: &str,
2455) -> Result<MigrationResult, MigrateError> {
2456 if toml_src.lines().any(|l| {
2457 l.trim() == "[[hooks.permission_denied]]" || l.trim() == "# [[hooks.permission_denied]]"
2458 }) {
2459 return Ok(MigrationResult {
2460 output: toml_src.to_owned(),
2461 changed_count: 0,
2462 sections_changed: Vec::new(),
2463 });
2464 }
2465
2466 let comment = "\n# [[hooks.permission_denied]] — hook fired when a tool call is denied (#3303).\n\
2467 # Available env vars: ZEPH_TOOL, ZEPH_DENY_REASON, ZEPH_TOOL_INPUT_JSON.\n\
2468 # [[hooks.permission_denied]]\n\
2469 # [hooks.permission_denied.action]\n\
2470 # type = \"command\"\n\
2471 # command = \"echo denied: $ZEPH_TOOL\"\n";
2472 let output = format!("{toml_src}{comment}");
2473
2474 Ok(MigrationResult {
2475 output,
2476 changed_count: 1,
2477 sections_changed: vec!["hooks.permission_denied".to_owned()],
2478 })
2479}
2480
2481pub fn migrate_memory_graph_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2492 if toml_src.contains("retrieval_strategy")
2493 || toml_src.contains("[memory.graph.beam_search]")
2494 || toml_src.contains("# [memory.graph.beam_search]")
2495 {
2496 return Ok(MigrationResult {
2497 output: toml_src.to_owned(),
2498 changed_count: 0,
2499 sections_changed: Vec::new(),
2500 });
2501 }
2502
2503 let comment = "\n# [memory.graph] retrieval strategy options (#3311).\n\
2504 # retrieval_strategy = \"synapse\" # synapse | bfs | astar | watercircles | beam_search | hybrid\n\
2505 #\n\
2506 # [memory.graph.beam_search] # active when retrieval_strategy = \"beam_search\"\n\
2507 # beam_width = 10 # top-K candidates kept per hop\n\
2508 #\n\
2509 # [memory.graph.watercircles] # active when retrieval_strategy = \"watercircles\"\n\
2510 # ring_limit = 0 # max facts per ring; 0 = auto\n\
2511 #\n\
2512 # [memory.graph.experience] # experience memory recording\n\
2513 # enabled = false\n\
2514 # evolution_sweep_enabled = false\n\
2515 # confidence_prune_threshold = 0.1 # prune edges below this threshold\n\
2516 # evolution_sweep_interval = 50 # turns between sweeps\n";
2517 let output = format!("{toml_src}{comment}");
2518
2519 Ok(MigrationResult {
2520 output,
2521 changed_count: 1,
2522 sections_changed: vec!["memory.graph.retrieval".to_owned()],
2523 })
2524}
2525
2526pub fn migrate_scheduler_daemon_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2537 if toml_src
2538 .lines()
2539 .any(|l| l.trim() == "[scheduler.daemon]" || l.trim() == "# [scheduler.daemon]")
2540 {
2541 return Ok(MigrationResult {
2542 output: toml_src.to_owned(),
2543 changed_count: 0,
2544 sections_changed: Vec::new(),
2545 });
2546 }
2547
2548 let comment = "\n# [scheduler.daemon] — daemon process config for `zeph serve` (#3332).\n\
2549 # [scheduler.daemon]\n\
2550 # pid_file = \"/tmp/zeph-scheduler.pid\" # PID file path (must be on a local filesystem)\n\
2551 # log_file = \"/tmp/zeph-scheduler.log\" # daemon log file path (append-only; rotate externally)\n\
2552 # tick_secs = 60 # scheduler tick interval in seconds (clamped 5..=3600)\n\
2553 # shutdown_grace_secs = 30 # grace period after SIGTERM before process exits\n\
2554 # catch_up = true # replay missed cron tasks on daemon restart\n";
2555 let output = format!("{toml_src}{comment}");
2556
2557 Ok(MigrationResult {
2558 output,
2559 changed_count: 1,
2560 sections_changed: vec!["scheduler.daemon".to_owned()],
2561 })
2562}
2563
2564pub fn migrate_memory_retrieval_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2575 if toml_src
2576 .lines()
2577 .any(|l| l.trim() == "[memory.retrieval]" || l.trim() == "# [memory.retrieval]")
2578 {
2579 return Ok(MigrationResult {
2580 output: toml_src.to_owned(),
2581 changed_count: 0,
2582 sections_changed: Vec::new(),
2583 });
2584 }
2585
2586 let comment = "\n# [memory.retrieval] — MemMachine-inspired retrieval tuning (#3340, #3341).\n\
2587 # [memory.retrieval]\n\
2588 # depth = 0 # ANN candidates fetched from the vector store, directly.\n\
2589 # # 0 = legacy behavior (recall_limit * 2). Set to an explicit\n\
2590 # # value >= recall_limit * 2 to enlarge the candidate pool.\n\
2591 # search_prompt_template = \"\" # embedding query template; {query} = raw user query; empty = identity\n\
2592 # context_format = \"structured\" # structured | plain — memory snippet rendering format\n\
2593 # query_bias_correction = true # shift first-person queries towards user profile centroid (MM-F3)\n\
2594 # query_bias_profile_weight = 0.25 # blend weight [0.0, 1.0]; 0.0 = off, 1.0 = full centroid\n\
2595 # query_bias_centroid_ttl_secs = 300 # seconds before profile centroid cache is recomputed\n";
2596 let output = format!("{toml_src}{comment}");
2597
2598 Ok(MigrationResult {
2599 output,
2600 changed_count: 1,
2601 sections_changed: vec!["memory.retrieval".to_owned()],
2602 })
2603}
2604
2605pub fn migrate_memory_reasoning_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2616 if toml_src
2617 .lines()
2618 .any(|l| l.trim() == "[memory.reasoning]" || l.trim() == "# [memory.reasoning]")
2619 {
2620 return Ok(MigrationResult {
2621 output: toml_src.to_owned(),
2622 changed_count: 0,
2623 sections_changed: Vec::new(),
2624 });
2625 }
2626
2627 let comment = "\n# [memory.reasoning] — ReasoningBank: distilled strategy memory (#3369).\n\
2628 # [memory.reasoning]\n\
2629 # enabled = false\n\
2630 # extract_provider = \"\" # SLM: self-judge (JSON response) — leave blank to use primary\n\
2631 # distill_provider = \"\" # SLM: strategy distillation — leave blank to use primary\n\
2632 # top_k = 3 # strategies injected per turn\n\
2633 # store_limit = 1000 # max rows in reasoning_strategies table\n\
2634 # context_budget_tokens = 500\n\
2635 # extraction_timeout_secs = 30\n\
2636 # distill_timeout_secs = 30\n\
2637 # max_messages = 6\n\
2638 # min_messages = 2\n\
2639 # max_message_chars = 2000\n";
2640 let output = format!("{toml_src}{comment}");
2641
2642 Ok(MigrationResult {
2643 output,
2644 changed_count: 1,
2645 sections_changed: vec!["memory.reasoning".to_owned()],
2646 })
2647}
2648
2649pub fn migrate_memory_reasoning_judge_config(
2661 toml_src: &str,
2662) -> Result<MigrationResult, MigrateError> {
2663 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.reasoning]");
2664 if !has_section {
2665 return Ok(MigrationResult {
2666 output: toml_src.to_owned(),
2667 changed_count: 0,
2668 sections_changed: Vec::new(),
2669 });
2670 }
2671
2672 let has_window = toml_src.lines().any(|l| {
2674 let t = l.trim().trim_start_matches('#').trim();
2675 t.starts_with("self_judge_window")
2676 });
2677 let has_min_chars = toml_src.lines().any(|l| {
2678 let t = l.trim().trim_start_matches('#').trim();
2679 t.starts_with("min_assistant_chars")
2680 });
2681 if has_window && has_min_chars {
2682 return Ok(MigrationResult {
2683 output: toml_src.to_owned(),
2684 changed_count: 0,
2685 sections_changed: Vec::new(),
2686 });
2687 }
2688
2689 let lines: Vec<&str> = toml_src.lines().collect();
2693 let mut section_start = None;
2694 let mut insert_after = None;
2695
2696 for (i, line) in lines.iter().enumerate() {
2697 if line.trim() == "[memory.reasoning]" {
2698 section_start = Some(i);
2699 }
2700 if let Some(start) = section_start {
2701 let trimmed = line.trim();
2702 if i > start && trimmed.starts_with('[') && !trimmed.starts_with("[[") {
2704 break;
2705 }
2706 insert_after = Some(i);
2707 }
2708 }
2709
2710 let Some(insert_idx) = insert_after else {
2711 return Ok(MigrationResult {
2712 output: toml_src.to_owned(),
2713 changed_count: 0,
2714 sections_changed: Vec::new(),
2715 });
2716 };
2717
2718 let mut new_lines: Vec<String> = lines.iter().map(|l| (*l).to_owned()).collect();
2719 let mut additions = Vec::new();
2720 if !has_window {
2721 additions.push(
2722 "# self_judge_window = 2 # max recent messages passed to self-judge (#3383)"
2723 .to_owned(),
2724 );
2725 }
2726 if !has_min_chars {
2727 additions.push(
2728 "# min_assistant_chars = 50 # skip self-judge for short replies (#3383)".to_owned(),
2729 );
2730 }
2731 for (offset, line) in additions.iter().enumerate() {
2732 new_lines.insert(insert_idx + 1 + offset, line.clone());
2733 }
2734
2735 let output = new_lines.join("\n") + if toml_src.ends_with('\n') { "\n" } else { "" };
2736 Ok(MigrationResult {
2737 output,
2738 changed_count: additions.len(),
2739 sections_changed: vec!["memory.reasoning".to_owned()],
2740 })
2741}
2742
2743pub fn migrate_memory_hebbian_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2753 if toml_src
2754 .lines()
2755 .any(|l| l.trim() == "[memory.hebbian]" || l.trim() == "# [memory.hebbian]")
2756 {
2757 return Ok(MigrationResult {
2758 output: toml_src.to_owned(),
2759 changed_count: 0,
2760 sections_changed: Vec::new(),
2761 });
2762 }
2763
2764 let comment = "\n# [memory.hebbian] # HL-F1/F2 (#3344) Hebbian edge reinforcement\n\
2765 # [memory.hebbian]\n\
2766 # enabled = false # opt-in master switch; no DB writes when false\n\
2767 # hebbian_lr = 0.1 # weight increment per co-activation (0.01–0.5)\n";
2768 let output = format!("{toml_src}{comment}");
2769
2770 Ok(MigrationResult {
2771 output,
2772 changed_count: 1,
2773 sections_changed: vec!["memory.hebbian".to_owned()],
2774 })
2775}
2776
2777pub fn migrate_memory_hebbian_consolidation_config(
2789 toml_src: &str,
2790) -> Result<MigrationResult, MigrateError> {
2791 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2792
2793 if !has_section {
2794 return Ok(MigrationResult {
2795 output: toml_src.to_owned(),
2796 changed_count: 0,
2797 sections_changed: Vec::new(),
2798 });
2799 }
2800
2801 let has_interval = toml_src
2803 .lines()
2804 .any(|l| l.trim().starts_with("consolidation_interval_secs"));
2805 let has_threshold = toml_src
2806 .lines()
2807 .any(|l| l.trim().starts_with("consolidation_threshold"));
2808 let has_provider = toml_src
2809 .lines()
2810 .any(|l| l.trim().starts_with("consolidate_provider"));
2811
2812 if has_interval && has_threshold && has_provider {
2813 return Ok(MigrationResult {
2814 output: toml_src.to_owned(),
2815 changed_count: 0,
2816 sections_changed: Vec::new(),
2817 });
2818 }
2819
2820 let extra = "\n# HL-F3/F4 consolidation fields (#3345) — splice into existing [memory.hebbian] section:\n\
2821 # consolidation_interval_secs = 3600 # how often the sweep runs (0 = disabled)\n\
2822 # consolidation_threshold = 5.0 # degree × avg_weight score to qualify\n\
2823 # consolidate_provider = \"fast\" # provider name for LLM distillation\n\
2824 # max_candidates_per_sweep = 10\n\
2825 # consolidation_cooldown_secs = 86400 # re-consolidation cooldown per entity\n\
2826 # consolidation_prompt_timeout_secs = 30\n\
2827 # consolidation_max_neighbors = 20\n";
2828
2829 let output = format!("{toml_src}{extra}");
2830 Ok(MigrationResult {
2831 output,
2832 changed_count: 1,
2833 sections_changed: vec!["memory.hebbian".to_owned()],
2834 })
2835}
2836
2837pub fn migrate_memory_hebbian_spread_config(
2849 toml_src: &str,
2850) -> Result<MigrationResult, MigrateError> {
2851 let has_section = toml_src.lines().any(|l| l.trim() == "[memory.hebbian]");
2852
2853 if !has_section {
2854 return Ok(MigrationResult {
2855 output: toml_src.to_owned(),
2856 changed_count: 0,
2857 sections_changed: Vec::new(),
2858 });
2859 }
2860
2861 let has_spreading = toml_src
2863 .lines()
2864 .any(|l| l.trim().starts_with("spreading_activation"));
2865 let has_depth = toml_src
2866 .lines()
2867 .any(|l| l.trim().starts_with("spread_depth"));
2868 let has_budget = toml_src
2869 .lines()
2870 .any(|l| l.trim().starts_with("step_budget_ms"));
2871
2872 if has_spreading && has_depth && has_budget {
2873 return Ok(MigrationResult {
2874 output: toml_src.to_owned(),
2875 changed_count: 0,
2876 sections_changed: Vec::new(),
2877 });
2878 }
2879
2880 let extra = "\n# HL-F5 spreading-activation fields (#3346) — splice into existing [memory.hebbian] section:\n\
2881 # spreading_activation = false # opt-in BFS from top-1 ANN anchor; requires enabled=true\n\
2882 # spread_depth = 2 # BFS hops, clamped [1,6]\n\
2883 # spread_edge_types = [] # MAGMA edge types to traverse; empty = all\n\
2884 # step_budget_ms = 8 # per-step circuit-breaker timeout (anchor ANN / edges / vectors)\n";
2885
2886 let output = format!("{toml_src}{extra}");
2887 Ok(MigrationResult {
2888 output,
2889 changed_count: 1,
2890 sections_changed: vec!["memory.hebbian.spreading_activation".to_owned()],
2891 })
2892}
2893
2894pub fn migrate_hooks_turn_complete_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
2908 if toml_src
2909 .lines()
2910 .any(|l| l.trim() == "[[hooks.turn_complete]]" || l.trim() == "# [[hooks.turn_complete]]")
2911 {
2912 return Ok(MigrationResult {
2913 output: toml_src.to_owned(),
2914 changed_count: 0,
2915 sections_changed: Vec::new(),
2916 });
2917 }
2918
2919 let comment = "\n# [[hooks.turn_complete]] — hook fired after every agent turn completes (#3308).\n\
2920 # Available env vars: ZEPH_TURN_DURATION_MS, ZEPH_TURN_STATUS, ZEPH_TURN_PREVIEW,\n\
2921 # ZEPH_TURN_LLM_REQUESTS.\n\
2922 # Note: ZEPH_TURN_PREVIEW is available as env var but should not be embedded\n\
2923 # directly in the command string to avoid shell injection. Use a wrapper script instead.\n\
2924 # [[hooks.turn_complete]]\n\
2925 # command = \"osascript -e 'display notification \\\"Task complete\\\" with title \\\"Zeph\\\"'\"\n\
2926 # timeout_secs = 3\n\
2927 # fail_closed = false\n";
2928 let output = format!("{toml_src}{comment}");
2929
2930 Ok(MigrationResult {
2931 output,
2932 changed_count: 1,
2933 sections_changed: vec!["hooks.turn_complete".to_owned()],
2934 })
2935}
2936
2937pub fn migrate_focus_auto_consolidate_min_window(
2954 toml_src: &str,
2955) -> Result<MigrationResult, MigrateError> {
2956 if toml_src.contains("auto_consolidate_min_window") {
2957 return Ok(MigrationResult {
2958 output: toml_src.to_owned(),
2959 changed_count: 0,
2960 sections_changed: Vec::new(),
2961 });
2962 }
2963
2964 if !toml_src.lines().any(|l| l.trim() == "[agent.focus]") {
2966 return Ok(MigrationResult {
2967 output: toml_src.to_owned(),
2968 changed_count: 0,
2969 sections_changed: Vec::new(),
2970 });
2971 }
2972
2973 let comment = "\n# Minimum messages in a low-relevance window before Focus auto-consolidation \
2974 runs (#3313).\n\
2975 # auto_consolidate_min_window = 6\n";
2976 let output = insert_after_section(toml_src, "agent.focus", comment);
2977
2978 Ok(MigrationResult {
2979 output,
2980 changed_count: 1,
2981 sections_changed: vec!["agent.focus.auto_consolidate_min_window".to_owned()],
2982 })
2983}
2984
2985pub fn migrate_session_provider_persistence(
2995 toml_src: &str,
2996) -> Result<MigrationResult, MigrateError> {
2997 if toml_src
2998 .lines()
2999 .any(|l| l.trim() == "[session]" || l.trim() == "# [session]")
3000 {
3001 return Ok(MigrationResult {
3002 output: toml_src.to_owned(),
3003 changed_count: 0,
3004 sections_changed: Vec::new(),
3005 });
3006 }
3007
3008 let comment = "\n# [session] — session-scoped user experience settings (#3308).\n\
3009 [session]\n\
3010 # Persist the last-used provider per channel across restarts.\n\
3011 # When true, the agent saves the active provider name to SQLite after each\n\
3012 # /provider switch and restores it on the next session start for the same channel.\n\
3013 provider_persistence = true\n";
3014 let output = format!("{toml_src}{comment}");
3015
3016 Ok(MigrationResult {
3017 output,
3018 changed_count: 1,
3019 sections_changed: vec!["session".to_owned()],
3020 })
3021}
3022
3023pub fn migrate_memory_retrieval_query_bias(
3035 toml_src: &str,
3036) -> Result<MigrationResult, MigrateError> {
3037 if !toml_src.lines().any(|l| l.trim() == "[memory.retrieval]") {
3040 return Ok(MigrationResult {
3041 output: toml_src.to_owned(),
3042 changed_count: 0,
3043 sections_changed: Vec::new(),
3044 });
3045 }
3046
3047 if toml_src
3049 .lines()
3050 .any(|l| l.trim().starts_with("query_bias_correction"))
3051 {
3052 return Ok(MigrationResult {
3053 output: toml_src.to_owned(),
3054 changed_count: 0,
3055 sections_changed: Vec::new(),
3056 });
3057 }
3058
3059 let comment = "\n# MM-F3 (#3341): shift first-person queries toward the user profile centroid.\n\
3060 # No-op when the persona table is empty.\n\
3061 # query_bias_correction = true\n";
3062 let output = insert_after_section(toml_src, "memory.retrieval", comment);
3063
3064 Ok(MigrationResult {
3065 output,
3066 changed_count: 1,
3067 sections_changed: vec!["memory.retrieval.query_bias_correction".to_owned()],
3068 })
3069}
3070
3071pub fn migrate_memory_persona_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3080 if toml_src
3081 .lines()
3082 .any(|l| l.trim() == "[memory.persona]" || l.trim() == "# [memory.persona]")
3083 {
3084 return Ok(MigrationResult {
3085 output: toml_src.to_owned(),
3086 changed_count: 0,
3087 sections_changed: Vec::new(),
3088 });
3089 }
3090
3091 let comment = "\n# [memory.persona] — user persona profile for query-bias correction (#3341).\n\
3092 # Verified working in CI-604/CI-605. No-op when disabled.\n\
3093 # [memory.persona]\n\
3094 # enabled = true\n\
3095 # min_messages = 2 # minimum user messages before persona extraction fires\n\
3096 # min_confidence = 0.5 # minimum extraction confidence threshold (0.0–1.0)\n";
3097 let output = format!("{toml_src}{comment}");
3098
3099 Ok(MigrationResult {
3100 output,
3101 changed_count: 1,
3102 sections_changed: vec!["memory.persona".to_owned()],
3103 })
3104}
3105
3106pub fn migrate_qdrant_api_key(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3115 if toml_src.contains("qdrant_api_key") {
3116 return Ok(MigrationResult {
3117 output: toml_src.to_owned(),
3118 changed_count: 0,
3119 sections_changed: Vec::new(),
3120 });
3121 }
3122
3123 let mut doc = toml_src.parse::<toml_edit::DocumentMut>()?;
3124
3125 if !doc.contains_key("memory") {
3126 doc.insert("memory", toml_edit::Item::Table(toml_edit::Table::new()));
3127 }
3128
3129 let comment = "\n# Qdrant API key (optional; required when connecting to remote/managed Qdrant clusters).\n\
3130 # Leave empty for local Qdrant instances. Store the actual key in the vault:\n\
3131 # zeph vault set ZEPH_QDRANT_API_KEY \"<key>\"\n\
3132 # qdrant_api_key = \"\"\n";
3133 let raw = doc.to_string();
3134 let output = format!("{raw}{comment}");
3135
3136 Ok(MigrationResult {
3137 output,
3138 changed_count: 1,
3139 sections_changed: vec!["memory.qdrant_api_key".to_owned()],
3140 })
3141}
3142
3143pub fn migrate_goals_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3149 if toml_src.contains("[goals]") {
3150 return Ok(MigrationResult {
3151 output: toml_src.to_owned(),
3152 changed_count: 0,
3153 sections_changed: Vec::new(),
3154 });
3155 }
3156
3157 let comment = "\n# Long-horizon goal lifecycle tracking (#3567).\n\
3158 # [goals]\n\
3159 # enabled = false\n\
3160 # inject_into_system_prompt = true\n\
3161 # max_text_chars = 2000\n\
3162 # max_history = 50\n";
3163
3164 Ok(MigrationResult {
3165 output: format!("{toml_src}{comment}"),
3166 changed_count: 1,
3167 sections_changed: vec!["goals".to_owned()],
3168 })
3169}
3170
3171pub fn migrate_tools_compression_config(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3177 if toml_src.contains("tools.compression")
3178 || toml_src.contains("[tools]\n") && toml_src.contains("compression")
3179 {
3180 return Ok(MigrationResult {
3181 output: toml_src.to_owned(),
3182 changed_count: 0,
3183 sections_changed: Vec::new(),
3184 });
3185 }
3186
3187 let comment = "\n# TACO self-evolving tool output compression (#3306).\n\
3188 # [tools.compression]\n\
3189 # enabled = false\n\
3190 # min_lines_to_compress = 10\n\
3191 # evolution_provider = \"\"\n\
3192 # evolution_min_interval_secs = 3600\n\
3193 # max_rules = 200\n";
3194
3195 Ok(MigrationResult {
3196 output: format!("{toml_src}{comment}"),
3197 changed_count: 1,
3198 sections_changed: vec!["tools.compression".to_owned()],
3199 })
3200}
3201
3202pub fn migrate_orchestration_orchestrator_provider(
3208 toml_src: &str,
3209) -> Result<MigrationResult, MigrateError> {
3210 if toml_src.contains("orchestrator_provider") {
3211 return Ok(MigrationResult {
3212 output: toml_src.to_owned(),
3213 changed_count: 0,
3214 sections_changed: Vec::new(),
3215 });
3216 }
3217
3218 let comment = "\n# Provider for scheduling-tier LLM calls (aggregation, predicate, verify fallback).\n\
3219 # Set to a cheap/fast model to reduce orchestration cost. Empty = primary provider.\n\
3220 # Add under the orchestration section in your config:\n\
3221 # orchestrator_provider = \"\"\n";
3222
3223 Ok(MigrationResult {
3224 output: format!("{toml_src}{comment}"),
3225 changed_count: 1,
3226 sections_changed: vec!["orchestration".to_owned()],
3227 })
3228}
3229
3230pub fn migrate_provider_max_concurrent(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3240 if toml_src.contains("max_concurrent") {
3241 return Ok(MigrationResult {
3242 output: toml_src.to_owned(),
3243 changed_count: 0,
3244 sections_changed: Vec::new(),
3245 });
3246 }
3247
3248 if !toml_src.contains("[[llm.providers]]") {
3249 return Ok(MigrationResult {
3250 output: toml_src.to_owned(),
3251 changed_count: 0,
3252 sections_changed: Vec::new(),
3253 });
3254 }
3255
3256 let comment = "\n# Optional: maximum concurrent sub-agent calls to this provider (admission control).\n\
3257 # Remove the comment to enable; omit or set to 0 for unlimited.\n\
3258 # max_concurrent = 4\n";
3259
3260 Ok(MigrationResult {
3261 output: format!("{toml_src}{comment}"),
3262 changed_count: 1,
3263 sections_changed: vec!["llm.providers".to_owned()],
3264 })
3265}
3266
3267pub trait Migration: Send + Sync {
3294 fn name(&self) -> &'static str;
3296
3297 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
3303}
3304
3305mod steps;
3306use steps::{
3307 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
3308 MigrateAutodreamConfig, MigrateCocoonProviderNotice, MigrateCompressionPredictorConfig,
3309 MigrateDatabaseUrl, MigrateEgressConfig, MigrateFocusAutoConsolidateMinWindow,
3310 MigrateForgettingConfig, MigrateGoalsConfig, MigrateGonkagateToGonka,
3311 MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete, MigrateMagicDocsConfig,
3312 MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpTrustLevels,
3313 MigrateMemoryGraph, MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation,
3314 MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig, MigrateMemoryReasoning,
3315 MigrateMemoryReasoningJudge, MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias,
3316 MigrateMicrocompactConfig, MigrateOrchestrationPersistence, MigrateOrchestratorProvider,
3317 MigrateOtelFilter, MigratePlannerModelToProvider, MigrateProviderMaxConcurrent,
3318 MigrateQdrantApiKey, MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter,
3319 MigrateSchedulerDaemon, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
3320 MigrateShellTransactional, MigrateSttToProvider, MigrateSupervisorConfig,
3321 MigrateTelemetryConfig, MigrateToolsCompressionConfig, MigrateVigilConfig,
3322};
3323
3324pub(crate) fn migrate_gonkagate_to_gonka(toml_src: &str) -> MigrationResult {
3330 const MARKER: &str = "# [migration] GonkaGate detected: consider migrating to type = \"gonka\"";
3332
3333 if !toml_src.contains("gonkagate") {
3334 return MigrationResult {
3335 output: toml_src.to_owned(),
3336 changed_count: 0,
3337 sections_changed: vec![],
3338 };
3339 }
3340
3341 let mut changed_count = 0;
3342 let mut lines: Vec<String> = toml_src.lines().map(str::to_owned).collect();
3343
3344 let indices: Vec<usize> = lines
3348 .iter()
3349 .enumerate()
3350 .filter(|(_, l)| l.contains("gonkagate"))
3351 .map(|(i, _)| i)
3352 .rev()
3353 .collect();
3354
3355 for gonka_idx in indices {
3356 let header_idx = (0..=gonka_idx)
3358 .rev()
3359 .find(|&i| lines[i].starts_with("[["))
3360 .unwrap_or(gonka_idx);
3361
3362 let already_marked = header_idx > 0 && lines[header_idx - 1].contains(MARKER);
3364 if already_marked {
3365 continue;
3366 }
3367
3368 lines.insert(
3369 header_idx,
3370 format!("{MARKER} (see docs/guides/gonka-native.md)"),
3371 );
3372 changed_count += 1;
3373 }
3374
3375 let output = lines.join("\n");
3376 let output = if toml_src.ends_with('\n') {
3377 format!("{output}\n")
3378 } else {
3379 output
3380 };
3381
3382 MigrationResult {
3383 output,
3384 changed_count,
3385 sections_changed: if changed_count > 0 {
3386 vec!["llm".into()]
3387 } else {
3388 vec![]
3389 },
3390 }
3391}
3392
3393pub fn migrate_cocoon_provider_notice(toml_src: &str) -> Result<MigrationResult, MigrateError> {
3402 Ok(MigrationResult {
3403 output: toml_src.to_owned(),
3404 changed_count: 0,
3405 sections_changed: vec![],
3406 })
3407}
3408
3409pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
3426 std::sync::LazyLock::new(|| {
3427 vec![
3428 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
3430 Box::new(MigratePlannerModelToProvider),
3431 Box::new(MigrateMcpTrustLevels),
3432 Box::new(MigrateAgentRetryToToolsRetry),
3433 Box::new(MigrateDatabaseUrl),
3434 Box::new(MigrateShellTransactional),
3435 Box::new(MigrateAgentBudgetHint),
3436 Box::new(MigrateForgettingConfig),
3437 Box::new(MigrateCompressionPredictorConfig),
3438 Box::new(MigrateMicrocompactConfig),
3439 Box::new(MigrateAutodreamConfig),
3440 Box::new(MigrateMagicDocsConfig),
3441 Box::new(MigrateTelemetryConfig),
3442 Box::new(MigrateSupervisorConfig),
3443 Box::new(MigrateOtelFilter),
3444 Box::new(MigrateEgressConfig),
3445 Box::new(MigrateVigilConfig),
3446 Box::new(MigrateSandboxConfig),
3447 Box::new(MigrateSandboxEgressFilter),
3448 Box::new(MigrateOrchestrationPersistence),
3449 Box::new(MigrateSessionRecapConfig),
3450 Box::new(MigrateMcpElicitationConfig),
3451 Box::new(MigrateQualityConfig),
3452 Box::new(MigrateAcpSubagentsConfig),
3453 Box::new(MigrateHooksPermissionDeniedConfig),
3454 Box::new(MigrateMemoryGraph),
3456 Box::new(MigrateSchedulerDaemon),
3457 Box::new(MigrateMemoryRetrieval),
3458 Box::new(MigrateMemoryReasoning),
3459 Box::new(MigrateMemoryReasoningJudge),
3460 Box::new(MigrateMemoryHebbian),
3461 Box::new(MigrateMemoryHebbianConsolidation),
3462 Box::new(MigrateMemoryHebbianSpread),
3463 Box::new(MigrateHooksTurnComplete),
3464 Box::new(MigrateFocusAutoConsolidateMinWindow),
3465 Box::new(MigrateSessionProviderPersistence),
3467 Box::new(MigrateMemoryRetrievalQueryBias),
3468 Box::new(MigrateMemoryPersonaConfig),
3469 Box::new(MigrateQdrantApiKey),
3471 Box::new(MigrateMcpMaxConnectAttempts),
3473 Box::new(MigrateGoalsConfig),
3475 Box::new(MigrateToolsCompressionConfig),
3476 Box::new(MigrateOrchestratorProvider),
3478 Box::new(MigrateProviderMaxConcurrent),
3480 Box::new(MigrateGonkagateToGonka),
3482 Box::new(MigrateCocoonProviderNotice),
3484 ]
3485 });
3486
3487#[cfg(test)]
3489fn make_formatted_str(s: &str) -> Value {
3490 use toml_edit::Formatted;
3491 Value::String(Formatted::new(s.to_owned()))
3492}
3493
3494#[cfg(test)]
3495mod tests {
3496 use super::*;
3497
3498 #[test]
3499 fn migrations_registry_has_all_steps() {
3500 assert_eq!(
3501 MIGRATIONS.len(),
3502 46,
3503 "MIGRATIONS registry must contain all 46 sequential steps"
3504 );
3505 for m in MIGRATIONS.iter() {
3506 assert!(
3507 !m.name().is_empty(),
3508 "each migration must have a non-empty name"
3509 );
3510 }
3511 }
3512
3513 #[test]
3514 fn migrations_registry_applies_to_empty_config() {
3515 let mut toml = String::new();
3516 for m in MIGRATIONS.iter() {
3517 toml = m
3518 .apply(&toml)
3519 .expect("migration must not fail on empty config")
3520 .output;
3521 }
3522 toml.parse::<toml_edit::DocumentMut>()
3524 .expect("registry output must be valid TOML");
3525 }
3526
3527 #[test]
3528 fn empty_config_gets_sections_as_comments() {
3529 let migrator = ConfigMigrator::new();
3530 let result = migrator.migrate("").expect("migrate empty");
3531 assert!(result.changed_count > 0 || !result.sections_changed.is_empty());
3533 assert!(
3535 result.output.contains("[agent]") || result.output.contains("# [agent]"),
3536 "expected agent section in output, got:\n{}",
3537 result.output
3538 );
3539 }
3540
3541 #[test]
3542 fn existing_values_not_overwritten() {
3543 let user = r#"
3544[agent]
3545name = "MyAgent"
3546max_tool_iterations = 5
3547"#;
3548 let migrator = ConfigMigrator::new();
3549 let result = migrator.migrate(user).expect("migrate");
3550 assert!(
3552 result.output.contains("name = \"MyAgent\""),
3553 "user value should be preserved"
3554 );
3555 assert!(
3556 result.output.contains("max_tool_iterations = 5"),
3557 "user value should be preserved"
3558 );
3559 assert!(
3561 !result.output.contains("# max_tool_iterations = 10"),
3562 "already-set key should not appear as comment"
3563 );
3564 }
3565
3566 #[test]
3567 fn missing_nested_key_added_as_comment() {
3568 let user = r#"
3570[memory]
3571sqlite_path = ".zeph/data/zeph.db"
3572"#;
3573 let migrator = ConfigMigrator::new();
3574 let result = migrator.migrate(user).expect("migrate");
3575 assert!(
3577 result.output.contains("# history_limit"),
3578 "missing key should be added as comment, got:\n{}",
3579 result.output
3580 );
3581 }
3582
3583 #[test]
3584 fn unknown_user_keys_preserved() {
3585 let user = r#"
3586[agent]
3587name = "Test"
3588my_custom_key = "preserved"
3589"#;
3590 let migrator = ConfigMigrator::new();
3591 let result = migrator.migrate(user).expect("migrate");
3592 assert!(
3593 result.output.contains("my_custom_key = \"preserved\""),
3594 "custom user keys must not be removed"
3595 );
3596 }
3597
3598 #[test]
3599 fn idempotent() {
3600 let migrator = ConfigMigrator::new();
3601 let first = migrator
3602 .migrate("[agent]\nname = \"Zeph\"\n")
3603 .expect("first migrate");
3604 let second = migrator.migrate(&first.output).expect("second migrate");
3605 assert_eq!(
3606 first.output, second.output,
3607 "idempotent: full output must be identical on second run"
3608 );
3609 }
3610
3611 #[test]
3612 fn malformed_input_returns_error() {
3613 let migrator = ConfigMigrator::new();
3614 let err = migrator
3615 .migrate("[[invalid toml [[[")
3616 .expect_err("should error");
3617 assert!(
3618 matches!(err, MigrateError::Parse(_)),
3619 "expected Parse error"
3620 );
3621 }
3622
3623 #[test]
3624 fn array_of_tables_preserved() {
3625 let user = r#"
3626[mcp]
3627allowed_commands = ["npx"]
3628
3629[[mcp.servers]]
3630id = "my-server"
3631command = "npx"
3632args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
3633"#;
3634 let migrator = ConfigMigrator::new();
3635 let result = migrator.migrate(user).expect("migrate");
3636 assert!(
3638 result.output.contains("[[mcp.servers]]"),
3639 "array-of-tables entries must be preserved"
3640 );
3641 assert!(result.output.contains("id = \"my-server\""));
3642 }
3643
3644 #[test]
3645 fn canonical_ordering_applied() {
3646 let user = r#"
3648[memory]
3649sqlite_path = ".zeph/data/zeph.db"
3650
3651[agent]
3652name = "Test"
3653"#;
3654 let migrator = ConfigMigrator::new();
3655 let result = migrator.migrate(user).expect("migrate");
3656 let agent_pos = result.output.find("[agent]");
3658 let memory_pos = result.output.find("[memory]");
3659 if let (Some(a), Some(m)) = (agent_pos, memory_pos) {
3660 assert!(a < m, "agent section should precede memory section");
3661 }
3662 }
3663
3664 #[test]
3665 fn value_to_toml_string_formats_correctly() {
3666 use toml_edit::Formatted;
3667
3668 let s = make_formatted_str("hello");
3669 assert_eq!(value_to_toml_string(&s), "\"hello\"");
3670
3671 let i = Value::Integer(Formatted::new(42_i64));
3672 assert_eq!(value_to_toml_string(&i), "42");
3673
3674 let b = Value::Boolean(Formatted::new(true));
3675 assert_eq!(value_to_toml_string(&b), "true");
3676
3677 let f = Value::Float(Formatted::new(1.0_f64));
3678 assert_eq!(value_to_toml_string(&f), "1.0");
3679
3680 let f2 = Value::Float(Formatted::new(157_f64 / 50.0));
3681 assert_eq!(value_to_toml_string(&f2), "3.14");
3682
3683 let arr: Array = ["a", "b"].iter().map(|s| make_formatted_str(s)).collect();
3684 let arr_val = Value::Array(arr);
3685 assert_eq!(value_to_toml_string(&arr_val), r#"["a", "b"]"#);
3686
3687 let empty_arr = Value::Array(Array::new());
3688 assert_eq!(value_to_toml_string(&empty_arr), "[]");
3689 }
3690
3691 #[test]
3692 fn idempotent_full_output_unchanged() {
3693 let migrator = ConfigMigrator::new();
3695 let first = migrator
3696 .migrate("[agent]\nname = \"Zeph\"\n")
3697 .expect("first migrate");
3698 let second = migrator.migrate(&first.output).expect("second migrate");
3699 assert_eq!(
3700 first.output, second.output,
3701 "full output string must be identical after second migration pass"
3702 );
3703 }
3704
3705 #[test]
3706 fn full_config_produces_zero_additions() {
3707 let reference = include_str!("../../config/default.toml");
3709 let migrator = ConfigMigrator::new();
3710 let result = migrator.migrate(reference).expect("migrate reference");
3711 assert_eq!(
3712 result.changed_count, 0,
3713 "migrating the canonical reference should add nothing (changed_count = {})",
3714 result.changed_count
3715 );
3716 assert!(
3717 result.sections_changed.is_empty(),
3718 "migrating the canonical reference should report no sections_changed: {:?}",
3719 result.sections_changed
3720 );
3721 }
3722
3723 #[test]
3724 fn empty_config_changed_count_is_positive() {
3725 let migrator = ConfigMigrator::new();
3727 let result = migrator.migrate("").expect("migrate empty");
3728 assert!(
3729 result.changed_count > 0,
3730 "empty config must report changed_count > 0"
3731 );
3732 }
3733
3734 #[test]
3737 fn security_without_guardrail_gets_guardrail_commented() {
3738 let user = "[security]\nredact_secrets = true\n";
3739 let migrator = ConfigMigrator::new();
3740 let result = migrator.migrate(user).expect("migrate");
3741 assert!(
3743 result.output.contains("guardrail"),
3744 "migration must add guardrail keys for configs without [security.guardrail]: \
3745 got:\n{}",
3746 result.output
3747 );
3748 }
3749
3750 #[test]
3751 fn migrate_reference_contains_tools_policy() {
3752 let reference = include_str!("../../config/default.toml");
3757 assert!(
3758 reference.contains("[tools.policy]"),
3759 "default.toml must contain [tools.policy] section so migrate-config can surface it"
3760 );
3761 assert!(
3762 reference.contains("enabled = false"),
3763 "tools.policy section must include enabled = false default"
3764 );
3765 }
3766
3767 #[test]
3768 fn migrate_reference_contains_probe_section() {
3769 let reference = include_str!("../../config/default.toml");
3772 assert!(
3773 reference.contains("[memory.compression.probe]"),
3774 "default.toml must contain [memory.compression.probe] section comment"
3775 );
3776 assert!(
3777 reference.contains("hard_fail_threshold"),
3778 "probe section must include hard_fail_threshold default"
3779 );
3780 }
3781
3782 #[test]
3785 fn migrate_llm_no_llm_section_is_noop() {
3786 let src = "[agent]\nname = \"Zeph\"\n";
3787 let result = migrate_llm_to_providers(src).expect("migrate");
3788 assert_eq!(result.changed_count, 0);
3789 assert_eq!(result.output, src);
3790 }
3791
3792 #[test]
3793 fn migrate_llm_already_new_format_is_noop() {
3794 let src = r#"
3795[llm]
3796[[llm.providers]]
3797type = "ollama"
3798model = "qwen3:8b"
3799"#;
3800 let result = migrate_llm_to_providers(src).expect("migrate");
3801 assert_eq!(result.changed_count, 0);
3802 }
3803
3804 #[test]
3805 fn migrate_llm_ollama_produces_providers_block() {
3806 let src = r#"
3807[llm]
3808provider = "ollama"
3809model = "qwen3:8b"
3810base_url = "http://localhost:11434"
3811embedding_model = "nomic-embed-text"
3812"#;
3813 let result = migrate_llm_to_providers(src).expect("migrate");
3814 assert!(
3815 result.output.contains("[[llm.providers]]"),
3816 "should contain [[llm.providers]]:\n{}",
3817 result.output
3818 );
3819 assert!(
3820 result.output.contains("type = \"ollama\""),
3821 "{}",
3822 result.output
3823 );
3824 assert!(
3825 result.output.contains("model = \"qwen3:8b\""),
3826 "{}",
3827 result.output
3828 );
3829 }
3830
3831 #[test]
3832 fn migrate_llm_claude_produces_providers_block() {
3833 let src = r#"
3834[llm]
3835provider = "claude"
3836
3837[llm.cloud]
3838model = "claude-sonnet-4-6"
3839max_tokens = 8192
3840server_compaction = true
3841"#;
3842 let result = migrate_llm_to_providers(src).expect("migrate");
3843 assert!(
3844 result.output.contains("[[llm.providers]]"),
3845 "{}",
3846 result.output
3847 );
3848 assert!(
3849 result.output.contains("type = \"claude\""),
3850 "{}",
3851 result.output
3852 );
3853 assert!(
3854 result.output.contains("model = \"claude-sonnet-4-6\""),
3855 "{}",
3856 result.output
3857 );
3858 assert!(
3859 result.output.contains("server_compaction = true"),
3860 "{}",
3861 result.output
3862 );
3863 }
3864
3865 #[test]
3866 fn migrate_llm_openai_copies_fields() {
3867 let src = r#"
3868[llm]
3869provider = "openai"
3870
3871[llm.openai]
3872base_url = "https://api.openai.com/v1"
3873model = "gpt-4o"
3874max_tokens = 4096
3875"#;
3876 let result = migrate_llm_to_providers(src).expect("migrate");
3877 assert!(
3878 result.output.contains("type = \"openai\""),
3879 "{}",
3880 result.output
3881 );
3882 assert!(
3883 result
3884 .output
3885 .contains("base_url = \"https://api.openai.com/v1\""),
3886 "{}",
3887 result.output
3888 );
3889 }
3890
3891 #[test]
3892 fn migrate_llm_gemini_copies_fields() {
3893 let src = r#"
3894[llm]
3895provider = "gemini"
3896
3897[llm.gemini]
3898model = "gemini-2.0-flash"
3899max_tokens = 8192
3900base_url = "https://generativelanguage.googleapis.com"
3901"#;
3902 let result = migrate_llm_to_providers(src).expect("migrate");
3903 assert!(
3904 result.output.contains("type = \"gemini\""),
3905 "{}",
3906 result.output
3907 );
3908 assert!(
3909 result.output.contains("model = \"gemini-2.0-flash\""),
3910 "{}",
3911 result.output
3912 );
3913 }
3914
3915 #[test]
3916 fn migrate_llm_compatible_copies_multiple_entries() {
3917 let src = r#"
3918[llm]
3919provider = "compatible"
3920
3921[[llm.compatible]]
3922name = "proxy-a"
3923base_url = "http://proxy-a:8080/v1"
3924model = "llama3"
3925max_tokens = 4096
3926
3927[[llm.compatible]]
3928name = "proxy-b"
3929base_url = "http://proxy-b:8080/v1"
3930model = "mistral"
3931max_tokens = 2048
3932"#;
3933 let result = migrate_llm_to_providers(src).expect("migrate");
3934 let count = result.output.matches("[[llm.providers]]").count();
3936 assert_eq!(
3937 count, 2,
3938 "expected 2 [[llm.providers]] blocks:\n{}",
3939 result.output
3940 );
3941 assert!(
3942 result.output.contains("name = \"proxy-a\""),
3943 "{}",
3944 result.output
3945 );
3946 assert!(
3947 result.output.contains("name = \"proxy-b\""),
3948 "{}",
3949 result.output
3950 );
3951 }
3952
3953 #[test]
3954 fn migrate_llm_mixed_format_errors() {
3955 let src = r#"
3957[llm]
3958provider = "ollama"
3959
3960[[llm.providers]]
3961type = "ollama"
3962"#;
3963 assert!(
3964 migrate_llm_to_providers(src).is_err(),
3965 "mixed format must return error"
3966 );
3967 }
3968
3969 #[test]
3972 fn stt_migration_no_stt_section_returns_unchanged() {
3973 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\nmodel = \"gpt-5.4\"\n";
3974 let result = migrate_stt_to_provider(src).unwrap();
3975 assert_eq!(result.changed_count, 0);
3976 assert_eq!(result.output, src);
3977 }
3978
3979 #[test]
3980 fn stt_migration_no_model_or_base_url_returns_unchanged() {
3981 let src = "[llm]\n\n[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n\n[llm.stt]\nprovider = \"quality\"\nlanguage = \"en\"\n";
3982 let result = migrate_stt_to_provider(src).unwrap();
3983 assert_eq!(result.changed_count, 0);
3984 }
3985
3986 #[test]
3987 fn stt_migration_moves_model_to_provider_entry() {
3988 let src = r#"
3989[llm]
3990
3991[[llm.providers]]
3992type = "openai"
3993name = "quality"
3994model = "gpt-5.4"
3995
3996[llm.stt]
3997provider = "quality"
3998model = "gpt-4o-mini-transcribe"
3999language = "en"
4000"#;
4001 let result = migrate_stt_to_provider(src).unwrap();
4002 assert_eq!(result.changed_count, 1);
4003 assert!(
4005 result.output.contains("stt_model"),
4006 "stt_model must be in output"
4007 );
4008 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4011 let stt = doc
4012 .get("llm")
4013 .and_then(toml_edit::Item::as_table)
4014 .and_then(|l| l.get("stt"))
4015 .and_then(toml_edit::Item::as_table)
4016 .unwrap();
4017 assert!(
4018 stt.get("model").is_none(),
4019 "model must be removed from [llm.stt]"
4020 );
4021 assert_eq!(
4022 stt.get("provider").and_then(toml_edit::Item::as_str),
4023 Some("quality")
4024 );
4025 }
4026
4027 #[test]
4028 fn stt_migration_creates_new_provider_when_no_match() {
4029 let src = r#"
4030[llm]
4031
4032[[llm.providers]]
4033type = "ollama"
4034name = "local"
4035model = "qwen3:8b"
4036
4037[llm.stt]
4038provider = "whisper"
4039model = "whisper-1"
4040base_url = "https://api.openai.com/v1"
4041language = "en"
4042"#;
4043 let result = migrate_stt_to_provider(src).unwrap();
4044 assert!(
4045 result.output.contains("openai-stt"),
4046 "new entry name must be openai-stt"
4047 );
4048 assert!(
4049 result.output.contains("stt_model"),
4050 "stt_model must be in output"
4051 );
4052 }
4053
4054 #[test]
4055 fn stt_migration_candle_whisper_creates_candle_entry() {
4056 let src = r#"
4057[llm]
4058
4059[llm.stt]
4060provider = "candle-whisper"
4061model = "openai/whisper-tiny"
4062language = "auto"
4063"#;
4064 let result = migrate_stt_to_provider(src).unwrap();
4065 assert!(
4066 result.output.contains("local-whisper"),
4067 "candle entry name must be local-whisper"
4068 );
4069 assert!(result.output.contains("candle"), "type must be candle");
4070 }
4071
4072 #[test]
4073 fn stt_migration_w2_assigns_explicit_name() {
4074 let src = r#"
4076[llm]
4077
4078[[llm.providers]]
4079type = "openai"
4080model = "gpt-5.4"
4081
4082[llm.stt]
4083provider = "openai"
4084model = "whisper-1"
4085language = "auto"
4086"#;
4087 let result = migrate_stt_to_provider(src).unwrap();
4088 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4089 let providers = doc
4090 .get("llm")
4091 .and_then(toml_edit::Item::as_table)
4092 .and_then(|l| l.get("providers"))
4093 .and_then(toml_edit::Item::as_array_of_tables)
4094 .unwrap();
4095 let entry = providers
4096 .iter()
4097 .find(|t| t.get("stt_model").is_some())
4098 .unwrap();
4099 assert!(
4101 entry.get("name").is_some(),
4102 "migrated entry must have explicit name"
4103 );
4104 }
4105
4106 #[test]
4107 fn stt_migration_removes_base_url_from_stt_table() {
4108 let src = r#"
4110[llm]
4111
4112[[llm.providers]]
4113type = "openai"
4114name = "quality"
4115model = "gpt-5.4"
4116
4117[llm.stt]
4118provider = "quality"
4119model = "whisper-1"
4120base_url = "https://api.openai.com/v1"
4121language = "en"
4122"#;
4123 let result = migrate_stt_to_provider(src).unwrap();
4124 let doc: toml_edit::DocumentMut = result.output.parse().unwrap();
4125 let stt = doc
4126 .get("llm")
4127 .and_then(toml_edit::Item::as_table)
4128 .and_then(|l| l.get("stt"))
4129 .and_then(toml_edit::Item::as_table)
4130 .unwrap();
4131 assert!(
4132 stt.get("model").is_none(),
4133 "model must be removed from [llm.stt]"
4134 );
4135 assert!(
4136 stt.get("base_url").is_none(),
4137 "base_url must be removed from [llm.stt]"
4138 );
4139 }
4140
4141 #[test]
4142 fn migrate_planner_model_to_provider_with_field() {
4143 let input = r#"
4144[orchestration]
4145enabled = true
4146planner_model = "gpt-4o"
4147max_tasks = 20
4148"#;
4149 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4150 assert_eq!(result.changed_count, 1, "changed_count must be 1");
4151 assert!(
4152 !result.output.contains("planner_model = "),
4153 "planner_model key must be removed from output"
4154 );
4155 assert!(
4156 result.output.contains("# planner_provider"),
4157 "commented-out planner_provider entry must be present"
4158 );
4159 assert!(
4160 result.output.contains("gpt-4o"),
4161 "old value must appear in the comment"
4162 );
4163 assert!(
4164 result.output.contains("MIGRATED"),
4165 "comment must include MIGRATED marker"
4166 );
4167 }
4168
4169 #[test]
4170 fn migrate_planner_model_to_provider_no_op() {
4171 let input = r"
4172[orchestration]
4173enabled = true
4174max_tasks = 20
4175";
4176 let result = migrate_planner_model_to_provider(input).expect("migration must succeed");
4177 assert_eq!(
4178 result.changed_count, 0,
4179 "changed_count must be 0 when field is absent"
4180 );
4181 assert_eq!(
4182 result.output, input,
4183 "output must equal input when nothing to migrate"
4184 );
4185 }
4186
4187 #[test]
4188 fn migrate_error_invalid_structure_formats_correctly() {
4189 let err = MigrateError::InvalidStructure("test sentinel");
4194 assert!(
4195 matches!(err, MigrateError::InvalidStructure(_)),
4196 "variant must match"
4197 );
4198 let msg = err.to_string();
4199 assert!(
4200 msg.contains("invalid TOML structure"),
4201 "error message must mention 'invalid TOML structure', got: {msg}"
4202 );
4203 assert!(
4204 msg.contains("test sentinel"),
4205 "message must include reason: {msg}"
4206 );
4207 }
4208
4209 #[test]
4212 fn migrate_mcp_trust_levels_adds_trusted_to_entries_without_field() {
4213 let src = r#"
4214[mcp]
4215allowed_commands = ["npx"]
4216
4217[[mcp.servers]]
4218id = "srv-a"
4219command = "npx"
4220args = ["-y", "some-mcp"]
4221
4222[[mcp.servers]]
4223id = "srv-b"
4224command = "npx"
4225args = ["-y", "other-mcp"]
4226"#;
4227 let result = migrate_mcp_trust_levels(src).expect("migrate");
4228 assert_eq!(
4229 result.changed_count, 2,
4230 "both entries must get trust_level added"
4231 );
4232 assert!(
4233 result
4234 .sections_changed
4235 .contains(&"mcp.servers.trust_level".to_owned()),
4236 "sections_changed must report mcp.servers.trust_level"
4237 );
4238 let occurrences = result.output.matches("trust_level = \"trusted\"").count();
4240 assert_eq!(
4241 occurrences, 2,
4242 "each entry must have trust_level = \"trusted\""
4243 );
4244 }
4245
4246 #[test]
4247 fn migrate_mcp_trust_levels_does_not_overwrite_existing_field() {
4248 let src = r#"
4249[[mcp.servers]]
4250id = "srv-a"
4251command = "npx"
4252trust_level = "sandboxed"
4253tool_allowlist = ["read_file"]
4254
4255[[mcp.servers]]
4256id = "srv-b"
4257command = "npx"
4258"#;
4259 let result = migrate_mcp_trust_levels(src).expect("migrate");
4260 assert_eq!(
4262 result.changed_count, 1,
4263 "only entry without trust_level gets updated"
4264 );
4265 assert!(
4267 result.output.contains("trust_level = \"sandboxed\""),
4268 "existing trust_level must not be overwritten"
4269 );
4270 assert!(
4272 result.output.contains("trust_level = \"trusted\""),
4273 "entry without trust_level must get trusted"
4274 );
4275 }
4276
4277 #[test]
4278 fn migrate_mcp_trust_levels_no_mcp_section_is_noop() {
4279 let src = "[agent]\nname = \"Zeph\"\n";
4280 let result = migrate_mcp_trust_levels(src).expect("migrate");
4281 assert_eq!(result.changed_count, 0);
4282 assert!(result.sections_changed.is_empty());
4283 assert_eq!(result.output, src);
4284 }
4285
4286 #[test]
4287 fn migrate_mcp_trust_levels_no_servers_is_noop() {
4288 let src = "[mcp]\nallowed_commands = [\"npx\"]\n";
4289 let result = migrate_mcp_trust_levels(src).expect("migrate");
4290 assert_eq!(result.changed_count, 0);
4291 assert!(result.sections_changed.is_empty());
4292 assert_eq!(result.output, src);
4293 }
4294
4295 #[test]
4296 fn migrate_mcp_trust_levels_all_entries_already_have_field_is_noop() {
4297 let src = r#"
4298[[mcp.servers]]
4299id = "srv-a"
4300trust_level = "trusted"
4301
4302[[mcp.servers]]
4303id = "srv-b"
4304trust_level = "untrusted"
4305"#;
4306 let result = migrate_mcp_trust_levels(src).expect("migrate");
4307 assert_eq!(result.changed_count, 0);
4308 assert!(result.sections_changed.is_empty());
4309 }
4310
4311 #[test]
4312 fn migrate_database_url_adds_comment_when_absent() {
4313 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\n";
4314 let result = migrate_database_url(src).expect("migrate");
4315 assert_eq!(result.changed_count, 1);
4316 assert!(
4317 result
4318 .sections_changed
4319 .contains(&"memory.database_url".to_owned())
4320 );
4321 assert!(result.output.contains("# database_url = \"\""));
4322 }
4323
4324 #[test]
4325 fn migrate_database_url_is_noop_when_present() {
4326 let src = "[memory]\nsqlite_path = \"/tmp/zeph.db\"\ndatabase_url = \"postgres://localhost/zeph\"\n";
4327 let result = migrate_database_url(src).expect("migrate");
4328 assert_eq!(result.changed_count, 0);
4329 assert!(result.sections_changed.is_empty());
4330 assert_eq!(result.output, src);
4331 }
4332
4333 #[test]
4334 fn migrate_database_url_creates_memory_section_when_absent() {
4335 let src = "[agent]\nname = \"Zeph\"\n";
4336 let result = migrate_database_url(src).expect("migrate");
4337 assert_eq!(result.changed_count, 1);
4338 assert!(result.output.contains("# database_url = \"\""));
4339 }
4340
4341 #[test]
4344 fn migrate_agent_budget_hint_adds_comment_to_existing_agent_section() {
4345 let src = "[agent]\nname = \"Zeph\"\n";
4346 let result = migrate_agent_budget_hint(src).expect("migrate");
4347 assert_eq!(result.changed_count, 1);
4348 assert!(result.output.contains("budget_hint_enabled"));
4349 assert!(
4350 result
4351 .sections_changed
4352 .contains(&"agent.budget_hint_enabled".to_owned())
4353 );
4354 }
4355
4356 #[test]
4357 fn migrate_agent_budget_hint_no_agent_section_is_noop() {
4358 let src = "[llm]\nmodel = \"gpt-4o\"\n";
4359 let result = migrate_agent_budget_hint(src).expect("migrate");
4360 assert_eq!(result.changed_count, 0);
4361 assert_eq!(result.output, src);
4362 }
4363
4364 #[test]
4365 fn migrate_agent_budget_hint_already_present_is_noop() {
4366 let src = "[agent]\nname = \"Zeph\"\nbudget_hint_enabled = true\n";
4367 let result = migrate_agent_budget_hint(src).expect("migrate");
4368 assert_eq!(result.changed_count, 0);
4369 assert_eq!(result.output, src);
4370 }
4371
4372 #[test]
4373 fn migrate_telemetry_config_empty_config_appends_comment_block() {
4374 let src = "[agent]\nname = \"Zeph\"\n";
4375 let result = migrate_telemetry_config(src).expect("migrate");
4376 assert_eq!(result.changed_count, 1);
4377 assert_eq!(result.sections_changed, vec!["telemetry"]);
4378 assert!(
4379 result.output.contains("# [telemetry]"),
4380 "expected commented-out [telemetry] block in output"
4381 );
4382 assert!(
4383 result.output.contains("enabled = false"),
4384 "expected enabled = false in telemetry comment block"
4385 );
4386 }
4387
4388 #[test]
4389 fn migrate_telemetry_config_existing_section_is_noop() {
4390 let src = "[agent]\nname = \"Zeph\"\n\n[telemetry]\nenabled = true\n";
4391 let result = migrate_telemetry_config(src).expect("migrate");
4392 assert_eq!(result.changed_count, 0);
4393 assert_eq!(result.output, src);
4394 }
4395
4396 #[test]
4397 fn migrate_telemetry_config_existing_comment_is_noop() {
4398 let src = "[agent]\nname = \"Zeph\"\n\n# [telemetry]\n# enabled = false\n";
4400 let result = migrate_telemetry_config(src).expect("migrate");
4401 assert_eq!(result.changed_count, 0);
4402 assert_eq!(result.output, src);
4403 }
4404
4405 #[test]
4408 fn migrate_otel_filter_already_present_is_noop() {
4409 let src = "[telemetry]\nenabled = true\notel_filter = \"debug\"\n";
4411 let result = migrate_otel_filter(src).expect("migrate");
4412 assert_eq!(result.changed_count, 0);
4413 assert_eq!(result.output, src);
4414 }
4415
4416 #[test]
4417 fn migrate_otel_filter_commented_key_is_noop() {
4418 let src = "[telemetry]\nenabled = true\n# otel_filter = \"info\"\n";
4420 let result = migrate_otel_filter(src).expect("migrate");
4421 assert_eq!(result.changed_count, 0);
4422 assert_eq!(result.output, src);
4423 }
4424
4425 #[test]
4426 fn migrate_otel_filter_no_telemetry_section_is_noop() {
4427 let src = "[agent]\nname = \"Zeph\"\n";
4429 let result = migrate_otel_filter(src).expect("migrate");
4430 assert_eq!(result.changed_count, 0);
4431 assert_eq!(result.output, src);
4432 assert!(!result.output.contains("otel_filter"));
4433 }
4434
4435 #[test]
4436 fn migrate_otel_filter_injects_within_telemetry_section() {
4437 let src = "[telemetry]\nenabled = true\n\n[agent]\nname = \"Zeph\"\n";
4438 let result = migrate_otel_filter(src).expect("migrate");
4439 assert_eq!(result.changed_count, 1);
4440 assert_eq!(result.sections_changed, vec!["telemetry.otel_filter"]);
4441 assert!(
4442 result.output.contains("otel_filter"),
4443 "otel_filter comment must appear"
4444 );
4445 let otel_pos = result
4447 .output
4448 .find("otel_filter")
4449 .expect("otel_filter present");
4450 let agent_pos = result.output.find("[agent]").expect("[agent] present");
4451 assert!(
4452 otel_pos < agent_pos,
4453 "otel_filter comment should appear before [agent] section"
4454 );
4455 }
4456
4457 #[test]
4458 fn sandbox_migration_adds_commented_section_when_absent() {
4459 let src = "[agent]\nname = \"Z\"\n";
4460 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4461 assert_eq!(result.changed_count, 1);
4462 assert!(result.output.contains("# [tools.sandbox]"));
4463 assert!(result.output.contains("# profile = \"workspace\""));
4464 }
4465
4466 #[test]
4467 fn sandbox_migration_noop_when_section_present() {
4468 let src = "[tools.sandbox]\nenabled = true\n";
4469 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4470 assert_eq!(result.changed_count, 0);
4471 }
4472
4473 #[test]
4474 fn sandbox_migration_noop_when_dotted_key_present() {
4475 let src = "[tools]\nsandbox = { enabled = true }\n";
4476 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4477 assert_eq!(result.changed_count, 0);
4478 }
4479
4480 #[test]
4481 fn sandbox_migration_false_positive_comment_does_not_block() {
4482 let src = "# tools.sandbox was planned for #3070\n[agent]\nname = \"Z\"\n";
4484 let result = migrate_sandbox_config(src).expect("migrate sandbox");
4485 assert_eq!(result.changed_count, 1);
4486 }
4487
4488 #[test]
4489 fn embedded_default_mentions_tools_sandbox() {
4490 let default_src = include_str!("../../config/default.toml");
4491 assert!(
4492 default_src.contains("tools.sandbox"),
4493 "embedded default.toml must include tools.sandbox for ConfigMigrator discovery"
4494 );
4495 }
4496
4497 #[test]
4498 fn sandbox_migration_idempotent_on_own_output() {
4499 let base = "[agent]\nmodel = \"test\"\n";
4500 let first = migrate_sandbox_config(base).unwrap();
4501 assert_eq!(first.changed_count, 1);
4502 let second = migrate_sandbox_config(&first.output).unwrap();
4503 assert_eq!(second.changed_count, 0, "second run must not double-append");
4504 assert_eq!(second.output, first.output);
4505 }
4506
4507 #[test]
4508 fn migrate_agent_budget_hint_idempotent_on_commented_output() {
4509 let base = "[agent]\nname = \"Zeph\"\n";
4510 let first = migrate_agent_budget_hint(base).unwrap();
4511 assert_eq!(first.changed_count, 1);
4512 let second = migrate_agent_budget_hint(&first.output).unwrap();
4513 assert_eq!(second.changed_count, 0, "second run must not double-append");
4514 assert_eq!(second.output, first.output);
4515 }
4516
4517 #[test]
4518 fn migrate_forgetting_config_idempotent_on_commented_output() {
4519 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4520 let first = migrate_forgetting_config(base).unwrap();
4521 assert_eq!(first.changed_count, 1);
4522 let second = migrate_forgetting_config(&first.output).unwrap();
4523 assert_eq!(second.changed_count, 0, "second run must not double-append");
4524 assert_eq!(second.output, first.output);
4525 }
4526
4527 #[test]
4528 fn migrate_microcompact_config_idempotent_on_commented_output() {
4529 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4530 let first = migrate_microcompact_config(base).unwrap();
4531 assert_eq!(first.changed_count, 1);
4532 let second = migrate_microcompact_config(&first.output).unwrap();
4533 assert_eq!(second.changed_count, 0, "second run must not double-append");
4534 assert_eq!(second.output, first.output);
4535 }
4536
4537 #[test]
4538 fn migrate_autodream_config_idempotent_on_commented_output() {
4539 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4540 let first = migrate_autodream_config(base).unwrap();
4541 assert_eq!(first.changed_count, 1);
4542 let second = migrate_autodream_config(&first.output).unwrap();
4543 assert_eq!(second.changed_count, 0, "second run must not double-append");
4544 assert_eq!(second.output, first.output);
4545 }
4546
4547 #[test]
4548 fn migrate_compression_predictor_strips_active_section() {
4549 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\nmin_samples = 10\n[memory.other]\nfoo = 1\n";
4550 let result = migrate_compression_predictor_config(base).unwrap();
4551 assert!(!result.output.contains("[memory.compression.predictor]"));
4552 assert!(!result.output.contains("min_samples"));
4553 assert!(result.output.contains("[memory.other]"));
4554 assert_eq!(result.changed_count, 1);
4555 }
4556
4557 #[test]
4558 fn migrate_compression_predictor_strips_commented_section() {
4559 let base = "[memory]\ndb_path = \"test\"\n# [memory.compression.predictor]\n# enabled = false\n[memory.other]\nfoo = 1\n";
4560 let result = migrate_compression_predictor_config(base).unwrap();
4561 assert!(!result.output.contains("compression.predictor"));
4562 assert!(result.output.contains("[memory.other]"));
4563 }
4564
4565 #[test]
4566 fn migrate_compression_predictor_idempotent() {
4567 let base = "[memory]\ndb_path = \"test\"\n[memory.compression.predictor]\nenabled = false\n[memory.other]\nfoo = 1\n";
4568 let first = migrate_compression_predictor_config(base).unwrap();
4569 let second = migrate_compression_predictor_config(&first.output).unwrap();
4570 assert_eq!(second.output, first.output);
4571 assert_eq!(second.changed_count, 0);
4572 }
4573
4574 #[test]
4575 fn migrate_compression_predictor_noop_when_absent() {
4576 let base = "[memory]\ndb_path = \"test\"\n";
4577 let result = migrate_compression_predictor_config(base).unwrap();
4578 assert_eq!(result.output, base);
4579 assert_eq!(result.changed_count, 0);
4580 }
4581
4582 #[test]
4583 fn migrate_database_url_idempotent_on_commented_output() {
4584 let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n";
4585 let first = migrate_database_url(base).unwrap();
4586 assert_eq!(first.changed_count, 1);
4587 let second = migrate_database_url(&first.output).unwrap();
4588 assert_eq!(second.changed_count, 0, "second run must not double-append");
4589 assert_eq!(second.output, first.output);
4590 }
4591
4592 #[test]
4593 fn migrate_shell_transactional_idempotent_on_commented_output() {
4594 let base = "[tools]\n[tools.shell]\nallow_list = []\n";
4595 let first = migrate_shell_transactional(base).unwrap();
4596 assert_eq!(first.changed_count, 1);
4597 let second = migrate_shell_transactional(&first.output).unwrap();
4598 assert_eq!(second.changed_count, 0, "second run must not double-append");
4599 assert_eq!(second.output, first.output);
4600 }
4601
4602 #[test]
4603 fn migrate_otel_filter_idempotent_on_commented_output() {
4604 let base = "[telemetry]\nenabled = true\n";
4605 let first = migrate_otel_filter(base).unwrap();
4606 assert_eq!(first.changed_count, 1);
4607 let second = migrate_otel_filter(&first.output).unwrap();
4608 assert_eq!(second.changed_count, 0, "second run must not double-append");
4609 assert_eq!(second.output, first.output);
4610 }
4611
4612 #[test]
4613 fn config_migrator_does_not_suppress_duplicate_key_across_sections() {
4614 let migrator = ConfigMigrator::new();
4615 let src = "[telemetry]\nenabled = true\n\n[security]\n[security.content_isolation]\n";
4616 let result = migrator.migrate(src).expect("migrate");
4617 let sec_body_start = result
4618 .output
4619 .find("[security.content_isolation]")
4620 .unwrap_or(0);
4621 let sec_body = &result.output[sec_body_start..];
4622 let next_header = sec_body[1..].find("\n[").map_or(sec_body.len(), |p| p + 1);
4623 let sec_slice = &sec_body[..next_header];
4624 assert!(
4625 sec_slice.contains("# enabled"),
4626 "[security.content_isolation] body must contain `# enabled` hint; got: {sec_slice:?}"
4627 );
4628 }
4629
4630 #[test]
4631 fn config_migrator_idempotent_on_realistic_config() {
4632 let base = r#"
4633[agent]
4634name = "Zeph"
4635
4636[memory]
4637db_path = "~/.zeph/memory.db"
4638soft_compaction_threshold = 0.6
4639
4640[index]
4641max_chunks = 12
4642
4643[tools]
4644[tools.shell]
4645allow_list = []
4646
4647[telemetry]
4648enabled = false
4649
4650[security]
4651[security.content_isolation]
4652enabled = true
4653"#;
4654 let migrator = ConfigMigrator::new();
4655 let first = migrator.migrate(base).expect("first migrate");
4656 let second = migrator.migrate(&first.output).expect("second migrate");
4657 assert_eq!(
4658 second.changed_count, 0,
4659 "second run of ConfigMigrator::migrate must add 0 entries, got {}",
4660 second.changed_count
4661 );
4662 assert_eq!(
4663 first.output, second.output,
4664 "output must be identical on second run"
4665 );
4666 for line in first.output.lines() {
4667 if line.starts_with('[') && !line.starts_with("[[") {
4668 assert!(
4669 !line.contains('#'),
4670 "section header must not have inline comment: {line:?}"
4671 );
4672 }
4673 }
4674 }
4675
4676 #[test]
4677 fn migrate_claude_prompt_cache_ttl_1h_survives() {
4678 let src = r#"
4679[llm]
4680provider = "claude"
4681
4682[llm.cloud]
4683model = "claude-sonnet-4-6"
4684prompt_cache_ttl = "1h"
4685"#;
4686 let result = migrate_llm_to_providers(src).expect("migrate");
4687 assert!(
4688 result.output.contains("prompt_cache_ttl = \"1h\""),
4689 "1h TTL must be preserved in migrated output:\n{}",
4690 result.output
4691 );
4692 }
4693
4694 #[test]
4695 fn migrate_claude_prompt_cache_ttl_ephemeral_suppressed() {
4696 let src = r#"
4697[llm]
4698provider = "claude"
4699
4700[llm.cloud]
4701model = "claude-sonnet-4-6"
4702prompt_cache_ttl = "ephemeral"
4703"#;
4704 let result = migrate_llm_to_providers(src).expect("migrate");
4705 assert!(
4706 !result.output.contains("prompt_cache_ttl"),
4707 "ephemeral TTL must be suppressed (M2 idempotency guard):\n{}",
4708 result.output
4709 );
4710 }
4711
4712 #[test]
4713 fn migrate_claude_prompt_cache_ttl_1h_idempotent() {
4714 let src = r#"
4715[[llm.providers]]
4716type = "claude"
4717model = "claude-sonnet-4-6"
4718prompt_cache_ttl = "1h"
4719"#;
4720 let migrator = ConfigMigrator::new();
4721 let first = migrator.migrate(src).expect("first migrate");
4722 let second = migrator.migrate(&first.output).expect("second migrate");
4723 assert_eq!(
4724 first.output, second.output,
4725 "migration must be idempotent when prompt_cache_ttl = \"1h\" already present"
4726 );
4727 }
4728
4729 #[test]
4732 fn migrate_session_recap_adds_block_when_absent() {
4733 let src = "[agent]\nname = \"Zeph\"\n";
4734 let result = migrate_session_recap_config(src).expect("migrate");
4735 assert_eq!(result.changed_count, 1);
4736 assert!(
4737 result
4738 .sections_changed
4739 .contains(&"session.recap".to_owned())
4740 );
4741 assert!(result.output.contains("# [session.recap]"));
4742 assert!(result.output.contains("on_resume = true"));
4743 }
4744
4745 #[test]
4746 fn migrate_session_recap_idempotent_on_commented_block() {
4747 let src = "[agent]\nname = \"Zeph\"\n# [session.recap]\n# on_resume = true\n";
4748 let result = migrate_session_recap_config(src).expect("migrate");
4749 assert_eq!(result.changed_count, 0);
4750 assert_eq!(result.output, src);
4751 }
4752
4753 #[test]
4754 fn migrate_session_recap_idempotent_on_active_section() {
4755 let src = "[agent]\nname = \"Zeph\"\n[session.recap]\non_resume = false\n";
4756 let result = migrate_session_recap_config(src).expect("migrate");
4757 assert_eq!(result.changed_count, 0);
4758 assert_eq!(result.output, src);
4759 }
4760
4761 #[test]
4764 fn migrate_mcp_elicitation_adds_keys_when_absent() {
4765 let src = "[mcp]\nallowed_commands = []\n";
4766 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4767 assert_eq!(result.changed_count, 1);
4768 assert!(
4769 result
4770 .sections_changed
4771 .contains(&"mcp.elicitation".to_owned())
4772 );
4773 assert!(result.output.contains("# elicitation_enabled = false"));
4774 assert!(result.output.contains("# elicitation_timeout = 120"));
4775 }
4776
4777 #[test]
4778 fn migrate_mcp_elicitation_idempotent_when_key_present() {
4779 let src = "[mcp]\nelicitation_enabled = true\n";
4780 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4781 assert_eq!(result.changed_count, 0);
4782 assert_eq!(result.output, src);
4783 }
4784
4785 #[test]
4786 fn migrate_mcp_elicitation_skips_when_no_mcp_section() {
4787 let src = "[agent]\nname = \"Zeph\"\n";
4788 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4789 assert_eq!(result.changed_count, 0);
4790 assert_eq!(result.output, src);
4791 }
4792
4793 #[test]
4794 fn migrate_mcp_elicitation_skips_without_trailing_newline() {
4795 let src = "[mcp]";
4797 let result = migrate_mcp_elicitation_config(src).expect("migrate");
4798 assert_eq!(result.changed_count, 0);
4799 assert_eq!(result.output, src);
4800 }
4801
4802 #[test]
4805 fn migrate_quality_adds_block_when_absent() {
4806 let src = "[agent]\nname = \"Zeph\"\n";
4807 let result = migrate_quality_config(src).expect("migrate");
4808 assert_eq!(result.changed_count, 1);
4809 assert!(result.sections_changed.contains(&"quality".to_owned()));
4810 assert!(result.output.contains("# [quality]"));
4811 assert!(result.output.contains("self_check = false"));
4812 assert!(result.output.contains("trigger = \"has_retrieval\""));
4813 }
4814
4815 #[test]
4816 fn migrate_quality_idempotent_on_commented_block() {
4817 let src = "[agent]\nname = \"Zeph\"\n# [quality]\n# self_check = false\n";
4818 let result = migrate_quality_config(src).expect("migrate");
4819 assert_eq!(result.changed_count, 0);
4820 assert_eq!(result.output, src);
4821 }
4822
4823 #[test]
4824 fn migrate_quality_idempotent_on_active_section() {
4825 let src = "[agent]\nname = \"Zeph\"\n[quality]\nself_check = true\n";
4826 let result = migrate_quality_config(src).expect("migrate");
4827 assert_eq!(result.changed_count, 0);
4828 assert_eq!(result.output, src);
4829 }
4830
4831 #[test]
4834 fn migrate_acp_subagents_adds_block_when_absent() {
4835 let src = "[agent]\nname = \"Zeph\"\n";
4836 let result = migrate_acp_subagents_config(src).expect("migrate");
4837 assert_eq!(result.changed_count, 1);
4838 assert!(
4839 result
4840 .sections_changed
4841 .contains(&"acp.subagents".to_owned())
4842 );
4843 assert!(result.output.contains("# [acp.subagents]"));
4844 assert!(result.output.contains("enabled = false"));
4845 }
4846
4847 #[test]
4848 fn migrate_acp_subagents_idempotent_on_existing_block() {
4849 let src = "[agent]\nname = \"Zeph\"\n# [acp.subagents]\n# enabled = false\n";
4850 let result = migrate_acp_subagents_config(src).expect("migrate");
4851 assert_eq!(result.changed_count, 0);
4852 assert_eq!(result.output, src);
4853 }
4854
4855 #[test]
4858 fn migrate_hooks_permission_denied_adds_block_when_absent() {
4859 let src = "[agent]\nname = \"Zeph\"\n";
4860 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4861 assert_eq!(result.changed_count, 1);
4862 assert!(
4863 result
4864 .sections_changed
4865 .contains(&"hooks.permission_denied".to_owned())
4866 );
4867 assert!(result.output.contains("# [[hooks.permission_denied]]"));
4868 assert!(result.output.contains("ZEPH_TOOL"));
4869 }
4870
4871 #[test]
4872 fn migrate_hooks_permission_denied_idempotent_on_existing_block() {
4873 let src = "[agent]\nname = \"Zeph\"\n# [[hooks.permission_denied]]\n# type = \"command\"\n";
4874 let result = migrate_hooks_permission_denied_config(src).expect("migrate");
4875 assert_eq!(result.changed_count, 0);
4876 assert_eq!(result.output, src);
4877 }
4878
4879 #[test]
4882 fn migrate_memory_graph_adds_block_when_absent() {
4883 let src = "[agent]\nname = \"Zeph\"\n";
4884 let result = migrate_memory_graph_config(src).expect("migrate");
4885 assert_eq!(result.changed_count, 1);
4886 assert!(
4887 result
4888 .sections_changed
4889 .contains(&"memory.graph.retrieval".to_owned())
4890 );
4891 assert!(result.output.contains("retrieval_strategy"));
4892 assert!(result.output.contains("# [memory.graph.beam_search]"));
4893 }
4894
4895 #[test]
4896 fn migrate_memory_graph_idempotent_on_existing_block() {
4897 let src = "[agent]\nname = \"Zeph\"\n# [memory.graph.beam_search]\n# beam_width = 10\n";
4898 let result = migrate_memory_graph_config(src).expect("migrate");
4899 assert_eq!(result.changed_count, 0);
4900 assert_eq!(result.output, src);
4901 }
4902
4903 #[test]
4906 fn migrate_scheduler_daemon_adds_block_when_absent() {
4907 let src = "[agent]\nname = \"Zeph\"\n";
4908 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4909 assert_eq!(result.changed_count, 1);
4910 assert!(
4911 result
4912 .sections_changed
4913 .contains(&"scheduler.daemon".to_owned())
4914 );
4915 assert!(result.output.contains("# [scheduler.daemon]"));
4916 assert!(result.output.contains("pid_file"));
4917 assert!(result.output.contains("tick_secs = 60"));
4918 assert!(result.output.contains("shutdown_grace_secs = 30"));
4919 assert!(result.output.contains("catch_up = true"));
4920 }
4921
4922 #[test]
4923 fn migrate_scheduler_daemon_idempotent_on_existing_block() {
4924 let src = "[agent]\nname = \"Zeph\"\n# [scheduler.daemon]\n# tick_secs = 60\n";
4925 let result = migrate_scheduler_daemon_config(src).expect("migrate");
4926 assert_eq!(result.changed_count, 0);
4927 assert_eq!(result.output, src);
4928 }
4929
4930 #[test]
4933 fn migrate_memory_retrieval_adds_block_when_absent() {
4934 let src = "[agent]\nname = \"Zeph\"\n";
4935 let result = migrate_memory_retrieval_config(src).expect("migrate");
4936 assert_eq!(result.changed_count, 1);
4937 assert!(
4938 result
4939 .sections_changed
4940 .contains(&"memory.retrieval".to_owned())
4941 );
4942 assert!(result.output.contains("# [memory.retrieval]"));
4943 assert!(result.output.contains("depth = 0"));
4944 assert!(result.output.contains("context_format"));
4945 }
4946
4947 #[test]
4948 fn migrate_memory_retrieval_idempotent_on_active_section() {
4949 let src = "[memory.retrieval]\ndepth = 40\n";
4950 let result = migrate_memory_retrieval_config(src).expect("migrate");
4951 assert_eq!(result.changed_count, 0);
4952 assert_eq!(result.output, src);
4953 }
4954
4955 #[test]
4956 fn migrate_memory_retrieval_idempotent_on_commented_section() {
4957 let src = "[agent]\nname = \"Zeph\"\n# [memory.retrieval]\n# depth = 0\n";
4958 let result = migrate_memory_retrieval_config(src).expect("migrate");
4959 assert_eq!(result.changed_count, 0);
4960 assert_eq!(result.output, src);
4961 }
4962
4963 #[test]
4966 fn migrate_adds_pr4_acp_keys_commented() {
4967 let migrator = ConfigMigrator::new();
4968 let input = include_str!("../../tests/fixtures/acp_pr4_v0_19.toml");
4969 let out = migrator.migrate(input).expect("migrate");
4970 assert!(
4971 out.output.contains("# additional_directories = []"),
4972 "expected commented additional_directories; got:\n{}",
4973 out.output
4974 );
4975 assert!(
4976 out.output.contains("# auth_methods = [\"agent\"]"),
4977 "expected commented auth_methods; got:\n{}",
4978 out.output
4979 );
4980 assert!(
4981 out.output.contains("# message_ids_enabled = true"),
4982 "expected commented message_ids_enabled; got:\n{}",
4983 out.output
4984 );
4985 }
4986
4987 #[test]
4990 fn migrate_memory_reasoning_adds_block_when_absent() {
4991 let input = "[agent]\nmodel = \"gpt-4o\"\n";
4992 let result = migrate_memory_reasoning_config(input).unwrap();
4993 assert_eq!(result.changed_count, 1);
4994 assert!(
4995 result
4996 .sections_changed
4997 .contains(&"memory.reasoning".to_owned())
4998 );
4999 assert!(result.output.contains("# [memory.reasoning]"));
5000 assert!(result.output.contains("extraction_timeout_secs = 30"));
5001 assert!(result.output.contains("max_message_chars = 2000"));
5002 }
5003
5004 #[test]
5005 fn migrate_memory_reasoning_idempotent_on_existing_block() {
5006 let input = "[agent]\nmodel = \"gpt-4o\"\n# [memory.reasoning]\n# enabled = false\n";
5007 let result = migrate_memory_reasoning_config(input).unwrap();
5008 assert_eq!(result.changed_count, 0);
5009 assert!(result.sections_changed.is_empty());
5010 assert_eq!(result.output, input);
5011 }
5012
5013 #[test]
5016 fn migrate_hooks_turn_complete_adds_block_when_absent() {
5017 let input = "[agent]\nmodel = \"gpt-4o\"\n";
5018 let result = migrate_hooks_turn_complete_config(input).unwrap();
5019 assert_eq!(result.changed_count, 1);
5020 assert!(
5021 result
5022 .sections_changed
5023 .contains(&"hooks.turn_complete".to_owned())
5024 );
5025 assert!(result.output.contains("# [[hooks.turn_complete]]"));
5026 assert!(result.output.contains("ZEPH_TURN_PREVIEW"));
5027 assert!(result.output.contains("timeout_secs = 3"));
5028 }
5029
5030 #[test]
5031 fn migrate_hooks_turn_complete_idempotent_on_existing_block() {
5032 let input =
5033 "[agent]\nmodel = \"gpt-4o\"\n# [[hooks.turn_complete]]\n# command = \"echo done\"\n";
5034 let result = migrate_hooks_turn_complete_config(input).unwrap();
5035 assert_eq!(result.changed_count, 0);
5036 assert!(result.sections_changed.is_empty());
5037 assert_eq!(result.output, input);
5038 }
5039
5040 #[test]
5044 fn migrate_focus_auto_consolidate_injects_inside_section() {
5045 let input = "[agent.focus]\nenabled = true\n\n[other]\nfoo = 1\n";
5046 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5047 assert_eq!(result.changed_count, 1);
5048 let comment_pos = result
5049 .output
5050 .find("auto_consolidate_min_window")
5051 .expect("comment must be present");
5052 let other_pos = result
5053 .output
5054 .find("[other]")
5055 .expect("[other] must be present");
5056 assert!(
5057 comment_pos < other_pos,
5058 "auto_consolidate_min_window comment must appear before [other] section"
5059 );
5060 }
5061
5062 #[test]
5063 fn migrate_focus_auto_consolidate_idempotent() {
5064 let input = "[agent.focus]\nenabled = true\nauto_consolidate_min_window = 6\n";
5065 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5066 assert_eq!(result.changed_count, 0);
5067 assert_eq!(result.output, input);
5068 }
5069
5070 #[test]
5071 fn migrate_focus_auto_consolidate_noop_when_section_absent() {
5072 let input = "[agent]\nname = \"zeph\"\n";
5073 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5074 assert_eq!(result.changed_count, 0);
5075 assert_eq!(result.output, input);
5076 }
5077
5078 #[test]
5079 fn migrate_focus_auto_consolidate_noop_when_only_commented_section() {
5080 let input = "[agent]\n# [agent.focus]\n# enabled = false\n";
5081 let result = migrate_focus_auto_consolidate_min_window(input).unwrap();
5082 assert_eq!(result.changed_count, 0);
5083 assert_eq!(result.output, input);
5084 }
5085
5086 #[test]
5089 fn registry_has_forty_six_entries() {
5090 assert_eq!(MIGRATIONS.len(), 46);
5091 }
5092
5093 #[test]
5094 fn registry_names_are_unique_and_non_empty() {
5095 let names: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5096 for name in &names {
5097 assert!(!name.is_empty(), "migration name must not be empty");
5098 }
5099 let mut deduped = names.clone();
5100 deduped.sort_unstable();
5101 deduped.dedup();
5102 assert_eq!(deduped.len(), names.len(), "migration names must be unique");
5103 }
5104
5105 #[test]
5106 fn registry_is_idempotent_on_empty_input() {
5107 const COMMENT_ONLY: &[&str] = &["migrate_magic_docs_config"];
5110
5111 let mut toml = String::new();
5112 for m in MIGRATIONS.iter() {
5113 let result = m.apply(&toml).expect("registry migration must not fail");
5114 toml = result.output;
5115 }
5116 for m in MIGRATIONS.iter() {
5117 if COMMENT_ONLY.contains(&m.name()) {
5118 continue;
5119 }
5120 let result = m
5121 .apply(&toml)
5122 .expect("registry migration must not fail on second pass");
5123 assert_eq!(result.changed_count, 0, "{} is not idempotent", m.name());
5124 }
5125 }
5126
5127 #[test]
5128 fn registry_preserves_order_matches_dispatch() {
5129 let expected = [
5131 "migrate_stt_to_provider",
5132 "migrate_planner_model_to_provider",
5133 "migrate_mcp_trust_levels",
5134 "migrate_agent_retry_to_tools_retry",
5135 "migrate_database_url",
5136 "migrate_shell_transactional",
5137 "migrate_agent_budget_hint",
5138 "migrate_forgetting_config",
5139 "migrate_compression_predictor_config",
5140 "migrate_microcompact_config",
5141 "migrate_autodream_config",
5142 "migrate_magic_docs_config",
5143 "migrate_telemetry_config",
5144 "migrate_supervisor_config",
5145 "migrate_otel_filter",
5146 "migrate_egress_config",
5147 "migrate_vigil_config",
5148 "migrate_sandbox_config",
5149 "migrate_sandbox_egress_filter",
5150 "migrate_orchestration_persistence",
5151 "migrate_session_recap_config",
5152 "migrate_mcp_elicitation_config",
5153 "migrate_quality_config",
5154 "migrate_acp_subagents_config",
5155 "migrate_hooks_permission_denied_config",
5156 "migrate_memory_graph_config",
5157 "migrate_scheduler_daemon_config",
5158 "migrate_memory_retrieval_config",
5159 "migrate_memory_reasoning_config",
5160 "migrate_memory_reasoning_judge_config",
5161 "migrate_memory_hebbian_config",
5162 "migrate_memory_hebbian_consolidation_config",
5163 "migrate_memory_hebbian_spread_config",
5164 "migrate_hooks_turn_complete_config",
5165 "migrate_focus_auto_consolidate_min_window",
5166 "migrate_session_provider_persistence",
5167 "migrate_memory_retrieval_query_bias",
5168 "migrate_memory_persona_config",
5169 "migrate_qdrant_api_key",
5170 "migrate_mcp_max_connect_attempts",
5171 "migrate_goals_config",
5172 "migrate_tools_compression_config",
5173 "migrate_orchestrator_provider",
5174 "migrate_provider_max_concurrent",
5175 "migrate_gonkagate_to_gonka",
5176 "migrate_cocoon_provider_notice",
5177 ];
5178 let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect();
5179 assert_eq!(actual, expected);
5180 }
5181
5182 #[test]
5185 fn migrate_qdrant_api_key_adds_comment_when_absent() {
5186 let src = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5187 let result = migrate_qdrant_api_key(src).expect("migrate");
5188 assert_eq!(result.changed_count, 1);
5189 assert!(
5190 result
5191 .sections_changed
5192 .contains(&"memory.qdrant_api_key".to_owned())
5193 );
5194 assert!(result.output.contains("# qdrant_api_key = \"\""));
5195 }
5196
5197 #[test]
5198 fn migrate_qdrant_api_key_is_noop_when_present() {
5199 let src =
5200 "[memory]\nqdrant_url = \"https://xyz.cloud.qdrant.io\"\nqdrant_api_key = \"secret\"\n";
5201 let result = migrate_qdrant_api_key(src).expect("migrate");
5202 assert_eq!(result.changed_count, 0);
5203 assert!(result.sections_changed.is_empty());
5204 assert_eq!(result.output, src);
5205 }
5206
5207 #[test]
5208 fn migrate_qdrant_api_key_creates_memory_section_when_absent() {
5209 let src = "[agent]\nname = \"Zeph\"\n";
5210 let result = migrate_qdrant_api_key(src).expect("migrate");
5211 assert_eq!(result.changed_count, 1);
5212 assert!(result.output.contains("# qdrant_api_key = \"\""));
5213 }
5214
5215 #[test]
5216 fn migrate_qdrant_api_key_idempotent_on_commented_output() {
5217 let base = "[memory]\nqdrant_url = \"http://localhost:6334\"\n";
5218 let first = migrate_qdrant_api_key(base).unwrap();
5219 assert_eq!(first.changed_count, 1);
5220 let second = migrate_qdrant_api_key(&first.output).unwrap();
5221 assert_eq!(second.changed_count, 0, "second run must not double-append");
5222 assert_eq!(second.output, first.output);
5223 }
5224
5225 #[test]
5226 fn migrate_mcp_max_connect_attempts_adds_comment_when_absent() {
5227 let src = "[mcp]\nallowed_commands = []\n";
5228 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5229 assert_eq!(result.changed_count, 1);
5230 assert!(
5231 result.output.contains("max_connect_attempts"),
5232 "output must mention max_connect_attempts"
5233 );
5234 }
5235
5236 #[test]
5237 fn migrate_mcp_max_connect_attempts_idempotent_when_present() {
5238 let src = "[mcp]\n# max_connect_attempts = 3\nallowed_commands = []\n";
5239 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5240 assert_eq!(
5241 result.changed_count, 0,
5242 "must not modify already-present key"
5243 );
5244 assert_eq!(result.output, src);
5245 }
5246
5247 #[test]
5248 fn migrate_mcp_max_connect_attempts_skips_when_no_mcp_section() {
5249 let src = "[agent]\nname = \"Zeph\"\n";
5250 let result = migrate_mcp_max_connect_attempts(src).expect("migrate");
5251 assert_eq!(result.changed_count, 0);
5252 assert_eq!(result.output, src);
5253 }
5254
5255 #[test]
5258 fn step43_adds_orchestrator_provider_comment_when_absent() {
5259 let src = "[orchestration]\nenabled = true\n";
5260 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5261 assert_eq!(result.changed_count, 1);
5262 assert!(
5263 result.output.contains("orchestrator_provider"),
5264 "migration must inject orchestrator_provider hint"
5265 );
5266 }
5267
5268 #[test]
5269 fn step43_noop_when_orchestrator_provider_already_present() {
5270 let src = "[orchestration]\nenabled = true\norchestrator_provider = \"\"\n";
5271 let result = migrate_orchestration_orchestrator_provider(src).expect("migrate");
5272 assert_eq!(
5273 result.changed_count, 0,
5274 "must not modify already-present key"
5275 );
5276 assert_eq!(result.output, src);
5277 }
5278
5279 #[test]
5282 fn step44_adds_max_concurrent_comment_when_providers_present() {
5283 let src = "[[llm.providers]]\nname = \"quality\"\ntype = \"openai\"\n";
5284 let result = migrate_provider_max_concurrent(src).expect("migrate");
5285 assert_eq!(result.changed_count, 1);
5286 assert!(
5287 result.output.contains("max_concurrent"),
5288 "migration must inject max_concurrent hint"
5289 );
5290 }
5291
5292 #[test]
5293 fn step44_noop_when_max_concurrent_already_present() {
5294 let src = "[[llm.providers]]\nname = \"quality\"\nmax_concurrent = 4\n";
5295 let result = migrate_provider_max_concurrent(src).expect("migrate");
5296 assert_eq!(
5297 result.changed_count, 0,
5298 "must not modify already-present key"
5299 );
5300 assert_eq!(result.output, src);
5301 }
5302
5303 #[test]
5304 fn step44_noop_when_no_providers_section() {
5305 let src = "[agent]\nname = \"Zeph\"\n";
5306 let result = migrate_provider_max_concurrent(src).expect("migrate");
5307 assert_eq!(result.changed_count, 0);
5308 assert_eq!(result.output, src);
5309 }
5310
5311 #[test]
5314 fn step45_adds_advisory_comment_when_gonkagate_present() {
5315 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5316 let result = migrate_gonkagate_to_gonka(src);
5317 assert!(result.changed_count > 0, "must detect gonkagate entry");
5318 assert!(
5319 result.output.contains("[migration] GonkaGate detected"),
5320 "advisory comment must be added"
5321 );
5322 let comment_pos = result
5324 .output
5325 .find("[migration] GonkaGate detected")
5326 .unwrap();
5327 let header_pos = result.output.find("[[llm.providers]]").unwrap();
5328 assert!(
5329 comment_pos < header_pos,
5330 "advisory comment must precede the [[llm.providers]] header"
5331 );
5332 }
5333
5334 #[test]
5335 fn step45_noop_when_no_gonkagate() {
5336 let src = "[[llm.providers]]\ntype = \"openai\"\nname = \"quality\"\n";
5337 let result = migrate_gonkagate_to_gonka(src);
5338 assert_eq!(result.changed_count, 0);
5339 assert_eq!(result.output, src);
5340 }
5341
5342 #[test]
5343 fn step45_does_not_double_insert_comment() {
5344 let src = "[[llm.providers]]\ntype = \"compatible\"\nname = \"gonkagate\"\n";
5345 let first = migrate_gonkagate_to_gonka(src);
5346 let second = migrate_gonkagate_to_gonka(&first.output);
5347 assert_eq!(second.changed_count, 0, "idempotent on second run");
5349 }
5350
5351 #[test]
5354 fn migrate_cocoon_noop_empty_config() {
5355 let src = "";
5356 let result = migrate_cocoon_provider_notice(src).unwrap();
5357 assert_eq!(result.changed_count, 0);
5358 assert_eq!(result.output, src);
5359 }
5360
5361 #[test]
5362 fn migrate_cocoon_noop_existing_config() {
5363 let src = "[agent]\nname = \"zeph\"\n\n[[llm.providers]]\ntype = \"ollama\"\n";
5364 let result = migrate_cocoon_provider_notice(src).unwrap();
5365 assert_eq!(result.changed_count, 0);
5366 assert_eq!(result.output, src);
5367 }
5368
5369 #[test]
5370 fn migrate_cocoon_idempotent() {
5371 let src = "[[llm.providers]]\ntype = \"cocoon\"\nname = \"tee\"\n";
5372 let first = migrate_cocoon_provider_notice(src).unwrap();
5373 let second = migrate_cocoon_provider_notice(&first.output).unwrap();
5374 assert_eq!(second.output, first.output);
5375 assert_eq!(second.changed_count, 0);
5376 }
5377}