Skip to main content

agentmux_srv/server/agent_handlers/
template.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use 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    // Two-tier picker — Phase 2 (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md
13    // Q2 Decision Y: hide templates).
14    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
28/// Resolve a definition's **fork**-lineage root by walking `parent_id`.
29///
30/// `parent_id` is overloaded in this schema: `agentdefcreatefromtemplate`
31/// sets it to the *template's* id for every freshly-instantiated user
32/// agent (see that handler below), not just `forkagentdefinition`'s actual
33/// forks — so two unrelated agents both created fresh from the same
34/// template share a `parent_id`, but are not forks of each other. The one
35/// field that reliably distinguishes an actual fork is `branch_label`:
36/// `forkagentdefinition` always sets it (non-empty); `agentdefcreatefromtemplate`
37/// always leaves it empty. Walk upward only while the current node is
38/// itself a fork (non-empty `branch_label`); stop at the first non-fork
39/// ancestor (a template, or a first-generation user agent) — that's the
40/// lineage's root for fork-counting purposes. Confirmed bug this fixes
41/// (Codex's review of PR #2721, docs/specs/SPEC_AGENT_QUICK_FORK_NEW_TAB_2026_08_21.md
42/// §2): the naming rule requires a flat, lineage-wide counter ("AgentX #2"
43/// → "AgentX #3"), but the previous `parent_id == source_id` filter only
44/// counted immediate children, so forking a fork produced "AgentX #2 #2".
45fn 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
57/// The definition at the root of `source`'s fork lineage (see
58/// `fork_lineage_root_id`); `source` itself if the root can't be found.
59fn 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
64/// Whether a user agent (not a seeded template) already goes by `name`,
65/// case-insensitively: as its name, or as its branch label, which is what a
66/// renamed fork shows. Names are unique among user agents: the picker lists
67/// them by name, and name-keyed registrations collide otherwise. A launched
68/// agent's instance name is the store's to check (`agents_matching_name`).
69fn 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
73/// `name_taken`, or the store's own name resolution finds it (slug, name,
74/// or a launched agent's instance name): every name a fork could take is
75/// checked against both, automatic or typed (#4746).
76fn 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
80/// Build the flat, lineage-wide "#N" auto-suggestion for forking `source`.
81///
82/// The name is built from the lineage **root's** name, not `source`'s own
83/// (possibly already-suffixed) name — forking "AgentX #2" gives "AgentX #3",
84/// not "AgentX #2 #3" (`SPEC_AGENT_NAMING_AND_ADDRESSING_HOST_LAN_WAN_2026_08_22.md`
85/// §4.5). The number is one past the highest "#N" any fork in the lineage
86/// already carries, or any deleted agent carried (`retired`, the store's
87/// `retired_agent_names`), not a count of the live ones: a count handed a
88/// deleted fork's number to the next fork, so two agents could share a name
89/// over time, and live ones too while a later fork still had it
90/// (REPORT_FORK_AGENT_ISOLATION_2026_10_12.md F4). A live fork's number is its
91/// name's trailing " #N", whatever its prefix, so renaming the root keeps the
92/// count; a deleted name counts under the root's current name, compared
93/// lowercased as it was tombstoned, and under each of the root's former names
94/// (`root_former`), so renaming the root after deleting forks keeps them
95/// counted too. A name `taken` says is in use is skipped.
96fn 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    // agentdefcreatefromtemplate → clone a seeded template into a new
126    // user-owned definition (Phase 1 two-tier picker —
127    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md). The template stays
128    // pristine; the new row carries `is_seeded = 0`. Returns the new
129    // definition_id so the frontend can immediately launch.
130    //
131    // Validation rules:
132    //  - `template_id` MUST resolve to a row with `is_seeded = 1`.
133    //    Cloning a user-owned row would be confusing semantics — use
134    //    the existing `forkagentdefinition` RPC for that case.
135    //  - `name` non-empty, ≤200 chars, and not already taken by any
136    //    `is_seeded = 0` row. Avoids collisions in the picker's
137    //    "My Agents" list.
138    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                // Runtime is the user's instantiation-time choice, not a
185                // template property. When supplied, the clone records it
186                // (and the matching `environment`); empty falls back to
187                // the template's value for back-compat with older callers.
188                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                // Template's EFFECTIVE provider, from the shared resolver
198                // (#2594; SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1). Used for both the vendor-base-url
199                // validation below and the clone's own `provider` field so
200                // the two can't disagree with each other.
201                let effective_provider = id_store.resolve_effective_provider_id(template);
202                // Model vendor base URL: `None` (omitted) inherits the
203                // template's own value (the only behavior before this
204                // field existed on this RPC); `Some(url)` overrides it,
205                // including `Some("")` to explicitly clear a
206                // template-inherited override. Same validation `agent.define`
207                // already applies — rejected unless the template's provider
208                // declares `base_url_env_var`.
209                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                    // agent_def_insert derives a unique slug from the
220                    // name when this is empty + collision-resolves.
221                    slug: String::new(),
222                    name: name.clone(),
223                    icon: template.icon.clone(),
224                    provider: effective_provider.clone(),
225                    description: template.description.clone(),
226                    // Force re-allocation of the per-agent working
227                    // directory at first launch via the new slug —
228                    // matches forkagentdefinition's behaviour.
229                    working_directory: String::new(),
230                    shell: template.shell.clone(),
231                    provider_flags: template.provider_flags.clone(),
232                    // Users opt in to auto-start explicitly; cloning
233                    // shouldn't carry it over (mirrors fork).
234                    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                    // New user-owned agent starts visible. Phase 2
247                    // (Q2 Decision Y) — hide applies only to seeded
248                    // templates, never to user-owned agents.
249                    user_hidden: 0,
250                    // Inherit container config from template so container-type
251                    // templates propagate their image to user-cloned agents.
252                    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                    // Fail-closed default, not inherited from the template —
260                    // same convention as auto_start/use_ambient_login/
261                    // auto_continue_enabled above (opt-in settings reset on
262                    // a fresh clone, not carried over).
263                    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                // Own dedicated ABF bundle, not the template's — every
269                // agent has its own (ARCHITECTURE_MANDATORY_ABF_RETHINK_
270                // 2026_08_14.md §3.2, "strong reading").
271                mstore.agent_def_provision_and_bind_bundle(&id_store, &mut new_def, now);
272                // After the own bundle exists, so a pick of it is dropped.
273                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    // agentdefhide → set user_hidden = 1 on a seeded template, so it
299    // disappears from the picker's "+ New from template" tier. Phase 2
300    // (Q2 Decision Y) of SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
301    //
302    // Validation:
303    //  - `definition_id` MUST exist. Missing → returns `{ ok: false }`.
304    //  - The row MUST be a seeded template (`is_seeded = 1`). User-owned
305    //    rows reject with a hard error — they have their own delete path
306    //    and a hide flag on them would be misleading.
307    //
308    // Broadcasts `agents:changed` so the picker refetches and the card
309    // disappears (existing list query already excludes hidden by default).
310    let mstore_hide = state.mstore.clone();
311    let broker_hide = state.broker.clone();
312    // Registered through `register_typed` (rather than `register_handler`)
313    // so this command's request/response pair lands in the engine's
314    // `RpcSchema` — the mapping the frontend's bindings will be generated
315    // from. See `backend/rpc/schema.rs`. Behaviour is unchanged: the
316    // deserialize-in / serialize-out steps this body used to perform are
317    // exactly what the wrapper now does.
318    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    // agentdefunhide → set user_hidden = 0 on a seeded template,
340    // bringing it back into the picker. Same validation + broadcast as
341    // agentdefhide. Phase 2 of the two-tier picker spec.
342    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    // agentdeflisthiddentemplates → templates the user has hidden
366    // (is_seeded = 1 AND user_hidden = 1). Used by the settings panel
367    // to render the unhide list. The picker proper never calls this —
368    // it uses `listagents` with the default-filter-out behaviour.
369    let mstore_lh = state.mstore.clone();
370    engine.register_typed(
371        COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
372        // `Option<_>`, not the bare struct — see the type's own comment:
373        // it is what makes both no-argument encodings (`{}` and `null`)
374        // deserialize, which a typed registration otherwise cannot do.
375        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    // ---- Definition fork ----
391
392    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                // Find the source definition by id.
404                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                // Build a new definition that shares the source's content but
414                // has a fresh id/slug and records the lineage. Seed-bit is
415                // cleared — forks are always user-owned, not built-in.
416                let now = agentmux_common::time::now_ms();
417
418                // branch_label is the fork's full display name when provided.
419                // When empty, auto-generate a flat, lineage-wide "Name #N"
420                // (see suggest_fork_name's doc comment).
421                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                // The root's slug and this fork's label: never the source's
438                // slug, which chained every generation (`loap-loap-2-loap-3`).
439                let root = fork_lineage_root(&all_defs, &source);
440                let fork_slug = agentmux_common::slug::fork_slug(&root.slug, &root.name, &branch_label);
441                // Source's EFFECTIVE provider, from the shared resolver
442                // (#2594; same as the create-from-template clone site).
443                let effective_provider = id_store.resolve_effective_provider_id(&source);
444                let mut fork = AgentDefinition {
445                    id: uuid::Uuid::new_v4().to_string(),
446                    // Empty slug → agent_def_insert derives + resolves collisions.
447                    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(), // force re-resolve via agentmuxHome()
453                    shell: source.shell.clone(),
454                    provider_flags: source.provider_flags.clone(),
455                    auto_start: 0, // forks don't auto-start; explicit launch only
456                    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(), // fresh bus id so broadcasts don't cross
462                    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                    // Forks inherit container config from source so forked container agents
469                    // retain their image and volumes.
470                    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                    // Fail-closed default, not inherited from source — same
478                    // convention as use_ambient_login/auto_continue_enabled
479                    // above (opt-in settings reset on a fork, not carried
480                    // over, even though other config like container image
481                    // and model vendor IS inherited).
482                    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                // Own dedicated ABF bundle, not the source's — same "every
488                // agent has its own" rule as the template-clone path above.
489                mstore.agent_def_provision_and_bind_bundle(&id_store, &mut fork, now);
490
491                // Deep-copy content blobs + skills from source. Cascade foreign
492                // keys on the source are unaffected — we're copying out, not
493                // moving.
494                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    // ---- Definition fork suggest (read-only — no mutation) ----
535
536    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    // ---- Rename a fork/agent tab's displayed title ----
561    //
562    // Deliberately separate from `updateagent`, which preserves
563    // `branch_label` unconditionally (core.rs: "parent_id + branch_label
564    // describe provenance and are immutable post-insert"). That contract
565    // stays true for every OTHER caller of `updateagent` — this handler is
566    // the one narrow, explicit path that's allowed to change it, and only
567    // the field `fork-set.ts`'s `titleOf()` actually displays: `branch_label`
568    // when the row already has one (a fork), else `name` (a lineage root).
569    // See SPEC_PANE_TAB_STRIP_COMPACT_SIZING_AND_RENAME_2026_07_22.md §4.
570    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                // The fields above are what the pane tab strip's `titleOf()`
606                // reads. "My Agents" rows render `instance_name` instead, so
607                // without this a rename from the picker closed its editor,
608                // refetched, and redisplayed the OLD name (codex P1 on
609                // PR #3262). See `Store::instance_rename` for why it's
610                // scoped to rows that already carry an `instance_name`.
611                //
612                // This also applies to the tab-strip rename, deliberately:
613                // since the agent-concept consolidation a fork tab and its
614                // picker row are ONE agent row, so letting the two names
615                // drift apart is the confusing outcome, not the safe one.
616                // Best-effort — the title change already landed, and a
617                // display-name mirror failing is not a failed rename.
618                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    // The template says "codex"; its bound bundle, from before bundles
665    // stopped carrying a harness, says "claude". The agent owns its harness
666    // (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1), so a clone or fork
667    // carries "codex".
668    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    /// Both commands migrated to `register_typed` in this PR are recorded
705    /// in the engine's schema with their exact type names — the property
706    /// the RPC codegen plan depends on (`register_typed_records_each_command_
707    /// request_and_response_type` in agent_handlers/mod.rs covers the first
708    /// two migrated commands; this covers the second batch).
709    #[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        // The remaining three, migrated in the agent-template slice. Their
728        // responses are the storage-level `AgentDefinition`, which is why they
729        // waited for that type to be generated.
730        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        // `Vec<T>` keeps its full path — truncating at the last `::` would
738        // yield `AgentDefinition>`, which names nothing — so match on the
739        // element type rather than on an exact string.
740        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    /// `agentdeflisthiddentemplates` takes no argument, and the RPC client
759    /// sends `{}` for that. A unit `Req` would reject every one of those
760    /// calls, because serde deserializes `()` only from JSON `null` — hence
761    /// the empty struct. This is the `bookmarks.list` failure re-tested at
762    /// the one place in this slice that could reproduce it.
763    #[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    /// The four payload-ignoring agent commands all take `Option<Req>`, so a
792    /// client that omits `data` entirely -- which the server turns into
793    /// `Value::Null` -- is accepted rather than answered with a deserialize
794    /// error. One test rather than four: the failure is a property of the
795    /// registration shape, not of any one command.
796    #[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    /// `branch_label` is `#[serde(default)]`, so a caller may omit it — the
830    /// generated TS cannot say so (ts-rs marks a non-`Option` field required),
831    /// which is why the stub derives `ForkAgentDefinitionInput` rather than
832    /// using the generated type directly. Pin the server half of that claim:
833    /// if the `default` is ever dropped, the derived TS type becomes a lie.
834    #[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    // #2721 Phase 1 (Codex's review) — the auto-suggested name must be a
913    // flat, lineage-wide counter off the ROOT's name, not the immediate
914    // parent's (possibly already-suffixed) name. Forking a fork used to
915    // produce "Drifted Template #2 #2" (immediate-parent-only count) or
916    // even "Drifted Template #2 #3" (root-based count, but still built off
917    // the parent's own name) — the correct result is the flat
918    // "Drifted Template #3".
919    #[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        // Slugs are flat too: the root's slug and the fork's own label, not
959        // the source's slug (`drifted-template-drifted-template-2-…`, F4).
960        assert_eq!(fork1.slug, "drifted-template-2");
961        assert_eq!(fork2.slug, "drifted-template-3");
962
963        // The suggest RPC (used to preview the name before the user
964        // confirms) must agree with what an actual fork would produce.
965        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    /// A user agent and its forks, as `suggest_fork_name` sees them.
982    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    /// REPORT_FORK_AGENT_ISOLATION_2026_10_12.md F4: after "Loap #2" is
997    /// deleted, a count gave the next fork "#3" while "Loap #3" still lived.
998    #[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    /// A suggested name another agent already has is skipped.
1007    #[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    /// Deleting the highest fork must not hand its number out again (#4746).
1018    /// Deleted agents' names are tombstoned, lowercased.
1019    #[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    /// Renaming the root keeps the count: forks keep their old prefix, and
1026    /// their trailing number still counts (#4746).
1027    #[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    /// A deleted non-ASCII name is tombstoned lowercased, and still counts (#4746).
1035    #[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    /// A fork deleted before the root was renamed still counts, through the
1046    /// root's former name (#4746).
1047    #[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    /// An automatic name another agent has in a way only the store knows (a
1057    /// launched agent's instance name) is skipped too (#4746).
1058    #[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    /// A renamed fork shows its branch label, which a new fork must not take
1065    /// (#4746).
1066    #[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}