diff --git a/.ideai/tickets/224/carnet.md b/.ideai/tickets/224/carnet.md index 951e1101..fd5fb47d 100644 --- a/.ideai/tickets/224/carnet.md +++ b/.ideai/tickets/224/carnet.md @@ -1,17 +1,34 @@ --- issueRef: "#224" -version: 5 +version: 14 updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1786981905837 +updatedAt: 1787222847970 --- 2026-08-17 Main - Demande: permettre le passage en custom CLI sur la version web telephone avec une UI correctement adaptee aux telephones. - UX: la bascule doit vivre dans la session agent plein ecran mobile, pas dans la liste Travail. Controle segmente `TUI native` / `CLI custom`, affichage exclusif d'un mode a la fois, composer fixe en bas, adaptation tactile/mobile, confirmations en bottom sheet. - Architect: aucun nouveau port backend; reutiliser AgentGateway et CustomAgentChatView. Introduire un host mobile de session agent, partager la logique d'eligibilite custom CLI avec le desktop, et ajouter un `draftScope` explicite pour la persistance mobile sans `layoutId`. -- DevFrontend: implementation annoncee terminee sur `feature/224-mobile-web-custom-cli`. -- QA: verdict ROUGE. Repro reelle sur fallback mobile quand un profil perd le support custom CLI apres le mount. -- Commande QA en echec: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx`. -- Symptom QA: test `falls back to native TUI when the mobile agent loses custom CLI support` KO, `terminal-view` introuvable, l'UI reste sur `custom-agent-chat-view` avec le bouton `CLI custom` toujours actif. -- Diagnostic QA: `WebAgentCell.tsx` ne re-resout agent/profil qu'au mount; le fallback depend d'un `resolvedProfile` perime. -- Signal secondaire QA: warning cleanup au detach depuis `CustomAgentChatView.tsx:924` avec `Cannot read properties of undefined (reading 'has')`. -- Prochaine etape: correctif DevFrontend puis nouveau passage QA. \ No newline at end of file +- DevFrontend: implementation livree puis correctif applique apres retour QA rouge sur le fallback quand le support custom disparait apres mount. +- QA finale: VERTE le 2026-08-17. +- Commandes QA vertes: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx` (8 passed), `npm test -- --run src/features/layout/LayoutGrid.chat.test.tsx` (14 passed), `npm run typecheck` (OK), `npm run build:web` (OK). +- Git: commit feature `c547b71`, merge local `911dcf8` dans `develop`, puis bookkeeping `b25c17c`. Aucun push. +- Statut: ticket cloture apres merge local dans `develop` avec QA verte. + +2026-08-20 Main +- Regression utilisateur: sur la version web telephone, le lancement de la custom CLI echoue maintenant avec un symptome runtime indiquant que `node` est introuvable. +- Decision de pilotage: reouverture du ticket #224 car le symptome touche le meme parcours produit `version web telephone -> custom CLI`. +- Ordre impose avant tout fix: QA doit d'abord ajouter des tests de non-regression sur le bon demarrage des serveurs MCP dans ce parcours web/mobile, vu l'historique de regressions repetes dans cette zone. +- Regle anti-boucle pour la reprise: ne pas partir directement sur un correctif applicatif tant que QA n'a pas produit un signal rouge/vert concret sur ce scenario. +- Retour QA: non-regression ajoutee dans `frontend/src/features/web/WebAgentCell.test.tsx` pour figer la couche existante la plus pertinente du parcours mobile web -> CLI custom. +- Bornes couvrees par QA: reprise visible d'une session custom mobile avec replay d'activite MCP au re-attach; echec de lancement mobile quand le runtime remonte un `spawn node ENOENT`, avec erreur visible cote UI. +- Commande QA executee: `cd /home/anthony/Documents/Projects/IdeA/frontend && npm test -- --run src/features/web/WebAgentCell.test.tsx`. +- Resultat QA: VERT, `Test Files 1 passed`, `Tests 10 passed`. +- Gap residuel explicitement note par QA: cette suite ne prouve pas encore le demarrage reel de `node` ni le boot effectif des serveurs MCP dans le runtime backend/web reel; elle fige le contrat frontend/integration autour du symptome. +- Retour UX: en cas d'echec de boot runtime/MCP, garder la vue `CLI custom` mais afficher une alerte inline bloquante avec message utilisateur clair, CTA `Reessayer` et `Revenir a TUI native`; le detail technique brut `spawn node ENOENT` ne doit pas etre le message principal final. +- Retour Architect: cause la plus probable cote backend/runtime sur le profil custom CLI Node-backed ou la resolution de commande dans le chemin web; points de verite a traiter d'abord dans `crates/web-server/src/lib.rs`, `StructuredSessionFactory`, et plus generalement dans le routage structured pour ne jamais degrader en PTY. +- Decision d'implementation: DevBackend d'abord; frontend ne revient que si le backend expose une erreur typee ou si l'etat visible doit etre nettoye apres le fix runtime. +- Decision Git: branche de travail locale `fix/224-web-custom-cli-node-runtime` creee depuis `develop@b25c17c`; commit scope puis merge local `--no-ff` vers `develop` une fois QA verte; aucun push. +- Retour DevBackend: fix runtime implemente sans commit. `crates/application/src/agent/lifecycle.rs` fait maintenant reutiliser `mcp_server_wiring(...)` par `opencode_config_json` pour ecrire une commande MCP OpenCode basee sur le binaire IdeA (`idea-serve ... mcp-server`) quand un `McpRuntime` web est injecte, au lieu de dependre d'un `node` externe. +- Preuve de test ajoutee par DevBackend: integration dans `crates/application/tests/agent_lifecycle.rs` garantissant qu'un lancement custom chat OpenCode avec runtime reste structured, n'ouvre aucun PTY, ecrit `opencode.json`, transmet `OPENCODE_CONFIG`, et que `mcp.idea.command[0] != "node"`. +- Tests verts annonces par DevBackend: `cargo fmt`; `cargo test -p application opencode_config_json_with_runtime_uses_idea_exe_not_node -- --nocapture`; `cargo test -p application --test agent_lifecycle launch_opencode_chat_with_runtime_uses_idea_bridge_without_pty_or_node -- --nocapture`; `cargo test -p backend mcp_bridge -- --nocapture`; `cargo test -p web-server web_root_candidates_include_appimage_resource_dir -- --nocapture`; `cargo test -p web-server authorized_open_project_arms_orchestrator_watch -- --nocapture`; `npm test -- WebAgentCell.test.tsx`. +- Note de reprise: un filtre large `cargo test -p application opencode -- --nocapture` remonte un echec existant non cible `p8c_opencode_same_pair_and_provider_are_isolated_by_project_root` (`left: 0 right: 2`), mais les non-regressions cibles du fix sont vertes separement. \ No newline at end of file diff --git a/.ideai/tickets/224/issue.md b/.ideai/tickets/224/issue.md index b67b7033..4ca6465e 100644 --- a/.ideai/tickets/224/issue.md +++ b/.ideai/tickets/224/issue.md @@ -2,16 +2,20 @@ id: "2be065b5-1859-4698-8c9e-7f1fc2c1f7a3" number: 224 title: "Version web téléphone: permettre le passage en custom CLI avec UI mobile adaptée" -status: "inProgress" +status: "qa" priority: "high" sprint: null links: [] -agentRefs: [] +agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"},{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"}] attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} createdAt: 1786980793023 -updatedAt: 1786981905837 -version: 5 +updatedAt: 1787222847970 +version: 14 --- -Ajouter sur la version web téléphone d'IdeA la possibilité de passer en custom CLI, avec une interface et un parcours adaptés aux écrans téléphone. \ No newline at end of file +Regression du parcours version web téléphone -> custom CLI. Le jeudi 20 août 2026, l'utilisateur signale qu'en lançant la custom CLI depuis la version web téléphone, le runtime indique que `node` est introuvable. Avant tout correctif de production, la priorité est d'ajouter des tests de non-régression prouvant le bon démarrage des serveurs MCP dans ce parcours web/mobile, car cette zone a déjà régressé plusieurs fois. + +Ordre de traitement imposé: +1. QA ajoute/étend la couverture de tests pour le démarrage des serveurs MCP via la version web téléphone et établit un signal rouge/vert fiable. +2. Ensuite seulement, l'implémentation du correctif sera cadrée et déléguée au(x) dev(s) concerné(s). \ No newline at end of file diff --git a/crates/application/src/agent/lifecycle.rs b/crates/application/src/agent/lifecycle.rs index a2f14ec9..76502233 100644 --- a/crates/application/src/agent/lifecycle.rs +++ b/crates/application/src/agent/lifecycle.rs @@ -3441,23 +3441,9 @@ fn opencode_config_json( }), ); - let (command, args) = match runtime { - Some(rt) => ( - rt.exe.clone(), - vec![ - "mcp-server".to_owned(), - "--endpoint".to_owned(), - rt.endpoint.clone(), - "--project".to_owned(), - rt.project_id.clone(), - "--requester".to_owned(), - rt.requester.clone(), - ], - ), - None => ("idea".to_owned(), vec!["mcp-server".to_owned()]), - }; - let command_array = std::iter::once(command) - .chain(args) + let wiring = mcp_server_wiring(domain::profile::McpTransport::Stdio, runtime); + let command_array = std::iter::once(wiring.command) + .chain(wiring.args) .map(serde_json::Value::String) .collect::>(); root.insert( @@ -5334,6 +5320,37 @@ command = "idea-mcp" assert!(body.get("permission").is_none()); } + #[test] + fn opencode_config_json_with_runtime_uses_idea_exe_not_node() { + let config = + domain::profile::OpenCodeConfig::new("http://localhost:8080", None, "m", None, None) + .unwrap(); + let runtime = McpRuntime { + exe: "/opt/idea/idea-serve".to_owned(), + endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(), + project_id: "0123456789abcdef0123456789abcdef".to_owned(), + requester: "11112222-3333-4444-5555-666677778888".to_owned(), + }; + + let body = opencode_config_json(&config, "/project", Some(&runtime), None); + let command = body["mcp"]["idea"]["command"] + .as_array() + .expect("OpenCode local command is an argv array") + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + + assert_eq!(command[0], "/opt/idea/idea-serve"); + assert_ne!(command[0], "node"); + assert_eq!(command[1], "mcp-server"); + assert!(command.contains(&"--endpoint")); + assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock")); + assert!(command.contains(&"--project")); + assert!(command.contains(&"0123456789abcdef0123456789abcdef")); + assert!(command.contains(&"--requester")); + assert!(command.contains(&"11112222-3333-4444-5555-666677778888")); + } + #[test] fn opencode_config_json_projects_allow_and_deny() { let config = diff --git a/crates/application/tests/agent_lifecycle.rs b/crates/application/tests/agent_lifecycle.rs index c650d85d..715708ac 100644 --- a/crates/application/tests/agent_lifecycle.rs +++ b/crates/application/tests/agent_lifecycle.rs @@ -2594,6 +2594,80 @@ async fn launch_opencode_writes_isolated_config_and_env() { ); } +#[tokio::test] +async fn launch_opencode_chat_with_runtime_uses_idea_bridge_without_pty_or_node() { + let profile = opencode_profile(pid(9)); + let (launch, agent, fs, pty, _bus, _sessions, tr, _session) = launch_fixture_with_profile( + profile, + Some(ContextInjectionPlan::File { + target: "AGENTS.md".to_owned(), + }), + ); + let factory = FakeStructuredFactory::new(Arc::clone(&tr), sid(888)); + let structured = Arc::new(StructuredSessions::new()); + let launch = launch + .with_structured_routing_mode(StructuredRoutingMode::HumanPtyFallback) + .with_structured(Arc::new(factory.clone()), Arc::clone(&structured)); + let runtime = application::McpRuntime { + exe: "/opt/idea/idea-serve".to_owned(), + endpoint: "/run/user/1000/idea-mcp/proj.sock".to_owned(), + project_id: "0123456789abcdef0123456789abcdef".to_owned(), + requester: agent.id.to_string(), + }; + let mut input = launch_input_with_runtime(agent.id, runtime); + input.require_structured = true; + input.node_id = Some(nid(224)); + + let out = launch.execute(input).await.unwrap(); + + assert_eq!(factory.starts(), vec![pid(9)]); + assert!( + pty.spawns().is_empty(), + "web custom chat must stay structured" + ); + assert!( + out.structured.is_some(), + "backend returns a chat descriptor" + ); + assert_eq!( + structured.node_for_agent_in_project(project().id, &agent.id), + Some(nid(224)) + ); + + let run_dir = format!("/home/me/proj/.ideai/run/{}", agent.id); + let opencode = writes_ending_with(&fs, "/opencode.json"); + assert_eq!(opencode.len(), 1, "one OpenCode config is written"); + let body: serde_json::Value = serde_json::from_slice(&opencode[0].1).unwrap(); + let command = body["mcp"]["idea"]["command"] + .as_array() + .expect("OpenCode local command is an argv array") + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + assert_eq!(command[0], "/opt/idea/idea-serve"); + assert_ne!(command[0], "node"); + assert_eq!(command[1], "mcp-server"); + assert!(command.contains(&"--endpoint")); + assert!(command.contains(&"/run/user/1000/idea-mcp/proj.sock")); + assert!(command.contains(&"--project")); + assert!(command.contains(&"0123456789abcdef0123456789abcdef")); + assert!(command.contains(&"--requester")); + let requester = agent.id.to_string(); + assert!(command.contains(&requester.as_str())); + + let env: std::collections::HashMap<_, _> = factory + .envs() + .into_iter() + .next() + .expect("structured start receives env") + .into_iter() + .collect(); + assert_eq!( + env.get("OPENCODE_CONFIG").map(String::as_str), + Some(format!("{run_dir}/opencode.json").as_str()) + ); +} + #[tokio::test] async fn launch_mcp_flag_appends_flag_and_path_to_args() { // Test 5 — Flag { flag: "--mcp-config" } ⇒ spec.args carries the flag followed diff --git a/crates/backend/src/lib.rs b/crates/backend/src/lib.rs index 9122d632..a0ebb467 100644 --- a/crates/backend/src/lib.rs +++ b/crates/backend/src/lib.rs @@ -113,6 +113,7 @@ use infrastructure::{ pub mod dto; pub mod events; +pub mod mcp_bridge; pub mod mcp_endpoint; pub mod openai_tools; pub mod stream; diff --git a/crates/backend/src/mcp_bridge.rs b/crates/backend/src/mcp_bridge.rs new file mode 100644 index 00000000..d6552528 --- /dev/null +++ b/crates/backend/src/mcp_bridge.rs @@ -0,0 +1,260 @@ +//! The `idea mcp-server` bridge shared by desktop and standalone web binaries. +//! +//! An MCP CLI spawns ` mcp-server --endpoint <…> --project <…> --requester <…>`. +//! This process stays headless and relays JSON-RPC lines between stdio and the +//! per-project local loopback endpoint where the real MCP server listens. + +use std::process::ExitCode; +use std::time::Duration; + +use interprocess::local_socket::tokio::Stream as LocalSocketStream; +use interprocess::local_socket::traits::tokio::Stream as _; +use interprocess::local_socket::{GenericFilePath, ToFsName as _}; +use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const DRAIN_GRACE: Duration = Duration::from_secs(1); + +/// Parsed `mcp-server` invocation arguments. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BridgeArgs { + /// The loopback address to connect to. + pub endpoint: String, + /// The project id forwarded in the handshake. + pub project: String, + /// The requester id forwarded in the handshake. + pub requester: String, +} + +impl BridgeArgs { + /// Parses `mcp-server`'s own arguments. + pub fn parse(args: I) -> Result + where + I: IntoIterator, + S: Into, + { + let mut endpoint: Option = None; + let mut project = String::new(); + let mut requester = String::new(); + + let mut it = args.into_iter().map(Into::into); + while let Some(flag) = it.next() { + match flag.as_str() { + "--endpoint" => { + endpoint = Some( + it.next() + .ok_or_else(|| "--endpoint requires a value".to_string())?, + ); + } + "--project" => { + project = it + .next() + .ok_or_else(|| "--project requires a value".to_string())?; + } + "--requester" => { + requester = it + .next() + .ok_or_else(|| "--requester requires a value".to_string())?; + } + other => return Err(format!("unknown argument: {other}")), + } + } + + let endpoint = endpoint.ok_or_else(|| "--endpoint is required".to_string())?; + Ok(Self { + endpoint, + project, + requester, + }) + } + + fn handshake_line(&self) -> Vec { + let line = serde_json::json!({ + "project": self.project, + "requester": self.requester, + }) + .to_string(); + let mut bytes = line.into_bytes(); + bytes.push(b'\n'); + bytes + } +} + +/// Synchronous entry point for the headless stdio bridge. +#[must_use] +pub fn run_mcp_bridge(args: I) -> ExitCode +where + I: IntoIterator, + S: Into, +{ + let args = match BridgeArgs::parse(args) { + Ok(a) => a, + Err(e) => { + eprintln!("idea mcp-server: {e}"); + return ExitCode::FAILURE; + } + }; + + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("idea mcp-server: failed to start runtime: {e}"); + return ExitCode::FAILURE; + } + }; + + runtime.block_on(async move { + match bridge_over_loopback(&args).await { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("idea mcp-server: {e}"); + ExitCode::FAILURE + } + } + }) +} + +async fn bridge_over_loopback(args: &BridgeArgs) -> Result<(), String> { + let conn = connect_loopback(&args.endpoint).await?; + let (lp_read, lp_write) = tokio::io::split(conn); + relay( + args, + tokio::io::stdin(), + tokio::io::stdout(), + lp_read, + lp_write, + ) + .await +} + +async fn connect_loopback(endpoint: &str) -> Result { + let name = endpoint + .to_fs_name::() + .map_err(|e| format!("invalid endpoint {endpoint:?}: {e}"))?; + + match tokio::time::timeout(CONNECT_TIMEOUT, LocalSocketStream::connect(name)).await { + Ok(Ok(stream)) => Ok(stream), + Ok(Err(e)) => Err(format!("cannot connect to endpoint {endpoint:?}: {e}")), + Err(_) => Err(format!( + "timed out connecting to endpoint {endpoint:?} after {}s", + CONNECT_TIMEOUT.as_secs() + )), + } +} + +async fn relay( + args: &BridgeArgs, + cli_in: CIn, + cli_out: COut, + lp_read: LIn, + mut lp_write: LOut, +) -> Result<(), String> +where + CIn: AsyncRead + Unpin, + COut: AsyncWrite + Unpin, + LIn: AsyncRead + Unpin, + LOut: AsyncWrite + Unpin, +{ + lp_write + .write_all(&args.handshake_line()) + .await + .map_err(|e| format!("handshake write failed: {e}"))?; + lp_write + .flush() + .await + .map_err(|e| format!("handshake flush failed: {e}"))?; + + let cli_to_lp = pump_lines(BufReader::new(cli_in), lp_write, "stdin", "loopback"); + let lp_to_cli = pump_lines(BufReader::new(lp_read), cli_out, "loopback", "stdout"); + tokio::pin!(cli_to_lp); + tokio::pin!(lp_to_cli); + + tokio::select! { + r = &mut cli_to_lp => { + let mut lp_write = r?; + let _ = lp_write.shutdown().await; + let _ = tokio::time::timeout(DRAIN_GRACE, &mut lp_to_cli).await; + Ok(()) + } + r = &mut lp_to_cli => r.map(|_| ()), + } +} + +async fn pump_lines( + mut reader: BufReader, + mut writer: W, + src: &str, + dst: &str, +) -> Result +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut line = String::new(); + loop { + line.clear(); + let n = reader + .read_line(&mut line) + .await + .map_err(|e| format!("{src} read failed: {e}"))?; + if n == 0 { + writer + .flush() + .await + .map_err(|e| format!("{dst} flush failed: {e}"))?; + return Ok(writer); + } + writer + .write_all(line.as_bytes()) + .await + .map_err(|e| format!("{dst} write failed: {e}"))?; + writer + .flush() + .await + .map_err(|e| format!("{dst} flush failed: {e}"))?; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_all_three_flags() { + let args = BridgeArgs::parse([ + "--endpoint", + "/tmp/x.sock", + "--project", + "p1", + "--requester", + "agent-7", + ]) + .unwrap(); + assert_eq!(args.endpoint, "/tmp/x.sock"); + assert_eq!(args.project, "p1"); + assert_eq!(args.requester, "agent-7"); + } + + #[test] + fn handshake_line_carries_identity_as_json() { + let args = BridgeArgs { + endpoint: "/tmp/x.sock".into(), + project: "proj".into(), + requester: "rq".into(), + }; + let line = args.handshake_line(); + assert_eq!(*line.last().unwrap(), b'\n'); + let value: serde_json::Value = serde_json::from_slice(&line[..line.len() - 1]).unwrap(); + assert_eq!(value["project"], "proj"); + assert_eq!(value["requester"], "rq"); + } + + #[test] + fn missing_endpoint_returns_failure() { + assert_eq!(run_mcp_bridge(["--project", "p"]), ExitCode::FAILURE); + } +} diff --git a/crates/web-server/src/bin/idea-serve.rs b/crates/web-server/src/bin/idea-serve.rs index 0adf2fc3..0d2d404c 100644 --- a/crates/web-server/src/bin/idea-serve.rs +++ b/crates/web-server/src/bin/idea-serve.rs @@ -1,5 +1,12 @@ use std::process::ExitCode; +const MCP_SERVER_SUBCOMMAND: &str = "mcp-server"; + fn main() -> ExitCode { + let mut args = std::env::args_os().skip(1); + if args.next().is_some_and(|a| a == *MCP_SERVER_SUBCOMMAND) { + let rest: Vec = args.map(|a| a.to_string_lossy().into_owned()).collect(); + return backend::mcp_bridge::run_mcp_bridge(rest); + } web_server::run_from_args(std::env::args().skip(1).collect()) } diff --git a/crates/web-server/src/lib.rs b/crates/web-server/src/lib.rs index 7c31e303..8175c65c 100644 --- a/crates/web-server/src/lib.rs +++ b/crates/web-server/src/lib.rs @@ -53,10 +53,11 @@ use application::{ PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput, ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput, ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput, - RenameDeviceInput, RenameLayoutInput, RenameSprintInput, ReorderSprintsInput, - ResizeTerminalInput, ResolveAgentPermissionsInput, ResolveAgentSystemPermissionsInput, - ResolveMemoryLinksInput, RevokeDeviceInput, RotateConversationLogInput, SetActiveLayoutInput, - StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, + ReconcileLayoutsInput, ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, + RenameSprintInput, ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput, + ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput, + RotateConversationLogInput, SetActiveLayoutInput, StopLiveAgentInput, + SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput, UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput, UpdateMemoryInput, @@ -506,33 +507,55 @@ fn config_with_effective_listen(mut config: ServerConfig, local_addr: SocketAddr } fn resolve_web_root(explicit: Option) -> Result { - if let Some(path) = explicit { - return validate_web_root(path, "--web-root"); - } - if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { - return validate_web_root(path, "IDEA_WEB_ROOT"); - } - - let mut candidates = Vec::new(); - if let Ok(exe) = env::current_exe() { - if let Some(dir) = exe.parent() { - candidates.push(dir.join("web")); - candidates.push(dir.join("frontend").join("dist")); - } - } - if let Ok(cwd) = env::current_dir() { - candidates.push(cwd.join("frontend").join("dist")); - } - - for candidate in candidates { + for (candidate, source) in web_root_candidates(explicit) { if candidate.join("index.html").is_file() { return Ok(candidate); } + if matches!(source, "--web-root" | "IDEA_WEB_ROOT") { + return validate_web_root(candidate, source); + } } Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned()) } +fn web_root_candidates(explicit: Option) -> Vec<(PathBuf, &'static str)> { + let mut candidates = Vec::new(); + if let Some(path) = explicit { + candidates.push((path, "--web-root")); + } + if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) { + candidates.push((path, "IDEA_WEB_ROOT")); + } + if let Some(appdir) = env::var_os("APPDIR").map(PathBuf::from) { + candidates.extend( + web_root_candidates_from_appdir(&appdir) + .into_iter() + .map(|candidate| (candidate, "APPDIR")), + ); + } + if let Ok(exe) = env::current_exe() { + if let Some(dir) = exe.parent() { + candidates.push((dir.join("web"), "current_exe")); + candidates.push((dir.join("frontend").join("dist"), "current_exe")); + if let Some(prefix) = dir.parent() { + candidates.push((prefix.join("lib").join("IdeA").join("web"), "current_exe")); + } + } + } + if let Ok(cwd) = env::current_dir() { + candidates.push((cwd.join("frontend").join("dist"), "cwd")); + } + candidates +} + +fn web_root_candidates_from_appdir(appdir: &Path) -> Vec { + vec![ + appdir.join("usr").join("lib").join("IdeA").join("web"), + appdir.join("web"), + ] +} + fn validate_web_root(path: PathBuf, source: &str) -> Result { if path.join("index.html").is_file() { Ok(path) @@ -3099,8 +3122,32 @@ async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result Result { + let id = parse_project_id(project_id)?; + let output = state + .open_project + .execute(OpenProjectInput { project_id: id }) + .await + .map_err(ErrorDto::from)?; + let _ = state + .reconcile_layouts + .execute(ReconcileLayoutsInput { project_id: id }) + .await; + let _ = state + .reconcile_live_state + .execute(ReconcileLiveStateInput { project_id: id }) + .await; + let _ = state.reconcile_background_tasks.execute(id).await; + state.ensure_orchestrator_watch(&output.project); + state.reconcile_claude_run_dirs(&output.project).await; + Ok(ProjectDto::from(output)) } async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result { @@ -8359,6 +8406,15 @@ mod tests { assert!(validate_web_root(create_web_root(), "--web-root").is_ok()); } + #[test] + fn web_root_candidates_include_appimage_resource_dir() { + let appdir = PathBuf::from("/tmp/.mount_IdeA_test"); + let candidates = web_root_candidates_from_appdir(&appdir); + + assert!(candidates.contains(&appdir.join("usr").join("lib").join("IdeA").join("web"))); + assert!(candidates.contains(&appdir.join("web"))); + } + #[tokio::test] async fn non_allowed_origin_is_refused() { let state = state(); @@ -8472,6 +8528,49 @@ mod tests { assert!(body["root"].is_string()); } + #[tokio::test] + async fn authorized_open_project_arms_orchestrator_watch() { + let config = test_config(); + let core = Arc::new(BackendCore::build(config.app_data_dir.clone())); + let project_id = create_project_on_core(&core, "MCP Armed Open").await; + let state = Arc::new(ServerState::with_core(config, core)); + state.set_pairing_code_for_test("PAIR1234".to_owned()); + let cookie = pair_and_cookie(Arc::clone(&state)).await; + + assert!( + state.app.open_project_ids().is_empty(), + "precondition: the project must not already be armed before HTTP open_project" + ); + + let response = request( + Arc::clone(&state), + Method::POST, + "/api/invoke", + json!({ "command": "open_project", "args": { "projectId": project_id } }), + &[("cookie", &cookie)], + ) + .await; + let (status, body, _) = response_json(response).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["id"], project_id); + let expected = parse_project_id(&project_id) + .expect("test project id parses") + .as_uuid() + .simple() + .to_string(); + let opened = state + .app + .open_project_ids() + .into_iter() + .map(|id| id.as_uuid().simple().to_string()) + .collect::>(); + assert!( + opened.iter().any(|id| id == &expected), + "expected open_project HTTP flow to arm the orchestrator watch" + ); + } + #[tokio::test] async fn authorized_layout_invokes_match_tauri_contracts() { let state = state(); diff --git a/frontend/src/features/web/WebAgentCell.test.tsx b/frontend/src/features/web/WebAgentCell.test.tsx index c3334532..295ca997 100644 --- a/frontend/src/features/web/WebAgentCell.test.tsx +++ b/frontend/src/features/web/WebAgentCell.test.tsx @@ -120,6 +120,51 @@ describe("WebAgentCell", () => { }); }); + it("replays startup MCP activity when the mobile custom CLI launches", async () => { + const { agent, profile, agentId } = await seededAgentGateway(structuredProfile); + const launchChatSpy = vi.spyOn(agent, "launchAgentChat"); + const startupScrollback = [ + { + kind: "progress" as const, + progress: { + source: "ideaLocal" as const, + kind: "mcp" as const, + stage: "started" as const, + label: "idea_context_read", + toolName: "idea_context_read", + text: "Bootstrap MCP tools", + }, + }, + { kind: "final" as const, content: "Custom CLI ready" }, + ]; + const reattachChatSpy = vi + .spyOn(agent, "reattachAgentChat") + .mockImplementation(async (sessionId: string) => ({ + sessionId, + scrollback: startupScrollback, + })); + + renderCell(agent, profile, agentId, { fullHeight: true }); + fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); + + await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(1)); + expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ + cwd: "/srv/demo", + nodeId: `web-mobile-${agentId}`, + }); + + fireEvent.click(screen.getByRole("button", { name: "TUI native" })); + await waitFor(() => expect(screen.queryByTestId("custom-agent-chat-view")).toBeNull()); + fireEvent.click(screen.getByRole("button", { name: "CLI custom" })); + + await waitFor(() => expect(reattachChatSpy).toHaveBeenCalledTimes(2)); + expect(await screen.findByText("MCP")).toBeTruthy(); + expect(screen.getByText("idea_context_read")).toBeTruthy(); + expect(screen.getByText("Bootstrap MCP tools")).toBeTruthy(); + expect(screen.getByText("Custom CLI ready")).toBeTruthy(); + }); + it("reattaches the mobile custom CLI session when returning to custom mode", async () => { const { agent, profile, agentId } = await seededAgentGateway(structuredProfile); const launchChatSpy = vi.spyOn(agent, "launchAgentChat"); @@ -138,6 +183,26 @@ describe("WebAgentCell", () => { expect(launchChatSpy).toHaveBeenCalledTimes(1); }); + it("surfaces a mobile custom CLI launch failure when the runtime cannot find node", async () => { + const { agent, profile, agentId } = await seededAgentGateway(structuredProfile); + const launchChatSpy = vi.spyOn(agent, "launchAgentChat").mockRejectedValueOnce({ + code: "PROCESS", + message: "spawn node ENOENT: node is not installed on this runtime", + }); + const reattachChatSpy = vi.spyOn(agent, "reattachAgentChat"); + + renderCell(agent, profile, agentId, { fullHeight: true }); + fireEvent.click(await screen.findByRole("button", { name: "CLI custom" })); + + await waitFor(() => expect(launchChatSpy).toHaveBeenCalledTimes(1)); + expect(launchChatSpy.mock.calls[0][2]).toMatchObject({ + cwd: "/srv/demo", + nodeId: `web-mobile-${agentId}`, + }); + expect(reattachChatSpy).not.toHaveBeenCalled(); + expect((await screen.findByRole("alert")).textContent).toContain("spawn node ENOENT"); + }); + it("falls back to native TUI when the mobile agent loses custom CLI support", async () => { const { agent, profile, agentId } = await seededAgentGateway(structuredProfile); const created = (await agent.listAgents(PROJECT_ID)).find((candidate) => candidate.id === agentId);