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:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -40,7 +40,8 @@ use crate::dto::{
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto, InspectConversationRequestDto,
LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto, LiveAgentListDto,
InterruptAgentRequestDto, LaunchAgentRequestDto, LayoutDto, LayoutOperationDto, ListLayoutsDto,
LiveAgentListDto, SubmitAgentInputRequestDto,
MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProjectDto, ProjectListDto, ReadAgentContextResponseDto, ReattachChatDto,
ReattachResultDto, RecallMemoryRequestDto, RenameLayoutRequestDto, ReplyChunk,
@ -1037,8 +1038,10 @@ pub async fn launch_agent(
// M5c handshake guard (`serve_peer`) compares against; the `--requester` is the
// launching agent's id. A missing executable path (should not happen) degrades
// to no runtime ⇒ apply_mcp_config writes the minimal declaration.
let mcp_runtime = std::env::current_exe().ok().map(|exe| McpRuntime {
exe: exe.to_string_lossy().into_owned(),
// L'exe vient de `idea_exe_path()` (privilégie `$APPIMAGE`, sinon `current_exe`)
// pour que chemin GUI et chemin `ask` écrivent un `command` identique et stable.
let mcp_runtime = crate::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
exe,
endpoint: crate::mcp_endpoint::mcp_endpoint(&project.id)
.as_cli_arg()
.to_owned(),
@ -1203,6 +1206,55 @@ pub async fn agent_send(
Ok(())
}
/// `submit_agent_input` — the human **Envoyer** path (cadrage C4 §4.2).
///
/// Routes the operator's text to [`OrchestratorService::submit_human_input`], which
/// enqueues a `from_human` ticket in the **same FIFO** the inter-agent delegations
/// use (serialised per agent). Fire-and-forget: the human watches the terminal for
/// the answer. The mediator emits `AgentBusyChanged` at the source.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
/// `NOT_FOUND` if the project or agent is unknown, `PROCESS` on a launch/PTY failure).
#[tauri::command]
pub async fn submit_agent_input(
request: SubmitAgentInputRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.orchestrator_service
.submit_human_input(&project, agent_id, request.text)
.await
.map(|_| ())
.map_err(ErrorDto::from)
}
/// `interrupt_agent` — the **Interrompre** path (cadrage C4 §4.2).
///
/// Routes to [`OrchestratorService::interrupt_agent`], which `preempt`s the agent's
/// running turn (best-effort interrupt to its PTY). Not an enqueue; resolves no
/// ticket. Idempotent on an idle agent.
///
/// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for a malformed id or unwired mediator,
/// `NOT_FOUND` if the project or agent is unknown).
#[tauri::command]
pub async fn interrupt_agent(
request: InterruptAgentRequestDto,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.orchestrator_service
.interrupt_agent(&project, agent_id)
.await
.map(|_| ())
.map_err(ErrorDto::from)
}
/// `reattach_agent_chat` — re-bind a view to a **still-living** structured session
/// without re-sending or re-spawning it (ARCHITECTURE §17.6/§17.7).
///

View File

@ -1186,6 +1186,31 @@ impl From<LaunchAgentOutput> for TerminalSessionDto {
}
}
/// Request DTO for `submit_agent_input` (cadrage C4 §4.2): the human Envoyer path.
/// The frontend's [`InputGateway`](../../../frontend/src/ports) sends
/// `{ request: { projectId, agentId, text } }`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SubmitAgentInputRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Id of the agent to send the human input to.
pub agent_id: String,
/// The text the operator typed (enqueued as a `from_human` ticket).
pub text: String,
}
/// Request DTO for `interrupt_agent` (cadrage C4 §4.2): the Interrompre path. The
/// frontend sends `{ request: { projectId, agentId } }`.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InterruptAgentRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Id of the agent whose running turn to preempt.
pub agent_id: String,
}
/// Request DTO for `change_agent_profile` (§15.1): hot-swap an agent's runtime
/// profile, optionally relaunching its live session in place.
#[derive(Debug, Clone, Deserialize)]

View File

@ -47,6 +47,15 @@ pub enum DomainEventDto {
/// Exit code.
code: i32,
},
/// An agent's busy/idle state changed (cadrage C4 §4.2). The frontend dims
/// "Envoyer" while `busy` is `true`.
#[serde(rename_all = "camelCase")]
AgentBusyChanged {
/// Agent id.
agent_id: String,
/// `true` when a turn is in flight, `false` when idle.
busy: bool,
},
/// A target agent produced a synchronous reply to an inter-agent `ask` (§17.4).
#[serde(rename_all = "camelCase")]
AgentReplied {
@ -212,6 +221,10 @@ impl From<&DomainEvent> for DomainEventDto {
agent_id: agent_id.to_string(),
code: *code,
},
DomainEvent::AgentBusyChanged { agent_id, busy } => Self::AgentBusyChanged {
agent_id: agent_id.to_string(),
busy: *busy,
},
DomainEvent::AgentReplied {
agent_id,
reply_len,

View File

@ -166,6 +166,8 @@ pub fn run() {
commands::launch_agent,
commands::change_agent_profile,
commands::agent_send,
commands::submit_agent_input,
commands::interrupt_agent,
commands::reattach_agent_chat,
commands::close_agent_session,
commands::list_resumable_agents,

View File

@ -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é");
}
}

View File

@ -42,7 +42,8 @@ use infrastructure::{
embedder_from_profile, AdaptiveMemoryRecall, ClaudeTranscriptInspector, CliAgentRuntime,
EmbedderEnvProbe, FsEmbedderProfileStore, FsEmbedderPromptStore, FsMemoryStore,
FsOrchestratorWatcher, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore,
Git2Repository, IdeaiContextStore, LocalFileSystem, LocalProcessSpawner, McpServer,
Git2Repository, IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox,
LocalFileSystem, LocalProcessSpawner, McpServer, MediatedInbox, SystemMillisClock,
NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, StructuredSessionFactory,
SystemClock, TokioBroadcastEventBus,
UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR,
@ -50,7 +51,7 @@ use infrastructure::{
};
use crate::chat::ChatBridge;
use crate::mcp_endpoint::{mcp_endpoint, McpEndpoint};
use crate::mcp_endpoint::{mcp_endpoint, AppMcpRuntimeProvider, McpEndpoint};
use crate::pty::PtyBridge;
use infrastructure::StdioTransport;
@ -558,27 +559,29 @@ impl AppState {
));
// LaunchAgent shares the SAME pty_port and terminal_sessions as the terminal
// use cases — indispensable for the PtyBridge to work correctly.
let launch_agent = Arc::new(
LaunchAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&runtime_port),
Arc::clone(&fs_port),
Arc::clone(&pty_port),
Arc::clone(&skill_store_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
)
// Routage structuré §17.4 : un profil avec `structured_adapter` lance une
// AgentSession (cellule chat) au lieu d'un PTY ; sinon chemin PTY inchangé.
.with_structured(
Arc::clone(&session_factory),
Arc::clone(&structured_sessions),
),
);
//
// Option 1 « Terminal + MCP » (lot B-2) : on **ne câble plus** la fabrique
// structurée. La vue humaine d'un agent est désormais le **terminal brut
// natif** (PTY interactif) — réflexion live + Échap natifs CLI, zéro parsing —
// et la délégation inter-agents passe par les outils MCP (`idea_ask_agent` /
// `idea_reply`), pas par une `AgentChatView`. Sans `with_structured`, le point
// de routage §17.4 de `LaunchAgent::execute` retombe **toujours** sur le chemin
// PTH : tout profil (Claude/Codex inclus) ouvre une cellule terminal. Le code
// `launch_structured` reste en place (mort-code retiré au lot de nettoyage B-6).
let _session_factory = session_factory; // décâblé en B-2 (nettoyage B-6)
let launch_agent = Arc::new(LaunchAgent::new(
Arc::clone(&contexts_port),
Arc::clone(&profile_store_port),
Arc::clone(&runtime_port),
Arc::clone(&fs_port),
Arc::clone(&pty_port),
Arc::clone(&skill_store_port),
Arc::clone(&terminal_sessions),
Arc::clone(&events_port),
Arc::clone(&ids) as Arc<dyn IdGenerator>,
Arc::clone(&memory_recall_port),
Some(Arc::clone(&check_embedder_suggestion)),
));
// Hot-swap an agent's runtime profile (§15.1). Reuses the shared context/
// profile/project/fs stores, the live-session registry and PTY port, and
@ -737,6 +740,30 @@ impl AppState {
// the UI drives (IdeA stays the single source of truth for the agent/skill
// lifecycle). The per-project watcher that feeds it is started lazily when
// a project is opened (see `ensure_orchestrator_watch`).
// File inter-agents (Option 1 « Terminal + MCP », B-3) : un ticket par tâche
// déléguée, résolu par `idea_reply`. Une instance par AppState ⇒ partagée par
// tous les projets (clé interne = AgentId, jamais de collision cross-projet).
// Médiateur d'entrée (cadrage C3) : enveloppe l'`InMemoryMailbox` (moteur de
// corrélation par ticket) + porte la **livraison** du tour dans le PTY de la
// cible (écriture sérialisée, une seule voie d'entrée). Le mailbox concret est
// partagé pour `resolve`/`resolve_ticket`/`cancel_head` côté orchestrateur.
let inmemory_mailbox = Arc::new(InMemoryMailbox::new());
let mailbox =
Arc::clone(&inmemory_mailbox) as Arc<dyn domain::mailbox::AgentMailbox>;
let input_mediator = Arc::new(
MediatedInbox::with_pty(
Arc::clone(&inmemory_mailbox),
Arc::new(SystemMillisClock),
Arc::clone(&pty_port),
)
// Émet `AgentBusyChanged` à la source (Busy à l'enqueue qui démarre un
// tour, Idle au mark_idle) ⇒ relayé au front en event Tauri (cadrage C4).
.with_events(Arc::clone(&events_port)),
) as Arc<dyn domain::input::InputMediator>;
// Registre des conversations par paire (cadrage C3) : un fil par paire, session
// vivante keyée par conversation (lève l'ambiguïté session/agent).
let conversation_registry =
Arc::new(InMemoryConversationRegistry::new()) as Arc<dyn domain::conversation::ConversationRegistry>;
let orchestrator_service = Arc::new(
OrchestratorService::new(
Arc::clone(&create_agent),
@ -748,10 +775,17 @@ impl AppState {
Arc::clone(&profile_store_port),
Arc::clone(&terminal_sessions),
)
// Messagerie inter-agents §17.4 : registre structuré + bus pour
// `agent.message` (AskAgent) rendez-vous synchrone via send_blocking.
.with_structured(Arc::clone(&structured_sessions))
.with_events(Arc::clone(&events_port)),
// Messagerie inter-agents (cadrage C3) : médiateur d'entrée (file + livraison
// PTV sérialisée) + registre de conversations + bus pour AgentReplied.
.with_input_mediator(Arc::clone(&input_mediator), Arc::clone(&mailbox))
.with_conversations(Arc::clone(&conversation_registry))
.with_events(Arc::clone(&events_port))
// Faits OS/runtime (exe $APPIMAGE/current_exe + endpoint loopback) pour
// que les (re)lancements issus du chemin `ask` écrivent la déclaration MCP
// réelle ⇒ le pont MCP de la cible se spawne et `idea_reply` débloque le round-trip.
.with_mcp_runtime_provider(
Arc::new(AppMcpRuntimeProvider) as Arc<dyn application::McpRuntimeProvider>
),
);
// --- Windows (L10) ---
@ -982,6 +1016,21 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// D1 — reclaim the **corpse** socket left by a SIGKILL'd run BEFORE binding.
// `reclaim_name(true)` (below) only unlinks the socket on *drop*; in
// `interprocess` 2.4 it does **not** clear a pre-existing inode at bind time,
// so a stale socket file makes the bind fail with `EADDRINUSE` (the D1 test
// proves this). We therefore unlink it ourselves first — but only when the
// path is actually a **socket** (never clobber a real file we don't own).
#[cfg(unix)]
{
use std::os::unix::fs::FileTypeExt;
if let Ok(meta) = std::fs::symlink_metadata(&path) {
if meta.file_type().is_socket() {
let _ = std::fs::remove_file(&path);
}
}
}
}
let name = endpoint
.as_cli_arg()
@ -989,7 +1038,7 @@ fn bind_endpoint(endpoint: &McpEndpoint) -> Option<LocalSocketListener> {
.ok()?;
ListenerOptions::new()
.name(name)
// Replace a corpse socket left by a previous run; reclaim (unlink) on drop.
// Reclaim (unlink) on drop so a clean close leaves no socket behind.
.reclaim_name(true)
.create_tokio()
.ok()
@ -1234,7 +1283,10 @@ mod mcp_serve_peer_tests {
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
@ -1532,6 +1584,9 @@ mod mcp_serve_peer_tests {
/// Builds an `OrchestratorService` over the in-memory fakes (no structured
/// registry), mirroring `infrastructure/tests/mcp_server.rs::build_service`.
fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour
// `idea_ask_agent`, car seul Claude consomme réellement le pont `.mcp.json`.
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
@ -1542,7 +1597,12 @@ mod mcp_serve_peer_tests {
"{agentRunDir}",
None,
)
.unwrap()])));
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
@ -1681,14 +1741,24 @@ mod mcp_serve_peer_tests {
for expected in [
"idea_list_agents",
"idea_ask_agent",
"idea_reply",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_create_skill",
// FileGuard-mediated context/memory tools (cadrage C7).
"idea_context_read",
"idea_context_propose",
"idea_memory_read",
"idea_memory_write",
] {
assert!(names.contains(&expected), "missing tool {expected}; got {names:?}");
}
assert_eq!(tools.len(), 6, "exactly the six idea_* tools; got {names:?}");
assert_eq!(
tools.len(),
11,
"exactly the eleven idea_* tools (7 base + 4 FileGuard C7); got {names:?}"
);
drop(client); // EOF ⇒ serve loop ends
tokio::time::timeout(TIMEOUT, peer)
@ -2158,19 +2228,22 @@ mod mcp_e2e_loopback_tests {
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use domain::agent::{AgentManifest, ManifestEntry};
use domain::events::{DomainEvent, OrchestrationSource};
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, SkillId};
use domain::markdown::MarkdownDoc;
use domain::ports::{
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, ContextInjectionPlan,
DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError, IdGenerator, OutputStream,
PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath, ReplyEvent,
ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
ExitStatus, FileSystem, FsError, IdGenerator, OutputStream, PreparedContext, ProfileStore,
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
StoreError,
};
use domain::profile::{
AgentProfile, ContextInjection, McpCapability, McpConfigStrategy, McpTransport,
StructuredAdapter,
};
use domain::profile::{AgentProfile, ContextInjection};
use domain::project::{Project, ProjectPath};
use domain::remote::RemoteRef;
use domain::skill::{Skill, SkillScope};
@ -2180,7 +2253,9 @@ mod mcp_e2e_loopback_tests {
use super::{bind_endpoint, mcp_endpoint, McpServerHandle};
use crate::mcp_endpoint::McpEndpoint;
use infrastructure::McpServer;
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, SystemMillisClock,
};
/// Test timeout for any single loopback interaction. Generous but finite: a
/// correct round-trip is sub-millisecond, so this only ever fires on a real hang.
@ -2417,36 +2492,6 @@ mod mcp_e2e_loopback_tests {
}
}
/// A structured session whose turn deterministically ends on a `Final` carrying a
/// fixed reply — lets us exercise `idea_ask_agent` without a real CLI.
struct FakeSession {
id: SessionId,
reply: String,
}
#[async_trait]
impl AgentSession for FakeSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
let events = vec![
ReplyEvent::TextDelta {
text: "thinking…".to_owned(),
},
ReplyEvent::Final {
content: self.reply.clone(),
},
];
Ok(Box::new(events.into_iter()))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Default, Clone)]
struct NoopBus;
impl EventBus for NoopBus {
@ -2495,17 +2540,19 @@ mod mcp_e2e_loopback_tests {
}
/// Builds an `OrchestratorService` over the in-memory fakes, returning the
/// shared `TerminalSessions` (so a test can pre-bind a live **PTY** session for a
/// raw-PTY target) and `StructuredSessions` (so a test can pre-insert a replying
/// **structured** session for `idea_ask_agent`). Mirrors the established harness;
/// no use case is re-invented.
/// shared `TerminalSessions` (so a test can pre-bind a live PTY target for an
/// `idea_ask_agent`) and the `InMemoryMailbox` (so a test can observe pending
/// tickets). Mirrors the established harness; no use case is re-invented.
fn build_service(
contexts: FakeContexts,
) -> (
Arc<OrchestratorService>,
Arc<TerminalSessions>,
Arc<StructuredSessions>,
Arc<InMemoryMailbox>,
) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) laisse passer pour
// `idea_ask_agent` — le round-trip e2e testé ici suppose une cible éligible.
let profiles = Arc::new(FakeProfiles(Arc::new(vec![AgentProfile::new(
ProfileId::from_uuid(Uuid::from_u128(9)),
"Claude Code",
@ -2516,9 +2563,14 @@ mod mcp_e2e_loopback_tests {
"{agentRunDir}",
None,
)
.unwrap()])));
.unwrap()
.with_structured_adapter(StructuredAdapter::Claude)
.with_mcp(McpCapability::new(
McpConfigStrategy::config_file(".mcp.json").unwrap(),
McpTransport::Stdio,
))])));
let sessions = Arc::new(TerminalSessions::new());
let structured = Arc::new(StructuredSessions::new());
let mailbox = Arc::new(InMemoryMailbox::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
@ -2545,6 +2597,13 @@ mod mcp_e2e_loopback_tests {
Arc::new(FakeSkills) as Arc<dyn SkillStore>,
Arc::new(SeqIds(Mutex::new(1))),
));
let input = Arc::new(MediatedInbox::with_pty(
Arc::clone(&mailbox),
Arc::new(SystemMillisClock),
Arc::new(FakePty) as Arc<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>;
let conversations =
Arc::new(InMemoryConversationRegistry::new()) as Arc<dyn domain::conversation::ConversationRegistry>;
let service = OrchestratorService::new(
create,
launch,
@ -2555,8 +2614,26 @@ mod mcp_e2e_loopback_tests {
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
)
.with_structured(Arc::clone(&structured));
(Arc::new(service), sessions, structured)
.with_input_mediator(
input,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.with_conversations(conversations);
(Arc::new(service), sessions, mailbox)
}
/// Pre-seeds a live PTY terminal session for `agent_id` so `ask_agent` reuses it.
fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: SessionId) {
sessions.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(7)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
}
// --- real loopback harness ---------------------------------------------
@ -2648,7 +2725,7 @@ mod mcp_e2e_loopback_tests {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
contexts.seed_agent("dev-backend");
let (service, _pty, _structured) = build_service(contexts);
let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2689,34 +2766,31 @@ mod mcp_e2e_loopback_tests {
}
// -----------------------------------------------------------------------
// 2. idea_ask_agent e2e: structured target ⇒ reply returned INLINE.
// 2. idea_ask_agent → idea_reply e2e over the real loopback (Option 1, B-3/B-4):
// the asker's `idea_ask_agent` blocks on the mailbox; the target's `idea_reply`
// (on a second connection, with its agent id as the handshake requester)
// resolves it and the asker gets the result INLINE.
// -----------------------------------------------------------------------
#[tokio::test]
async fn ask_structured_agent_returns_reply_inline_over_real_loopback() {
async fn ask_then_reply_round_trips_inline_over_real_loopback() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, _pty, structured) = build_service(contexts);
structured.insert(
Arc::new(FakeSession {
id: SessionId::from_uuid(Uuid::from_u128(4242)),
reply: "the answer is 42".to_owned(),
}),
agent_id,
NodeId::from_uuid(Uuid::from_u128(7)),
);
let (service, sessions, mailbox) = build_service(contexts);
// Target already live in the PTY registry, so the ask reuses its terminal.
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
let conn = connect_client(&endpoint).await;
let (read_half, mut write_half) = tokio::io::split(conn);
let mut reader = BufReader::new(read_half);
write_half
// Connection A: the asker.
let conn_a = connect_client(&endpoint).await;
let (read_a, mut write_a) = tokio::io::split(conn_a);
let mut reader_a = BufReader::new(read_a);
write_a
.write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes())
.await
.unwrap();
write_half
write_a
.write_all(
tools_call_line(
7,
@ -2727,9 +2801,41 @@ mod mcp_e2e_loopback_tests {
)
.await
.unwrap();
write_half.flush().await.unwrap();
write_a.flush().await.unwrap();
let resp = read_one_response(&mut reader)
// The ask is now blocked awaiting the reply — observe the pending ticket.
tokio::time::timeout(TIMEOUT, async {
while mailbox.pending(&agent_id) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("ask must enqueue a ticket");
// Connection B: the target rendering its result. Its handshake requester is
// the target agent's id, which the server injects as the Reply `from`.
let conn_b = connect_client(&endpoint).await;
let (read_b, mut write_b) = tokio::io::split(conn_b);
let mut reader_b = BufReader::new(read_b);
write_b
.write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes())
.await
.unwrap();
write_b
.write_all(
tools_call_line(8, "idea_reply", json!({ "result": "the answer is 42" }))
.as_bytes(),
)
.await
.unwrap();
write_b.flush().await.unwrap();
let reply_resp = read_one_response(&mut reader_b)
.await
.expect("a reply ack line");
assert_eq!(reply_resp["result"]["isError"], json!(false), "got {reply_resp}");
// The asker now receives its inline result.
let resp = read_one_response(&mut reader_a)
.await
.expect("an ask response line");
assert_eq!(resp["id"], json!(7));
@ -2742,33 +2848,21 @@ mod mcp_e2e_loopback_tests {
"ask reply must be returned inline over the real loopback; got {result}"
);
drop(write_half);
drop(write_a);
drop(write_b);
handle.stop();
}
// -----------------------------------------------------------------------
// 3. idea_ask_agent against a raw-PTY target ⇒ typed tool error (isError:true),
// no panic, connection stays healthy for a follow-up call.
// 3. idea_reply with no in-flight ask ⇒ typed tool error (isError:true), no
// panic, connection stays healthy for a follow-up call.
// -----------------------------------------------------------------------
#[tokio::test]
async fn ask_raw_pty_target_is_typed_error_over_real_loopback() {
async fn orphan_reply_is_typed_error_over_real_loopback() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("dev");
let (service, pty, _structured) = build_service(contexts);
// Pre-bind a live **PTY** session for the agent (no structured session): the
// ask path must reject it with AppError::Invalid ⇒ tool result isError:true.
let session_id = SessionId::from_uuid(Uuid::from_u128(555));
pty.insert(
PtyHandle { session_id },
TerminalSession::starting(
session_id,
NodeId::from_uuid(Uuid::from_u128(8)),
ProjectPath::new("/home/me/proj").unwrap(),
SessionKind::Agent { agent_id },
PtySize { rows: 24, cols: 80 },
),
);
let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2776,14 +2870,15 @@ mod mcp_e2e_loopback_tests {
let (read_half, mut write_half) = tokio::io::split(conn);
let mut reader = BufReader::new(read_half);
// The peer identifies as `agent_id`; an idea_reply with no matching ask in
// flight ⇒ the IdeA command fails ⇒ tool result isError:true (not transport).
write_half
.write_all(handshake_line(&project_id_arg(&proj), "agent-asker").as_bytes())
.write_all(handshake_line(&project_id_arg(&proj), &agent_id.to_string()).as_bytes())
.await
.unwrap();
write_half
.write_all(
tools_call_line(3, "idea_ask_agent", json!({ "target": "dev", "task": "hi" }))
.as_bytes(),
tools_call_line(3, "idea_reply", json!({ "result": "orphan" })).as_bytes(),
)
.await
.unwrap();
@ -2791,14 +2886,12 @@ mod mcp_e2e_loopback_tests {
let resp = read_one_response(&mut reader)
.await
.expect("an ask response line");
.expect("a reply response line");
assert_eq!(resp["id"], json!(3));
// Healthy connection: NO JSON-RPC transport error...
assert!(
resp["error"].is_null(),
"a raw-PTY target must be a tool error, not a transport error: {resp}"
"an orphan reply must be a tool error, not a transport error: {resp}"
);
// ...but the tool result is flagged as an error.
let result = &resp["result"];
assert_eq!(result["isError"], json!(true), "got {result}");
assert!(
@ -2844,7 +2937,7 @@ mod mcp_e2e_loopback_tests {
async fn malformed_jsonrpc_after_handshake_errors_and_server_survives() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
let (service, _pty, _structured) = build_service(contexts);
let (service, _sessions, _mailbox) = build_service(contexts);
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, None);
@ -2906,7 +2999,7 @@ mod mcp_e2e_loopback_tests {
#[tokio::test]
async fn handshake_requester_propagates_over_real_loopback() {
let contexts = FakeContexts::new();
let (service, _pty, _structured) = build_service(contexts);
let (service, _sessions, _mailbox) = build_service(contexts);
let (publish, captured) = capturing_events();
let proj = project();
let (handle, endpoint) = start_real_server(service, &proj, Some(publish));
@ -2991,3 +3084,78 @@ mod mcp_e2e_loopback_tests {
handle.stop();
}
}
#[cfg(test)]
mod bind_endpoint_d1_tests {
//! D1 — non-regression lock on [`bind_endpoint`]'s corpse-socket reclaim
//! (cadrage §7 row D1, §5.2 "verrouille `reclaim_name(true)`").
//!
//! A crashed run (SIGKILL) leaves the Unix socket **file** behind: a plain
//! `bind` would then fail with `EADDRINUSE`. The production path passes
//! `reclaim_name(true)`, which **unlinks the corpse** before binding. These tests
//! pin that behaviour so a future refactor cannot silently drop the flag and
//! resurrect the "address already in use" failure on restart.
//!
//! Unix-only: on Windows the endpoint is a named pipe with no filesystem corpse to
//! reclaim, so there is nothing to assert.
#![cfg(unix)]
use super::{bind_endpoint, mcp_endpoint};
use domain::ProjectId;
use uuid::Uuid;
/// Rebinding after a **corpse** socket (file left in place, as after a SIGKILL)
/// succeeds — no `EADDRINUSE` — because `reclaim_name(true)` unlinks it first.
///
/// The corpse is reproduced **faithfully**: a `std::os::unix::net::UnixListener`
/// binds the path then is dropped — std does **not** unlink on drop, so the socket
/// inode is left on the filesystem with **no live listener**, exactly the state a
/// SIGKILL'd run leaves behind (the fd is gone, the inode lingers). A plain `bind`
/// over that inode would return `EADDRINUSE`; `bind_endpoint`'s `reclaim_name(true)`
/// must unlink it first.
#[tokio::test]
async fn rebind_after_corpse_socket_succeeds() {
use std::os::unix::net::UnixListener;
let ep = mcp_endpoint(&ProjectId::from_uuid(Uuid::from_u128(0xD1_0001)));
let path = ep.socket_path().expect("unix endpoint exposes a socket path");
// Clean any leftover from a previous run of this very test.
let _ = std::fs::remove_file(&path);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
// 1) Lay down a CORPSE: bind with std (which leaves the inode on drop) and
// drop it ⇒ socket file remains with no live listener (SIGKILL aftermath).
{
let corpse = UnixListener::bind(&path).expect("lay corpse socket");
drop(corpse);
}
assert!(path.exists(), "corpse socket inode remains (no live listener)");
// 2) Rebind over the corpse: must succeed (reclaim unlinks then binds), NOT
// fail with EADDRINUSE.
let first = bind_endpoint(&ep);
assert!(
first.is_some(),
"rebind over the corpse socket must succeed (reclaim_name unlinks it)"
);
assert!(path.exists(), "a fresh live socket now sits at the path");
// 3) Idempotent over a live socket: a second bind also reclaims + succeeds.
let second = bind_endpoint(&ep);
assert!(second.is_some(), "bind is idempotent across a live socket");
// 4) No file leak after a clean close: dropping the live listener(s) unlinks
// the socket (interprocess reclaim guard on drop).
drop(first);
drop(second);
assert!(
!path.exists(),
"socket file unlinked on clean close (no leak)"
);
}
}