1mod 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;
28pub use side_question::register as register_side_question_handlers;
33pub 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
48pub(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
92fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 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 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 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 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 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 #[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 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 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 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 assert!(after.iter().any(|a| a.id == "user-a"));
1187
1188 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 #[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 assert!(
1233 row["request"].as_str().unwrap().contains("::"),
1234 "{cmd} request should be fully qualified, got {:?}",
1235 row["request"]
1236 );
1237 }
1238
1239 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 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 #[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 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 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 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 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 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 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 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}