Skip to main content

agentmux_srv/server/agent_handlers/
mod.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4mod account_label;
5mod core;
6mod skills;
7mod history;
8mod template;
9mod identity;
10mod instance;
11mod session;
12mod bundle;
13mod bash_prefix;
14pub(crate) mod container_turn;
15pub(crate) mod input;
16mod identity_env;
17mod side_question;
18
19use std::sync::Arc;
20
21
22use crate::backend::agent_session::archive;
23use crate::backend::rpc::engine::WshRpcEngine;
24
25use super::AppState;
26
27pub use input::register_agent_input_handlers;
28// `/btw` slash command backend — registered alongside
29// `register_agent_input_handlers` in `server/websocket.rs` (both need the
30// same `AgentTurnDeps`-shaped wiring), not folded into
31// `register_agent_handlers` below.
32pub use side_question::register as register_side_question_handlers;
33// Re-exported for `bootstrap::install_agent_turn_delivery`, which starts an
34// agent turn from the reactive handler's message sender rather than over RPC.
35pub use input::{run_agent_turn, AgentTurnDeps, TurnRegistration};
36
37pub fn register_agent_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
38    core::register(engine, state);
39    skills::register(engine, state);
40    history::register(engine, state);
41    template::register(engine, state);
42    identity::register(engine, state);
43    instance::register(engine, state);
44    session::register(engine, state);
45    bundle::register(engine, state);
46}
47
48/// Purge a deleted agent's rows from the IDENTITY store — the second
49/// physical database, reachable only from this layer.
50///
51/// `Store::agent_def_delete` / `instance_delete` purge the dependents they
52/// can see, but they run against the object store, and
53/// `db_agent_identity_links`, `db_agent_credentials` and both native-memory
54/// tables exist in BOTH schemas with the identity store holding the live
55/// rows (`SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md` —
56/// `listrecentsessions` reads links from
57/// `identity_store.agent_identity_list_all()`, not the object store's
58/// same-named table). Without this the delete leaves an agent's real
59/// credentials and account links on disk, which the Delete confirm dialog
60/// explicitly promises it removes.
61///
62/// Called by BOTH delete RPCs (`deleteagent` and `deleteagentinstance`):
63/// they are one deletion under two names, and a fix applied to only one of
64/// them is the same bug reachable by the other route (ReAgent P1 on
65/// PR #3262). Best-effort — the agent row is already gone by the time this
66/// runs, so a failure here is leftover rows to log, not a failed delete.
67///
68/// MuxBus keeps the cached cloud credentials in `id_store`, a third database
69/// in a normal boot, so the agent's rows there go too, in every channel.
70pub(super) fn purge_identity_store_rows(
71    identity_store: &crate::backend::storage::store::Store,
72    id_store: &crate::backend::storage::store::Store,
73    agent_id: &str,
74    caller: &'static str,
75) {
76    if let Err(e) = id_store.agent_credentials_purge_agent(agent_id) {
77        tracing::warn!(agent_id, caller, error = %e, "cloud credentials left behind for deleted agent");
78    }
79    match identity_store.agent_dependents_purge(agent_id) {
80        Ok(0) => {}
81        Ok(removed) => tracing::debug!(
82            agent_id, caller, removed,
83            "purged identity-store rows for deleted agent"
84        ),
85        Err(e) => tracing::warn!(
86            agent_id, caller, error = %e,
87            "identity-store rows left behind for deleted agent"
88        ),
89    }
90}
91
92/// Read the per-block `output.state.json` snapshot from filestore and
93/// extract a `(preview, node_count)` pair for the AgentPicker's
94/// "Recent sessions" list.
95///
96/// The snapshot shape is owned by the frontend (see
97/// `frontend/app/view/agent/agent-view.tsx::writeSnapshotNow`):
98/// `{ schemaVersion, savedAt, highWaterMark, historyOffset, nodes: [DocumentNode...] }`.
99/// We only touch two fields:
100/// - `nodes.length` → `node_count`.
101/// - The first node with `type === "user_message"`, `message` field →
102///   `preview` (trimmed, newlines collapsed, max 240 chars).
103///
104/// On any error (snapshot missing, malformed JSON, no user message),
105/// returns `("", 0)`. Callers treat that the same as "no preview".
106fn read_session_preview(
107    filestore: &crate::backend::storage::filestore::FileStore,
108    block_id: &str,
109) -> (String, usize) {
110    let bytes = match filestore.read_file(block_id, "output.state.json") {
111        Ok(Some(b)) => b,
112        _ => return (String::new(), 0),
113    };
114    // Cap the parse budget — a misbehaving / corrupted snapshot
115    // shouldn't be able to stall this handler. 4MiB is well above the
116    // typical conversation snapshot (Maks's was ~750KiB for 169 nodes)
117    // but bounded enough to fail fast on garbage.
118    if bytes.len() > archive::MAX_PREVIEW_SNAPSHOT_BYTES {
119        tracing::warn!(
120            block_id = %block_id,
121            size = bytes.len(),
122            "listrecentsessions: snapshot too large; skipping preview"
123        );
124        return (String::new(), 0);
125    }
126    archive::snapshot_preview(&bytes)
127}
128
129#[cfg(test)]
130mod recent_sessions_tests {
131    use super::*;
132    use archive::collapse_preview;
133    use crate::backend::rpc_types::{ListRecentSessionsResult, COMMAND_LIST_RECENT_SESSIONS};
134    use crate::backend::storage::filestore::FileStore;
135
136    fn fresh_filestore() -> std::sync::Arc<FileStore> {
137        std::sync::Arc::new(FileStore::open_in_memory().unwrap())
138    }
139
140    fn write_snapshot(fs: &FileStore, block_id: &str, body: &str) {
141        // make_file then write_file mirrors the production
142        // BlockfileWriteState handler path.
143        let meta: crate::backend::storage::filestore::FileMeta =
144            std::collections::HashMap::new();
145        let opts = crate::backend::storage::filestore::FileOpts::default();
146        fs.make_file(block_id, "output.state.json", meta, opts)
147            .expect("make_file");
148        fs.write_file(block_id, "output.state.json", body.as_bytes())
149            .expect("write_file");
150    }
151
152    #[test]
153    fn collapse_preview_strips_newlines_and_caps_length() {
154        let s = "hello\n\nworld\n  next   line";
155        assert_eq!(collapse_preview(s), "hello world next line");
156        let long: String = "a".repeat(500);
157        let out = collapse_preview(&long);
158        // 240 chars + ellipsis.
159        assert!(out.ends_with('\u{2026}'));
160        assert!(out.chars().count() <= 241);
161    }
162
163    #[test]
164    fn read_session_preview_missing_returns_zero() {
165        let fs = fresh_filestore();
166        let (preview, count) = read_session_preview(&fs, "no-such-block");
167        assert_eq!(preview, "");
168        assert_eq!(count, 0);
169    }
170
171    #[test]
172    fn read_session_preview_extracts_first_user_message_skipping_context() {
173        let fs = fresh_filestore();
174        // Two user messages: first is the boilerplate Session Context;
175        // second is the user's real prompt. Preview should be the real one.
176        let snapshot = serde_json::json!({
177            "schemaVersion": 1,
178            "savedAt": "2026-05-23T08:00:00Z",
179            "highWaterMark": 169,
180            "historyOffset": 0,
181            "nodes": [
182                {
183                    "type": "user_message",
184                    "id": "u0",
185                    "timestamp": 0,
186                    "collapsed": false,
187                    "summary": "👤 User Message",
188                    "message": "# Session Context\nIdentity: Claude\n## Description\nStartup boilerplate"
189                },
190                { "type": "markdown", "id": "m0", "content": "ack" },
191                {
192                    "type": "user_message",
193                    "id": "u1",
194                    "timestamp": 100,
195                    "collapsed": false,
196                    "summary": "👤 User Message",
197                    "message": "check the agentmuxai/agentmux history, get the latest code"
198                }
199            ]
200        });
201        write_snapshot(&fs, "blk-1", &snapshot.to_string());
202        let (preview, count) = read_session_preview(&fs, "blk-1");
203        assert_eq!(count, 3);
204        assert!(preview.starts_with("check the agentmuxai/agentmux"));
205    }
206
207    #[test]
208    fn read_session_preview_falls_back_to_session_context_when_only_one() {
209        let fs = fresh_filestore();
210        let snapshot = serde_json::json!({
211            "schemaVersion": 1,
212            "nodes": [
213                {
214                    "type": "user_message",
215                    "id": "u0",
216                    "message": "# Session Context\nIdentity: Claude\nStartup boilerplate"
217                }
218            ]
219        });
220        write_snapshot(&fs, "blk-2", &snapshot.to_string());
221        let (preview, count) = read_session_preview(&fs, "blk-2");
222        assert_eq!(count, 1);
223        // Newlines collapsed; starts with the boilerplate marker.
224        assert!(preview.starts_with("# Session Context"));
225    }
226
227    #[test]
228    fn read_session_preview_handles_malformed_json() {
229        let fs = fresh_filestore();
230        write_snapshot(&fs, "blk-3", "not valid json {");
231        let (preview, count) = read_session_preview(&fs, "blk-3");
232        assert_eq!(preview, "");
233        assert_eq!(count, 0);
234    }
235
236    #[test]
237    fn read_session_preview_handles_no_user_messages() {
238        let fs = fresh_filestore();
239        let snapshot = serde_json::json!({
240            "schemaVersion": 1,
241            "nodes": [
242                { "type": "markdown", "id": "m0", "content": "system note" }
243            ]
244        });
245        write_snapshot(&fs, "blk-4", &snapshot.to_string());
246        let (preview, count) = read_session_preview(&fs, "blk-4");
247        assert_eq!(preview, "");
248        assert_eq!(count, 1);
249    }
250
251    // ── Integration test: full listrecentsessions handler ────────────
252    //
253    // Spins up the same engine + state shape as the production
254    // websocket path so the handler runs end-to-end against an
255    // in-memory mstore + filestore. Asserts the row shape, the
256    // identity filter, the snapshot-first sort, the preview extraction,
257    // and the cross-version "no snapshot" fallback. This is the
258    // backend correctness gate for the AgentPicker's Recent Sessions
259    // surface (cascade follow-up 2026-05-23).
260    use crate::backend::storage::store::{
261        AgentDefinition, AgentInstance, IdentityAccount, InstanceStatus, Bundle, SecretRef, Store,
262    };
263    use crate::backend::rpc::engine::WshRpcEngine;
264    use crate::server::AppState;
265    use std::sync::Arc;
266
267    /// Drive a single RPC round-trip against the in-memory engine,
268    /// asserting success + deserializing the JSON payload into `T`.
269    async fn call_rpc<T: serde::de::DeserializeOwned>(
270        engine: &Arc<WshRpcEngine>,
271        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
272        command: &str,
273        data: serde_json::Value,
274    ) -> T {
275        let req_id = format!("test-{}", uuid::Uuid::new_v4());
276        let msg = crate::backend::rpc_types::RpcMessage {
277            command: command.to_string(),
278            reqid: req_id.clone(),
279            data: Some(data),
280            ..Default::default()
281        };
282        engine.handle_message(msg);
283        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
284            .await
285            .expect("handler timed out")
286            .expect("output channel closed");
287        assert_eq!(resp.resid, req_id, "unexpected response id");
288        assert!(resp.error.is_empty(), "handler returned error: {}", resp.error);
289        let payload = resp.data.unwrap_or(serde_json::Value::Null);
290        serde_json::from_value(payload).expect("response deserialize")
291    }
292
293    fn build_state_with_seed() -> (
294        AppState,
295        Arc<WshRpcEngine>,
296        tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
297    ) {
298        let mstore = Arc::new(Store::open_in_memory().unwrap());
299        let filestore = Arc::new(FileStore::open_in_memory().unwrap());
300        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
301        let broker = Arc::new(crate::backend::mps::Broker::new());
302        let reactive_handler = crate::backend::reactive::get_global_handler();
303        let poller = Arc::new(crate::backend::reactive::Poller::new(
304            crate::backend::reactive::PollerConfig {
305                muxbus_url: None,
306                muxbus_token: None,
307                poll_interval_secs: 30,
308            },
309            reactive_handler,
310        ));
311        crate::backend::wcore::ensure_initial_data(&mstore).unwrap();
312        let config_watcher = Arc::new(crate::backend::wconfig::ConfigState::new());
313        let process_tracker = Arc::new(
314            crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
315        );
316        let process_broker = Arc::new(crate::broker::ProcessBroker::new(Some(broker.clone())));
317        let fs_watch_pool = crate::backend::fs_watch::FsWatchPool::new();
318        let state = AppState {
319            auth_key: "test".to_string(),
320            lan_key: "test-lan".to_string(),
321            boot_id: std::sync::Arc::from("test-boot"),
322            version: "test".to_string(),
323            hostname: "test-host".to_string(),
324            app_path: String::new(),
325            mstore: mstore.clone(),
326            shared_store: None,
327            id_store: mstore.clone(),
328            identity_store: mstore.clone(),
329            filestore: filestore.clone(),
330            global_transcript_store: None,
331            event_bus: event_bus.clone(),
332            broker: broker.clone(),
333            reactive_handler,
334            poller,
335            config_watcher,
336            messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
337            http_client: reqwest::Client::new(),
338            host_ipc: Arc::new(tokio::sync::Mutex::new(None)),
339            host_reg_secret: None,
340            local_web_url: String::new(),
341            subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus.clone(), mstore.clone(), mstore.clone(), mstore.clone())),
342            history_service: Arc::new(crate::backend::history::HistoryService::new()),
343            lan_discovery: Arc::new(crate::backend::lan_discovery::LanDiscoveryController::new(
344                "test-instance".to_string(),
345                "test-host".to_string(),
346                "0.28.20".to_string(),
347                0,
348                event_bus.clone(),
349                String::new(),
350            )),
351            lan_listeners: Arc::new(crate::backend::lan_listeners::LanListenerSupervisor::new(0, 0)),
352            fleet_feed: Arc::new(crate::backend::fleet_feed::FleetFeed::new(
353                "test-host".to_string(),
354                "test-channel".to_string(),
355                "0.0.0-test".to_string(),
356            )),
357            viewer: crate::backend::viewer::ViewerService::new(None, &broker),
358            lsp_supervisor: Arc::new(crate::backend::lsp::LspSupervisor::new(event_bus.clone())),
359            process_tracker,
360            process_broker,
361            dock_snapshots: Arc::new(crate::backend::dock_snapshot::DockSnapshotCache::new()),
362            pending_background_pids: Arc::new(crate::backend::pending_background_pids::PendingBackgroundPids::new()),
363            narrated_events: Arc::new(crate::backend::narrated_events::NarratedEvents::new()),
364            srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
365            srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
366            saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
367            saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
368            auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
369            install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
370            container_manager: Arc::new(crate::backend::container::ContainerRuntimeHandle::disabled()),
371            dev_proxy: crate::backend::dev_proxy::DevProxyRegistry::new(),
372            shell_sessions: crate::backend::shell_node::ShellSessionRegistry::new(),
373            cron_scheduler: crate::backend::cron::CronScheduler::new(
374                None,
375                reqwest::Client::new(),
376                String::new(),
377                "test".to_string(),
378                broker.clone(),
379            ),
380            editor_file_watcher: crate::backend::editor_file_watcher::EditorFileWatcher::new(
381                fs_watch_pool.clone(),
382                broker.clone(),
383            ),
384            media_file_watcher: crate::backend::media_file_watcher::MediaFileWatcher::new(
385                fs_watch_pool.clone(),
386                broker.clone(),
387            ),
388            files_watcher: crate::backend::files_watcher::FilesWatcher::new(
389                fs_watch_pool.clone(),
390                broker.clone(),
391            ),
392            fs_watch_pool: fs_watch_pool.clone(),
393        };
394
395        // Seed: 1 SEEDED definition (template), 1 account + direct
396        // identity link, 1 bundle. Phase 3b note: seeded as a
397        // template so that
398        // each instance projection in `db_agents` lands on its own row
399        // (`is_template = 0`, `id = inst.id`, `parent_template_id =
400        // def.id`) rather than folding into the def-projection and
401        // clobbering its name. The handler resolves `definition_name`
402        // via `defs.iter().find(|d| d.id == inst.definition_id)`, which
403        // hits the template row and returns "Claude Code". Under the
404        // pre-Phase 3b reader, def name was always preserved because
405        // `agent_def_list` queried `db_agent_definitions` directly;
406        // db_agents fold semantics require the seed shape to avoid
407        // the collision.
408        let def = AgentDefinition {
409            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
410            id: "def-claude".to_string(),
411            slug: "claude-code".to_string(),
412            name: "Claude Code".to_string(),
413            icon: String::new(),
414            provider: "claude".to_string(),
415            description: String::new(),
416            working_directory: String::new(),
417            shell: String::new(),
418            provider_flags: String::new(),
419            auto_start: 0,
420            restart_on_crash: 0,
421            idle_timeout_minutes: 0,
422            created_at: 0,
423            agent_type: "host".to_string(),
424            environment: String::new(),
425            agent_bus_id: String::new(),
426            is_seeded: 1,
427            accounts: String::new(),
428            parent_id: String::new(),
429            branch_label: String::new(),
430            updated_at: 0,
431            user_hidden: 0,
432            container_image: String::new(),
433            container_volumes: "[]".to_string(),
434            container_name: String::new(),
435            use_ambient_login: 0,
436            auto_continue_enabled: 0,
437            model_vendor_base_url: String::new(),
438            memory_id: String::new(),
439        };
440        let mut def_mut = def.clone();
441        mstore.agent_def_insert(&mut def_mut).unwrap();
442        // Identity display name resolves via the direct agent<->account
443        // link (db_agent_identity_links/db_accounts) now, not a bundle —
444        // see agent_handlers::session's listrecentsessions.
445        let account = IdentityAccount {
446            id: "acct-work".to_string(),
447            name: "Work".to_string(),
448            provider: "github".to_string(),
449            kind: "pat".to_string(),
450            display_name: String::new(),
451            secret_ref: SecretRef::Env { env_var: "GITHUB_TOKEN".to_string() },
452            context: serde_json::json!({}),
453            status: "unknown".to_string(),
454            created_at: 0,
455            updated_at: 0,
456        };
457        mstore.identity_upsert(&account).unwrap();
458        // Linked to the template: `db_agent_identity_links` still has its FK
459        // on `db_agent_definitions`, so a launched agent's own row cannot
460        // hold a link until Phase 3c re-points it. The handler therefore
461        // falls back to the template's links — see its own note.
462        mstore
463            .agent_identity_link("def-claude", "acct-work", "github")
464            .unwrap();
465        let memory = Bundle {
466            id: "mem-notes".to_string(),
467            name: "Notes".to_string(),
468            description: String::new(),
469            is_blank: false,
470            is_global: false,
471            provider: String::new(),
472            model: String::new(),
473            instructions: String::new(),
474            instructions_by_provider: "{}".to_string(),
475            context_files: "[]".to_string(),
476            mcp_servers: "[]".to_string(),
477            skills: "[]".to_string(),
478            sort_order: 0,
479            created_at: 0,
480            updated_at: 0,
481            is_system: false,
482        };
483        mstore.bundle_upsert(&memory).unwrap();
484
485        // 3 instances:
486        //   - blk-recent: has snapshot, more recent activity
487        //   - blk-older:  has snapshot, older activity
488        //   - blk-none:   no snapshot at all (legacy / pre-persistence row)
489        // All three share the same identity_id value so the filter test
490        // can also exercise it without re-seeding.
491        for (id, block, started) in [
492            ("inst-recent", "blk-recent", 1_700_000_100_000_i64),
493            ("inst-older", "blk-older", 1_700_000_000_000_i64),
494            ("inst-none", "blk-none", 1_700_000_050_000_i64),
495        ] {
496            let inst = AgentInstance {
497                id: id.to_string(),
498                definition_id: "def-claude".to_string(),
499                parent_instance_id: String::new(),
500                block_id: block.to_string(),
501                session_id: String::new(),
502                status: InstanceStatus::Running.as_str().to_string(),
503                github_context: String::new(),
504                started_at: started,
505                ended_at: 0,
506                created_at: started,
507                identity_id: "id-work".to_string(),
508                memory_id: "mem-notes".to_string(),
509                instance_name: format!("name-{id}"),
510                working_directory: format!("/tmp/{id}"),
511                display_hidden: false,
512            };
513            mstore.instance_create(&inst).unwrap();
514        }
515
516        // Snapshots for the two with snapshots. Write the OLDER one
517        // first so its filestore-stamped modts is strictly less than the
518        // recent one — the handler sorts snapshot-bearing rows by modts
519        // desc, so writing blk-older second would invert the assertions.
520        // (Pre-Phase 3b this ordering was fragile because the dual-write
521        // chain ran fewer SQL statements between successive inserts, so
522        // adjacent writes landed in the same millisecond and the stable
523        // sort preserved instance_list_named's started_at order; now the
524        // additional db_agents UPDATE per instance widens the gap and
525        // distinct modts dominate the stable sort.)
526        let snap_older = serde_json::json!({
527            "schemaVersion": 1,
528            "nodes": [
529                {"type": "user_message", "id": "u0",
530                 "message": "earlier conversation"}
531            ]
532        });
533        write_snapshot(&filestore, "blk-older", &snap_older.to_string());
534        let snap_recent = serde_json::json!({
535            "schemaVersion": 1,
536            "nodes": [
537                {"type": "user_message", "id": "u0",
538                 "message": "# Session Context\nboilerplate"},
539                {"type": "markdown", "id": "m0", "content": "ack"},
540                {"type": "user_message", "id": "u1",
541                 "message": "fix the live-feed hover delay"}
542            ]
543        });
544        write_snapshot(&filestore, "blk-recent", &snap_recent.to_string());
545
546        let (engine, rx) = WshRpcEngine::new();
547        super::register_agent_handlers(&engine, &state);
548        (state, engine, rx)
549    }
550
551    #[tokio::test]
552    async fn handler_returns_sessions_with_previews_sorted_by_snapshot_first() {
553        let (_state, engine, mut rx) = build_state_with_seed();
554        let result: ListRecentSessionsResult = call_rpc(
555            &engine,
556            &mut rx,
557            COMMAND_LIST_RECENT_SESSIONS,
558            serde_json::json!({}),
559        )
560        .await;
561        let rows = result.rows;
562        assert!(result.degraded.is_empty(), "healthy seed must report no degraded sources");
563        assert_eq!(rows.len(), 3, "all three sessions surfaced");
564
565        // Sort: snapshot-bearing rows first (recent then older), then
566        // the no-snapshot row at the tail.
567        assert_eq!(rows[0].instance_id, "inst-recent");
568        assert!(rows[0].has_snapshot);
569        assert_eq!(rows[0].node_count, 3);
570        assert!(
571            rows[0].preview.starts_with("fix the live-feed"),
572            "preview should be the post-context user message, got {:?}",
573            rows[0].preview
574        );
575
576        assert_eq!(rows[1].instance_id, "inst-older");
577        assert!(rows[1].has_snapshot);
578        assert_eq!(rows[1].node_count, 1);
579        assert_eq!(rows[1].preview, "earlier conversation");
580
581        assert_eq!(rows[2].instance_id, "inst-none");
582        assert!(!rows[2].has_snapshot);
583        assert_eq!(rows[2].node_count, 0);
584        assert_eq!(rows[2].preview, "");
585
586        // Joins: definition + identity + memory names resolved.
587        // Consolidation Phase 3b: the row's name IS the agent's own name, and
588        // `instance_create` names a template launch after the launch. The
589        // template's "Claude Code" is no longer what this field carries — the
590        // picker renders `instance_name || definition_name` (MyAgentsList.tsx),
591        // so this is a fallback the UI only reaches for a nameless row.
592        assert_eq!(rows[0].definition_name, "name-inst-recent");
593        assert_eq!(rows[0].identity_name, "Work");
594        assert_eq!(rows[0].memory_name, "Notes");
595        assert_eq!(rows[0].block_id_hint, "blk-recent");
596    }
597
598    #[tokio::test]
599    async fn handler_identity_filter_restricts_rows() {
600        let (_state, engine, mut rx) = build_state_with_seed();
601        // Filter to a non-existent identity → empty list.
602        let result: ListRecentSessionsResult = call_rpc(
603            &engine,
604            &mut rx,
605            COMMAND_LIST_RECENT_SESSIONS,
606            serde_json::json!({ "identity_id": "no-such-bundle" }),
607        )
608        .await;
609        assert_eq!(result.rows.len(), 0);
610
611        // Filter to the seeded one → all three.
612        let result: ListRecentSessionsResult = call_rpc(
613            &engine,
614            &mut rx,
615            COMMAND_LIST_RECENT_SESSIONS,
616            serde_json::json!({ "identity_id": "id-work" }),
617        )
618        .await;
619        assert_eq!(result.rows.len(), 3);
620
621        // Empty-string identity_id is treated as "no filter" so the
622        // frontend can pass `""` without special-casing.
623        let result: ListRecentSessionsResult = call_rpc(
624            &engine,
625            &mut rx,
626            COMMAND_LIST_RECENT_SESSIONS,
627            serde_json::json!({ "identity_id": "" }),
628        )
629        .await;
630        assert_eq!(result.rows.len(), 3);
631    }
632
633    #[tokio::test]
634    async fn handler_respects_limit() {
635        let (_state, engine, mut rx) = build_state_with_seed();
636        let result: ListRecentSessionsResult = call_rpc(
637            &engine,
638            &mut rx,
639            COMMAND_LIST_RECENT_SESSIONS,
640            serde_json::json!({ "limit": 1 }),
641        )
642        .await;
643        assert_eq!(result.rows.len(), 1);
644        assert_eq!(result.rows[0].instance_id, "inst-recent");
645    }
646
647    // ---- Two-tier picker Phase 1: create-from-template + listagents filter ----
648    //
649    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md.
650
651    /// Same shape as build_state_with_seed but with a seeded template
652    /// and no instances, so the create-from-template path is exercised
653    /// against a known-good template row.
654    fn build_state_with_template_seed() -> (
655        AppState,
656        Arc<WshRpcEngine>,
657        tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
658    ) {
659        let mstore = Arc::new(Store::open_in_memory().unwrap());
660        let filestore = Arc::new(FileStore::open_in_memory().unwrap());
661        let event_bus = Arc::new(crate::backend::eventbus::EventBus::new());
662        let broker = Arc::new(crate::backend::mps::Broker::new());
663        let reactive_handler = crate::backend::reactive::get_global_handler();
664        let poller = Arc::new(crate::backend::reactive::Poller::new(
665            crate::backend::reactive::PollerConfig {
666                muxbus_url: None,
667                muxbus_token: None,
668                poll_interval_secs: 30,
669            },
670            reactive_handler,
671        ));
672        crate::backend::wcore::ensure_initial_data(&mstore).unwrap();
673        let config_watcher = Arc::new(crate::backend::wconfig::ConfigState::new());
674        let process_tracker = Arc::new(
675            crate::backend::process_tracker::registry::AgentProcessRegistry::new(Some(broker.clone())),
676        );
677        let process_broker = Arc::new(crate::broker::ProcessBroker::new(Some(broker.clone())));
678        let fs_watch_pool = crate::backend::fs_watch::FsWatchPool::new();
679        let state = AppState {
680            auth_key: "test".to_string(),
681            lan_key: "test-lan".to_string(),
682            boot_id: std::sync::Arc::from("test-boot"),
683            version: "test".to_string(),
684            hostname: "test-host".to_string(),
685            app_path: String::new(),
686            mstore: mstore.clone(),
687            shared_store: None,
688            id_store: mstore.clone(),
689            identity_store: mstore.clone(),
690            filestore: filestore.clone(),
691            global_transcript_store: None,
692            event_bus: event_bus.clone(),
693            broker: broker.clone(),
694            reactive_handler,
695            poller,
696            config_watcher,
697            messagebus: Arc::new(crate::backend::messagebus::MessageBus::new()),
698            http_client: reqwest::Client::new(),
699            host_ipc: Arc::new(tokio::sync::Mutex::new(None)),
700            host_reg_secret: None,
701            local_web_url: String::new(),
702            subagent_watcher: Arc::new(crate::backend::subagent_watcher::SubagentWatcher::new(event_bus.clone(), mstore.clone(), mstore.clone(), mstore.clone())),
703            history_service: Arc::new(crate::backend::history::HistoryService::new()),
704            lan_discovery: Arc::new(crate::backend::lan_discovery::LanDiscoveryController::new(
705                "test-instance".to_string(),
706                "test-host".to_string(),
707                "0.28.20".to_string(),
708                0,
709                event_bus.clone(),
710                String::new(),
711            )),
712            lan_listeners: Arc::new(crate::backend::lan_listeners::LanListenerSupervisor::new(0, 0)),
713            fleet_feed: Arc::new(crate::backend::fleet_feed::FleetFeed::new(
714                "test-host".to_string(),
715                "test-channel".to_string(),
716                "0.0.0-test".to_string(),
717            )),
718            viewer: crate::backend::viewer::ViewerService::new(None, &broker),
719            lsp_supervisor: Arc::new(crate::backend::lsp::LspSupervisor::new(event_bus.clone())),
720            process_tracker,
721            process_broker,
722            dock_snapshots: Arc::new(crate::backend::dock_snapshot::DockSnapshotCache::new()),
723            pending_background_pids: Arc::new(crate::backend::pending_background_pids::PendingBackgroundPids::new()),
724            narrated_events: Arc::new(crate::backend::narrated_events::NarratedEvents::new()),
725            srv_state: Arc::new(tokio::sync::Mutex::new(crate::state::State::default())),
726            srv_events_tx: tokio::sync::broadcast::channel::<agentmux_common::ipc::Event>(64).0,
727            saga_id_alloc: Arc::new(std::sync::atomic::AtomicU64::new(0)),
728            saga_log: Arc::new(crate::sagas::log::SagaLog::open_in_memory().unwrap()),
729            auth_session_manager: Arc::new(crate::identity::auth_session::AuthSessionManager::new()),
730            install_sessions: crate::server::install_handlers::InstallSessionRegistry::new(),
731            container_manager: Arc::new(crate::backend::container::ContainerRuntimeHandle::disabled()),
732            dev_proxy: crate::backend::dev_proxy::DevProxyRegistry::new(),
733            shell_sessions: crate::backend::shell_node::ShellSessionRegistry::new(),
734            cron_scheduler: crate::backend::cron::CronScheduler::new(
735                None,
736                reqwest::Client::new(),
737                String::new(),
738                "test".to_string(),
739                broker.clone(),
740            ),
741            editor_file_watcher: crate::backend::editor_file_watcher::EditorFileWatcher::new(
742                fs_watch_pool.clone(),
743                broker.clone(),
744            ),
745            media_file_watcher: crate::backend::media_file_watcher::MediaFileWatcher::new(
746                fs_watch_pool.clone(),
747                broker.clone(),
748            ),
749            files_watcher: crate::backend::files_watcher::FilesWatcher::new(
750                fs_watch_pool.clone(),
751                broker.clone(),
752            ),
753            fs_watch_pool: fs_watch_pool.clone(),
754        };
755
756        // One seeded template + one already-user-owned definition.
757        let mut tpl = AgentDefinition {
758            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
759            id: "tpl-claude".to_string(),
760            slug: String::new(),
761            name: "Claude Code".to_string(),
762            icon: String::new(),
763            provider: "claude".to_string(),
764            description: "Anthropic's coding agent".to_string(),
765            working_directory: String::new(),
766            shell: String::new(),
767            provider_flags: "--model haiku".to_string(),
768            auto_start: 0,
769            restart_on_crash: 0,
770            idle_timeout_minutes: 0,
771            created_at: 1_700_000_000_000,
772            agent_type: "host".to_string(),
773            environment: String::new(),
774            agent_bus_id: String::new(),
775            is_seeded: 1,
776            accounts: String::new(),
777            parent_id: String::new(),
778            branch_label: String::new(),
779            updated_at: 1_700_000_000_000,
780            user_hidden: 0,
781            container_image: String::new(),
782            container_volumes: "[]".to_string(),
783            container_name: String::new(),
784            use_ambient_login: 0,
785            auto_continue_enabled: 0,
786            model_vendor_base_url: String::new(),
787            memory_id: String::new(),
788        };
789        mstore.agent_def_insert(&mut tpl).unwrap();
790
791        let mut user_a = AgentDefinition {
792            conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
793            id: "user-a".to_string(),
794            slug: String::new(),
795            name: "Maks".to_string(),
796            icon: String::new(),
797            provider: "claude".to_string(),
798            description: String::new(),
799            working_directory: String::new(),
800            shell: String::new(),
801            provider_flags: String::new(),
802            auto_start: 0,
803            restart_on_crash: 0,
804            idle_timeout_minutes: 0,
805            created_at: 1_700_000_001_000,
806            agent_type: "host".to_string(),
807            environment: String::new(),
808            agent_bus_id: String::new(),
809            is_seeded: 0,
810            accounts: String::new(),
811            parent_id: String::new(),
812            branch_label: String::new(),
813            updated_at: 1_700_000_001_000,
814            user_hidden: 0,
815            container_image: String::new(),
816            container_volumes: "[]".to_string(),
817            container_name: String::new(),
818            use_ambient_login: 0,
819            auto_continue_enabled: 0,
820            model_vendor_base_url: String::new(),
821            memory_id: String::new(),
822        };
823        mstore.agent_def_insert(&mut user_a).unwrap();
824
825        let (engine, rx) = WshRpcEngine::new();
826        super::register_agent_handlers(&engine, &state);
827        (state, engine, rx)
828    }
829
830    #[tokio::test]
831    async fn listagents_no_filter_returns_all() {
832        let (_state, engine, mut rx) = build_state_with_template_seed();
833        let agents: Vec<AgentDefinition> = call_rpc(
834            &engine,
835            &mut rx,
836            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
837            serde_json::json!({}),
838        )
839        .await;
840        assert!(agents.iter().any(|a| a.id == "tpl-claude"));
841        assert!(agents.iter().any(|a| a.id == "user-a"));
842    }
843
844    #[tokio::test]
845    async fn listagents_filter_templates_only() {
846        let (_state, engine, mut rx) = build_state_with_template_seed();
847        let agents: Vec<AgentDefinition> = call_rpc(
848            &engine,
849            &mut rx,
850            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
851            serde_json::json!({ "is_seeded": 1 }),
852        )
853        .await;
854        assert!(agents.iter().all(|a| a.is_seeded == 1));
855        assert!(agents.iter().any(|a| a.id == "tpl-claude"));
856        assert!(!agents.iter().any(|a| a.id == "user-a"));
857    }
858
859    #[tokio::test]
860    async fn listagents_filter_user_owned_only() {
861        let (_state, engine, mut rx) = build_state_with_template_seed();
862        let agents: Vec<AgentDefinition> = call_rpc(
863            &engine,
864            &mut rx,
865            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
866            serde_json::json!({ "is_seeded": 0 }),
867        )
868        .await;
869        assert!(agents.iter().all(|a| a.is_seeded == 0));
870        assert!(agents.iter().any(|a| a.id == "user-a"));
871        assert!(!agents.iter().any(|a| a.id == "tpl-claude"));
872    }
873
874    #[tokio::test]
875    async fn create_from_template_happy_path_clones_and_returns_id() {
876        let (_state, engine, mut rx) = build_state_with_template_seed();
877        let resp: crate::backend::rpc_types::AgentDefCreateFromTemplateResult = call_rpc(
878            &engine,
879            &mut rx,
880            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
881            serde_json::json!({
882                "template_id": "tpl-claude",
883                "name": "Asaf",
884                "identity_id": "id-work",
885                "memory_id": "mem-notes",
886            }),
887        )
888        .await;
889        assert!(!resp.definition_id.is_empty());
890        assert_eq!(resp.identity_id, "id-work");
891        assert_eq!(resp.memory_id, "mem-notes");
892
893        // The new row is user-owned, carries provider + flags from template.
894        let agents: Vec<AgentDefinition> = call_rpc(
895            &engine,
896            &mut rx,
897            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
898            serde_json::json!({}),
899        )
900        .await;
901        let new_def = agents
902            .iter()
903            .find(|a| a.id == resp.definition_id)
904            .expect("new definition should appear in listagents");
905        assert_eq!(new_def.is_seeded, 0);
906        assert_eq!(new_def.name, "Asaf");
907        assert_eq!(new_def.provider, "claude");
908        assert_eq!(new_def.provider_flags, "--model haiku");
909        assert_eq!(new_def.parent_id, "tpl-claude");
910    }
911
912    /// #3573: the response carries the slug the backend gave the new row,
913    /// punctuation-trimmed and collision-suffixed, so the launch's
914    /// `AGENTMUX_AGENT_ID` names this agent and not another (or none).
915    #[tokio::test]
916    async fn create_from_template_returns_the_rows_own_slug() {
917        let (_state, engine, mut rx) = build_state_with_template_seed();
918        let mut created = Vec::new();
919        for name in ["Reviewer!", "Reviewer?"] {
920            let resp: crate::backend::rpc_types::AgentDefCreateFromTemplateResult = call_rpc(
921                &engine,
922                &mut rx,
923                crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
924                serde_json::json!({ "template_id": "tpl-claude", "name": name }),
925            )
926            .await;
927            created.push(resp);
928        }
929        let (first, second) = (&created[0], &created[1]);
930        assert_eq!(first.slug, "reviewer");
931        assert_eq!(second.slug, "reviewer-2");
932
933        let agents: Vec<AgentDefinition> = call_rpc(
934            &engine,
935            &mut rx,
936            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
937            serde_json::json!({}),
938        )
939        .await;
940        for resp in &created {
941            let row = agents.iter().find(|a| a.id == resp.definition_id).unwrap();
942            assert_eq!(row.slug, resp.slug);
943        }
944    }
945
946    // ---- Mandatory ABF: definition-time bundle provisioning ----
947    //
948    // ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md §3.2 — every
949    // freshly-created agent definition gets its own dedicated bundle
950    // (not a shared/inherited one), with provider/model derived from the
951    // agent's own harness. m0021 (crates/srv/src/migrations/
952    // m0021_backfill_agent_bundles.rs) covers the backfill half for
953    // agents that already existed before this shipped; these tests cover
954    // the forward-going creation paths.
955
956    #[tokio::test]
957    async fn create_from_template_provisions_its_own_bundle() {
958        let (state, engine, mut rx) = build_state_with_template_seed();
959        let resp: crate::backend::rpc_types::AgentDefCreateFromTemplateResult = call_rpc(
960            &engine,
961            &mut rx,
962            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
963            serde_json::json!({
964                "template_id": "tpl-claude",
965                "name": "Asaf",
966            }),
967        )
968        .await;
969        let new_def = state
970            .mstore
971            .agent_def_get(&resp.definition_id)
972            .unwrap()
973            .expect("new definition should exist");
974        assert!(!new_def.memory_id.is_empty(), "new definition must have a bundle bound");
975        let bundle = state
976            .mstore
977            .bundle_get(&new_def.memory_id)
978            .unwrap()
979            .expect("bound bundle should exist");
980        assert!(!bundle.is_blank, "must be a real bundle, not the shared blank singleton");
981        assert_eq!((bundle.provider.as_str(), bundle.model.as_str()), ("", ""), "the bundle carries no harness");
982        assert!(bundle.name.contains("Asaf"));
983
984        // "Own" bundle — not the template's own (empty, since tpl-claude
985        // was seeded directly via agent_def_insert, bypassing this path).
986        let tpl = state.mstore.agent_def_get("tpl-claude").unwrap().unwrap();
987        assert_ne!(new_def.memory_id, tpl.memory_id);
988    }
989
990    #[tokio::test]
991    async fn fork_agent_definition_provisions_its_own_bundle_distinct_from_source() {
992        let (state, engine, mut rx) = build_state_with_template_seed();
993        // forkagentdefinition returns the full new AgentDefinition directly.
994        let fork_resp: AgentDefinition = call_rpc(
995            &engine,
996            &mut rx,
997            crate::backend::rpc_types::COMMAND_FORK_AGENT_DEFINITION,
998            serde_json::json!({ "source_id": "user-a", "branch_label": "" }),
999        )
1000        .await;
1001        let fork = state
1002            .mstore
1003            .agent_def_get(&fork_resp.id)
1004            .unwrap()
1005            .expect("fork should exist");
1006        assert!(!fork.memory_id.is_empty(), "fork must have its own bundle bound");
1007        assert_eq!(fork.memory_id, fork_resp.memory_id, "response should already reflect the bound bundle");
1008
1009        // "user-a" was seeded directly via agent_def_insert (empty memory_id),
1010        // so this also proves the fork didn't just inherit a shared/parent id.
1011        let source = state.mstore.agent_def_get("user-a").unwrap().unwrap();
1012        assert_ne!(fork.memory_id, source.memory_id);
1013    }
1014
1015    #[tokio::test]
1016    async fn createagent_provisions_own_bundle_and_response_reflects_it() {
1017        let (state, engine, mut rx) = build_state_with_template_seed();
1018        let created: AgentDefinition = call_rpc(
1019            &engine,
1020            &mut rx,
1021            crate::backend::rpc_types::COMMAND_CREATE_AGENT,
1022            serde_json::json!({ "name": "Codex Agent", "provider": "codex" }),
1023        )
1024        .await;
1025        // The RPC response itself must reflect the bound bundle, not the
1026        // empty placeholder the struct started with — createagent
1027        // serializes the same in-memory struct that
1028        // agent_def_provision_and_bind_bundle mutates.
1029        assert!(!created.memory_id.is_empty(), "response should already reflect the bound bundle");
1030
1031        let stored = state.mstore.agent_def_get(&created.id).unwrap().unwrap();
1032        assert_eq!(stored.memory_id, created.memory_id);
1033        let bundle = state.mstore.bundle_get(&created.memory_id).unwrap().unwrap();
1034        assert_eq!((bundle.provider.as_str(), bundle.model.as_str()), ("", ""), "the bundle carries no harness");
1035        assert_eq!(stored.provider, "codex");
1036    }
1037
1038    // agent.define's own provisioning coverage lives in
1039    // server/app_api/agent_define.rs's test module (it calls
1040    // agent_define_core directly — that RPC isn't wired onto this file's
1041    // build_state_with_template_seed engine, which only registers
1042    // register_agent_handlers, not app_api::register).
1043
1044    async fn call_rpc_expect_error(
1045        engine: &Arc<WshRpcEngine>,
1046        rx: &mut tokio::sync::mpsc::UnboundedReceiver<crate::backend::rpc_types::RpcMessage>,
1047        command: &str,
1048        data: serde_json::Value,
1049    ) -> String {
1050        let req_id = format!("test-{}", uuid::Uuid::new_v4());
1051        let msg = crate::backend::rpc_types::RpcMessage {
1052            command: command.to_string(),
1053            reqid: req_id.clone(),
1054            data: Some(data),
1055            ..Default::default()
1056        };
1057        engine.handle_message(msg);
1058        let resp = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
1059            .await
1060            .expect("handler timed out")
1061            .expect("output channel closed");
1062        assert_eq!(resp.resid, req_id);
1063        assert!(
1064            !resp.error.is_empty(),
1065            "expected error, got success payload: {:?}",
1066            resp.data
1067        );
1068        resp.error
1069    }
1070
1071    #[tokio::test]
1072    async fn create_from_template_rejects_non_template_id() {
1073        let (_state, engine, mut rx) = build_state_with_template_seed();
1074        // "user-a" is is_seeded=0 — not a template.
1075        let err = call_rpc_expect_error(
1076            &engine,
1077            &mut rx,
1078            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
1079            serde_json::json!({
1080                "template_id": "user-a",
1081                "name": "another",
1082            }),
1083        )
1084        .await;
1085        assert!(
1086            err.contains("not a seeded template"),
1087            "wrong error: {err}"
1088        );
1089    }
1090
1091    #[tokio::test]
1092    async fn create_from_template_rejects_unknown_template_id() {
1093        let (_state, engine, mut rx) = build_state_with_template_seed();
1094        let err = call_rpc_expect_error(
1095            &engine,
1096            &mut rx,
1097            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
1098            serde_json::json!({
1099                "template_id": "no-such-id",
1100                "name": "x",
1101            }),
1102        )
1103        .await;
1104        assert!(err.contains("not found"), "wrong error: {err}");
1105    }
1106
1107    #[tokio::test]
1108    async fn create_from_template_rejects_duplicate_user_name() {
1109        let (_state, engine, mut rx) = build_state_with_template_seed();
1110        // "Maks" already exists as a user-owned agent.
1111        let err = call_rpc_expect_error(
1112            &engine,
1113            &mut rx,
1114            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
1115            serde_json::json!({
1116                "template_id": "tpl-claude",
1117                "name": "Maks",
1118            }),
1119        )
1120        .await;
1121        assert!(
1122            err.contains("already exists"),
1123            "wrong error: {err}"
1124        );
1125    }
1126
1127    #[tokio::test]
1128    async fn create_from_template_rejects_empty_name() {
1129        let (_state, engine, mut rx) = build_state_with_template_seed();
1130        let err = call_rpc_expect_error(
1131            &engine,
1132            &mut rx,
1133            crate::backend::rpc_types::COMMAND_AGENT_DEF_CREATE_FROM_TEMPLATE,
1134            serde_json::json!({
1135                "template_id": "tpl-claude",
1136                "name": "   ",
1137            }),
1138        )
1139        .await;
1140        assert!(err.contains("non-empty"), "wrong error: {err}");
1141    }
1142
1143    // ---- Two-tier picker Phase 2: hide / unhide templates ----
1144    //
1145    // SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md Q2 Decision Y.
1146
1147    #[tokio::test]
1148    async fn hide_template_then_listagents_excludes_it_by_default() {
1149        let (_state, engine, mut rx) = build_state_with_template_seed();
1150
1151        // Before hide: template is in the default listagents result.
1152        let before: Vec<AgentDefinition> = call_rpc(
1153            &engine,
1154            &mut rx,
1155            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
1156            serde_json::json!({}),
1157        )
1158        .await;
1159        assert!(before.iter().any(|a| a.id == "tpl-claude"));
1160
1161        // Hide the template.
1162        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
1163            &engine,
1164            &mut rx,
1165            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1166            serde_json::json!({ "definition_id": "tpl-claude" }),
1167        )
1168        .await;
1169        assert!(resp.ok);
1170
1171        // After hide: default listagents no longer surfaces it.
1172        let after: Vec<AgentDefinition> = call_rpc(
1173            &engine,
1174            &mut rx,
1175            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
1176            serde_json::json!({}),
1177        )
1178        .await;
1179        assert!(
1180            !after.iter().any(|a| a.id == "tpl-claude"),
1181            "hidden template should NOT appear by default",
1182        );
1183
1184        // But user-owned rows (is_seeded=0) still appear — hide only
1185        // affects templates.
1186        assert!(after.iter().any(|a| a.id == "user-a"));
1187
1188        // include_hidden = true brings it back (settings panel surface).
1189        let included: Vec<AgentDefinition> = call_rpc(
1190            &engine,
1191            &mut rx,
1192            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
1193            serde_json::json!({ "include_hidden": true }),
1194        )
1195        .await;
1196        let tpl = included
1197            .iter()
1198            .find(|a| a.id == "tpl-claude")
1199            .expect("hidden template should appear with include_hidden=true");
1200        assert_eq!(tpl.user_hidden, 1);
1201    }
1202
1203    /// The typed registration path records what it registers.
1204    ///
1205    /// This is the property the whole codegen plan rests on
1206    /// (`docs/specs/SPEC_RPC_BINDINGS_CODEGEN_2026_09_07.md`): the command →
1207    /// request/response mapping has to be data the process can hand out, and
1208    /// it has to come from registration itself rather than a second list
1209    /// someone maintains by hand. Asserting the exact type names means a
1210    /// rename that silently changes the generated bindings fails here.
1211    #[tokio::test]
1212    async fn register_typed_records_each_command_request_and_response_type() {
1213        let (_state, engine, _rx) = build_state_with_template_seed();
1214        let schema = engine.schema_json();
1215        let rows = schema.as_array().expect("schema is an array");
1216
1217        let find = |cmd: &str| {
1218            rows.iter()
1219                .find(|r| r["command"] == cmd)
1220                .unwrap_or_else(|| panic!("{cmd} missing from the schema — was it registered with register_handler instead of register_typed?"))
1221        };
1222
1223        for cmd in [
1224            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1225            crate::backend::rpc_types::COMMAND_AGENT_DEF_UNHIDE,
1226        ] {
1227            let row = find(cmd);
1228            assert_eq!(row["requestName"], "CommandAgentDefHideData", "{cmd} request type");
1229            assert_eq!(row["responseName"], "AgentDefHideResult", "{cmd} response type");
1230            // The full path is what disambiguates two same-named types in
1231            // different modules, so it must be a path, not a bare name.
1232            assert!(
1233                row["request"].as_str().unwrap().contains("::"),
1234                "{cmd} request should be fully qualified, got {:?}",
1235                row["request"]
1236            );
1237        }
1238
1239        // `getagentcontent` answers `Option<AgentContent>` rather than
1240        // hand-serializing to a `Value`. That distinction is invisible on the
1241        // wire — both send the same JSON — and only shows up here, in what the
1242        // registry recorded. Serializing by hand would record `Value` and quietly
1243        // drop this one endpoint out of the drift net the migration exists to
1244        // build, so assert the recorded type, not the response body.
1245        let content_row = find(crate::backend::rpc_types::COMMAND_GET_AGENT_CONTENT);
1246        let recorded = content_row["response"].as_str().unwrap_or_default();
1247        assert!(
1248            recorded.contains("AgentContent"),
1249            "getagentcontent should record AgentContent, got {recorded:?}",
1250        );
1251        assert!(
1252            !recorded.contains("serde_json::Value"),
1253            "getagentcontent must not hand-serialize its response — that records              Value and leaves it outside the drift net; got {recorded:?}",
1254        );
1255
1256        // `listagents` tolerates a null/malformed body for older clients, so its
1257        // request stays `Value` (see its registration in core.rs). Its response is
1258        // still recorded, which is where the drift risk lived.
1259        let list = find(crate::backend::rpc_types::COMMAND_LIST_AGENTS);
1260        assert_eq!(list["requestName"], "Value");
1261        assert_eq!(list["responseTs"]["name"], "Array<AgentDefinition>");
1262    }
1263
1264    /// A migrated handler answers exactly as it did before, including the
1265    /// error shape for a malformed request.
1266    #[tokio::test]
1267    async fn register_typed_preserves_the_wire_behaviour_of_a_migrated_handler() {
1268        let (_state, engine, mut rx) = build_state_with_template_seed();
1269
1270        // Happy path: unchanged response body.
1271        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
1272            &engine,
1273            &mut rx,
1274            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1275            serde_json::json!({ "definition_id": "tpl-claude" }),
1276        )
1277        .await;
1278        assert!(resp.ok);
1279
1280        // Malformed request: still an RPC error, still prefixed with the
1281        // command name the hand-written body used.
1282        let err = call_rpc_expect_error(
1283            &engine,
1284            &mut rx,
1285            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1286            serde_json::json!({ "definition_id": 42 }),
1287        )
1288        .await;
1289        assert!(
1290            err.starts_with("agentdefhide:"),
1291            "error should keep the command prefix, got {err:?}",
1292        );
1293    }
1294
1295    #[tokio::test]
1296    async fn hide_then_unhide_round_trip() {
1297        let (_state, engine, mut rx) = build_state_with_template_seed();
1298        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
1299            &engine,
1300            &mut rx,
1301            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1302            serde_json::json!({ "definition_id": "tpl-claude" }),
1303        )
1304        .await;
1305        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
1306            &engine,
1307            &mut rx,
1308            crate::backend::rpc_types::COMMAND_AGENT_DEF_UNHIDE,
1309            serde_json::json!({ "definition_id": "tpl-claude" }),
1310        )
1311        .await;
1312        assert!(resp.ok);
1313        // Listagents now shows it again, default-filter included.
1314        let agents: Vec<AgentDefinition> = call_rpc(
1315            &engine,
1316            &mut rx,
1317            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
1318            serde_json::json!({}),
1319        )
1320        .await;
1321        let tpl = agents
1322            .iter()
1323            .find(|a| a.id == "tpl-claude")
1324            .expect("unhidden template should appear");
1325        assert_eq!(tpl.user_hidden, 0);
1326    }
1327
1328    #[tokio::test]
1329    async fn hide_rejects_user_owned_definition() {
1330        let (_state, engine, mut rx) = build_state_with_template_seed();
1331        // "user-a" is is_seeded=0 — hide must reject.
1332        let err = call_rpc_expect_error(
1333            &engine,
1334            &mut rx,
1335            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1336            serde_json::json!({ "definition_id": "user-a" }),
1337        )
1338        .await;
1339        assert!(
1340            err.contains("not a seeded template"),
1341            "wrong error: {err}"
1342        );
1343    }
1344
1345    #[tokio::test]
1346    async fn hide_unknown_id_returns_ok_false() {
1347        let (_state, engine, mut rx) = build_state_with_template_seed();
1348        let resp: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
1349            &engine,
1350            &mut rx,
1351            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1352            serde_json::json!({ "definition_id": "no-such-id" }),
1353        )
1354        .await;
1355        assert!(!resp.ok);
1356    }
1357
1358    #[tokio::test]
1359    async fn list_hidden_templates_returns_only_hidden_templates() {
1360        let (_state, engine, mut rx) = build_state_with_template_seed();
1361        // Empty initially.
1362        let empty: Vec<AgentDefinition> = call_rpc(
1363            &engine,
1364            &mut rx,
1365            crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
1366            serde_json::json!({}),
1367        )
1368        .await;
1369        assert!(empty.is_empty());
1370
1371        // Hide one; expect it to surface.
1372        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
1373            &engine,
1374            &mut rx,
1375            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1376            serde_json::json!({ "definition_id": "tpl-claude" }),
1377        )
1378        .await;
1379        let hidden: Vec<AgentDefinition> = call_rpc(
1380            &engine,
1381            &mut rx,
1382            crate::backend::rpc_types::COMMAND_AGENT_DEF_LIST_HIDDEN_TEMPLATES,
1383            serde_json::json!({}),
1384        )
1385        .await;
1386        assert_eq!(hidden.len(), 1);
1387        assert_eq!(hidden[0].id, "tpl-claude");
1388        assert_eq!(hidden[0].is_seeded, 1);
1389        assert_eq!(hidden[0].user_hidden, 1);
1390    }
1391
1392    #[tokio::test]
1393    async fn listagents_is_seeded_filter_with_include_hidden_combines() {
1394        // Templates-only filter + include_hidden = the settings panel's
1395        // canonical query if it ever wanted the full template universe.
1396        // Without include_hidden + is_seeded=1 the hidden ones drop out.
1397        let (_state, engine, mut rx) = build_state_with_template_seed();
1398        let _: crate::backend::rpc_types::AgentDefHideResult = call_rpc(
1399            &engine,
1400            &mut rx,
1401            crate::backend::rpc_types::COMMAND_AGENT_DEF_HIDE,
1402            serde_json::json!({ "definition_id": "tpl-claude" }),
1403        )
1404        .await;
1405        let templates_visible: Vec<AgentDefinition> = call_rpc(
1406            &engine,
1407            &mut rx,
1408            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
1409            serde_json::json!({ "is_seeded": 1 }),
1410        )
1411        .await;
1412        assert!(
1413            !templates_visible.iter().any(|a| a.id == "tpl-claude"),
1414            "hidden template should be excluded from is_seeded=1 default query",
1415        );
1416        let templates_all: Vec<AgentDefinition> = call_rpc(
1417            &engine,
1418            &mut rx,
1419            crate::backend::rpc_types::COMMAND_LIST_AGENTS,
1420            serde_json::json!({ "is_seeded": 1, "include_hidden": true }),
1421        )
1422        .await;
1423        assert!(templates_all.iter().any(|a| a.id == "tpl-claude"));
1424    }
1425}