1use std::sync::Arc;
6
7
8use crate::backend::rpc::engine::WshRpcEngine;
9use crate::backend::rpc_types::{
10 COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
11 CommandAgentDefCreateFromTemplateData, AgentDefCreateFromTemplateResult,
12 COMMAND_AGENT_DEF_HIDE, COMMAND_AGENT_DEF_UNHIDE,
15 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
16 CommandAgentDefHideData, AgentDefHideResult,
17 COMMAND_FORK_AGENT_DEFINITION,
18 COMMAND_FORK_AGENT_DEFINITION_SUGGEST,
19 CommandForkAgentDefinitionSuggestData, ForkAgentDefinitionSuggestResult,
20 CommandForkAgentDefinitionData, CommandListHiddenTemplatesData,
21 COMMAND_RENAME_AGENT_DEFINITION_TITLE,
22 CommandRenameAgentDefinitionTitleData,
23};
24use crate::backend::storage::{AgentDefinition, AgentContent, AgentSkill};
25
26use super::super::AppState;
27
28fn fork_lineage_root_id(defs: &[AgentDefinition], start_id: &str) -> String {
46 let mut current = start_id.to_string();
47 loop {
48 match defs.iter().find(|a| a.id == current) {
49 Some(def) if !def.branch_label.is_empty() && !def.parent_id.is_empty() => {
50 current = def.parent_id.clone();
51 }
52 _ => return current,
53 }
54 }
55}
56
57fn fork_lineage_root<'a>(defs: &'a [AgentDefinition], source: &'a AgentDefinition) -> &'a AgentDefinition {
60 let root_id = fork_lineage_root_id(defs, &source.id);
61 defs.iter().find(|a| a.id == root_id).unwrap_or(source)
62}
63
64fn name_taken(defs: &[AgentDefinition], name: &str) -> bool {
70 defs.iter().any(|a| a.is_seeded == 0 && (a.name.eq_ignore_ascii_case(name) || a.branch_label.eq_ignore_ascii_case(name)))
71}
72
73fn name_in_use(store: &crate::backend::storage::store::Store, defs: &[AgentDefinition], name: &str) -> bool {
77 name_taken(defs, name) || store.agents_matching_name(name).map(|m| !m.is_empty()).unwrap_or(false)
78}
79
80fn suggest_fork_name(
97 defs: &[AgentDefinition],
98 retired: &[String],
99 root_former: &[String],
100 source: &AgentDefinition,
101 taken: impl Fn(&str) -> bool,
102) -> String {
103 let root = fork_lineage_root(defs, source);
104 let prefix = format!("{} #", root.name);
105 let suffix = |name: &str| name.rsplit_once(" #")?.1.trim().parse::<u32>().ok();
106 let live = defs
107 .iter()
108 .filter(|a| a.is_seeded == 0 && !a.branch_label.is_empty() && fork_lineage_root_id(defs, &a.id) == root.id)
109 .filter_map(|a| suffix(&a.name));
110 let retired_prefixes: Vec<String> =
111 std::iter::once(&root.name).chain(root_former).map(|n| format!("{n} #").to_lowercase()).collect();
112 let deleted = retired.iter().filter_map(|n| {
113 let n = n.to_lowercase();
114 retired_prefixes.iter().find_map(|p| n.strip_prefix(p.as_str())?.trim().parse::<u32>().ok())
115 });
116 let highest = live.chain(deleted).max().unwrap_or(1);
117 let mut n = highest.max(1) + 1;
118 while taken(&format!("{prefix}{n}")) {
119 n += 1;
120 }
121 format!("{prefix}{n}")
122}
123
124pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
125 let mstore_act = state.mstore.clone();
139 let id_store_act = state.id_store.clone();
140 let broker_act = state.broker.clone();
141 engine.register_typed(
142 COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
143 move |cmd: CommandAgentDefCreateFromTemplateData, _ctx| {
144 let mstore = mstore_act.clone();
145 let id_store = id_store_act.clone();
146 let broker = broker_act.clone();
147 async move {
148 let name = cmd.name.trim().to_string();
149 if name.is_empty() {
150 return Err("agentdefcreatefromtemplate: name must be non-empty".into());
151 }
152 if name.chars().count() > 200 {
153 return Err(
154 "agentdefcreatefromtemplate: name must be ≤200 characters".into(),
155 );
156 }
157
158 let all = mstore
159 .agent_def_list()
160 .map_err(|e| format!("agentdefcreatefromtemplate: list: {e}"))?;
161 let template = all
162 .iter()
163 .find(|a| a.id == cmd.template_id)
164 .ok_or_else(|| {
165 format!(
166 "agentdefcreatefromtemplate: template {} not found",
167 cmd.template_id
168 )
169 })?;
170 if template.is_seeded != 1 {
171 return Err(format!(
172 "agentdefcreatefromtemplate: {} is not a seeded template (is_seeded={})",
173 cmd.template_id, template.is_seeded
174 ));
175 }
176 if name_taken(&all, &name) {
177 return Err(format!(
178 "agentdefcreatefromtemplate: an agent named {:?} already exists",
179 name
180 ));
181 }
182
183 let now = agentmux_common::time::now_ms();
184 let chosen_agent_type = match cmd.agent_type.as_deref().unwrap_or("").trim() {
189 "host" | "container" => cmd.agent_type.as_deref().unwrap_or("").trim().to_string(),
190 _ => template.agent_type.clone(),
191 };
192 let chosen_environment = if chosen_agent_type == "container" {
193 "docker".to_string()
194 } else {
195 "local".to_string()
196 };
197 let effective_provider = id_store.resolve_effective_provider_id(template);
202 let chosen_model_vendor_base_url = match &cmd.model_vendor_base_url {
210 Some(url) => {
211 crate::server::app_api::validate_vendor_base_url(&effective_provider, url)
212 .map_err(|e| format!("agentdefcreatefromtemplate: {e}"))?;
213 url.clone()
214 }
215 None => template.model_vendor_base_url.clone(),
216 };
217 let mut new_def = AgentDefinition {
218 id: uuid::Uuid::new_v4().to_string(),
219 slug: String::new(),
222 name: name.clone(),
223 icon: template.icon.clone(),
224 provider: effective_provider.clone(),
225 description: template.description.clone(),
226 working_directory: String::new(),
230 shell: template.shell.clone(),
231 provider_flags: template.provider_flags.clone(),
232 auto_start: 0,
235 restart_on_crash: template.restart_on_crash,
236 idle_timeout_minutes: template.idle_timeout_minutes,
237 created_at: now,
238 agent_type: chosen_agent_type,
239 environment: chosen_environment,
240 agent_bus_id: String::new(),
241 is_seeded: 0,
242 accounts: String::new(),
243 parent_id: template.id.clone(),
244 branch_label: String::new(),
245 updated_at: now,
246 user_hidden: 0,
250 container_image: template.container_image.clone(),
253 container_volumes: template.container_volumes.clone(),
254 container_name: String::new(),
255 use_ambient_login: 0,
256 model_vendor_base_url: chosen_model_vendor_base_url,
257 auto_continue_enabled: 0,
258 memory_id: String::new(),
259 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
264 };
265 mstore
266 .agent_def_insert(&mut new_def)
267 .map_err(|e| format!("agentdefcreatefromtemplate: insert: {e}"))?;
268 mstore.agent_def_provision_and_bind_bundle(&id_store, &mut new_def, now);
272 if let Some(bundle_ids) = cmd.bundle_ids.as_deref().filter(|ids| !ids.is_empty()) {
274 mstore
275 .agent_bundles_set(&new_def.id, bundle_ids)
276 .map_err(|e| format!("agentdefcreatefromtemplate: bundles: {e}"))?;
277 }
278
279 broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
280
281 let resp = AgentDefCreateFromTemplateResult {
282 definition_id: new_def.id.clone(),
283 identity_id: cmd.identity_id.unwrap_or_default(),
284 memory_id: cmd.memory_id.unwrap_or_default(),
285 slug: new_def.slug.clone(),
286 };
287 tracing::info!(
288 template_id = %cmd.template_id,
289 new_definition_id = %new_def.id,
290 new_name = %new_def.name,
291 "agentdefcreatefromtemplate: cloned template into user agent"
292 );
293 Ok(resp)
294 }
295 },
296 );
297
298 let mstore_hide = state.mstore.clone();
311 let broker_hide = state.broker.clone();
312 engine.register_typed(
319 COMMAND_AGENT_DEF_HIDE,
320 move |cmd: CommandAgentDefHideData, _ctx| {
321 let mstore = mstore_hide.clone();
322 let broker = broker_hide.clone();
323 async move {
324 let ok = mstore
325 .agent_def_set_hidden(&cmd.definition_id, true)
326 .map_err(|e| format!("agentdefhide: {e}"))?;
327 if ok {
328 broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
329 tracing::info!(
330 definition_id = %cmd.definition_id,
331 "agentdefhide: hid template"
332 );
333 }
334 Ok(AgentDefHideResult { ok })
335 }
336 },
337 );
338
339 let mstore_unhide = state.mstore.clone();
343 let broker_unhide = state.broker.clone();
344 engine.register_typed(
345 COMMAND_AGENT_DEF_UNHIDE,
346 move |cmd: CommandAgentDefHideData, _ctx| {
347 let mstore = mstore_unhide.clone();
348 let broker = broker_unhide.clone();
349 async move {
350 let ok = mstore
351 .agent_def_set_hidden(&cmd.definition_id, false)
352 .map_err(|e| format!("agentdefunhide: {e}"))?;
353 if ok {
354 broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
355 tracing::info!(
356 definition_id = %cmd.definition_id,
357 "agentdefunhide: unhid template"
358 );
359 }
360 Ok(AgentDefHideResult { ok })
361 }
362 },
363 );
364
365 let mstore_lh = state.mstore.clone();
370 engine.register_typed(
371 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
372 move |_req: Option<CommandListHiddenTemplatesData>, _ctx| {
376 let mstore = mstore_lh.clone();
377 async move {
378 let agents = mstore
379 .agent_def_list()
380 .map_err(|e| format!("agentdeflisthiddentemplates: {e}"))?;
381 let hidden: Vec<_> = agents
382 .into_iter()
383 .filter(|a| a.is_seeded == 1 && a.user_hidden == 1)
384 .collect();
385 Ok(hidden)
386 }
387 },
388 );
389
390 let mstore = state.mstore.clone();
393 let id_store = state.id_store.clone();
394 let broker = state.broker.clone();
395 engine.register_typed(
396 COMMAND_FORK_AGENT_DEFINITION,
397 move |cmd: CommandForkAgentDefinitionData, _ctx| {
398 let mstore = mstore.clone();
399 let id_store = id_store.clone();
400 let broker = broker.clone();
401 async move {
402
403 let all_defs = mstore
405 .agent_def_list()
406 .map_err(|e| format!("forkagentdefinition: {e}"))?;
407 let source = all_defs
408 .iter()
409 .find(|a| a.id == cmd.source_id)
410 .cloned()
411 .ok_or_else(|| format!("forkagentdefinition: source not found: {}", cmd.source_id))?;
412
413 let now = agentmux_common::time::now_ms();
417
418 let fork_name = if cmd.branch_label.is_empty() {
422 let root_former = mstore.agent_former_names(&fork_lineage_root(&all_defs, &source).id).unwrap_or_default();
423 suggest_fork_name(&all_defs, &mstore.retired_agent_names().unwrap_or_default(), &root_former, &source, |n| {
424 name_in_use(&mstore, &all_defs, n)
425 })
426 } else {
427 cmd.branch_label.clone()
428 };
429 let branch_label = if cmd.branch_label.is_empty() {
430 fork_name.clone()
431 } else {
432 cmd.branch_label.clone()
433 };
434 if !cmd.branch_label.is_empty() && name_in_use(&mstore, &all_defs, &fork_name) {
435 return Err(format!("forkagentdefinition: an agent named {fork_name:?} already exists"));
436 }
437 let root = fork_lineage_root(&all_defs, &source);
440 let fork_slug = agentmux_common::slug::fork_slug(&root.slug, &root.name, &branch_label);
441 let effective_provider = id_store.resolve_effective_provider_id(&source);
444 let mut fork = AgentDefinition {
445 id: uuid::Uuid::new_v4().to_string(),
446 slug: fork_slug,
448 name: fork_name,
449 icon: source.icon.clone(),
450 provider: effective_provider,
451 description: source.description.clone(),
452 working_directory: String::new(), shell: source.shell.clone(),
454 provider_flags: source.provider_flags.clone(),
455 auto_start: 0, restart_on_crash: source.restart_on_crash,
457 idle_timeout_minutes: source.idle_timeout_minutes,
458 created_at: now,
459 agent_type: source.agent_type.clone(),
460 environment: source.environment.clone(),
461 agent_bus_id: String::new(), is_seeded: 0,
463 accounts: String::new(),
464 parent_id: source.id.clone(),
465 branch_label: branch_label.clone(),
466 updated_at: now,
467 user_hidden: 0,
468 container_image: source.container_image.clone(),
471 container_volumes: source.container_volumes.clone(),
472 container_name: String::new(),
473 use_ambient_login: 0,
474 model_vendor_base_url: source.model_vendor_base_url.clone(),
475 auto_continue_enabled: 0,
476 memory_id: String::new(),
477 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
483 };
484 mstore
485 .agent_def_insert(&mut fork)
486 .map_err(|e| format!("forkagentdefinition: {e}"))?;
487 mstore.agent_def_provision_and_bind_bundle(&id_store, &mut fork, now);
490
491 let source_contents = mstore
495 .agent_content_get_all(&source.id)
496 .map_err(|e| format!("forkagentdefinition content: {e}"))?;
497 for c in source_contents {
498 let new_content = AgentContent {
499 agent_id: fork.id.clone(),
500 content_type: c.content_type,
501 content: c.content,
502 updated_at: now,
503 };
504 mstore
505 .agent_content_set(&new_content)
506 .map_err(|e| format!("forkagentdefinition content: {e}"))?;
507 }
508 let source_skills = mstore
509 .agent_skill_list(&source.id)
510 .map_err(|e| format!("forkagentdefinition skills: {e}"))?;
511 for s in source_skills {
512 let new_skill = AgentSkill {
513 id: uuid::Uuid::new_v4().to_string(),
514 agent_id: fork.id.clone(),
515 name: s.name,
516 trigger: s.trigger,
517 skill_type: s.skill_type,
518 description: s.description,
519 content: s.content,
520 created_at: now,
521 };
522 mstore
523 .agent_skill_insert(&new_skill)
524 .map_err(|e| format!("forkagentdefinition skill: {e}"))?;
525 }
526
527 broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
528
529 Ok(fork)
530 }
531 },
532 );
533
534 let mstore_sug = state.mstore.clone();
537 engine.register_typed(
538 COMMAND_FORK_AGENT_DEFINITION_SUGGEST,
539 move |cmd: CommandForkAgentDefinitionSuggestData, _ctx| {
540 let mstore = mstore_sug.clone();
541 async move {
542 let all = mstore
543 .agent_def_list()
544 .map_err(|e| format!("forkagentdefinitionsuggest: {e}"))?;
545 let source = all
546 .iter()
547 .find(|a| a.id == cmd.source_id)
548 .ok_or_else(|| format!("forkagentdefinitionsuggest: source not found: {}", cmd.source_id))?;
549
550 let root_former = mstore.agent_former_names(&fork_lineage_root(&all, source).id).unwrap_or_default();
551 let suggested_label = suggest_fork_name(&all, &mstore.retired_agent_names().unwrap_or_default(), &root_former, source, |n| {
552 name_in_use(&mstore, &all, n)
553 });
554
555 Ok(ForkAgentDefinitionSuggestResult { suggested_label })
556 }
557 },
558 );
559
560 let mstore_rn = state.mstore.clone();
571 let broker_rn = state.broker.clone();
572 engine.register_typed(
573 COMMAND_RENAME_AGENT_DEFINITION_TITLE,
574 move |cmd: CommandRenameAgentDefinitionTitleData, _ctx| {
575 let mstore = mstore_rn.clone();
576 let broker = broker_rn.clone();
577 async move {
578 let title = cmd.title.trim();
579 if title.is_empty() {
580 return Err("renameagentdefinitiontitle: title must not be empty".to_string());
581 }
582
583 let all = mstore
584 .agent_def_list()
585 .map_err(|e| format!("renameagentdefinitiontitle: {e}"))?;
586 let old = all
587 .iter()
588 .find(|a| a.id == cmd.id)
589 .ok_or_else(|| format!("renameagentdefinitiontitle: agent {} not found", cmd.id))?;
590
591 let mut updated = old.clone();
592 if !updated.branch_label.is_empty() {
593 updated.branch_label = title.to_string();
594 } else {
595 updated.name = title.to_string();
596 }
597
598 let found = mstore
599 .agent_def_update(&mut updated)
600 .map_err(|e| format!("renameagentdefinitiontitle: {e}"))?;
601 if !found {
602 return Err(format!("renameagentdefinitiontitle: agent {} not found", cmd.id));
603 }
604
605 if let Err(e) = mstore.instance_rename(&cmd.id, title) {
619 tracing::warn!(
620 agent_id = %cmd.id, error = %e,
621 "renameagentdefinitiontitle: display name not updated; \
622 the picker row may keep showing the old name"
623 );
624 }
625
626 broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
627
628 Ok(updated)
629 }
630 },
631 );
632
633}
634
635#[cfg(test)]
636mod tests {
637 use super::*;
638 use crate::backend::rpc_types::RpcMessage;
639 use crate::backend::storage::bundles::Bundle;
640 use crate::server::tests::test_state;
641
642 fn seed_bundle(state: &AppState, id: &str, provider: &str) {
643 let bundle = Bundle {
644 id: id.to_string(),
645 name: "Bundle".to_string(),
646 description: String::new(),
647 is_blank: false,
648 is_global: false,
649 provider: provider.to_string(),
650 model: String::new(),
651 instructions: String::new(),
652 instructions_by_provider: "{}".to_string(),
653 context_files: "[]".to_string(),
654 mcp_servers: "[]".to_string(),
655 skills: "[]".to_string(),
656 sort_order: 0,
657 created_at: 0,
658 updated_at: 0,
659 is_system: false,
660 };
661 state.id_store.bundle_upsert(&bundle).unwrap();
662 }
663
664 fn seed_drifted_template(state: &AppState, def_id: &str, bundle_id: &str) {
669 let mut def = AgentDefinition {
670 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
671 id: def_id.to_string(),
672 slug: String::new(),
673 name: "Drifted Template".to_string(),
674 icon: String::new(),
675 provider: "codex".to_string(),
676 description: String::new(),
677 working_directory: String::new(),
678 shell: String::new(),
679 provider_flags: String::new(),
680 auto_start: 0,
681 restart_on_crash: 0,
682 idle_timeout_minutes: 0,
683 created_at: 0,
684 agent_type: "host".to_string(),
685 environment: String::new(),
686 agent_bus_id: String::new(),
687 is_seeded: 1,
688 accounts: String::new(),
689 parent_id: String::new(),
690 branch_label: String::new(),
691 updated_at: 0,
692 user_hidden: 0,
693 container_image: String::new(),
694 container_volumes: "[]".to_string(),
695 container_name: String::new(),
696 use_ambient_login: 0,
697 auto_continue_enabled: 0,
698 model_vendor_base_url: String::new(),
699 memory_id: bundle_id.to_string(),
700 };
701 state.mstore.agent_def_insert(&mut def).unwrap();
702 }
703
704 #[tokio::test]
710 async fn register_typed_records_the_second_batch_of_migrated_commands() {
711 let state = test_state();
712 let (engine, _rx) = WshRpcEngine::new();
713 register(&engine, &state);
714 let schema = engine.schema_json();
715 let rows = schema.as_array().unwrap();
716 let find = |cmd: &str| {
717 rows.iter()
718 .find(|r| r["command"] == cmd)
719 .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
720 };
721 let a = find(COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE);
722 assert_eq!(a["requestName"], "CommandAgentDefCreateFromTemplateData");
723 assert_eq!(a["responseName"], "AgentDefCreateFromTemplateResult");
724 let f = find(COMMAND_FORK_AGENT_DEFINITION_SUGGEST);
725 assert_eq!(f["requestName"], "CommandForkAgentDefinitionSuggestData");
726 assert_eq!(f["responseName"], "ForkAgentDefinitionSuggestResult");
727 let r = find(COMMAND_RENAME_AGENT_DEFINITION_TITLE);
731 assert_eq!(r["requestName"], "CommandRenameAgentDefinitionTitleData");
732 assert_eq!(r["responseName"], "AgentDefinition");
733 let fk = find(COMMAND_FORK_AGENT_DEFINITION);
734 assert_eq!(fk["requestName"], "CommandForkAgentDefinitionData");
735 assert_eq!(fk["responseName"], "AgentDefinition");
736
737 let lh = find(COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES);
741 let lh_req = lh["requestName"].as_str().unwrap_or_default();
742 assert!(
743 lh_req.starts_with("core::option::Option<")
744 && lh_req.contains("CommandListHiddenTemplatesData"),
745 "agentdeflisthiddentemplates takes Option<CommandListHiddenTemplatesData> so both no-argument encodings deserialize, got {lh_req:?}",
746 );
747 let lh_resp = lh["responseName"].as_str().unwrap_or_default();
748 assert!(
749 lh_resp.starts_with("alloc::vec::Vec<") && lh_resp.contains("AgentDefinition"),
750 "agentdeflisthiddentemplates should answer Vec<AgentDefinition>, got {lh_resp:?}",
751 );
752 assert!(
753 !lh_resp.contains("serde_json::Value"),
754 "hand-serializing would record Value and leave this endpoint outside the drift net; got {lh_resp:?}",
755 );
756 }
757
758 #[tokio::test]
764 async fn listhiddentemplates_accepts_the_empty_object_the_stub_sends() {
765 let state = test_state();
766 let (engine, mut rx) = WshRpcEngine::new();
767 register(&engine, &state);
768
769 for payload in [serde_json::json!({}), serde_json::Value::Null] {
770 engine.handle_message(RpcMessage {
771 command: COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES.to_string(),
772 reqid: format!("req-{payload}"),
773 data: Some(payload.clone()),
774 ..Default::default()
775 });
776 let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
777 .await
778 .unwrap()
779 .unwrap();
780 assert!(
781 resp.error.is_empty(),
782 "{payload} should be accepted, got error: {}",
783 resp.error,
784 );
785 let rows: Vec<AgentDefinition> =
786 serde_json::from_value(resp.data.expect("expected result data")).unwrap();
787 assert!(rows.is_empty(), "nothing is hidden in a fresh store");
788 }
789 }
790
791 #[tokio::test]
797 async fn no_argument_commands_accept_a_null_body() {
798 use crate::backend::rpc_types::{
799 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES, COMMAND_CONTAINER_RUNTIME_AVAILABLE,
800 };
801
802 let state = test_state();
803 let (engine, mut rx) = WshRpcEngine::new();
804 register(&engine, &state);
805 crate::server::agent_handlers::core::register(&engine, &state);
806
807 for cmd in [
808 COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
809 COMMAND_CONTAINER_RUNTIME_AVAILABLE,
810 ] {
811 engine.handle_message(RpcMessage {
812 command: cmd.to_string(),
813 reqid: format!("req-{cmd}"),
814 data: Some(serde_json::Value::Null),
815 ..Default::default()
816 });
817 let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
818 .await
819 .unwrap()
820 .unwrap();
821 assert!(
822 resp.error.is_empty(),
823 "{cmd} should accept a null body, got error: {}",
824 resp.error,
825 );
826 }
827 }
828
829 #[test]
835 fn fork_request_accepts_a_missing_branch_label() {
836 let cmd: CommandForkAgentDefinitionData =
837 serde_json::from_value(serde_json::json!({ "source_id": "abc" }))
838 .expect("branch_label is serde(default) and may be omitted");
839 assert_eq!(cmd.source_id, "abc");
840 assert_eq!(cmd.branch_label, "", "an omitted branch_label defaults to empty");
841 }
842
843 #[tokio::test]
844 async fn agentdefcreatefromtemplate_carries_the_templates_own_provider() {
845 let state = test_state();
846 seed_bundle(&state, "bundle-claude", "claude");
847 seed_drifted_template(&state, "tpl-1", "bundle-claude");
848
849 let (engine, mut output_rx) = WshRpcEngine::new();
850 register(&engine, &state);
851
852 engine.handle_message(RpcMessage {
853 command: COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE.to_string(),
854 reqid: "req-1".to_string(),
855 data: Some(serde_json::json!({
856 "template_id": "tpl-1",
857 "name": "My Clone",
858 })),
859 ..Default::default()
860 });
861 let resp = tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
862 .await
863 .unwrap()
864 .unwrap();
865 assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
866 let result: AgentDefCreateFromTemplateResult =
867 serde_json::from_value(resp.data.expect("expected result data")).unwrap();
868
869 let cloned = state
870 .mstore
871 .agent_def_get(&result.definition_id)
872 .unwrap()
873 .unwrap();
874 assert_eq!(
875 cloned.provider, "codex",
876 "the agent owns its harness; the bundle's provider is a hint (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1)"
877 );
878 }
879
880 #[tokio::test]
881 async fn forkagentdefinition_carries_the_sources_own_provider() {
882 let state = test_state();
883 seed_bundle(&state, "bundle-claude", "claude");
884 seed_drifted_template(&state, "src-1", "bundle-claude");
885
886 let (engine, mut output_rx) = WshRpcEngine::new();
887 register(&engine, &state);
888
889 engine.handle_message(RpcMessage {
890 command: COMMAND_FORK_AGENT_DEFINITION.to_string(),
891 reqid: "req-1".to_string(),
892 data: Some(serde_json::json!({
893 "source_id": "src-1",
894 "branch_label": "",
895 })),
896 ..Default::default()
897 });
898 let resp = tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
899 .await
900 .unwrap()
901 .unwrap();
902 assert!(resp.error.is_empty(), "unexpected error: {}", resp.error);
903 let fork: AgentDefinition =
904 serde_json::from_value(resp.data.expect("expected result data")).unwrap();
905
906 assert_eq!(
907 fork.provider, "codex",
908 "the agent owns its harness; the bundle's provider is a hint (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1)"
909 );
910 }
911
912 #[tokio::test]
920 async fn forking_a_fork_produces_a_flat_lineage_wide_name() {
921 let state = test_state();
922 seed_bundle(&state, "bundle-claude", "claude");
923 seed_drifted_template(&state, "root-1", "bundle-claude");
924
925 let (engine, mut output_rx) = WshRpcEngine::new();
926 register(&engine, &state);
927
928 let fork_once = |engine: &Arc<WshRpcEngine>, source_id: &str| {
929 engine.handle_message(RpcMessage {
930 command: COMMAND_FORK_AGENT_DEFINITION.to_string(),
931 reqid: "req".to_string(),
932 data: Some(serde_json::json!({ "source_id": source_id, "branch_label": "" })),
933 ..Default::default()
934 });
935 };
936
937 fork_once(&engine, "root-1");
938 let resp1 = tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
939 .await
940 .unwrap()
941 .unwrap();
942 assert!(resp1.error.is_empty(), "unexpected error: {}", resp1.error);
943 let fork1: AgentDefinition = serde_json::from_value(resp1.data.expect("expected result data")).unwrap();
944 assert_eq!(fork1.name, "Drifted Template #2");
945
946 fork_once(&engine, &fork1.id);
947 let resp2 = tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
948 .await
949 .unwrap()
950 .unwrap();
951 assert!(resp2.error.is_empty(), "unexpected error: {}", resp2.error);
952 let fork2: AgentDefinition = serde_json::from_value(resp2.data.expect("expected result data")).unwrap();
953 assert_eq!(
954 fork2.name, "Drifted Template #3",
955 "forking a fork must produce a flat, lineage-wide name — not \"#2 #2\" (immediate-parent-only \
956 count) or \"#2 #3\" (root-based count off the parent's own already-suffixed name)"
957 );
958 assert_eq!(fork1.slug, "drifted-template-2");
961 assert_eq!(fork2.slug, "drifted-template-3");
962
963 engine.handle_message(RpcMessage {
966 command: COMMAND_FORK_AGENT_DEFINITION_SUGGEST.to_string(),
967 reqid: "req-suggest".to_string(),
968 data: Some(serde_json::json!({ "source_id": fork2.id })),
969 ..Default::default()
970 });
971 let resp3 = tokio::time::timeout(std::time::Duration::from_secs(2), output_rx.recv())
972 .await
973 .unwrap()
974 .unwrap();
975 assert!(resp3.error.is_empty(), "unexpected error: {}", resp3.error);
976 let suggestion: ForkAgentDefinitionSuggestResult =
977 serde_json::from_value(resp3.data.expect("expected result data")).unwrap();
978 assert_eq!(suggestion.suggested_label, "Drifted Template #4");
979 }
980
981 fn lineage(forks: &[(&str, &str)]) -> Vec<AgentDefinition> {
983 let mut root = crate::backend::storage::agents::test_agent_def("root", "Loap", "claude", "host", 1, "");
984 root.is_seeded = 0;
985 let mut defs = vec![root];
986 for (id, name) in forks {
987 let mut d = crate::backend::storage::agents::test_agent_def(id, name, "claude", "host", 2, "");
988 d.is_seeded = 0;
989 d.parent_id = "root".to_string();
990 d.branch_label = name.to_string();
991 defs.push(d);
992 }
993 defs
994 }
995
996 #[test]
999 fn a_fork_number_is_one_past_the_highest_not_a_count() {
1000 let defs = lineage(&[("f3", "Loap #3")]);
1001 assert_eq!(suggest_fork_name(&defs, &[], &[], &defs[0], |n| name_taken(&defs, n)), "Loap #4");
1002 let first = lineage(&[]);
1003 assert_eq!(suggest_fork_name(&first, &[], &[], &first[0], |n| name_taken(&first, n)), "Loap #2");
1004 }
1005
1006 #[test]
1008 fn a_fork_name_some_other_agent_has_is_skipped() {
1009 let mut defs = lineage(&[]);
1010 let mut other = crate::backend::storage::agents::test_agent_def("other", "Loap #2", "claude", "host", 3, "");
1011 other.is_seeded = 0;
1012 defs.push(other);
1013 assert_eq!(suggest_fork_name(&defs, &[], &[], &defs[0], |n| name_taken(&defs, n)), "Loap #3");
1014 assert!(name_taken(&defs, "loap #2"), "case-insensitively");
1015 }
1016
1017 #[test]
1020 fn a_deleted_forks_number_is_not_handed_out_again() {
1021 let defs = lineage(&[]);
1022 assert_eq!(suggest_fork_name(&defs, &["loap #2".to_string()], &[], &defs[0], |n| name_taken(&defs, n)), "Loap #3");
1023 }
1024
1025 #[test]
1028 fn renaming_the_root_keeps_the_fork_count() {
1029 let mut defs = lineage(&[("f2", "Loap #2"), ("f3", "Loap #3")]);
1030 defs[0].name = "Foo".to_string();
1031 assert_eq!(suggest_fork_name(&defs, &[], &[], &defs[0], |n| name_taken(&defs, n)), "Foo #4");
1032 }
1033
1034 #[test]
1036 fn a_deleted_non_ascii_forks_number_counts() {
1037 let mut defs = lineage(&[]);
1038 defs[0].name = "Ägent".to_string();
1039 assert_eq!(
1040 suggest_fork_name(&defs, &["ägent #2".to_string()], &[], &defs[0], |n| name_taken(&defs, n)),
1041 "Ägent #3"
1042 );
1043 }
1044
1045 #[test]
1048 fn a_fork_deleted_before_the_root_was_renamed_still_counts() {
1049 let mut defs = lineage(&[]);
1050 defs[0].name = "Foo".to_string();
1051 let retired = ["loap #2".to_string()];
1052 let former = ["Loap".to_string()];
1053 assert_eq!(suggest_fork_name(&defs, &retired, &former, &defs[0], |n| name_taken(&defs, n)), "Foo #3");
1054 }
1055
1056 #[test]
1059 fn an_automatic_name_the_taken_check_rejects_is_skipped() {
1060 let defs = lineage(&[]);
1061 assert_eq!(suggest_fork_name(&defs, &[], &[], &defs[0], |n| n == "Loap #2"), "Loap #3");
1062 }
1063
1064 #[test]
1067 fn a_renamed_forks_label_is_taken() {
1068 let mut defs = lineage(&[("f2", "Loap #2")]);
1069 defs[1].branch_label = "Experiment".to_string();
1070 assert!(name_taken(&defs, "experiment"));
1071 }
1072}