Skip to main content

agentmux_srv/backend/storage/agents/
mod.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Agent subsystem — definitions, instances, and their lifecycle CRUD.
5//!
6//! Covers `agent_def_*` methods (template + user-clone definitions),
7//! `instance_*` methods (per-launch instance rows, named-agent
8//! continuation, identity-bound active-for-block resolution), the
9//! `AgentDefinition` / `AgentInstance` structs, and the `InstanceStatus` enum.
10//!
11//! This file holds the types and defaults; the methods are in `definition`
12//! (`agent_def_*`) and `instance` (`instance_*`), the shared column lists and
13//! row mappers in `row`, slugs and name-keyed rows in `name_keys`, and the
14//! delete cascade in `purge`.
15//!
16//! Every method in both groups reads and writes `db_agents` only, as of the
17//! agent-concept consolidation's definition flip
18//! (`docs/specs/SPEC_AGENT_ARCHITECTURE_2026_05_27.md` Phase 3d) — the
19//! instance side flipped first (Phase 3b, PR 2 of 4, #3080), the definition
20//! side followed once the six agent-child tables' FK re-pointed to
21//! `db_agents` (Phase 3c, PR 3 of 4, #3088) removed the reason
22//! `agent_def_get` had to stay scoped to `db_agent_definitions`. Neither
23//! legacy table has a live writer left in this file; the old `dual_write` module, which
24//! used to mirror definition writes into `db_agents`, is gone — there is no
25//! second table left to mirror into one from.
26//!
27//! `db_agent_definitions` and `db_agent_instances` are still physically
28//! present in the schema (dropped in a later PR) — do not read either as a
29//! live source anywhere in this file; every read here is `db_agents`.
30
31use rusqlite::params;
32use serde::{Deserialize, Serialize};
33
34use super::error::StoreError;
35use super::store::Store;
36use crate::registry::RecordScope;
37
38mod definition;
39mod instance;
40mod name_keys;
41mod purge;
42mod row;
43
44pub use name_keys::AgentNameMatch;
45#[cfg(test)]
46pub(super) use name_keys::key_tombstones_for_tests;
47#[cfg(test)]
48pub(super) use purge::purge_agent_dependents_for_tests;
49#[allow(unused_imports)]
50use self::{name_keys::*, purge::*, row::*};
51
52/// A user-defined AI agent in the user's agent-definition catalog.
53#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
54#[ts(export, export_to = "../../../frontend/types/rpc/")]
55pub struct AgentDefinition {
56    pub id: String,
57    /// Stable, filesystem-safe identifier. Drives working directory,
58    /// env var keys (AGENTMUX_AGENT_ID), and cross-references.
59    /// NEVER changes after creation — distinct from `name` which is
60    /// the renameable display. See
61    /// docs/specs/SPEC_AGENT_IDENTITY_RESTRUCTURE_2026_04_14.md.
62    #[serde(default)]
63    pub slug: String,
64    pub name: String,
65    pub icon: String,
66    pub provider: String,
67    pub description: String,
68    #[serde(default)]
69    pub working_directory: String,
70    #[serde(default)]
71    pub shell: String,
72    #[serde(default)]
73    pub provider_flags: String,
74    #[serde(default)]
75    #[ts(type = "number")]
76    pub auto_start: i64,
77    #[serde(default)]
78    #[ts(type = "number")]
79    pub restart_on_crash: i64,
80    #[serde(default)]
81    #[ts(type = "number")]
82    pub idle_timeout_minutes: i64,
83    #[ts(type = "number")]
84    pub created_at: i64,
85    #[serde(default = "default_agent_type")]
86    pub agent_type: String,
87    #[serde(default)]
88    pub environment: String,
89    #[serde(default)]
90    pub agent_bus_id: String,
91    #[serde(default)]
92    #[ts(type = "number")]
93    pub is_seeded: i64,
94    /// JSON-encoded per-provider account assignments
95    /// (`{"github":"acct-id", …}`). Written by the Agent pane's Identity
96    /// tab (`AgentIdentityPanel`) via `updateagent`, read back by
97    /// `parseAgentAccounts` and consumed by startup credential
98    /// resolution. (An older v6 comment called this deprecated in favour
99    /// of `db_agent_identity_links`; that migration never completed — the
100    /// JSON blob is still the live store, so the schema flatten keeps the
101    /// column.)
102    #[serde(default)]
103    pub accounts: String,
104    /// Parent definition id (db_agent_definitions.id). Empty string = root
105    /// definition; non-empty = this agent was forked from another.
106    /// Added in v6. See spec §Phase 1.
107    #[serde(default)]
108    pub parent_id: String,
109    /// Label describing the branch (e.g. `"pr-422-review"`,
110    /// `"experiment-refactor"`). Empty for root definitions and for
111    /// branches that didn't set a label. Added in v6.
112    #[serde(default)]
113    pub branch_label: String,
114    /// Last-modified timestamp (epoch ms). Set to `created_at` on insert
115    /// and refreshed on every `agent_def_update`. Schema v2. `0` for
116    /// rows written before v2 (until next update).
117    #[serde(default)]
118    #[ts(type = "number")]
119    pub updated_at: i64,
120    /// Per-user hide flag for seeded templates. `1` = the user clicked
121    /// "Hide template" on the picker's `+ New from template` tier; the
122    /// row stays on disk (templates are manifest-managed; deletion would
123    /// fight re-seed) but the default `listagents` view filters it out.
124    /// Reset to `0` by the agent-seed re-sync flow for any NEW template
125    /// id newly added to the manifest, so a fresh template surfaces once
126    /// even if a same-named one was previously hidden. Schema v3 (Phase
127    /// 2 of `SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md` — Q2 Decision Y).
128    /// User-owned rows (`is_seeded = 0`) MUST stay at `0` here; their
129    /// removal path is `deleteagent`, not hide.
130    #[serde(default)]
131    #[ts(type = "number")]
132    pub user_hidden: i64,
133    /// Docker image to use when `agent_type == "container"`.
134    /// e.g. `"ghcr.io/agentmuxai/agent-base:latest"`.
135    /// Empty string for host agents. Schema v6.
136    #[serde(default)]
137    pub container_image: String,
138    /// JSON array of volume mount specs for container agents.
139    /// Each element is a string in Docker bind-mount format:
140    /// `"source:target"` or `"source:target:options"`.
141    /// Empty JSON array (`"[]"`) for host agents. Schema v6.
142    #[serde(default = "default_container_volumes")]
143    pub container_volumes: String,
144    /// Stable Docker container name managed by the server.
145    /// Set to `"agentmux-<slug>"` on first container spawn;
146    /// empty for host agents. Schema v6.
147    #[serde(default)]
148    pub container_name: String,
149    /// Inert. It was a per-agent opt-in to the CLI's global login when no
150    /// account resolved at spawn; the spawn gate no longer reads it (an
151    /// oauth-class agent with no bound account is always refused), no RPC
152    /// sets it and no UI shows it. The column and this field stay only so
153    /// the shared definition registry keeps the value older builds read
154    /// (m0017/m0018 grandfathered linkless agents to `1` there).
155    /// SPEC_MY_AGENTS_TILES_AUTH_AND_HISTORY_2026_10_03.md R10.
156    #[serde(default)]
157    #[ts(type = "number")]
158    pub use_ambient_login: i64,
159    /// Redirects this agent's harness (CLI) at a non-default model vendor
160    /// backend — e.g. `"https://my-proxy.example.com"` for a `claude`-provider
161    /// agent, injected into `ANTHROPIC_BASE_URL` at spawn time. Empty string
162    /// (default) = use the harness's default vendor endpoint. Only settable
163    /// when the resolved provider declares `ProviderConfig::base_url_env_var`
164    /// (validated in `agent_define_core`) — see docs/specs for the harness
165    /// vs. model-vendor concept this formalizes. Schema v15. Channel-local:
166    /// does not currently survive a cross-channel reopen of the same agent
167    /// (known limitation, not wired into the registry mirror).
168    #[serde(default)]
169    pub model_vendor_base_url: String,
170    /// Per-agent opt-in: when non-zero, a running Warden Supervisor watcher
171    /// agent is permitted to auto-continue this agent's session on
172    /// turn-end (subject to a server-side consecutive-nudge ceiling).
173    /// Default 0 = opt-in required. Schema v17. Toggled from the Warden Supervisor
174    /// panel. See
175    /// docs/analysis/ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md.
176    #[serde(default)]
177    #[ts(type = "number")]
178    pub auto_continue_enabled: i64,
179    /// The agent's own dedicated ABF bundle (`db_bundles.id`). Distinct from
180    /// `AgentInstance.memory_id` (a specific *launch*'s bundle, which can
181    /// still be pointed at a different bundle on purpose — this is just the
182    /// default a launch inherits when it doesn't override). Empty string =
183    /// not yet provisioned (legacy row predating this field, or a definition
184    /// awaiting `m0021`'s backfill). Stored in `db_agents.default_memory_id`
185    /// — a separate column from `db_agents.memory_id`, which is the
186    /// instance-scoped field this doc paragraph opens by distinguishing it
187    /// from. Schema v19; the two-column split dates to
188    /// ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md §3.1.
189    #[serde(default)]
190    pub memory_id: String,
191    /// This agent's own disclosure policy for an incoming cross-tier
192    /// `transcript_request` jekt (`muxspect` Phase B/C — LAN/WAN
193    /// conversation visibility, `SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md`,
194    /// jekt tier rules confirmed in `SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md`).
195    /// One of `"private"` (default — auto-deny, fail-closed), `"trusted_peers"`
196    /// (auto-approve only a requester in `db_conversation_trust_grants`), or
197    /// `"ask"` (force human escalation, never auto-respond). Schema v26.
198    /// Channel-local only, like `model_vendor_base_url`/`memory_id` above —
199    /// deliberately NOT added to `DefinitionRecordV1`'s cross-channel wire
200    /// format yet; a cross-channel-reopened agent starts back at the safe
201    /// `"private"` default rather than carrying a stale value. Known
202    /// limitation, not a bug — same precedent as those two fields.
203    #[serde(default = "default_conversation_visibility")]
204    pub conversation_visibility: String,
205}
206
207/// `AgentDefinition::conversation_visibility`'s serde default — the safe,
208/// fail-closed value for any row/context that doesn't specify one
209/// (including every pre-existing row before this column existed).
210pub fn default_conversation_visibility() -> String {
211    "private".to_string()
212}
213
214/// The working directory `agent.open` substitutes when an
215/// `AgentDefinition`'s own `working_directory` is blank —
216/// `~/.agentmux/agents/<name-derived-slug>`.
217///
218/// Blank is the DEFAULT for a newly defined agent, so this is the path most
219/// agents actually run in. Extracted so `agent.open` (which creates and uses
220/// it) and native-memory resolution (which must find the memories written
221/// inside it) share one definition and cannot drift — they previously
222/// disagreed, and the resolver simply gave up on a blank field, breaking
223/// Memory → Personal for the common case. See
224/// `docs/specs/SPEC_FIX_PERSONAL_MEMORY_EMPTY_WORKDIR_2026_09_01.md`.
225///
226/// NOTE: deliberately NOT `derive_slug` above. `agent.open`'s own inline
227/// derivation is `name.to_lowercase()` with a Unicode-aware
228/// `char::is_alphanumeric()` filter (keeping `-`/`_`), which differs from
229/// `derive_slug`'s ASCII-only rule, dash-collapsing, 64-char trim and
230/// `"agent"` fallback. Reusing `derive_slug` here would silently point at a
231/// DIFFERENT directory than the one the agent is really running in for any
232/// non-ASCII or dash-heavy name. This mirrors the real behaviour exactly.
233pub fn default_agent_working_dir(agent_name: &str) -> String {
234    format!("~/.agentmux/agents/{}", agentmux_common::slug::path_slug(agent_name))
235}
236
237/// Derive a filesystem-safe slug from a display name
238/// (`agentmux_common::slug::definition_slug`).
239pub fn derive_slug(name: &str) -> String {
240    agentmux_common::slug::definition_slug(name)
241}
242
243fn default_agent_type() -> String {
244    "standalone".to_string()
245}
246
247fn default_container_volumes() -> String {
248    "[]".to_string()
249}
250
251/// Instance lifecycle status. Serialised lowercase to match the DB text.
252#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
253#[serde(rename_all = "lowercase")]
254pub enum InstanceStatus {
255    Running,
256    Paused,
257    Stopped,
258    Crashed,
259    Detached,
260}
261
262impl InstanceStatus {
263    pub fn as_str(&self) -> &'static str {
264        match self {
265            Self::Running => "running",
266            Self::Paused => "paused",
267            Self::Stopped => "stopped",
268            Self::Crashed => "crashed",
269            Self::Detached => "detached",
270        }
271    }
272}
273
274/// Partial-update payload for [`Store::instance_update_partial`]. Each
275/// `Some` field is written; `None` leaves the column untouched. Mirrors
276/// the mutable subset of `CommandUpdateAgentInstanceData`
277/// (`block_id`/`session_id`/`status`/`github_context`/`ended_at`) — the
278/// only columns `instance_update` ever wrote. `Some("")` for a string
279/// field explicitly clears it.
280#[derive(Debug, Clone, Default)]
281pub struct InstanceUpdate {
282    pub block_id: Option<String>,
283    pub session_id: Option<String>,
284    pub status: Option<String>,
285    pub github_context: Option<String>,
286    pub ended_at: Option<i64>,
287}
288
289/// One row per running/historical execution of an agent definition.
290/// `block_id` / `session_id` / `github_context` are modelled as empty
291/// strings on the wire rather than `Option<String>` to match the
292/// existing schema conventions (`NOT NULL DEFAULT ''`). Callers
293/// that need structured absence can use `.is_empty()`.
294#[derive(Debug, Clone, Serialize, Deserialize, ts_rs::TS)]
295#[ts(export, export_to = "../../../frontend/types/rpc/")]
296pub struct AgentInstance {
297    pub id: String,
298    pub definition_id: String,
299    #[serde(default)]
300    pub parent_instance_id: String,
301    #[serde(default)]
302    pub block_id: String,
303    #[serde(default)]
304    pub session_id: String,
305    pub status: String,
306    /// JSON-encoded `GitHubContext`, or empty string.
307    #[serde(default)]
308    pub github_context: String,
309    #[ts(type = "number")]
310    pub started_at: i64,
311    #[serde(default)]
312    #[ts(type = "number")]
313    pub ended_at: i64,
314    #[ts(type = "number")]
315    pub created_at: i64,
316    /// Legacy Identity-bundle id column — `db_identity_bundles` was
317    /// dropped in Phase 4c of SPEC_PRESET_TO_BUNDLE_REFACTOR_2026_07_02.md.
318    /// The launch modal now writes an account_id here instead; credential
319    /// resolution and display names both go through
320    /// `db_agent_identity_links`/`db_accounts`. Empty string means no
321    /// account was picked at launch.
322    #[serde(default)]
323    pub identity_id: String,
324    /// FK to `db_bundles.id`. Empty string means "use the blank
325    /// singleton" (= vanilla CLI, no instructions). Set at
326    /// instantiation via the launch modal's Bundle dropdown.
327    #[serde(default)]
328    pub memory_id: String,
329    /// User-chosen instance name (becomes `AGENTMUX_AGENT_ID` in the
330    /// spawn env). Empty for pre-v8 rows and for un-named launches.
331    /// Drives the "Continue agent" dropdown in the launch modal.
332    #[serde(default)]
333    pub instance_name: String,
334    /// Absolute path returned by `allocate_agent_workdir` at spawn.
335    /// Stored explicitly (rather than re-derived from the slug at
336    /// continue-time) so the continue flow is robust against
337    /// slug-rule changes and user-side renames.
338    #[serde(default)]
339    pub working_directory: String,
340    /// Soft-delete flag for the "Forget agent" affordance. Hidden
341    /// rows stay on disk for audit + recovery.
342    #[serde(default)]
343    pub display_hidden: bool,
344}
345
346/// Shared test fixture: an `AgentDefinition` with every field defaulted to
347/// an empty/zero value except the ones callers actually vary. Originally
348/// shared to avoid `tests::bare_agent_def` and
349/// `bundle_provisioning_store_separation_tests::base_agent` each hardcoding
350/// this 30-field struct literal independently (reagentx P2 on PR #2602);
351/// `bare_agent_def` is gone (its only caller pinned pre-flip behavior this
352/// module no longer has), leaving `base_agent` as the remaining caller —
353/// kept as one definition here regardless, so a future field addition only
354/// needs updating once.
355#[cfg(test)]
356pub(crate) fn test_agent_def(
357    id: &str,
358    name: &str,
359    provider: &str,
360    agent_type: &str,
361    timestamp: i64,
362    model_vendor_base_url: &str,
363) -> AgentDefinition {
364    AgentDefinition {
365        id: id.to_string(),
366        slug: id.to_string(),
367        name: name.to_string(),
368        icon: String::new(),
369        provider: provider.to_string(),
370        description: String::new(),
371        working_directory: String::new(),
372        shell: String::new(),
373        provider_flags: String::new(),
374        auto_start: 0,
375        restart_on_crash: 0,
376        idle_timeout_minutes: 0,
377        created_at: timestamp,
378        agent_type: agent_type.to_string(),
379        environment: String::new(),
380        agent_bus_id: String::new(),
381        is_seeded: 0,
382        accounts: String::new(),
383        parent_id: String::new(),
384        branch_label: String::new(),
385        updated_at: timestamp,
386        user_hidden: 0,
387        container_image: String::new(),
388        container_volumes: "[]".to_string(),
389        container_name: String::new(),
390        use_ambient_login: 0,
391        model_vendor_base_url: model_vendor_base_url.to_string(),
392        auto_continue_enabled: 0,
393        memory_id: String::new(),
394        conversation_visibility: default_conversation_visibility(),
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401    use crate::registry::{
402        DefContentBlob, DefSkillBlob, DefinitionRecord, DefinitionRecordV1, DefinitionStore,
403        DEF_MAX_SUPPORTED_SCHEMA,
404    };
405    use std::sync::Arc;
406
407    /// Mirrors `def_registry_mirror.rs`'s own test fixture — a user
408    /// agent that exists only in the global cross-channel registry,
409    /// with one content blob and one skill, so a backfill has
410    /// something real to preserve.
411    fn global_user_agent(id: &str, name: &str) -> DefinitionRecord {
412        DefinitionRecord {
413            schema_version: DEF_MAX_SUPPORTED_SCHEMA,
414            data: DefinitionRecordV1 {
415                id: id.to_string(),
416                name: name.to_string(),
417                provider: "claude".to_string(),
418                is_seeded: 0,
419                updated_at: 42,
420                content: vec![DefContentBlob {
421                    content_type: "agentmd".to_string(),
422                    content: "be helpful".to_string(),
423                }],
424                skills: vec![DefSkillBlob {
425                    id: "sk1".to_string(),
426                    name: "greet".to_string(),
427                    ..Default::default()
428                }],
429                ..Default::default()
430            },
431        }
432    }
433
434    fn instance(id: &str, definition_id: &str) -> AgentInstance {
435        AgentInstance {
436            id: id.to_string(),
437            definition_id: definition_id.to_string(),
438            parent_instance_id: String::new(),
439            block_id: String::new(),
440            session_id: String::new(),
441            status: "init".to_string(),
442            github_context: String::new(),
443            started_at: 1,
444            ended_at: 0,
445            created_at: 1,
446            identity_id: String::new(),
447            memory_id: String::new(),
448            instance_name: id.to_string(),
449            working_directory: String::new(),
450            display_hidden: false,
451        }
452    }
453
454    #[test]
455    fn instance_create_backfills_local_definition_from_registry_only_record() {
456        let store = Store::open_in_memory().unwrap();
457        let tmp = tempfile::tempdir().unwrap();
458        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
459        def_store
460            .upsert(&global_user_agent("remote-1", "Remote"))
461            .unwrap();
462        store.set_def_registry(def_store.clone());
463
464        // Local db_agent_definitions has no row for "remote-1" — this
465        // is exactly the Moras/Oozp/Parko scenario. Without the
466        // backfill this INSERT fails with a FOREIGN KEY error.
467        let result = store.instance_create(&instance("inst-1", "remote-1"));
468        assert!(result.is_ok(), "instance_create failed: {result:?}");
469
470        // The backfilled local row must exist and satisfy the FK.
471        let local = store.agent_row_get("remote-1").unwrap();
472        assert!(local.is_some(), "backfill must create a local definition row");
473
474        // Critical regression guard: the registry's real content/skills
475        // must NOT have been wiped by the backfill (this is exactly
476        // what a naive `agent_def_insert`-based backfill would do —
477        // see agent_def_backfill_local_from_registry's doc comment).
478        let rec = def_store.get("remote-1").unwrap().unwrap();
479        assert_eq!(rec.data.content.len(), 1, "registry content must survive backfill");
480        assert_eq!(rec.data.skills.len(), 1, "registry skills must survive backfill");
481    }
482
483    #[test]
484    fn instance_create_still_errors_when_definition_missing_everywhere() {
485        let store = Store::open_in_memory().unwrap();
486        let tmp = tempfile::tempdir().unwrap();
487        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
488        store.set_def_registry(def_store);
489
490        // No local row, nothing in the registry either — genuinely
491        // orphaned definition_id. Must still fail loudly, not silently
492        // swallow a real error.
493        let result = store.instance_create(&instance("inst-2", "nowhere"));
494        assert!(result.is_err(), "instance_create must still fail for a truly orphaned definition_id");
495    }
496
497    // SPEC_AGENT_PANE_HISTORY_ALIGNMENT_2026_08_05.md follow-up: App-API
498    // self-lookup callers (MemoryList/Read/Write, IdentityAccounts,
499    // IdentityValidate, bundle.self.get) pass the AGENTMUX_AGENT_ID routing
500    // slug, not the literal display name. Confirmed live: a real agent
501    // named "AgentY" (slug "agenty") got "agent agenty not found" from
502    // every one of those endpoints before `instance_get_by_slug` existed.
503    #[test]
504    fn instance_get_by_slug_resolves_a_mixed_case_display_name_via_its_slug() {
505        let store = Store::open_in_memory().unwrap();
506        let tmp = tempfile::tempdir().unwrap();
507        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
508        def_store.upsert(&global_user_agent("def-agenty", "AgentY")).unwrap();
509        store.set_def_registry(def_store);
510
511        let mut inst = instance("inst-agenty", "def-agenty");
512        inst.instance_name = "AgentY".to_string();
513        store.instance_create(&inst).unwrap();
514
515        // The routing slug ("agenty", what every MCP-tool-backed App-API
516        // endpoint actually has) must resolve to the display-cased row.
517        let found = store.instance_get_by_slug("agenty").unwrap();
518        assert!(found.is_some(), "must resolve via the persisted slug column");
519        assert_eq!(found.unwrap().instance_name, "AgentY");
520    }
521
522    #[test]
523    fn instance_get_by_slug_returns_none_for_a_genuinely_unrelated_slug() {
524        let store = Store::open_in_memory().unwrap();
525        let tmp = tempfile::tempdir().unwrap();
526        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
527        def_store.upsert(&global_user_agent("def-agenty2", "AgentY")).unwrap();
528        store.set_def_registry(def_store);
529
530        let mut inst = instance("inst-agenty2", "def-agenty2");
531        inst.instance_name = "AgentY".to_string();
532        store.instance_create(&inst).unwrap();
533
534        let found = store.instance_get_by_slug("someone-else").unwrap();
535        assert!(found.is_none(), "an unrelated slug must not match by coincidence");
536    }
537
538    // reagentx P1 on PR #2428 (round 2): a slug-normalized re-derivation of
539    // the CURRENT display name (`derive_slug(instance_name)`) only equals
540    // the real routing slug when the display name has never changed and
541    // never collided with another agent's at creation. `slug` is stable
542    // and persisted once, `name`/`instance_name` are renameable — this
543    // test deliberately sets them to unrelated values (as if the agent
544    // were renamed, or its slug got a "-2" collision suffix at creation)
545    // to prove the fix resolves via the real persisted `slug` column, not
546    // a fresh re-derivation that a rename would silently invalidate.
547    #[test]
548    fn instance_get_by_slug_resolves_via_the_persisted_slug_even_after_a_rename() {
549        let store = Store::open_in_memory().unwrap();
550        let tmp = tempfile::tempdir().unwrap();
551        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
552        // The registry's own `name` is the CURRENT (post-rename) display
553        // name — deliberately unrelated to `slug`, which was fixed at
554        // creation and never changes.
555        def_store
556            .upsert(&DefinitionRecord {
557                schema_version: DEF_MAX_SUPPORTED_SCHEMA,
558                data: DefinitionRecordV1 {
559                    id: "def-renamed".to_string(),
560                    slug: "agenty".to_string(),
561                    name: "Agent Y Renamed".to_string(),
562                    provider: "claude".to_string(),
563                    updated_at: 42,
564                    ..Default::default()
565                },
566            })
567            .unwrap();
568        store.set_def_registry(def_store);
569
570        let mut inst = instance("inst-renamed", "def-renamed");
571        inst.instance_name = "Agent Y Renamed".to_string();
572        store.instance_create(&inst).unwrap();
573
574        // derive_slug("Agent Y Renamed") would be "agent-y-renamed" — NOT
575        // "agenty". Only a lookup against the real persisted slug column
576        // resolves this; a re-derivation from the current name would miss
577        // it, reproducing the exact bug this test guards against.
578        let found = store.instance_get_by_slug("agenty").unwrap();
579        assert!(found.is_some(), "must resolve via the persisted slug, not a re-derivation of the current name");
580        assert_eq!(found.unwrap().instance_name, "Agent Y Renamed");
581    }
582
583    // reagentx P1 on PR #2428 (round 3): a single query matching
584    // `(instance_name = ?1 OR slug = ?1)` let a coincidental cross-
585    // namespace collision (one agent's literal `instance_name` equaling a
586    // DIFFERENT agent's `slug`) match both rows simultaneously, silently
587    // disambiguated by recency — meaning an App-API self-lookup call could
588    // return an unrelated agent's memory, accounts or bundle. This test builds
589    // exactly that collision (agent A's `instance_name` == agent B's
590    // `slug` == "shared-name") and proves `instance_get_by_slug` stays
591    // within its own namespace: it finds ONLY the slug match (B), never
592    // A's row (its literal-name counterpart is gone — see below).
593    #[test]
594    fn instance_get_by_name_and_by_slug_never_cross_the_others_namespace() {
595        let store = Store::open_in_memory().unwrap();
596        let tmp = tempfile::tempdir().unwrap();
597        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
598
599        // Agent A: literal display name is exactly "shared-name"; its own
600        // slug is unrelated ("agent-a-slug").
601        def_store
602            .upsert(&DefinitionRecord {
603                schema_version: DEF_MAX_SUPPORTED_SCHEMA,
604                data: DefinitionRecordV1 {
605                    id: "def-a".to_string(),
606                    slug: "agent-a-slug".to_string(),
607                    name: "shared-name".to_string(),
608                    provider: "claude".to_string(),
609                    updated_at: 1,
610                    ..Default::default()
611                },
612            })
613            .unwrap();
614        // Agent B: slug is exactly "shared-name" (e.g. a rename left its
615        // slug pointing at a string that collides with A's literal name);
616        // its own display name is unrelated.
617        def_store
618            .upsert(&DefinitionRecord {
619                schema_version: DEF_MAX_SUPPORTED_SCHEMA,
620                data: DefinitionRecordV1 {
621                    id: "def-b".to_string(),
622                    slug: "shared-name".to_string(),
623                    name: "Agent B Display".to_string(),
624                    provider: "claude".to_string(),
625                    updated_at: 2,
626                    ..Default::default()
627                },
628            })
629            .unwrap();
630        store.set_def_registry(def_store);
631
632        let mut inst_a = instance("inst-a", "def-a");
633        inst_a.instance_name = "shared-name".to_string();
634        store.instance_create(&inst_a).unwrap();
635
636        let mut inst_b = instance("inst-b", "def-b");
637        inst_b.instance_name = "Agent B Display".to_string();
638        store.instance_create(&inst_b).unwrap();
639
640        // `instance_get_by_name` was deleted in consolidation Phase 3b (PR 2)
641        // — it had no production caller — so the slug side is the whole
642        // guarantee now: the slug lookup must never resolve to A's row just
643        // because A's literal instance_name equals B's slug.
644        let by_slug = store.instance_get_by_slug("shared-name").unwrap()
645            .expect("must find agent B by slug");
646        assert_eq!(by_slug.id, "def-b", "slug lookup must never resolve to A's row");
647    }
648
649    // The §4 safety net. `instance_create` used to bind `def.slug` verbatim,
650    // making it the one writer that could mint a duplicate — `agent_def_insert`
651    // has always suffix-resolved, and no UNIQUE constraint backs the column.
652    //
653    // Note this is a contract fix, not a fix for an observed fault: the launch
654    // flow names its agent and goes through `agent_def_insert`, so this branch
655    // is reached only by handing `instance_create` a seeded template id
656    // directly. A data check across 47 databases found 0 duplicate slugs and 18
657    // launches, none inheriting a template's slug (§2.5.2).
658    #[test]
659    fn two_launches_of_one_template_get_distinct_slugs() {
660        let store = Store::open_in_memory().unwrap();
661        let mut tmpl = test_agent_def("tmpl-1", "Shared Template", "claude", "agent", 1, "");
662        tmpl.slug = "shared-template".to_string();
663        tmpl.is_seeded = 1;
664        store.agent_def_insert_local_only(&mut tmpl, None).unwrap();
665
666        let mut first = instance("launch-a", "tmpl-1");
667        first.instance_name = "Launch A".to_string();
668        store.instance_create(&first).unwrap();
669        let mut second = instance("launch-b", "tmpl-1");
670        second.instance_name = "Launch B".to_string();
671        store.instance_create(&second).unwrap();
672
673        // Neither launch may leave a resolvable duplicate behind: whatever
674        // slugs they got, no slug may name two non-template rows.
675        let conn = store.conn.lock().unwrap();
676        let dupes: i64 = conn
677            .query_row(
678                "SELECT COUNT(*) FROM (SELECT slug FROM db_agents
679                 WHERE is_template = 0 AND user_hidden = 0 AND slug <> ''
680                 GROUP BY slug HAVING COUNT(*) > 1)",
681                [],
682                |r| r.get(0),
683            )
684            .unwrap();
685        assert_eq!(dupes, 0, "a template launch must not mint a duplicate slug");
686    }
687
688    // A slug does not identify an agent once two rows share one, so the honest
689    // answer is "unknown" — the same fail-closed rule
690    // `agent_registry_lookup::find_active_record_by_slug` adopted for the
691    // registry in #3480, here for `db_agents` (#3500).
692    //
693    // This test predates the §4 safety net above and used to build its
694    // duplicate by launching one template twice, on the reasoning that
695    // `instance_create` copied `def.slug` verbatim. That is no longer true —
696    // and was never true of the real launch flow either (§2.5.2) — so the
697    // duplicate is now forced deliberately. The state remains reachable in
698    // production: no UNIQUE constraint backs the column.
699    #[test]
700    fn instance_get_by_slug_fails_closed_when_two_rows_share_a_slug() {
701        let store = Store::open_in_memory().unwrap();
702
703        let mut tmpl = test_agent_def("tmpl-1", "Shared Template", "claude", "agent", 1, "");
704        tmpl.slug = "shared-template".to_string();
705        tmpl.is_seeded = 1;
706        store.agent_def_insert_local_only(&mut tmpl, None).unwrap();
707        assert_eq!(tmpl.slug, "shared-template", "fixture precondition");
708
709        let mut first = instance("launch-a", "tmpl-1");
710        first.instance_name = "Launch A".to_string();
711        store.instance_create(&first).unwrap();
712
713        let mut second = instance("launch-b", "tmpl-1");
714        second.instance_name = "Launch B".to_string();
715        store.instance_create(&second).unwrap();
716
717        // Both write paths suffix-resolve now (§4 safety net), so two launches
718        // no longer collide on their own — see
719        // `two_launches_of_one_template_get_distinct_slugs`. Force the duplicate
720        // this guard exists for.
721        //
722        // Done in two steps on purpose. Asserting only the final `None` cannot
723        // tell "refused an ambiguous slug" from "nothing matched that slug" —
724        // both are `None`, and after the safety net the second is what a naive
725        // fixture actually produces. Resolving ONE row first pins that the
726        // lookup is live and the fixture is real; the second row is then the
727        // only thing that changed.
728        store.test_force_slug("launch-a", "collide-me").unwrap();
729        let single = store.instance_get_by_slug("collide-me").unwrap();
730        assert_eq!(
731            single.map(|i| i.id).as_deref(),
732            Some("launch-a"),
733            "one row with the slug must resolve — otherwise the check below is vacuous"
734        );
735
736        store.test_force_slug("launch-b", "collide-me").unwrap();
737
738        // Before this guard, `ORDER BY updated_at DESC LIMIT 1` handed back
739        // whichever row was touched last — deterministically the same wrong
740        // agent every time — and `HistoryService::sessions_for_agent` then
741        // resolved one agent's conversation history into the other's request.
742        assert!(
743            store.instance_get_by_slug("collide-me").unwrap().is_none(),
744            "adding a second row with the same slug must flip resolve to refusal"
745        );
746    }
747
748    #[test]
749    fn instance_create_preserves_registry_updated_at_on_backfill() {
750        let store = Store::open_in_memory().unwrap();
751        let tmp = tempfile::tempdir().unwrap();
752        let def_store = Arc::new(DefinitionStore::open(tmp.path().join("definitions")).unwrap());
753        let record = global_user_agent("remote-2", "Remote2");
754        def_store.upsert(&record).unwrap();
755        store.set_def_registry(def_store);
756
757        // Call the backfill directly rather than through a full
758        // `instance_create`: "remote-2" is a user agent (is_seeded = 0), so
759        // `instance_create` immediately follows the backfill with its own
760        // launch-state fold, which legitimately bumps `updated_at` again —
761        // by design, every launch touch does (see `agent_def_list`'s doc
762        // comment). That second, later bump isn't what this test is
763        // pinning; the backfill INSERT preserving the registry's real
764        // `updated_at`, rather than resetting it to `created_at`, is.
765        store.agent_def_backfill_local_from_registry(&record).unwrap();
766
767        let local = store.agent_row_get("remote-2").unwrap().unwrap();
768        assert_eq!(
769            local.updated_at, 42,
770            "backfilled row must preserve the registry's real updated_at, not reset to created_at"
771        );
772    }
773
774    /// Pins the definition flip's actual point: `agent_def_list()` and
775    /// `agent_def_get()` now read the SAME table, so a row visible to one
776    /// is visible to the other. Before the flip these deliberately
777    /// disagreed (`agent_def_get` was scoped to `db_agent_definitions`
778    /// only) — that inconsistency meant an agent visible in "My Agents"
779    /// (`agent_def_list`) could 404 the moment something tried to open its
780    /// detail view (`agent_def_get`) if it existed only as a `db_agents`
781    /// row (a template launch). A row written directly into `db_agents`,
782    /// bypassing `agent_def_insert` entirely, now resolves through both.
783    #[test]
784    fn agent_def_list_and_agent_def_get_agree_on_the_same_row() {
785        let store = Store::open_in_memory().unwrap();
786
787        // A `db_agents` row with no `agent_def_insert` ever having run for
788        // it — stands in for a template-launch row, which never had one.
789        {
790            let conn = store.conn.lock().unwrap();
791            conn.execute(
792                "INSERT INTO db_agents (id, slug, name, provider, is_template, created_at, updated_at, is_seeded)
793                 VALUES ('launch-only', 'launch-only', 'LaunchOnly', 'claude', 0, 1, 1, 0)",
794                [],
795            )
796            .unwrap();
797        }
798
799        let listed = store.agent_def_list().unwrap();
800        assert!(
801            listed.iter().any(|d| d.id == "launch-only"),
802            "agent_def_list() must read db_agents"
803        );
804        let got = store.agent_def_get("launch-only").unwrap();
805        assert!(
806            got.is_some(),
807            "agent_def_get() must resolve a row agent_def_list() already shows — no more disagreement between the two"
808        );
809        assert_eq!(got.unwrap().name, "LaunchOnly");
810    }
811}
812
813// P1/P2 fixes (Codex + ReAgent review on PR #2587):
814// - bundle_provision_for_new_agent/agent_def_provision_and_bind_bundle must
815//   write the bundle into an explicitly-passed store, never assume the
816//   caller's own definition store IS the bundle store — the two are
817//   different databases whenever a shared store is configured.
818// - resolve_effective_vendor must respect model_vendor_base_url ("custom"),
819//   not just the provider's bare default.
820#[cfg(test)]
821mod bundle_provisioning_store_separation_tests {
822    use super::*;
823
824    fn base_agent(id: &str, name: &str, provider: &str, model_vendor_base_url: &str) -> AgentDefinition {
825        super::test_agent_def(id, name, provider, "host", 0, model_vendor_base_url)
826    }
827
828    #[test]
829    fn resolve_effective_vendor_defaults_to_the_providers_first_supported_vendor() {
830        assert_eq!(Store::resolve_effective_vendor("claude", ""), "anthropic");
831        assert_eq!(Store::resolve_effective_vendor("codex", ""), "openai");
832    }
833
834    #[test]
835    fn resolve_effective_vendor_returns_custom_when_a_base_url_override_is_set() {
836        assert_eq!(
837            Store::resolve_effective_vendor("claude", "https://my-proxy.example.com"),
838            "custom",
839            "an agent with a vendor override must not claim the provider's default vendor"
840        );
841    }
842
843    #[test]
844    fn resolve_effective_vendor_ignores_a_whitespace_only_override() {
845        assert_eq!(Store::resolve_effective_vendor("claude", "   "), "anthropic");
846    }
847
848    #[test]
849    fn resolve_effective_vendor_falls_back_to_the_provider_id_for_an_unknown_provider() {
850        assert_eq!(Store::resolve_effective_vendor("not-a-real-provider", ""), "not-a-real-provider");
851    }
852
853    #[test]
854    fn bundle_provision_for_new_agent_writes_into_whichever_store_its_called_on() {
855        let bundle_store = Store::open_in_memory().unwrap();
856        let agent = base_agent("a1", "Agent One", "claude", "");
857        let bundle_id = bundle_store.bundle_provision_for_new_agent(&agent, 0).unwrap();
858        assert!(bundle_store.bundle_get(&bundle_id).unwrap().is_some());
859    }
860
861    // The core P1 regression test: definition_store (self) and bundle_store
862    // are two GENUINELY SEPARATE Store instances — proves
863    // agent_def_provision_and_bind_bundle writes the bundle into
864    // bundle_store specifically, never into whichever store the method
865    // happens to be called on for the definition side.
866    #[test]
867    fn agent_def_provision_and_bind_bundle_writes_the_bundle_into_the_explicit_bundle_store_not_self() {
868        let definition_store = Store::open_in_memory().unwrap();
869        let bundle_store = Store::open_in_memory().unwrap();
870        let mut agent = base_agent("a1", "Agent One", "claude", "");
871        definition_store.agent_def_insert(&mut agent).unwrap();
872
873        definition_store.agent_def_provision_and_bind_bundle(&bundle_store, &mut agent, 0);
874
875        assert!(!agent.memory_id.is_empty(), "caller's struct must reflect the bound bundle id");
876        let def = definition_store.agent_def_get(&agent.id).unwrap().unwrap();
877        assert_eq!(def.memory_id, agent.memory_id, "binding must land in the definition store (self)");
878
879        // The bundle itself must be absent from definition_store and
880        // present only in bundle_store.
881        assert!(
882            definition_store.bundle_get(&agent.memory_id).unwrap().is_none(),
883            "bundle must NOT be written into the definition store"
884        );
885        assert!(
886            bundle_store.bundle_get(&agent.memory_id).unwrap().is_some(),
887            "bundle must be reachable via the explicit bundle_store"
888        );
889    }
890
891    // P1 regression tests (ReAgent review on PR #2587 round 4):
892    // db_bundles.name is UNIQUE but only AgentDefinition.slug is
893    // guaranteed unique, not the display name two agents can share — a
894    // naive "{name} — ABF" used to collide, silently leaving a
895    // runtime-provisioned agent unbound and unconditionally aborting
896    // m0021's entire backfill loop on the first collision.
897
898    /// A collision suffix keeps a slug a valid agent id: a 64-char slug plus
899    /// "-2" used to make a 66-char one that failed every registration
900    /// (REPORT_FORK_AGENT_ISOLATION_2026_10_12.md F4).
901    #[test]
902    fn a_collision_suffixed_slug_stays_within_64_chars() {
903        let store = Store::open_in_memory().unwrap();
904        let long = "a".repeat(64);
905        let mut first = test_agent_def("id-1", "First", "claude", "host", 1, "");
906        first.slug = long.clone();
907        store.agent_def_insert(&mut first).unwrap();
908        let mut second = test_agent_def("id-2", "Second", "claude", "host", 2, "");
909        second.slug = long.clone();
910        store.agent_def_insert(&mut second).unwrap();
911        assert_eq!(first.slug, long);
912        assert_ne!(second.slug, long);
913        assert!(agentmux_common::slug::is_agent_id(&second.slug), "{} is a valid id", second.slug);
914        assert!(second.slug.ends_with("-2"));
915    }
916
917    #[test]
918    fn resolve_unique_bundle_name_returns_the_base_name_when_no_collision() {
919        let store = Store::open_in_memory().unwrap();
920        assert_eq!(store.resolve_unique_bundle_name("Agent One — ABF").unwrap(), "Agent One — ABF");
921    }
922
923    #[test]
924    fn resolve_unique_bundle_name_disambiguates_on_collision() {
925        let store = Store::open_in_memory().unwrap();
926        let agent_a = base_agent("a1", "Same Name", "claude", "");
927        store.bundle_provision_for_new_agent(&agent_a, 0).unwrap();
928
929        let unique = store.resolve_unique_bundle_name("Same Name — ABF").unwrap();
930        assert_eq!(unique, "Same Name — ABF (2)");
931    }
932
933    #[test]
934    fn resolve_unique_bundle_name_walks_past_multiple_collisions() {
935        let store = Store::open_in_memory().unwrap();
936        store.bundle_provision_for_new_agent(&base_agent("a1", "Dup", "claude", ""), 0).unwrap();
937        // Directly seed the "(2)" slot too, so the resolver must walk to "(3)".
938        let taken = super::super::bundles::Bundle {
939            id: "taken-2".to_string(),
940            name: "Dup — ABF (2)".to_string(),
941            description: String::new(),
942            is_blank: false,
943            is_global: false,
944            provider: String::new(),
945            model: String::new(),
946            instructions: String::new(),
947            instructions_by_provider: "{}".to_string(),
948            context_files: "[]".to_string(),
949            mcp_servers: "[]".to_string(),
950            skills: "[]".to_string(),
951            sort_order: 0,
952            created_at: 0,
953            updated_at: 0,
954            is_system: false,
955        };
956        store.bundle_upsert(&taken).unwrap();
957
958        let unique = store.resolve_unique_bundle_name("Dup — ABF").unwrap();
959        assert_eq!(unique, "Dup — ABF (3)");
960    }
961
962    #[test]
963    fn two_agents_with_the_same_display_name_both_get_bound_not_left_unbound_on_collision() {
964        let definition_store = Store::open_in_memory().unwrap();
965        let bundle_store = Store::open_in_memory().unwrap();
966        let mut agent_a = base_agent("a1", "Twin", "claude", "");
967        let mut agent_b = base_agent("a2", "Twin", "claude", "");
968        definition_store.agent_def_insert(&mut agent_a).unwrap();
969        definition_store.agent_def_insert(&mut agent_b).unwrap();
970
971        definition_store.agent_def_provision_and_bind_bundle(&bundle_store, &mut agent_a, 0);
972        definition_store.agent_def_provision_and_bind_bundle(&bundle_store, &mut agent_b, 0);
973
974        assert!(!agent_a.memory_id.is_empty(), "first same-named agent must still get bound");
975        assert!(!agent_b.memory_id.is_empty(), "second same-named agent must NOT be silently left unbound");
976        assert_ne!(agent_a.memory_id, agent_b.memory_id, "each agent still gets its own distinct bundle");
977    }
978
979    // resolve_effective_provider_id — consolidated shared implementation
980    // (was previously duplicated between agent_open.rs's spawn path and
981    // identity/resolver/inject.rs's layer-3 credential gate; the latter
982    // was found reading agent.provider directly during a follow-up
983    // scoping pass, the same class of bug agent_open.rs already had
984    // fixed for itself). These mirror agent_open.rs's own former
985    // `resolve_effective_provider_id_tests` module one-for-one, plus the
986    // store-separation case matching this file's other bundle-resolution
987    // tests.
988
989    // A bundle row from before the bundle stopped carrying a harness.
990    fn bundle_with_provider(store: &Store, id: &str, provider: &str) {
991        store
992            .bundle_upsert(&super::super::bundles::Bundle {
993                id: id.to_string(),
994                name: id.to_string(),
995                description: String::new(),
996                is_blank: false,
997                is_global: false,
998                provider: provider.to_string(),
999                model: String::new(),
1000                instructions: String::new(),
1001                instructions_by_provider: "{}".to_string(),
1002                context_files: "[]".to_string(),
1003                mcp_servers: "[]".to_string(),
1004                skills: "[]".to_string(),
1005                sort_order: 0,
1006                created_at: 0,
1007                updated_at: 0,
1008                is_system: false,
1009            })
1010            .unwrap();
1011    }
1012
1013    #[test]
1014    fn resolve_effective_provider_id_is_the_agents_own_even_when_its_bundle_says_otherwise() {
1015        let store = Store::open_in_memory().unwrap();
1016        let mut agent = base_agent("a1", "Agent One", "codex", "");
1017        bundle_with_provider(&store, "b1", "claude");
1018        agent.memory_id = "b1".to_string();
1019        assert_eq!(store.resolve_effective_provider_id(&agent), "codex");
1020    }
1021
1022    #[test]
1023    fn resolve_effective_provider_id_reads_the_bundle_only_for_an_agent_with_no_provider() {
1024        let store = Store::open_in_memory().unwrap();
1025        let mut agent = base_agent("a1", "Agent One", "", "");
1026        bundle_with_provider(&store, "b1", "gemini");
1027        agent.memory_id = "b1".to_string();
1028        assert_eq!(store.resolve_effective_provider_id(&agent), "gemini");
1029    }
1030
1031    #[test]
1032    fn a_new_agents_bundle_carries_no_harness() {
1033        let store = Store::open_in_memory().unwrap();
1034        let mut agent = base_agent("a1", "Agent One", "gemini", "");
1035        agent.model_vendor_base_url = "https://proxy.example.com".to_string();
1036        let bundle_id = store.bundle_provision_for_new_agent(&agent, 0).unwrap();
1037        let bundle = store.bundle_get(&bundle_id).unwrap().unwrap();
1038        assert_eq!((bundle.provider.as_str(), bundle.model.as_str()), ("", ""));
1039    }
1040
1041    #[test]
1042    fn resolve_effective_provider_id_falls_back_to_agent_provider_when_unbound() {
1043        let store = Store::open_in_memory().unwrap();
1044        let agent = base_agent("a1", "Agent One", "claude", "");
1045        assert_eq!(store.resolve_effective_provider_id(&agent), "claude");
1046    }
1047
1048    #[test]
1049    fn resolve_effective_provider_id_falls_back_when_bundle_row_is_missing() {
1050        let store = Store::open_in_memory().unwrap();
1051        let mut agent = base_agent("a1", "Agent One", "claude", "");
1052        agent.memory_id = "no-such-bundle".to_string();
1053        assert_eq!(store.resolve_effective_provider_id(&agent), "claude");
1054    }
1055
1056    // The fallback reads the bundle through the store it's called on: a
1057    // caller that passes mstore instead of id_store gets nothing, not a
1058    // silently wrong answer.
1059    #[test]
1060    fn resolve_effective_provider_id_fallback_reads_only_the_store_it_is_called_on() {
1061        let id_store = Store::open_in_memory().unwrap();
1062        let mstore = Store::open_in_memory().unwrap();
1063        let mut agent = base_agent("a1", "Agent One", "", "");
1064        bundle_with_provider(&id_store, "b1", "codex");
1065        agent.memory_id = "b1".to_string();
1066        assert_eq!(id_store.resolve_effective_provider_id(&agent), "codex");
1067        assert_eq!(mstore.resolve_effective_provider_id(&agent), "");
1068    }
1069}
1070
1071#[cfg(test)]
1072mod fallback_id_tests {
1073    use super::*;
1074
1075    /// Byte-for-byte with `agent-config-builder.ts`'s
1076    /// `name.toLowerCase().replace(/[^a-z0-9-_]/g, "-")` (UTF-16 units).
1077    #[test]
1078    fn the_frontend_fallback_matches_javascript_utf16_semantics() {
1079        assert_eq!(frontend_fallback_id("Agent 🚀"), "agent---");
1080        assert_eq!(frontend_fallback_id("Agent (v2)"), "agent--v2-");
1081        assert_eq!(frontend_fallback_id("Café Bot"), "caf--bot");
1082        assert_eq!(agent_open_fallback_id("Café Bot"), "café-bot");
1083    }
1084}
1085
1086/// The only keys a remembered runtime may hold, and the longest value of each.
1087const LAST_RUNTIME_KEYS: &[(&str, usize)] = &[("permissionMode", 32), ("model", 128), ("effort", 32)];
1088
1089/// Validate and canonicalise a remembered runtime before it is stored:
1090/// `''` clears; otherwise a JSON object of non-empty strings under the known
1091/// keys only (`permissionMode`, `model`, `effort`), each bounded. Anything
1092/// else is rejected, so the column can only ever hold something the launch
1093/// path knows how to read.
1094pub fn normalize_last_runtime(raw: &str) -> Result<String, String> {
1095    if raw.trim().is_empty() {
1096        return Ok(String::new());
1097    }
1098    let value: serde_json::Value =
1099        serde_json::from_str(raw).map_err(|e| format!("last runtime is not JSON: {e}"))?;
1100    let obj = value.as_object().ok_or("last runtime must be a JSON object")?;
1101    let mut out = serde_json::Map::new();
1102    for (key, v) in obj {
1103        let Some(&(_, max)) = LAST_RUNTIME_KEYS.iter().find(|(k, _)| k == key) else {
1104            return Err(format!("unknown last-runtime key {key:?}"));
1105        };
1106        let s = v.as_str().ok_or_else(|| format!("{key} must be a string"))?;
1107        if s.is_empty() || s.len() > max || s.chars().any(char::is_control) {
1108            return Err(format!("{key} is empty, too long or has control characters"));
1109        }
1110        out.insert(key.clone(), serde_json::Value::String(s.to_owned()));
1111    }
1112    if out.is_empty() {
1113        return Ok(String::new());
1114    }
1115    Ok(serde_json::Value::Object(out).to_string())
1116}