feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP
Coeur inter-agents consolidé et surface front réalignée sur la décision "terminal natif PTY, pas d'UI chat" (Option 1). Domaine - nouveaux modules conversation, mailbox, input, fileguard (ports + types) - orchestrator/profile/events étendus (conversation par paire, FIFO) Application / Infrastructure - orchestrator/service + context_guard : sérialisation FIFO par agent, garde RW mémoire/contexte, dispatch ask/reply - adapters in-memory conversation / mailbox / input / fileguard - registry session + lifecycle agent durcis (1 agent = 1 session vivante) - outils MCP idea_* alignés sur le nouveau dispatch Frontend - MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA, terminal = vue sortie inchangée - suppression de la vue chat structurée (AgentChatView) — abandonnée - adapter input + ports mis à jour Divers - .ideai/ : mémoire projet + briefs de cadrage versionnés ; requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA) Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -41,7 +41,8 @@
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use domain::ProjectId;
|
||||
use application::{McpRuntime, McpRuntimeProvider};
|
||||
use domain::{AgentId, Project, ProjectId};
|
||||
|
||||
/// The loopback address of a project's MCP endpoint — the value [`mcp_endpoint`]
|
||||
/// returns. Deterministic per [`ProjectId`]; the single source of truth shared by
|
||||
@ -127,6 +128,48 @@ pub fn mcp_endpoint(project_id: &ProjectId) -> McpEndpoint {
|
||||
McpEndpoint { addr }
|
||||
}
|
||||
|
||||
/// Chemin du binaire IdeA à inscrire comme `command` dans la déclaration MCP.
|
||||
///
|
||||
/// Privilégie `$APPIMAGE` (chemin **stable** du `.AppImage`, qui survit aux
|
||||
/// redémarrages) car sous AppImage `current_exe()` pointe sur un montage éphémère
|
||||
/// `/tmp/.mount_*` qui disparaît au redémarrage ⇒ `ENOENT` au respawn du pont MCP.
|
||||
/// Hors AppImage `$APPIMAGE` est absent ⇒ on retombe sur `current_exe()`. `None` si
|
||||
/// aucun des deux n'est résolvable (ne devrait pas arriver) ⇒ déclaration minimale.
|
||||
pub(crate) fn idea_exe_path() -> Option<String> {
|
||||
std::env::var("APPIMAGE")
|
||||
.ok()
|
||||
.or_else(|| std::env::current_exe().ok().map(|p| p.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Implémentation app-tauri du port [`McpRuntimeProvider`] : fournit à
|
||||
/// l'orchestrateur (couche `application`) les **faits OS/runtime** nécessaires pour
|
||||
/// écrire la déclaration MCP réelle quand il (re)lance une cible sur le chemin
|
||||
/// `ask` (`ensure_live_pty`).
|
||||
///
|
||||
/// Sans état : tout est dérivé du `Project` et de l'`AgentId` reçus + de
|
||||
/// l'environnement (`$APPIMAGE`/`current_exe`). C'est le **seul** détenteur légitime
|
||||
/// de ces faits (la couche `application` ne doit pas dépendre de `current_exe` /
|
||||
/// `$APPIMAGE` / `mcp_endpoint`, cadrage v5 §0.3 / §7) ; seules des **chaînes**
|
||||
/// traversent la frontière via [`McpRuntime`].
|
||||
pub struct AppMcpRuntimeProvider;
|
||||
|
||||
impl McpRuntimeProvider for AppMcpRuntimeProvider {
|
||||
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera
|
||||
/// `idea_reply`). Réutilise la **même** logique que le chemin GUI
|
||||
/// (`commands.rs::launch_agent`) : même endpoint (source de vérité unique),
|
||||
/// même forme `simple` 32-hex du `project_id`. `None` (exe introuvable) ⇒
|
||||
/// l'orchestrateur dégrade vers la déclaration minimale, jamais d'échec de
|
||||
/// lancement.
|
||||
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime> {
|
||||
Some(McpRuntime {
|
||||
exe: idea_exe_path()?,
|
||||
endpoint: mcp_endpoint(&project.id).as_cli_arg().to_owned(),
|
||||
project_id: project.id.as_uuid().simple().to_string(),
|
||||
requester: agent_id.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -170,4 +213,77 @@ mod tests {
|
||||
assert!(path.extension().is_some_and(|e| e == "sock"));
|
||||
assert_eq!(path.parent().unwrap(), unix_runtime_dir());
|
||||
}
|
||||
|
||||
/// `idea_exe_path()` : `$APPIMAGE` posé ⇒ on renvoie sa valeur (chemin stable du
|
||||
/// `.AppImage`, qui survit aux redémarrages) ; absent ⇒ on retombe sur
|
||||
/// `current_exe()`. Les deux assertions sont regroupées dans **un seul** test pour
|
||||
/// éviter une course inter-tests sur la variable d'env globale du process ; la var
|
||||
/// est sauvegardée/restaurée pour ne pas polluer les autres tests.
|
||||
#[test]
|
||||
fn idea_exe_path_prefers_appimage_then_falls_back_to_current_exe() {
|
||||
let saved = std::env::var_os("APPIMAGE");
|
||||
|
||||
// APPIMAGE posé ⇒ valeur exacte renvoyée.
|
||||
std::env::set_var("APPIMAGE", "/opt/idea/IdeA.AppImage");
|
||||
assert_eq!(
|
||||
idea_exe_path().as_deref(),
|
||||
Some("/opt/idea/IdeA.AppImage"),
|
||||
"$APPIMAGE doit primer"
|
||||
);
|
||||
|
||||
// APPIMAGE absent ⇒ repli sur current_exe() (présent dans un binaire de test).
|
||||
std::env::remove_var("APPIMAGE");
|
||||
let fallback = idea_exe_path();
|
||||
let expected = std::env::current_exe()
|
||||
.ok()
|
||||
.map(|p| p.to_string_lossy().into_owned());
|
||||
assert_eq!(fallback, expected, "repli attendu sur current_exe()");
|
||||
assert!(fallback.is_some(), "current_exe() résolvable dans le test");
|
||||
|
||||
// Restauration de l'état initial de la variable.
|
||||
match saved {
|
||||
Some(v) => std::env::set_var("APPIMAGE", v),
|
||||
None => std::env::remove_var("APPIMAGE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `AppMcpRuntimeProvider::runtime_for` : cohérence des champs avec les sources de
|
||||
/// vérité — endpoint identique à `mcp_endpoint(project.id).as_cli_arg()`,
|
||||
/// `project_id` en forme `simple` 32-hex, `requester == agent_id.to_string()`.
|
||||
#[test]
|
||||
fn app_provider_runtime_for_matches_sources_of_truth() {
|
||||
use domain::{AgentId, ProjectId, Project};
|
||||
use domain::project::ProjectPath;
|
||||
use domain::remote::RemoteRef;
|
||||
|
||||
let project = Project::new(
|
||||
ProjectId::from_uuid(Uuid::parse_str("abcdef01-2345-6789-abcd-ef0123456789").unwrap()),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
1_700_000_000_000,
|
||||
)
|
||||
.unwrap();
|
||||
let agent_id = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
|
||||
// On s'assure que current_exe/$APPIMAGE est résolvable (sinon runtime_for None).
|
||||
let rt = AppMcpRuntimeProvider
|
||||
.runtime_for(&project, agent_id)
|
||||
.expect("runtime_for doit produire un McpRuntime (exe résolvable)");
|
||||
|
||||
// Endpoint = même source de vérité que le listener.
|
||||
assert_eq!(
|
||||
rt.endpoint,
|
||||
mcp_endpoint(&project.id).as_cli_arg(),
|
||||
"endpoint cohérent avec mcp_endpoint()"
|
||||
);
|
||||
// project_id en forme simple 32-hex (hyphen-free), consommée par la garde M5c.
|
||||
assert_eq!(rt.project_id, project.id.as_uuid().simple().to_string());
|
||||
assert_eq!(rt.project_id.len(), 32, "forme simple 32-hex");
|
||||
assert!(!rt.project_id.contains('-'), "pas de tirets");
|
||||
// requester = l'id de la cible relancée.
|
||||
assert_eq!(rt.requester, agent_id.to_string());
|
||||
// exe non vide.
|
||||
assert!(!rt.exe.is_empty(), "exe renseigné");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user