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}