1use super::*;
2
3pub fn register(engine: &Arc<WshRpcEngine>, state: &AppState) {
4 register_agent_send(engine, state);
5 register_agent_stop(engine, state);
6 register_agent_status(engine, state);
7 register_agent_list(engine, state);
8 register_agent_output(engine, state);
9 register_agent_process_list(engine, state);
10 register_agent_tracked_blocks(engine, state);
11 register_swarm_other_instances(engine, state);
12 register_ambient_outcomes(engine);
13 register_agent_open_panes(engine, state);
14 register_agent_kill_process(engine, state);
15 register_agent_kill_tree(engine, state);
16}
17
18fn register_agent_process_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
19 let process_tracker = state.process_tracker.clone();
20 engine.register_typed(COMMAND_AGENT_PROCESS_LIST, move |cmd: AgentProcessListCommand, _ctx| {
21 let process_tracker = process_tracker.clone();
22 async move {
23 let members = process_tracker.list_block(&cmd.block_id);
24 let confidence = process_tracker.confidence_of(&cmd.block_id);
25 let processes: Vec<AgentProcessInfo> = members
26 .into_iter()
27 .map(|m| AgentProcessInfo {
28 pid: m.pid,
29 command: m.command,
30 rss_bytes: m.rss_bytes,
31 started_at_ms: m.started_at_ms,
32 })
33 .collect();
34 Ok(AgentProcessListResult {
35 block_id: cmd.block_id,
36 confidence,
37 processes,
38 })
39 }
40 });
41}
42
43fn register_agent_tracked_blocks(engine: &Arc<WshRpcEngine>, state: &AppState) {
44 let process_broker = state.process_broker.clone();
45 engine.register_typed(COMMAND_AGENT_TRACKED_BLOCKS, move |_data: serde_json::Value, _ctx| {
47 let process_broker = process_broker.clone();
48 async move {
49 let block_ids: Vec<String> = process_broker
63 .list_agent_panes()
64 .into_iter()
65 .map(|status| status.block_id)
66 .collect();
67 Ok(AgentTrackedBlocksResult { block_ids })
68 }
69 });
70}
71
72fn register_swarm_other_instances(engine: &Arc<WshRpcEngine>, state: &AppState) {
78 let hostname = state.hostname.clone();
79 let own_url = state.local_web_url.clone();
80 let lan_discovery = state.lan_discovery.clone();
81 let http = state.http_client.clone();
82 engine.register_typed(COMMAND_SWARM_OTHER_INSTANCES, move |_data: serde_json::Value, _ctx| {
85 let hostname = hostname.clone();
86 let own_url = own_url.clone();
87 let lan_peers = lan_discovery.get_instances();
88 let http = http.clone();
89 async move {
90 let entries = tokio::task::spawn_blocking(|| {
92 crate::registry::resolve_shared_reactive_dir()
93 .map(|dir| crate::backend::reactive::registry::list_all_shared(&dir))
94 .unwrap_or_default()
95 })
96 .await
97 .unwrap_or_default();
98 let own_addrs = crate::backend::lan_listeners::cached_local_addresses();
99 let own_channel = crate::backend::reactive::registry::local_channel_id();
100 let now_ms = agentmux_common::time::now_ms_u64();
101 let targets = crate::backend::swarm_remote::channel_status_targets(&entries, &own_channel, &own_url, now_ms);
102 let statuses = crate::backend::swarm_remote::channel_statuses(&http, &targets).await;
103 let snapshot = crate::backend::swarm_remote::snapshot(
104 &entries,
105 &statuses,
106 &lan_peers,
107 &own_addrs,
108 &hostname,
109 &own_channel,
110 &own_url,
111 now_ms,
112 );
113 serde_json::to_value(&snapshot).map_err(|e| format!("swarm.other-instances: {e}"))
114 }
115 });
116}
117
118fn register_ambient_outcomes(engine: &Arc<WshRpcEngine>) {
122 engine.register_typed(COMMAND_AMBIENT_OUTCOMES, |_data: serde_json::Value, _ctx| async move {
124 serde_json::to_value(crate::ambient::outcome::snapshot()).map_err(|e| format!("ambient.outcomes: {e}"))
125 });
126}
127
128fn register_agent_open_panes(engine: &Arc<WshRpcEngine>, state: &AppState) {
135 let process_broker = state.process_broker.clone();
136 let mstore = state.mstore.clone();
137 engine.register_typed(COMMAND_AGENT_OPEN_PANES, move |_data: serde_json::Value, _ctx| {
138 let process_broker = process_broker.clone();
139 let mstore = mstore.clone();
140 async move {
141 let block_ids: Vec<String> = process_broker
142 .list_agent_panes()
143 .into_iter()
144 .map(|status| status.block_id)
145 .collect();
146 let panes = tokio::task::spawn_blocking(move || open_agent_panes(&mstore, &block_ids))
147 .await
148 .map_err(|e| format!("agent.open-panes: {e}"))?;
149 Ok(AgentOpenPanesResult { panes })
150 }
151 });
152}
153
154pub(crate) fn open_agent_panes(store: &Store, block_ids: &[String]) -> Vec<AgentOpenPane> {
163 let workspaces = store.get_all::<Workspace>().unwrap_or_default();
164 let windows = store.get_all::<obj::Window>().unwrap_or_default();
165 let mut panes: Vec<AgentOpenPane> = block_ids
166 .iter()
167 .filter_map(|block_id| {
168 let block = store.get::<Block>(block_id).ok().flatten()?;
169 let agent_id = match store.instance_get_active_for_block(block_id) {
170 Ok(Some(row)) if !row.id.is_empty() => row.id,
171 _ => {
172 let meta_id = obj::meta_get_string(&block.meta, "agentId", "");
173 if meta_id.is_empty() {
174 obj::meta_get_string(&block.meta, "agent:id", "")
175 } else {
176 meta_id
177 }
178 }
179 };
180 if agent_id.is_empty() {
181 return None;
182 }
183 let tab_id = block.parentoref.strip_prefix("tab:")?.to_string();
184 let tab = store.get::<Tab>(&tab_id).ok().flatten()?;
185 let mut window_ids: Vec<String> = workspaces
186 .iter()
187 .find(|w| w.tabids.contains(&tab_id) || w.pinnedtabids.contains(&tab_id))
188 .map(|ws| {
189 windows
190 .iter()
191 .filter(|w| w.workspaceid == ws.oid)
192 .map(|w| w.oid.clone())
193 .collect()
194 })
195 .unwrap_or_default();
196 window_ids.sort();
197 Some(AgentOpenPane { block_id: block_id.clone(), agent_id, tab_id, tab_name: tab.name, window_ids })
198 })
199 .collect();
200 panes.sort_by(|a, b| a.block_id.cmp(&b.block_id));
201 panes
202}
203
204fn register_agent_kill_process(engine: &Arc<WshRpcEngine>, state: &AppState) {
205 let process_tracker = state.process_tracker.clone();
206 engine.register_typed(COMMAND_AGENT_KILL_PROCESS, move |cmd: AgentKillProcessCommand, _ctx| {
207 let process_tracker = process_tracker.clone();
208 async move {
209 tracing::info!(
210 block_id = %cmd.block_id,
211 pid = cmd.pid,
212 "agent.kill-process"
213 );
214 let ok = process_tracker.kill_pid(&cmd.block_id, cmd.pid);
215 Ok(AgentKillResult { ok })
216 }
217 });
218}
219
220fn register_agent_kill_tree(engine: &Arc<WshRpcEngine>, state: &AppState) {
221 let process_tracker = state.process_tracker.clone();
222 engine.register_typed(COMMAND_AGENT_KILL_TREE, move |cmd: AgentKillTreeCommand, _ctx| {
223 let process_tracker = process_tracker.clone();
224 async move {
225 tracing::info!(block_id = %cmd.block_id, "agent.kill-tree");
226 let ok = process_tracker.kill_tree(&cmd.block_id);
227 Ok(AgentKillResult { ok })
228 }
229 });
230}
231
232pub(crate) async fn agent_send_spawn_env(
239 mstore: Arc<crate::backend::storage::store::Store>,
240 id_store: Arc<crate::backend::storage::store::Store>,
241 identity_store: Arc<crate::backend::storage::store::Store>,
242 broker: Option<Arc<crate::backend::mps::Broker>>,
243 block: &Block,
244 block_id: &str,
245 auth_key: &str,
246) -> Result<std::collections::HashMap<String, String>, crate::identity::resolver::SpawnGateError> {
247 let env_vars = crate::backend::blockcontroller::cmd_env_of(&block.meta);
248 crate::server::agent_handlers::input::build_persistent_spawn_env(
249 mstore,
250 id_store,
251 identity_store,
252 broker,
253 &block.meta,
254 block_id,
255 auth_key,
256 env_vars,
257 )
258 .await
259}
260
261fn register_agent_send(engine: &Arc<WshRpcEngine>, state: &AppState) {
262 let mstore = state.mstore.clone();
263 let id_store = state.id_store.clone();
264 let identity_store = state.identity_store.clone();
265 let broker = state.broker.clone();
266 let container_manager = state.container_manager.clone();
267 let filestore = state.filestore.clone();
268 let event_bus = state.event_bus.clone();
272 let auth_key = state.auth_key.clone();
275
276 engine.register_typed(COMMAND_AGENT_SEND, move |cmd: CommandAgentSendData, _ctx| {
277 let mstore = mstore.clone();
278 let id_store = id_store.clone();
279 let identity_store = identity_store.clone();
280 let broker = broker.clone();
281 let container_manager = container_manager.clone();
282 let filestore = filestore.clone();
283 let event_bus = event_bus.clone();
284 let auth_key = auth_key.clone();
285 async move {
286 tracing::info!(block_id = %cmd.block_id, "agent.send");
287
288 let ctrl = blockcontroller::get_controller(&cmd.block_id)
289 .ok_or_else(|| format!("NOT_RUNNING: no controller for block {}", cmd.block_id))?;
290
291 let block: Block = mstore
293 .get(&cmd.block_id)
294 .map_err(|e| format!("agent.send: {e}"))?
295 .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
296
297 let cli_command = obj::meta_get_string(&block.meta, "cmd", "claude");
298 let cli_args: Vec<String> = match block.meta.get("cmd:args") {
299 Some(serde_json::Value::Array(arr)) => arr
300 .iter()
301 .filter_map(|v| v.as_str().map(|s| s.to_string()))
302 .collect(),
303 _ => vec![],
304 };
305 let cli_args = crate::backend::agent_runtime::with_runtime_flags(&block.meta, cli_args);
307 let working_dir = obj::meta_get_string(&block.meta, "cmd:cwd", "");
308 let mut env_vars = match agent_send_spawn_env(
314 mstore.clone(),
315 id_store.clone(),
316 identity_store.clone(),
317 Some(broker.clone()),
318 &block,
319 &cmd.block_id,
320 &auth_key,
321 )
322 .await
323 {
324 Ok(env) => env,
325 Err(gate) => {
326 let error_frame = serde_json::json!({
327 "type": "result",
328 "is_error": true,
329 "subtype": "error_during_execution",
330 "error": {"message": format!("[AgentMux] {gate}")}
331 })
332 .to_string();
333 let global_zone = crate::backend::blockcontroller::shell::resolve_global_output_zone(
340 &Some(mstore.clone()),
341 &cmd.block_id,
342 );
343 crate::backend::blockcontroller::shell::handle_append_block_file(
344 &broker,
345 &cmd.block_id,
346 crate::backend::blockcontroller::subprocess::SUBPROCESS_OUTPUT_SUBJECT,
347 format!("{error_frame}\n").as_bytes(),
348 Some(&filestore),
349 global_zone.as_deref(),
350 );
351 let gate_failure = crate::agents::failure::classify(
359 None,
360 None,
361 &gate.to_string(),
362 None,
363 );
364 crate::backend::blockcontroller::core::persist_last_failure(
365 &cmd.block_id,
366 Some(&gate_failure),
367 &Some(mstore.clone()),
368 &Some(event_bus.clone()),
369 );
370 broker.publish_event::<crate::backend::mps_events::AgentFailed>(&gate_failure, vec![format!("block:{}", cmd.block_id)], 1);
371 return Err(format!("identity spawn gate: {gate}"));
372 }
373 };
374 let session_id_field = obj::meta_get_string(
375 &block.meta, "agent:session_id_field", "session_id",
376 );
377
378 let mut session_id = None;
380 if let Some(app_server_ctrl) = ctrl
387 .as_any()
388 .downcast_ref::<blockcontroller::app_server_controller::AppServerController>()
389 {
390 app_server_ctrl.send_message(cmd.message)?;
391 } else if let Some(persistent_ctrl) = ctrl
392 .as_any()
393 .downcast_ref::<blockcontroller::persistent::PersistentSubprocessController>()
394 {
395 let agent_mode = obj::meta_get_string(&block.meta, "agentMode", "host");
399 if agent_mode == "container" {
400 return Err("container agents require a subprocess controller; this provider uses a persistent controller".to_string());
401 }
402 let resume_flag = obj::meta_get_string(
406 &block.meta, "agent:resume_flag", "--resume",
407 );
408 let persisted_session_id = obj::meta_get_string(
409 &block.meta, "agent:sessionid", "",
410 );
411 let config = blockcontroller::persistent::PersistentSpawnConfig {
412 cli_command,
413 cli_args,
414 working_dir,
415 env_vars,
416 session_id_field,
417 resume_flag,
418 session_id: persisted_session_id,
419 message_id: None,
420 };
421 persistent_ctrl.send_message(cmd.message, config)?;
422 session_id = persistent_ctrl.session_id();
423 } else if let Some(subprocess_ctrl) = ctrl
424 .as_any()
425 .downcast_ref::<blockcontroller::subprocess::SubprocessController>()
426 {
427 let resume_flag = obj::meta_get_string(
428 &block.meta, "agent:resume_flag", "--resume",
429 );
430 let resume_strategy = obj::meta_get_string(
431 &block.meta,
432 "agent:resume_strategy",
433 if session_id_field == "thread_id" {
434 "codex-exec"
435 } else if resume_flag.is_empty() {
436 "none"
437 } else {
438 "flag"
439 },
440 );
441 let prompt_arg_flag =
442 obj::meta_get_string(&block.meta, "agent:prompt_arg_flag", "");
443 let persisted_session_id = obj::meta_get_string(
448 &block.meta, "agent:sessionid", "",
449 );
450
451 let agent_mode = obj::meta_get_string(&block.meta, "agentMode", "host");
454 let instance_id = obj::meta_get_string(&block.meta, "agentId", "");
459 if agent_mode == "container" {
460 let cm = container_manager.get().await
461 .ok_or_else(|| "Docker not available on this host; cannot start container agent".to_string())?;
462 let container_image = obj::meta_get_string(&block.meta, "agent:container_image", "");
463 let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
465 let container_name = crate::backend::container::container_name_for_slug(&agent_id);
466 let volumes_json = obj::meta_get_string(&block.meta, "agent:container_volumes", "[]");
467 let volumes: Vec<String> = serde_json::from_str(&volumes_json).unwrap_or_default();
468
469 let mount_spec = crate::backend::container::ContainerMountSpec {
474 claude_config_host_dir: env_vars
475 .get("CLAUDE_CONFIG_DIR")
476 .and_then(|d| crate::backend::container::credentials_dir_if_file_backed(d)),
477 workspace_host_dir: Some(working_dir.clone()).filter(|d| !d.is_empty()),
478 };
479
480 if let Ok(local_url) = std::env::var("AGENTMUX_LOCAL_URL") {
485 env_vars.insert(
486 "AGENTMUX_LOCAL_URL".to_string(),
487 crate::backend::container::rewrite_local_url_for_container(&local_url),
488 );
489 }
490
491 let container_command = obj::meta_get_string(
492 &block.meta, "agent:container_command", "claude",
493 );
494 let agent_provider = obj::meta_get_string(&block.meta, "agentProvider", "claude");
495
496 crate::server::agent_handlers::container_turn::prepare(
499 crate::server::agent_handlers::container_turn::ContainerTurnPrep {
500 cm: &cm,
501 block_id: &cmd.block_id,
502 container_name: &container_name,
503 image: &container_image,
504 volumes: &volumes,
505 mount_spec: &mount_spec,
506 provider_id: &agent_provider,
507 container_command: &container_command,
508 broker: &broker,
509 filestore: &filestore,
510 mstore: &mstore,
511 },
512 )
513 .await?;
514
515
516 tracing::info!(
517 container = %container_name,
518 image = %crate::backend::container_image::resolve_container_image(&container_image),
519 "container agent turn: bollard exec (env via Docker socket, not argv)",
520 );
521
522 let mut base_cmd = vec![container_command];
537 base_cmd.extend(cli_args);
538
539 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
540 cli_command: String::new(), cli_args: vec![], working_dir: String::new(), env_vars,
544 message: cmd.message,
545 resume_flag,
546 resume_strategy,
547 prompt_arg_flag,
548 session_id_field,
549 message_id: None,
550 session_id: if persisted_session_id.is_empty() {
551 None
552 } else {
553 Some(persisted_session_id)
554 },
555 instance_id: instance_id.clone(),
556 };
557 subprocess_ctrl.spawn_container_turn(cm.clone(), container_name, base_cmd, config)?;
558 } else {
559 let config = blockcontroller::subprocess::SubprocessSpawnConfig {
561 cli_command,
562 cli_args,
563 working_dir,
564 env_vars,
565 message: cmd.message,
566 resume_flag,
567 resume_strategy,
568 prompt_arg_flag,
569 session_id_field,
570 message_id: None,
571 session_id: if persisted_session_id.is_empty() {
572 None
573 } else {
574 Some(persisted_session_id)
575 },
576 instance_id,
577 };
578 subprocess_ctrl.spawn_turn(config)?;
579 }
580 } else if let Some(acp_ctrl) = ctrl
581 .as_any()
582 .downcast_ref::<blockcontroller::acp::AcpController>()
583 {
584 acp_ctrl.ensure_started(block.meta.clone())?;
587 acp_ctrl.send_message(cmd.message, None)?;
588 } else {
589 return Err("NOT_RUNNING: controller type not supported".to_string());
590 }
591
592 Ok(AgentSendResult {
593 block_id: cmd.block_id,
594 status: "running".to_string(),
595 session_id,
596 })
597 }
598 });
599}
600
601pub(crate) async fn stop_one_agent_block(
605 state: &AppState,
606 block_id: &str,
607 signal: Option<&str>,
608 stop_background: Option<bool>,
609) -> Result<AgentStopResult, String> {
610 tracing::info!(block_id = %block_id, signal = ?signal, ?stop_background, "agent.stop");
611
612 let force = matches!(signal, Some("SIGKILL") | Some("SIGTERM"));
613 crate::sagas::agent_teardown::stop(state, block_id, !force, stop_background).await?;
614
615 let exit_code = blockcontroller::get_block_controller_status(block_id)
616 .map(|s| s.shellprocexitcode);
617
618 Ok(AgentStopResult {
619 block_id: block_id.to_string(),
620 status: "done".to_string(),
621 exit_code,
622 })
623}
624
625fn register_agent_stop(engine: &Arc<WshRpcEngine>, state: &AppState) {
626 let state = state.clone();
627 engine.register_typed(COMMAND_AGENT_STOP_API, move |cmd: CommandAgentStopApiData, _ctx| {
628 let state = state.clone();
629 async move {
630 stop_one_agent_block(&state, &cmd.block_id, cmd.signal.as_deref(), cmd.stop_background).await
631 }
632 });
633}
634
635fn register_agent_status(engine: &Arc<WshRpcEngine>, state: &AppState) {
636 let mstore = state.mstore.clone();
637
638 engine.register_typed(COMMAND_AGENT_STATUS, move |cmd: CommandAgentStatusData, _ctx| {
639 let mstore = mstore.clone();
640 async move {
641 let block: Block = mstore
642 .get(&cmd.block_id)
643 .map_err(|e| format!("agent.status: {e}"))?
644 .ok_or_else(|| format!("BLOCK_NOT_FOUND: {}", cmd.block_id))?;
645
646 let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
647 let provider = obj::meta_get_string(&block.meta, "agentProvider", "");
648 let controller_type = obj::meta_get_string(&block.meta, "controller", "");
649
650 let runtime = blockcontroller::get_block_controller_status(&cmd.block_id);
651 let status = runtime.as_ref()
652 .map(|s| s.shellprocstatus.clone())
653 .unwrap_or_else(|| "init".to_string());
654 let exit_code = runtime.as_ref()
655 .and_then(|s| if s.shellprocstatus == "done" { Some(s.shellprocexitcode) } else { None });
656 let pid = None; let session_id = block.meta.get("agent:sessionid")
660 .and_then(|v| v.as_str())
661 .map(|s| s.to_string());
662
663 Ok(AgentStatusResult {
664 block_id: cmd.block_id,
665 agent_id,
666 provider,
667 controller_type,
668 status,
669 session_id,
670 pid,
671 exit_code,
672 })
673 }
674 });
675}
676
677fn register_agent_list(engine: &Arc<WshRpcEngine>, state: &AppState) {
678 let mstore = state.mstore.clone();
679
680 engine.register_typed(COMMAND_AGENT_LIST, move |_data: serde_json::Value, _ctx| {
682 let mstore = mstore.clone();
683 async move {
684 let tabs: Vec<Tab> = mstore.get_all::<Tab>()
685 .map_err(|e| format!("agent.list: {e}"))?;
686
687 let mut agents = Vec::new();
688 for tab in &tabs {
689 for block_id in &tab.blockids {
690 if let Ok(Some(block)) = mstore.get::<Block>(block_id) {
691 let agent_id = obj::meta_get_string(&block.meta, "agentId", "");
692 if agent_id.is_empty() {
693 continue;
694 }
695 let provider = obj::meta_get_string(&block.meta, "agentProvider", "");
696 let status = blockcontroller::get_block_controller_status(block_id)
697 .map(|s| s.shellprocstatus)
698 .unwrap_or_else(|| "init".to_string());
699 let session_id = block.meta.get("agent:sessionid")
700 .and_then(|v| v.as_str())
701 .map(|s| s.to_string());
702
703 agents.push(AgentListEntry {
704 block_id: block_id.clone(),
705 tab_id: tab.oid.clone(),
706 agent_id,
707 provider,
708 status,
709 session_id,
710 });
711 }
712 }
713 }
714
715 Ok(AgentListResult { agents })
716 }
717 });
718}
719
720fn register_agent_output(engine: &Arc<WshRpcEngine>, state: &AppState) {
721 let broker = state.broker.clone();
722
723 engine.register_typed(COMMAND_AGENT_OUTPUT, move |cmd: CommandAgentOutputData, _ctx| {
724 let broker = broker.clone();
725 async move {
726 let scope = format!("block:{}", cmd.block_id);
727 let max = cmd.max_lines.unwrap_or(1000);
728 let after = cmd.after_line.unwrap_or(0);
729
730 let mut all_lines: Vec<String> = Vec::new();
732 {
733 let events = broker.read_event_history(
734 crate::backend::mps::EVENT_BLOCK_FILE,
735 &scope,
736 max + after, );
738 for event in events {
739 if let Some(ref data) = event.data {
740 if let Some(data64) = data.get("data64").and_then(|v| v.as_str()) {
741 if let Ok(bytes) = base64::engine::general_purpose::STANDARD
742 .decode(data64)
743 {
744 let text = String::from_utf8_lossy(&bytes);
745 for line in text.lines() {
746 if !line.trim().is_empty() {
747 all_lines.push(line.to_string());
748 }
749 }
750 }
751 }
752 }
753 }
754 }
755
756 let total = all_lines.len();
757 let lines: Vec<String> = all_lines.into_iter()
758 .skip(after)
759 .take(max)
760 .collect();
761 let has_more = after + lines.len() < total;
762
763 Ok(AgentOutputResult {
764 block_id: cmd.block_id,
765 lines,
766 total_lines: total,
767 has_more,
768 })
769 }
770 });
771}
772
773#[cfg(test)]
774mod tests {
775 use super::*;
776 use crate::backend::storage::agents::test_agent_def;
777 use crate::backend::storage::store::Store;
778
779 fn block_with(store: &Store, block_id: &str, agent_id: &str) -> Block {
780 let mut block = Block {
781 oid: block_id.to_string(),
782 parentoref: String::new(),
783 version: 0,
784 runtimeopts: None,
785 stickers: None,
786 meta: {
787 let mut m = obj::MetaMapType::new();
788 m.insert("view".to_string(), serde_json::json!("agent"));
789 m.insert("agentId".to_string(), serde_json::json!(agent_id));
790 m.insert("agentName".to_string(), serde_json::json!("AgentY"));
791 m.insert("cmd:env".to_string(), serde_json::json!({"KEEP_ME": "1"}));
792 m
793 },
794 subblockids: None,
795 };
796 store.insert(&mut block).unwrap();
797 block
798 }
799
800 async fn env_for(
801 store: &Arc<Store>,
802 block: &Block,
803 ) -> std::collections::HashMap<String, String> {
804 agent_send_spawn_env(
805 store.clone(),
806 store.clone(),
807 store.clone(),
808 None,
809 block,
810 &block.oid,
811 "test-key",
812 )
813 .await
814 .expect("no spawn gate for a provider without credentials")
815 }
816
817 #[tokio::test]
822 async fn an_agent_send_spawn_carries_the_rows_uid_and_token() {
823 let store = Arc::new(Store::open_in_memory().unwrap());
824 store.apply_identity_schema_for_tests().unwrap();
825 let mut def = test_agent_def(
829 "uid-send-y",
830 "AgentY",
831 "test-no-credentials",
832 "agent",
833 1,
834 "",
835 );
836 def.slug = "agenty".to_string();
837 store.agent_def_insert(&mut def).unwrap();
838 let block = block_with(&store, "block-send-y", "uid-send-y");
839
840 let env = env_for(&store, &block).await;
841 assert_eq!(
842 env.get("AGENTMUX_AGENT_UID").map(String::as_str),
843 Some("uid-send-y")
844 );
845 let token = store
846 .agent_token_load("uid-send-y")
847 .unwrap()
848 .expect("minted");
849 assert_eq!(env.get("AGENTMUX_AGENT_TOKEN"), Some(&token));
850 assert_eq!(
851 env.get("KEEP_ME").map(String::as_str),
852 Some("1"),
853 "cmd:env kept"
854 );
855 assert_eq!(
856 env.get("AGENTMUX_AUTH_KEY").map(String::as_str),
857 Some("test-key")
858 );
859 }
860
861 #[tokio::test]
866 async fn an_agent_send_spawn_overrides_gh_config_dir_with_the_guard_dir() {
867 let store = Arc::new(Store::open_in_memory().unwrap());
868 store.apply_identity_schema_for_tests().unwrap();
869 let mut def = test_agent_def("uid-gh-y", "AgentY", "test-no-credentials", "agent", 1, "");
870 def.slug = "agenty".to_string();
871 store.agent_def_insert(&mut def).unwrap();
872 let mut block = block_with(&store, "block-gh-y", "uid-gh-y");
873 block.meta.insert(
874 "cmd:env".to_string(),
875 serde_json::json!({"GH_CONFIG_DIR": "/home/human/.config/gh"}),
876 );
877 store.update(&mut block).unwrap();
878
879 let env = env_for(&store, &block).await;
880 let dir = std::path::PathBuf::from(env.get("GH_CONFIG_DIR").expect("always set"));
881 assert_eq!(dir.parent(), Some(crate::backend::gh_guard::guard_config_home().as_path()));
882 assert!(
883 dir.file_name().unwrap().to_string_lossy().starts_with("gh-"),
884 "{}",
885 dir.display()
886 );
887 assert_ne!(dir, std::path::PathBuf::from("/home/human/.config/gh"));
888 }
889
890 #[tokio::test]
892 async fn an_agent_send_spawn_without_a_row_carries_no_uid_or_token() {
893 let store = Arc::new(Store::open_in_memory().unwrap());
894 store.apply_identity_schema_for_tests().unwrap();
895 let block = block_with(&store, "block-send-none", "claude");
896 let env = env_for(&store, &block).await;
897 assert!(!env.contains_key("AGENTMUX_AGENT_UID"));
898 assert!(!env.contains_key("AGENTMUX_AGENT_TOKEN"));
899 }
900}
901
902#[cfg(test)]
903mod open_panes_tests {
904 use super::*;
905 use crate::backend::storage::store::Store;
906
907 fn insert_tab(store: &Store, name: &str) -> Tab {
908 let mut tab = Tab { oid: uuid::Uuid::new_v4().to_string(), name: name.to_string(), ..Default::default() };
909 store.insert(&mut tab).unwrap();
910 tab
911 }
912
913 fn insert_workspace(store: &Store, tab_ids: &[&str]) -> Workspace {
914 let mut ws = Workspace {
915 oid: uuid::Uuid::new_v4().to_string(),
916 tabids: tab_ids.iter().map(|t| t.to_string()).collect(),
917 ..Default::default()
918 };
919 store.insert(&mut ws).unwrap();
920 ws
921 }
922
923 fn insert_window(store: &Store, workspace_id: &str) -> obj::Window {
924 let mut w = obj::Window { oid: uuid::Uuid::new_v4().to_string(), workspaceid: workspace_id.to_string(), ..Default::default() };
925 store.insert(&mut w).unwrap();
926 w
927 }
928
929 fn insert_block(store: &Store, tab_id: &str, agent_id: Option<&str>) -> String {
930 let mut meta = obj::MetaMapType::new();
931 if let Some(a) = agent_id {
932 meta.insert("agentId".to_string(), serde_json::json!(a));
933 }
934 let mut block = Block {
935 oid: uuid::Uuid::new_v4().to_string(),
936 parentoref: format!("tab:{tab_id}"),
937 meta,
938 ..Default::default()
939 };
940 store.insert(&mut block).unwrap();
941 block.oid
942 }
943
944 #[test]
948 fn lists_agent_panes_in_every_window_including_a_floating_one() {
949 let store = Store::open_in_memory().unwrap();
950 let main_tab = insert_tab(&store, "Tab 2");
951 let main_ws = insert_workspace(&store, &[&main_tab.oid]);
952 let main_win = insert_window(&store, &main_ws.oid);
953 let float_tab = insert_tab(&store, "");
954 let float_ws = insert_workspace(&store, &[&float_tab.oid]);
955 let float_win = insert_window(&store, &float_ws.oid);
956
957 let agent3 = insert_block(&store, &main_tab.oid, Some("agent3-def"));
958 let korp = insert_block(&store, &float_tab.oid, Some("korp-def"));
959
960 let panes = open_agent_panes(&store, &[agent3.clone(), korp.clone()]);
961 let korp_pane = panes.iter().find(|p| p.block_id == korp).expect("floating pane listed");
962 assert_eq!(korp_pane.agent_id, "korp-def");
963 assert_eq!(korp_pane.tab_id, float_tab.oid);
964 assert_eq!(korp_pane.window_ids, vec![float_win.oid]);
965 let a3 = panes.iter().find(|p| p.block_id == agent3).expect("main pane listed");
966 assert_eq!(a3.tab_name, "Tab 2");
967 assert_eq!(a3.window_ids, vec![main_win.oid]);
968 }
969
970 #[test]
972 fn resolves_a_legacy_agent_id_pane_to_its_agent_row() {
973 let store = Store::open_in_memory().unwrap();
974 let mut def = crate::backend::storage::agents::test_agent_def(
975 "uid-legacy",
976 "Korp",
977 "test-no-credentials",
978 "agent",
979 1,
980 "",
981 );
982 store.agent_def_insert(&mut def).unwrap();
983 let tab = insert_tab(&store, "t");
984 insert_workspace(&store, &[&tab.oid]);
985 let mut meta = obj::MetaMapType::new();
986 meta.insert("agent:id".to_string(), serde_json::json!("uid-legacy"));
987 let mut block = Block {
988 oid: uuid::Uuid::new_v4().to_string(),
989 parentoref: format!("tab:{}", tab.oid),
990 meta,
991 ..Default::default()
992 };
993 store.insert(&mut block).unwrap();
994
995 let panes = open_agent_panes(&store, &[block.oid.clone()]);
996 assert_eq!(panes.len(), 1);
997 assert_eq!(panes[0].agent_id, "uid-legacy");
998 }
999
1000 #[test]
1001 fn skips_non_agent_and_orphan_blocks_and_reports_a_windowless_workspace() {
1002 let store = Store::open_in_memory().unwrap();
1003 let tab = insert_tab(&store, "t");
1004 insert_workspace(&store, &[&tab.oid]); let terminal = insert_block(&store, &tab.oid, None);
1006 let agent = insert_block(&store, &tab.oid, Some("def"));
1007 let missing = "no-such-block".to_string();
1008
1009 let panes = open_agent_panes(&store, &[terminal, agent.clone(), missing]);
1010 assert_eq!(panes.len(), 1);
1011 assert_eq!(panes[0].block_id, agent);
1012 assert!(panes[0].window_ids.is_empty());
1013 }
1014}