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

View File

@ -1750,6 +1750,17 @@ pub(crate) fn compose_convention_file(
(lister les agents du projet). IdeA lancera ou réattachera l'agent cible \
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
);
// Protocole de délégation (Option 1, B-5) : côté agent SOLLICITÉ. Une tâche
// déléguée arrive dans ton terminal préfixée `[IdeA · tâche de … · ticket …]`.
// Tu DOIS y répondre via l'outil `idea_reply`, jamais en texte libre — sinon
// l'agent qui t'a sollicité reste bloqué (sa réponse ne lui parviendra pas).
out.push_str(
"Quand tu reçois une tâche déléguée par IdeA (un message préfixé \
`[IdeA · tâche de … · ticket …]`), traite-la puis appelle \
**impérativement** l'outil `idea_reply(result=…)` pour rendre ton \
résultat. Ne réponds **jamais** uniquement en texte : seul `idea_reply` \
débloque l'agent qui t'a sollicité.\n\n",
);
} else {
out.push_str(
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
@ -1785,6 +1796,7 @@ pub(crate) fn compose_convention_file(
out.push_str("- [");
out.push_str(&entry.title);
out.push_str("](");
out.push_str(".ideai/memory/");
out.push_str(entry.slug.as_str());
out.push_str(".md) — ");
out.push_str(&entry.hook);
@ -1998,9 +2010,13 @@ mod tests {
let section_at = doc.find("# Mémoire projet").unwrap();
assert!(persona_at < section_at, "memory comes after the persona");
// Exact line format: `- [Title](slug.md) — hook (type)`.
assert!(doc.contains("- [Alpha](alpha-note.md) — the first hook (user)"));
assert!(doc.contains("- [Beta](beta-note.md) — the second hook (reference)"));
// Exact line format: `- [Title](.ideai/memory/slug.md) — hook (type)`.
assert!(doc.contains("- [Alpha](.ideai/memory/alpha-note.md) — the first hook (user)"));
assert!(
doc.contains(
"- [Beta](.ideai/memory/beta-note.md) — the second hook (reference)"
)
);
// Deterministic order: first entry precedes the second.
let alpha_at = doc.find("[Alpha]").unwrap();
@ -2033,7 +2049,7 @@ mod tests {
assert!(doc.contains("# Skills"));
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("# Mémoire projet"));
assert!(doc.contains("- [Note](note.md) — a hook (project)"));
assert!(doc.contains("- [Note](.ideai/memory/note.md) — a hook (project)"));
// Skills section precedes the memory section (persona → skills → memory).
let skills_at = doc.find("# Skills").unwrap();
@ -2066,6 +2082,25 @@ mod tests {
);
}
#[test]
fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() {
// B-5 — the solicited-agent side of the protocol: a delegated task arrives as
// `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text.
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], true);
assert!(
doc.contains("idea_reply"),
"MCP prose must instruct answering via idea_reply"
);
assert!(
doc.contains("[IdeA · tâche"),
"MCP prose must describe the delegated-task prefix it answers to"
);
// Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply
// instruction (zero regression on the file path).
let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], false);
assert!(!file_doc.contains("idea_reply"));
}
#[test]
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
@ -2087,6 +2122,60 @@ mod tests {
);
}
#[test]
fn mcp_declaration_with_runtime_points_the_bridge_at_the_real_endpoint() {
// B-0 — a PTY-launched MCP-capable CLI (Claude/Codex REPL) auto-discovers the
// `.mcp.json` in its cwd (the run dir). When the composition root injects the
// real `McpRuntime`, the declaration written there must spawn *this* IdeA exe
// in `mcp-server` mode and dial the project's exact loopback endpoint — the
// same source of truth `ensure_mcp_server` binds — so the CLI can call
// `idea_list_agents` and get a reply end-to-end.
let rt = McpRuntime {
exe: "/opt/idea/idea".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 decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, Some(&rt));
// It is valid JSON with the IdeA server under `mcpServers/idea`.
let parsed: serde_json::Value =
serde_json::from_str(&decl).expect("declaration is valid JSON");
let idea = &parsed["mcpServers"]["idea"];
assert_eq!(idea["command"], "/opt/idea/idea");
let args = idea["args"].as_array().expect("args is an array");
let args: Vec<&str> = args.iter().filter_map(serde_json::Value::as_str).collect();
assert_eq!(args[0], "mcp-server");
// The real endpoint / project / requester are all threaded through.
assert!(args.contains(&"--endpoint"));
assert!(args.contains(&"/run/user/1000/idea-mcp/proj.sock"));
assert!(args.contains(&"--project"));
assert!(args.contains(&"0123456789abcdef0123456789abcdef"));
assert!(args.contains(&"--requester"));
assert_eq!(idea["transport"], "stdio");
}
#[test]
fn mcp_declaration_without_runtime_is_a_coherent_minimal_fallback() {
// Launches issued from inside `application` (orchestrator/hot-swap/tests) have
// no OS/runtime facts: the declaration falls back to the bare `idea mcp-server`
// command — still a valid, self-consistent `mcpServers/idea` entry.
let decl = mcp_server_declaration(domain::profile::McpTransport::Stdio, None);
let parsed: serde_json::Value =
serde_json::from_str(&decl).expect("minimal declaration is valid JSON");
let idea = &parsed["mcpServers"]["idea"];
assert_eq!(idea["command"], "idea");
let args: Vec<&str> = idea["args"]
.as_array()
.expect("args array")
.iter()
.filter_map(serde_json::Value::as_str)
.collect();
assert_eq!(args, vec!["mcp-server"]);
// No endpoint/project/requester when no runtime was injected.
assert!(!args.contains(&"--endpoint"));
}
#[test]
fn claude_settings_seed_grants_autonomy_and_keeps_guardrails() {
let json = claude_settings_seed("/home/me/proj");

View File

@ -71,7 +71,7 @@ pub use memory::{
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
pub use orchestrator::{McpRuntimeProvider, OrchestratorOutcome, OrchestratorService};
pub use project::{
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,

View File

@ -0,0 +1,819 @@
//! FileGuard-mediated context & memory use cases (cadrage C7).
//!
//! Four use cases — [`ReadContext`], [`ProposeContext`], [`ReadMemory`],
//! [`WriteMemory`] — route every read/write of IdeA-owned `.md` context and memory
//! through the domain [`FileGuard`] port **before** touching a store. Each acquires
//! the right lease (shared read / exclusive write) for the requesting
//! [`ConversationParty`], then delegates to the existing store ports.
//!
//! ## Single-writer global context
//!
//! The global project context is single-writer: only the orchestrator
//! ([`ConversationParty::User`]) may write it directly. A project agent that
//! *proposes* a change to the global context receives [`GuardError::Forbidden`] from
//! the guard; [`ProposeContext`] catches that and **materialises a proposal** under
//! `.ideai/proposals/<who>-<ts>.md` for later validation by the orchestrator/UI —
//! never overwriting the live context. An agent's *own* `.md` (and memory) is written
//! directly under a write-lease.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! The guard is **cooperative**: it serialises access inside the IdeA path (these use
//! cases + the MCP tools). It does **not** sandbox an agent that keeps a raw shell —
//! airtight revocation of raw fs access is an OS-sandbox (Landlock) concern, out of
//! scope here.
use std::sync::Arc;
use domain::conversation::ConversationParty;
use domain::fileguard::{FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{
AgentContextStore, Clock, FileSystem, MemoryStore, RemotePath,
};
use domain::{AgentId, Project};
use crate::error::AppError;
/// Convention filename of the project's global context at the project root.
const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md";
/// `.ideai/` subdirectory where a rejected global-context change is materialised.
const PROPOSALS_DIR: &str = ".ideai/proposals";
/// Joins a project root with a POSIX-relative segment (valid on every target).
fn join_root(project: &Project, rel: &str) -> RemotePath {
let base = project.root.as_str().trim_end_matches(['/', '\\']);
RemotePath::new(format!("{base}/{rel}"))
}
/// Resolves an agent display name to its [`AgentId`] via the project manifest
/// (case-insensitive), or [`AppError::NotFound`].
async fn resolve_agent(
contexts: &Arc<dyn AgentContextStore>,
project: &Project,
name: &str,
) -> Result<AgentId, AppError> {
let manifest = contexts.load_manifest(project).await?;
manifest
.entries
.into_iter()
.find(|e| e.name.eq_ignore_ascii_case(name))
.map(|e| e.agent_id)
.ok_or_else(|| AppError::NotFound(format!("agent `{name}`")))
}
/// Reads an IdeA-owned context under a **shared read-lease** ([`GuardedResource`]).
///
/// `target` absent ⇒ the global project context; otherwise the named agent's `.md`.
pub struct ReadContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
}
/// Input for [`ReadContext`].
pub struct ReadContextInput {
/// The project to read within.
pub project: Project,
/// Target agent display name; `None` ⇒ the global project context.
pub target: Option<String>,
/// The reading party (drives the read-lease holder identity).
pub requester: ConversationParty,
}
impl ReadContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
) -> Self {
Self { guard, contexts, fs }
}
/// Reads the requested context, returning its Markdown body.
///
/// # Errors
/// [`AppError`] when the agent/context does not exist or the store/fs fails.
pub async fn execute(&self, input: ReadContextInput) -> Result<MarkdownDoc, AppError> {
let ReadContextInput { project, target, requester } = input;
match target {
None => {
// Global project context: shared read-lease, then read the root file.
let _lease = self
.guard
.acquire_read(requester, GuardedResource::ProjectContext)
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let bytes = self.fs.read(&path).await?;
let text = String::from_utf8(bytes)
.map_err(|e| AppError::Invalid(e.to_string()))?;
Ok(MarkdownDoc::new(text))
}
Some(name) => {
let agent = resolve_agent(&self.contexts, &project, &name).await?;
let _lease = self
.guard
.acquire_read(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
Ok(self.contexts.read_context(&project, &agent).await?)
}
}
}
}
/// Proposes new content for an IdeA-owned context under the [`FileGuard`].
///
/// For an **agent** context: a direct write under an exclusive write-lease. For the
/// **global** project context by a non-orchestrator: the guard returns
/// [`GuardError::Forbidden`], which this use case turns into a *materialised proposal*
/// (a file under `.ideai/proposals/`) — never an overwrite of the live context.
pub struct ProposeContext {
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
}
/// Outcome of a [`ProposeContext`] call: whether it wrote directly or filed a proposal.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProposeOutcome {
/// The content was written directly (agent context, or global by the orchestrator).
Written,
/// A proposal was materialised at the given `.ideai/proposals/…` path (a
/// non-orchestrator targeting the single-writer global context).
Proposed {
/// The proposal file's path.
path: String,
},
}
/// Input for [`ProposeContext`].
pub struct ProposeContextInput {
/// The project to write within.
pub project: Project,
/// Target agent display name; `None` ⇒ the global project context.
pub target: Option<String>,
/// The proposed Markdown body.
pub content: String,
/// The proposing party.
pub requester: ConversationParty,
}
impl ProposeContext {
/// Builds the use case from its ports.
#[must_use]
pub fn new(
guard: Arc<dyn FileGuard>,
contexts: Arc<dyn AgentContextStore>,
fs: Arc<dyn FileSystem>,
clock: Arc<dyn Clock>,
) -> Self {
Self { guard, contexts, fs, clock }
}
/// Applies the proposal: direct write under a write-lease, or a materialised
/// proposal when the guard forbids a direct global-context write.
///
/// # Errors
/// [`AppError`] when the agent does not exist or the store/fs fails.
pub async fn execute(&self, input: ProposeContextInput) -> Result<ProposeOutcome, AppError> {
let ProposeContextInput { project, target, content, requester } = input;
match target {
Some(name) => {
// Per-agent context: direct write under an exclusive write-lease.
let agent = resolve_agent(&self.contexts, &project, &name).await?;
let _lease = self
.guard
.acquire_write(requester, GuardedResource::AgentContext(agent))
.await
.map_err(map_guard_err)?;
self.contexts
.write_context(&project, &agent, &MarkdownDoc::new(content))
.await?;
Ok(ProposeOutcome::Written)
}
None => {
// Global project context: single-writer. Try to acquire the write
// lease; Forbidden ⇒ materialise a proposal instead of overwriting.
match self
.guard
.acquire_write(requester, GuardedResource::ProjectContext)
.await
{
Ok(_lease) => {
let path = join_root(&project, PROJECT_CONTEXT_FILE);
self.fs.write(&path, content.as_bytes()).await?;
Ok(ProposeOutcome::Written)
}
Err(GuardError::Forbidden) => {
let path = self.file_proposal(&project, requester, &content).await?;
Ok(ProposeOutcome::Proposed { path })
}
Err(other) => Err(map_guard_err(other)),
}
}
}
}
/// Materialises a rejected global-context change as a proposal file under
/// `.ideai/proposals/<who>-<ts>.md`, returning its path.
async fn file_proposal(
&self,
project: &Project,
who: ConversationParty,
content: &str,
) -> Result<String, AppError> {
let who_label = match who {
ConversationParty::User => "orchestrator".to_owned(),
ConversationParty::Agent { agent_id } => agent_id.to_string(),
};
let ts = self.clock.now_millis();
let rel = format!("{PROPOSALS_DIR}/{who_label}-{ts}.md");
let dir = join_root(project, PROPOSALS_DIR);
self.fs.create_dir_all(&dir).await?;
let path = join_root(project, &rel);
self.fs.write(&path, content.as_bytes()).await?;
Ok(path.as_str().to_owned())
}
}
/// Reads project memory under a shared read-lease.
///
/// `slug` absent ⇒ the aggregated index (as Markdown lines); otherwise one note's body.
pub struct ReadMemory {
guard: Arc<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// Input for [`ReadMemory`].
pub struct ReadMemoryInput {
/// The project to read within.
pub project: Project,
/// Target note slug; `None` ⇒ the aggregated index.
pub slug: Option<String>,
/// The reading party.
pub requester: ConversationParty,
}
impl ReadMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
Self { guard, memory }
}
/// Reads the requested memory, returning its Markdown content.
///
/// # Errors
/// [`AppError`] when the note does not exist or the store fails. An invalid slug
/// is [`AppError::Invalid`].
pub async fn execute(&self, input: ReadMemoryInput) -> Result<String, AppError> {
let ReadMemoryInput { project, slug, requester } = input;
match slug {
Some(raw) => {
let slug = MemorySlug::new(raw).map_err(|e| AppError::Invalid(e.to_string()))?;
let _lease = self
.guard
.acquire_read(requester, GuardedResource::Memory(slug.clone()))
.await
.map_err(map_guard_err)?;
let note = self.memory.get(&project.root, &slug).await?;
Ok(note.body.into_string())
}
None => {
// The aggregated index is project-shared; read it as a rendered list.
let entries = self.memory.read_index(&project.root).await?;
let lines: Vec<String> = entries
.into_iter()
.map(|e| format!("- [{}]({}.md) — {}", e.title, e.slug, e.hook))
.collect();
Ok(lines.join("\n"))
}
}
}
}
/// Writes (creates or replaces) a project memory note under an exclusive write-lease.
pub struct WriteMemory {
guard: Arc<dyn FileGuard>,
memory: Arc<dyn MemoryStore>,
}
/// Input for [`WriteMemory`].
pub struct WriteMemoryInput {
/// The project to write within.
pub project: Project,
/// Target note slug.
pub slug: String,
/// The Markdown body to store.
pub content: String,
/// The writing party.
pub requester: ConversationParty,
}
impl WriteMemory {
/// Builds the use case from its ports.
#[must_use]
pub fn new(guard: Arc<dyn FileGuard>, memory: Arc<dyn MemoryStore>) -> Self {
Self { guard, memory }
}
/// Writes the note under a write-lease.
///
/// # Errors
/// [`AppError::Invalid`] for a bad slug or empty body; [`AppError`] on a store
/// failure.
pub async fn execute(&self, input: WriteMemoryInput) -> Result<(), AppError> {
let WriteMemoryInput { project, slug, content, requester } = input;
let slug = MemorySlug::new(slug).map_err(|e| AppError::Invalid(e.to_string()))?;
let _lease = self
.guard
.acquire_write(requester, GuardedResource::Memory(slug.clone()))
.await
.map_err(map_guard_err)?;
let frontmatter = MemoryFrontmatter {
name: slug.clone(),
description: format!("memory note {slug}"),
r#type: MemoryType::Project,
};
let note = Memory::new(frontmatter, MarkdownDoc::new(content))
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.memory.save(&project.root, &note).await?;
Ok(())
}
}
/// Maps a [`GuardError`] onto the application error shape. `Forbidden` is an invariant
/// violation ([`AppError::Invalid`]); `Busy` is a transient contention the cooperative
/// blocking adapter never returns, but is mapped for completeness.
fn map_guard_err(e: GuardError) -> AppError {
match e {
GuardError::Forbidden => AppError::Invalid(
"writing the global project context is reserved to the orchestrator; propose instead"
.to_owned(),
),
GuardError::Busy => AppError::Invalid("guarded resource is busy".to_owned()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use domain::agent::{AgentManifest, ManifestEntry};
use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, ReadLease, WriteLease};
use domain::ports::{FsError, MemoryError, StoreError};
use domain::project::ProjectPath;
use domain::{ProfileId, ProjectId, RemoteRef};
use std::collections::HashMap;
use std::sync::{Arc as StdArc, Mutex};
use std::time::Duration;
use tokio::sync::RwLock;
fn project() -> Project {
Project::new(
ProjectId::from_uuid(uuid::Uuid::from_u128(1)),
"demo",
ProjectPath::new("/tmp/demo").unwrap(),
RemoteRef::local(),
0,
)
.unwrap()
}
/// In-test [`FileGuard`] mirroring `infrastructure::RwFileGuard` (one tokio
/// `RwLock` per resource + the single-writer rule), so the application crate
/// stays free of any dependency on infrastructure (no dependency cycle).
#[derive(Default)]
struct TestGuard {
locks: Mutex<HashMap<GuardedResource, StdArc<RwLock<()>>>>,
}
impl TestGuard {
fn lock_for(&self, res: &GuardedResource) -> StdArc<RwLock<()>> {
self.locks
.lock()
.unwrap()
.entry(res.clone())
.or_insert_with(|| StdArc::new(RwLock::new(())))
.clone()
}
}
#[async_trait]
impl FileGuard for TestGuard {
async fn acquire_read(
&self,
_who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError> {
let lock = self.lock_for(&res);
Ok(ReadLease::new(Box::new(lock.read_owned().await)))
}
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
let lock = self.lock_for(&res);
Ok(WriteLease::new(Box::new(lock.write_owned().await)))
}
}
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
// ---- Fakes -----------------------------------------------------------
#[derive(Default)]
struct FakeFs {
files: Mutex<HashMap<String, Vec<u8>>>,
dirs: Mutex<Vec<String>>,
}
#[async_trait]
impl FileSystem for FakeFs {
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
self.files
.lock()
.unwrap()
.get(path.as_str())
.cloned()
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()))
}
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
self.files
.lock()
.unwrap()
.insert(path.as_str().to_owned(), data.to_vec());
Ok(())
}
async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
Ok(self.files.lock().unwrap().contains_key(path.as_str()))
}
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
self.dirs.lock().unwrap().push(path.as_str().to_owned());
Ok(())
}
async fn list(&self, _path: &RemotePath) -> Result<Vec<domain::ports::DirEntry>, FsError> {
Ok(Vec::new())
}
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
Ok(())
}
}
struct FakeContexts {
manifest: AgentManifest,
contexts: Mutex<HashMap<AgentId, String>>,
}
#[async_trait]
impl AgentContextStore for FakeContexts {
async fn read_context(
&self,
_project: &Project,
agent: &AgentId,
) -> Result<MarkdownDoc, StoreError> {
self.contexts
.lock()
.unwrap()
.get(agent)
.cloned()
.map(MarkdownDoc::new)
.ok_or(StoreError::NotFound)
}
async fn write_context(
&self,
_project: &Project,
agent: &AgentId,
md: &MarkdownDoc,
) -> Result<(), StoreError> {
self.contexts
.lock()
.unwrap()
.insert(*agent, md.as_str().to_owned());
Ok(())
}
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
Ok(self.manifest.clone())
}
async fn save_manifest(
&self,
_project: &Project,
_manifest: &AgentManifest,
) -> Result<(), StoreError> {
Ok(())
}
}
#[derive(Default)]
struct FakeMemory {
notes: Mutex<HashMap<String, String>>,
}
#[async_trait]
impl MemoryStore for FakeMemory {
async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
Ok(Vec::new())
}
async fn get(
&self,
_root: &ProjectPath,
slug: &MemorySlug,
) -> Result<Memory, MemoryError> {
let body = self
.notes
.lock()
.unwrap()
.get(slug.as_str())
.cloned()
.ok_or(MemoryError::NotFound)?;
Memory::new(
MemoryFrontmatter {
name: slug.clone(),
description: "d".to_owned(),
r#type: MemoryType::Project,
},
MarkdownDoc::new(body),
)
.map_err(|e| MemoryError::Frontmatter(e.to_string()))
}
async fn save(&self, _root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
self.notes
.lock()
.unwrap()
.insert(memory.slug().to_string(), memory.body.as_str().to_owned());
Ok(())
}
async fn delete(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<(), MemoryError> {
Ok(())
}
async fn read_index(
&self,
_root: &ProjectPath,
) -> Result<Vec<domain::memory::MemoryIndexEntry>, MemoryError> {
Ok(Vec::new())
}
async fn resolve_links(
&self,
_root: &ProjectPath,
_slug: &MemorySlug,
) -> Result<Vec<domain::memory::MemoryLink>, MemoryError> {
Ok(Vec::new())
}
}
struct FixedClock;
impl Clock for FixedClock {
fn now_millis(&self) -> i64 {
42
}
}
fn guard() -> Arc<dyn FileGuard> {
Arc::new(TestGuard::default())
}
fn contexts_with(name: &str, agent: AgentId, body: &str) -> Arc<dyn AgentContextStore> {
let mut contexts = HashMap::new();
contexts.insert(agent, body.to_owned());
Arc::new(FakeContexts {
manifest: AgentManifest {
version: 1,
entries: vec![ManifestEntry {
agent_id: agent,
name: name.to_owned(),
md_path: "agents/x.md".to_owned(),
profile_id: ProfileId::from_uuid(uuid::Uuid::from_u128(99)),
template_id: None,
synchronized: false,
synced_template_version: None,
skills: Vec::new(),
}],
},
contexts: Mutex::new(contexts),
})
}
// ---- Tests -----------------------------------------------------------
#[tokio::test]
async fn read_agent_context_returns_body() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let uc = ReadContext::new(
guard(),
contexts_with("Dev", agent, "# hello"),
Arc::new(FakeFs::default()),
);
let md = uc
.execute(ReadContextInput {
project: project(),
target: Some("dev".to_owned()), // case-insensitive
requester: agent_party(1),
})
.await
.unwrap();
assert_eq!(md.as_str(), "# hello");
}
#[tokio::test]
async fn read_global_context_reads_root_file() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec());
let uc = ReadContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
fs,
);
let md = uc
.execute(ReadContextInput {
project: project(),
target: None,
requester: ConversationParty::User,
})
.await
.unwrap();
assert_eq!(md.as_str(), "# project");
}
#[tokio::test]
async fn concurrent_reads_do_not_block_each_other() {
// Two readers on the same global context, held at once. If the read-lease
// were exclusive this would deadlock; a bounded timeout proves it does not.
let guard = guard();
let r1 = guard
.acquire_read(ConversationParty::User, GuardedResource::ProjectContext)
.await
.unwrap();
let r2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_read(agent_party(1), GuardedResource::ProjectContext),
)
.await
.expect("a second reader must not block")
.unwrap();
drop((r1, r2));
}
#[tokio::test]
async fn agent_proposing_global_context_files_a_proposal_not_a_write() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec());
let uc = ProposeContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: None,
content: "# hijack".to_owned(),
requester: agent_party(3),
})
.await
.unwrap();
// It is a *proposal*, not a write: the live context is untouched.
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# original",
"the live global context must NOT be overwritten by a proposal"
);
// The proposal landed under .ideai/proposals/.
let files = fs.files.lock().unwrap();
assert!(files
.keys()
.any(|k| k.contains("/.ideai/proposals/") && k.ends_with("-42.md")));
}
#[tokio::test]
async fn orchestrator_writes_global_context_directly() {
let fs = Arc::new(FakeFs::default());
let uc = ProposeContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
Arc::clone(&fs) as Arc<dyn FileSystem>,
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: None,
content: "# new".to_owned(),
requester: ConversationParty::User,
})
.await
.unwrap();
assert_eq!(outcome, ProposeOutcome::Written);
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
b"# new"
);
}
#[tokio::test]
async fn propose_agent_context_writes_directly() {
let agent = AgentId::from_uuid(uuid::Uuid::from_u128(7));
let contexts = contexts_with("Dev", agent, "# old");
let uc = ProposeContext::new(
guard(),
Arc::clone(&contexts),
Arc::new(FakeFs::default()),
Arc::new(FixedClock),
);
let outcome = uc
.execute(ProposeContextInput {
project: project(),
target: Some("Dev".to_owned()),
content: "# new body".to_owned(),
requester: agent_party(3),
})
.await
.unwrap();
assert_eq!(outcome, ProposeOutcome::Written);
assert_eq!(
contexts
.read_context(&project(), &agent)
.await
.unwrap()
.as_str(),
"# new body"
);
}
#[tokio::test]
async fn write_then_read_memory_round_trips_under_guard() {
let memory = Arc::new(FakeMemory::default());
let writer = WriteMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
writer
.execute(WriteMemoryInput {
project: project(),
slug: "note-a".to_owned(),
content: "body".to_owned(),
requester: agent_party(2),
})
.await
.unwrap();
let reader = ReadMemory::new(guard(), Arc::clone(&memory) as Arc<dyn MemoryStore>);
let body = reader
.execute(ReadMemoryInput {
project: project(),
slug: Some("note-a".to_owned()),
requester: agent_party(2),
})
.await
.unwrap();
assert_eq!(body, "body");
}
#[tokio::test]
async fn writes_to_same_memory_note_serialise() {
// Two write leases on the same note must not overlap (exclusive writer).
let guard = guard();
let slug = GuardedResource::Memory(MemorySlug::new("n").unwrap());
let w1 = guard
.acquire_write(agent_party(1), slug.clone())
.await
.unwrap();
// While w1 is held, a second writer blocks; it only succeeds after release.
let blocked = tokio::time::timeout(
Duration::from_millis(100),
guard.acquire_write(agent_party(2), slug.clone()),
)
.await;
assert!(blocked.is_err(), "a second writer must block while w1 holds");
drop(w1);
let w2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(agent_party(2), slug),
)
.await
.expect("w2 acquires after w1 releases")
.unwrap();
drop(w2);
}
}

View File

@ -4,6 +4,11 @@
//! use-case calls the UI makes, so an orchestrator agent can drive IdeA without
//! ever spawning a process itself. See [`service::OrchestratorService`].
mod context_guard;
mod service;
pub use service::{OrchestratorOutcome, OrchestratorService};
pub use context_guard::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
pub use service::{McpRuntimeProvider, OrchestratorOutcome, OrchestratorService};

View File

@ -19,16 +19,25 @@ use std::time::Duration;
use tokio::sync::Mutex as AsyncMutex;
use domain::ports::{EventBus, ProfileStore};
use domain::conversation::{
ConversationParty, ConversationRegistry, SessionRef, WaitForGraph,
};
use domain::input::InputMediator;
use domain::mailbox::{Ticket, TicketId};
use domain::ports::{EventBus, ProfileStore, PtyHandle};
use domain::{AgentId, DomainEvent, OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
use crate::agent::{
send_blocking, CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput,
ListAgents, ListAgentsInput, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
ListAgentsInput, McpRuntime, ReattachDecision, UpdateAgentContext, UpdateAgentContextInput,
};
use crate::error::AppError;
use crate::orchestrator::{
ProposeContext, ProposeContextInput, ProposeOutcome, ReadContext, ReadContextInput, ReadMemory,
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
use crate::skill::{CreateSkill, CreateSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::terminal::{CloseTerminal, CloseTerminalInput, TerminalSessions};
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
/// resizes the PTY to the real cell size on attach; these are sane starting rows
@ -61,6 +70,19 @@ const ASK_AGENT_TIMEOUT: Duration = Duration::from_secs(300);
/// pleins d'attente.
const ASK_QUEUE_WAIT_CAP: Duration = Duration::from_secs(600);
/// Fournit les faits OS/runtime (exe + endpoint projet) pour écrire la déclaration MCP
/// réelle quand l'orchestrateur (re)lance une cible sur le chemin `ask`. Implémenté dans
/// app-tauri (seul détenteur de current_exe/$APPIMAGE/mcp_endpoint).
///
/// La couche `application` ne connaît que ce **port** : elle ne calcule jamais le chemin
/// de l'exécutable ni l'endpoint loopback (ces faits vivent dans `app-tauri`, cadrage v5
/// §0.3 / §7). Seules les **chaînes** d'un [`McpRuntime`] traversent la frontière.
pub trait McpRuntimeProvider: Send + Sync {
/// `agent_id` = la cible relancée = le `--requester` (c'est elle qui appellera idea_reply).
/// `None` ⇒ dégrade vers la déclaration minimale (jamais d'échec de lancement).
fn runtime_for(&self, project: &Project, agent_id: AgentId) -> Option<McpRuntime>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
@ -71,11 +93,27 @@ pub struct OrchestratorService {
create_skill: Arc<CreateSkill>,
profiles: Arc<dyn ProfileStore>,
sessions: Arc<TerminalSessions>,
/// Registre des sessions **structurées** (§17.5) — la cible d'un `agent.message`
/// y est cherchée pour le rendez-vous synchrone. Injecté au câblage via
/// [`Self::with_structured`] ; `None` ⇒ `AskAgent` ne peut pas être servi (les
/// call sites/tests legacy qui n'utilisent pas la messagerie restent verts).
structured: Option<Arc<StructuredSessions>>,
/// Médiateur d'entrée (cadrage C3 §5.2) — point de convergence unique de l'entrée
/// d'un agent. La cible d'un `agent.message`/`idea_ask_agent` y reçoit un ticket
/// (`enqueue`) dont on **attend** la résolution (`idea_reply` ⇒
/// [`OrchestratorCommand::Reply`]) ; son impl écrit aussi le tour dans le PTY de la
/// cible (livraison sérialisée, plus d'écriture ad hoc ici). Injecté via
/// [`Self::with_input_mediator`] ; `None` ⇒ `AskAgent`/`Reply` non servis (call
/// sites/tests legacy restent verts).
input: Option<Arc<dyn InputMediator>>,
/// Mailbox sous-jacent du médiateur, pour `resolve`/`resolve_ticket`/`cancel_head`
/// (corrélation par ticket). C'est le **même** moteur de corrélation que celui que
/// `input` enveloppe ; injecté ensemble via [`Self::with_input_mediator`].
mailbox: Option<Arc<dyn domain::mailbox::AgentMailbox>>,
/// Registre des conversations par paire (cadrage C3 §5.2) — résout paresseusement
/// le fil `A↔B` (ou `User↔B`) d'un `ask`, sépare strictement les contextes.
/// Injecté via [`Self::with_conversations`] ; `None` ⇒ on retombe sur un routage
/// par agent sans matérialisation de fil (legacy).
conversations: Option<Arc<dyn ConversationRegistry>>,
/// Graphe d'attente inter-agents (cadrage C3 §6) — arête posée à l'`enqueue` d'un
/// `ask` A→B, retirée au reply/timeout (RAII via le garde de tour). Sert à
/// **refuser** une délégation ré-entrante (A→B→…→A) avant deadlock.
wait_for: StdMutex<WaitForGraph>,
/// Bus d'événements pour publier [`DomainEvent::AgentReplied`] à l'issue d'un
/// `ask` réussi (§17.4). Injecté via [`Self::with_events`] ; `None` ⇒ pas de
/// publication (l'`ask` fonctionne quand même).
@ -99,6 +137,30 @@ pub struct OrchestratorService {
/// Croissance bornée en pratique au nombre d'agents du projet ; une entrée
/// morte ne coûte qu'un `Arc<Mutex<()>>` vide (pas de session, pas de process).
ask_locks: StdMutex<HashMap<AgentId, Arc<AsyncMutex<()>>>>,
/// Fournisseur des faits OS/runtime (exe + endpoint) pour écrire la déclaration
/// MCP **réelle** quand `ensure_live_pty` (re)lance une cible sur le chemin `ask`
/// (B-3). Injecté au câblage via [`Self::with_mcp_runtime_provider`] depuis
/// app-tauri ; `None` ⇒ on conserve la déclaration minimale (`mcp_runtime: None`),
/// donc zéro régression pour les call sites/tests qui ne le branchent pas.
mcp_runtime_provider: Option<Arc<dyn McpRuntimeProvider>>,
/// FileGuard-mediated context/memory use cases (cadrage C7). Injected via
/// [`Self::with_context_guard`] ; `None` ⇒ les commandes `context.*`/`memory.*`
/// renvoient une erreur typée (call sites/tests legacy restent verts).
context_guard: Option<Arc<ContextGuardUseCases>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
/// ensemble dans l'[`OrchestratorService`] (cadrage C7). Regroupés pour garder la
/// signature de [`OrchestratorService::with_context_guard`] simple (un seul `Arc`).
pub struct ContextGuardUseCases {
/// Lecture d'un contexte `.md` IdeA sous read-lease.
pub read_context: Arc<ReadContext>,
/// Proposition/écriture d'un contexte `.md` IdeA sous le garde.
pub propose_context: Arc<ProposeContext>,
/// Lecture mémoire sous read-lease.
pub read_memory: Arc<ReadMemory>,
/// Écriture mémoire sous write-lease.
pub write_memory: Arc<WriteMemory>,
}
/// Outcome of dispatching a command — a short, human-readable success summary the
@ -137,12 +199,26 @@ impl OrchestratorService {
create_skill,
profiles,
sessions,
structured: None,
input: None,
mailbox: None,
conversations: None,
wait_for: StdMutex::new(WaitForGraph::new()),
events: None,
ask_locks: StdMutex::new(HashMap::new()),
mcp_runtime_provider: None,
context_guard: None,
}
}
/// Branche les use cases C7 (`context.*`/`memory.*`) sous
/// [`domain::fileguard::FileGuard`]. Builder additif (signature de [`Self::new`]
/// inchangée).
#[must_use]
pub fn with_context_guard(mut self, guard: Arc<ContextGuardUseCases>) -> Self {
self.context_guard = Some(guard);
self
}
/// Returns the per-agent **turn lock**, creating it on first use.
///
/// Get-or-create under the synchronous map mutex (held only for this lookup,
@ -157,13 +233,30 @@ impl OrchestratorService {
Arc::clone(locks.entry(*agent_id).or_default())
}
/// Branche le registre des sessions **structurées** (§17.5) pour servir
/// `agent.message`/[`OrchestratorCommand::AskAgent`]. Builder additif façon D3 :
/// signature de [`Self::new`] **inchangée** (les tests/call sites legacy restent
/// verts), le câblage fait `OrchestratorService::new(...).with_structured(reg)`.
/// Branche le **médiateur d'entrée** (cadrage C3 §5.2) pour servir
/// `agent.message`/[`OrchestratorCommand::AskAgent`] et
/// `agent.reply`/[`OrchestratorCommand::Reply`]. Le `mailbox` est le moteur de
/// corrélation **sous-jacent** au médiateur (le même `InMemoryMailbox` que
/// `MediatedInbox` enveloppe) : on l'injecte ensemble pour pouvoir `resolve`/
/// `resolve_ticket`/`cancel_head` un ticket. Builder additif : signature de
/// [`Self::new`] **inchangée** (les tests/call sites legacy restent verts).
#[must_use]
pub fn with_structured(mut self, structured: Arc<StructuredSessions>) -> Self {
self.structured = Some(structured);
pub fn with_input_mediator(
mut self,
input: Arc<dyn InputMediator>,
mailbox: Arc<dyn domain::mailbox::AgentMailbox>,
) -> Self {
self.input = Some(input);
self.mailbox = Some(mailbox);
self
}
/// Branche le [`ConversationRegistry`] (cadrage C3 §5.2) pour résoudre
/// paresseusement le fil `A↔B` (ou `User↔B`) d'un `ask` et séparer les contextes.
/// Builder additif (signature de [`Self::new`] inchangée).
#[must_use]
pub fn with_conversations(mut self, conversations: Arc<dyn ConversationRegistry>) -> Self {
self.conversations = Some(conversations);
self
}
@ -175,6 +268,17 @@ impl OrchestratorService {
self
}
/// Branche le [`McpRuntimeProvider`] (app-tauri) pour que les (re)lancements
/// issus du chemin `ask` (`ensure_live_pty`) écrivent la déclaration MCP **réelle**
/// (endpoint + exe + requester) au lieu de la minimale — sans quoi le pont MCP
/// n'est jamais spawné et la cible ne peut pas appeler `idea_reply` (timeout).
/// Builder additif : signature de [`Self::new`] **inchangée**.
#[must_use]
pub fn with_mcp_runtime_provider(mut self, provider: Arc<dyn McpRuntimeProvider>) -> Self {
self.mcp_runtime_provider = Some(provider);
self
}
/// Dispatches a validated command against `project`.
///
/// # Errors
@ -196,9 +300,16 @@ impl OrchestratorService {
self.spawn_agent(project, name, profile, context, visibility)
.await
}
OrchestratorCommand::AskAgent { target, task } => {
self.ask_agent(project, target, task).await
}
OrchestratorCommand::AskAgent {
target,
task,
requester,
} => self.ask_agent(project, target, task, requester).await,
OrchestratorCommand::Reply {
from,
ticket,
result,
} => self.reply(from, ticket, result),
OrchestratorCommand::ListAgents => self.list_agents(project).await,
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
OrchestratorCommand::UpdateAgentContext { name, context } => {
@ -209,9 +320,135 @@ impl OrchestratorService {
content,
scope,
} => self.create_skill(project, name, content, scope).await,
OrchestratorCommand::ReadContext { target, requester } => {
self.read_context(project, target, requester).await
}
OrchestratorCommand::ProposeContext {
target,
content,
requester,
} => self.propose_context(project, target, content, requester).await,
OrchestratorCommand::ReadMemory { slug, requester } => {
self.read_memory(project, slug, requester).await
}
OrchestratorCommand::WriteMemory {
slug,
content,
requester,
} => self.write_memory(project, slug, content, requester).await,
}
}
/// Returns the injected C7 use cases, or a typed error when unwired.
fn require_context_guard(&self) -> Result<&ContextGuardUseCases, AppError> {
self.context_guard.as_deref().ok_or_else(|| {
AppError::Invalid("FileGuard context/memory tools are not configured".to_owned())
})
}
/// `context.read` → reads an IdeA-owned context under a shared read-lease; the
/// body is returned inline in the outcome's `reply`.
async fn read_context(
&self,
project: &Project,
target: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let md = self
.require_context_guard()?
.read_context
.execute(ReadContextInput {
project: project.clone(),
target: target.clone(),
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!(
"read {} context",
target.as_deref().unwrap_or("project")
),
reply: Some(md.into_string()),
})
}
/// `context.propose` → direct write (agent ctx / orchestrator on global) or a
/// materialised proposal (non-orchestrator on global).
async fn propose_context(
&self,
project: &Project,
target: Option<String>,
content: String,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let outcome = self
.require_context_guard()?
.propose_context
.execute(ProposeContextInput {
project: project.clone(),
target: target.clone(),
content,
requester,
})
.await?;
let detail = match outcome {
ProposeOutcome::Written => format!(
"wrote {} context",
target.as_deref().unwrap_or("project")
),
ProposeOutcome::Proposed { path } => {
format!("filed proposal for project context at {path}")
}
};
Ok(OrchestratorOutcome { detail, reply: None })
}
/// `memory.read` → reads a note (or the index) under a shared read-lease; the
/// content is returned inline in the outcome's `reply`.
async fn read_memory(
&self,
project: &Project,
slug: Option<String>,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let content = self
.require_context_guard()?
.read_memory
.execute(ReadMemoryInput {
project: project.clone(),
slug: slug.clone(),
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("read memory {}", slug.as_deref().unwrap_or("index")),
reply: Some(content),
})
}
/// `memory.write` → writes a note under an exclusive write-lease.
async fn write_memory(
&self,
project: &Project,
slug: String,
content: String,
requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
self.require_context_guard()?
.write_memory
.execute(WriteMemoryInput {
project: project.clone(),
slug: slug.clone(),
content,
requester,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("wrote memory {slug}"),
reply: None,
})
}
/// `spawn_agent`: create the agent if the manifest doesn't already hold one by
/// that name, then launch it (which publishes `AgentLaunched` → the UI opens a
/// cell + the Agents tab).
@ -328,88 +565,346 @@ impl OrchestratorService {
})
}
/// `agent.message`: the **synchronous inter-agent rendezvous** (§17.4).
/// `agent.message` / `idea_ask_agent`: the **inter-agent delegation rendezvous**
/// (Option 1 « Terminal + MCP », lot B-3).
///
/// Resolves the target by name, ensures it has a **live structured session**
/// (launching it in structured mode if needed), sends `task` and **waits for the
/// turn's `Final`** via [`send_blocking`], then returns its content as
/// [`OrchestratorOutcome::reply`] and publishes [`DomainEvent::AgentReplied`].
/// The target's human-facing view is now a **raw native terminal** (PTY REPL), and
/// delegation flows through the terminal's single FIFO input plus the MCP mailbox:
///
/// Invariants:
/// - **1 session vivante/agent** : on réutilise la session structurée vivante si
/// elle existe ; sinon `LaunchAgent` (gardé sur les deux registres) la crée.
/// - **Timeout ne tue pas la session** : [`send_blocking`] remonte
/// [`AppError::Process`] (via [`domain::ports::AgentSessionError::Timeout`]) en
/// laissant la session vivante dans le registre (retry possible).
/// - **Cible PTY-only** (profil sans `structured_adapter`, ou agent déjà vivant en
/// PTY) ⇒ [`AppError::Invalid`] explicite, **jamais** un ACK trompeur.
/// 1. Resolve the target by name and acquire its **per-agent turn lock** so two
/// `ask`s for the same target serialise FIFO (1 agent = 1 employee).
/// 2. Ensure the target is **live in the PTY registry** — reusing its terminal if
/// it is already running, otherwise launching it in the background (a normal
/// PTH launch: a live PTY *is* the channel now, not an error as before).
/// 3. **Enqueue a ticket** in the [`AgentMailbox`] (registering the reply slot)
/// **then write** the task into the target's terminal, prefixed with the asking
/// agent + ticket id so the target knows to answer via `idea_reply`.
/// 4. **Await** the [`domain::mailbox::PendingReply`] bounded by [`ASK_AGENT_TIMEOUT`]:
/// the target's later `idea_reply(result)` lands in [`Self::reply`] →
/// `mailbox.resolve`, waking this await. On timeout the ticket is retired from
/// the head ([`AgentMailbox::cancel_head`]) — **the target stays alive** — and a
/// typed timeout is returned (retry possible).
/// 5. Return the reply as [`OrchestratorOutcome::reply`] and publish
/// [`DomainEvent::AgentReplied`].
///
/// # Errors
/// - [`AppError::NotFound`] si l'agent cible est inconnu ;
/// - [`AppError::Invalid`] si la messagerie structurée n'est pas câblée, ou si la
/// cible n'est pas pilotable en mode structuré ;
/// - [`AppError::Process`] sur échec/timeout du tour structuré.
/// - [`AppError::NotFound`] if the target agent is unknown;
/// - [`AppError::Invalid`] if the mailbox/PTY channel is not wired;
/// - [`AppError::Process`] on a launch/PTY-write failure, or on the await timeout
/// (turn timeout *or* queue-wait timeout — same typed error).
async fn ask_agent(
&self,
project: &Project,
target: String,
task: String,
requester: Option<AgentId>,
) -> Result<OrchestratorOutcome, AppError> {
let structured = self.structured.as_ref().ok_or_else(|| {
AppError::Invalid(
"la messagerie inter-agents (agent.message) n'est pas disponible : \
registre des sessions structurées non câblé"
.to_owned(),
)
})?;
let (input, mailbox) = match (&self.input, &self.mailbox) {
(Some(i), Some(m)) => (i, m),
_ => {
return Err(AppError::Invalid(
"la messagerie inter-agents (idea_ask_agent) n'est pas disponible : \
médiateur d'entrée non câblé"
.to_owned(),
))
}
};
let agent_id = self
.find_agent_id_by_name(project, &target)
let agent = self
.find_agent_by_name(project, &target)
.await?
.ok_or_else(|| AppError::NotFound(format!("agent {target}")))?;
let agent_id = agent.id;
// Sérialisation FIFO **par agent** (A0, cadrage v5 §4) : on acquiert le verrou
// de tour de la **cible** avant tout `send_blocking`, et on le tient pour TOUT
// le tour réel (rendez-vous direct *ou* lancement-puis-envoi sur cible morte).
// Le garde `_turn` est RAII : il tombe en fin de portée, y compris sur chaque
// early-return d'erreur ci-dessous ⇒ le tour suivant en file démarre alors.
//
// Verrou par `agent_id` ⇒ un `ask` vers A et un vers B ne se bloquent jamais ;
// un seul verrou tenu (celui de la cible) ⇒ pas d'inter-verrouillage/deadlock.
// Le timeout de TOUR ([`ASK_AGENT_TIMEOUT`]) borne `send_blocking`, **pas**
// l'attente du verrou ; cette attente a son **propre** plafond
// ([`ASK_QUEUE_WAIT_CAP`]) pour éviter l'inanition.
// F2 — garde profil : refuser **immédiatement** une cible dont le profil ne
// sait pas consommer le pont `idea_*` matérialisé via `.mcp.json`, plutôt que
// de laisser le round-trip échouer en timeout muet (300s).
self.guard_mcp_bridge_supported(&agent.profile_id, &target)
.await?;
// Détection de cycle (cadrage C3 §6) : si l'ask vient d'un **agent** A vers la
// cible B, refuser AVANT tout enqueue si poser l'arête A→B fermerait un cycle
// d'attente (B attend déjà …→A). Pur, sans I/O ⇒ jamais de deadlock.
if let Some(from) = requester {
let cycles = {
let g = self
.wait_for
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.would_cycle(from, agent_id)
};
if cycles {
return Err(AppError::Invalid(format!(
"délégation ré-entrante refusée : demander à l'agent '{target}' créerait \
un cycle d'attente inter-agents (deadlock évité)"
)));
}
}
// Résoudre paresseusement le **fil** de l'ask : A↔B si un agent demande, sinon
// User↔B. La session vivante est désormais keyée par conversation (lève
// `session-registry-agent-ambiguity`).
let conversation_id = self.resolve_conversation(requester, agent_id);
// Sérialisation FIFO **par agent** (A0) : verrou de tour de la **cible**, tenu
// pour TOUT le tour (enqueue → réponse). RAII : tombe sur chaque early-return.
let lock = self.ask_lock_for(&agent_id);
let _turn = match tokio::time::timeout(ASK_QUEUE_WAIT_CAP, lock.lock_owned()).await {
Ok(guard) => guard,
Err(_elapsed) => {
// Réutilise le **même** type de timeout que le tour (cadrage v5 §4) :
// `AgentSessionError::Timeout` ⇒ `AppError::Process`.
return Err(AppError::from(
domain::ports::AgentSessionError::Timeout,
));
return Err(AppError::from(domain::ports::AgentSessionError::Timeout));
}
};
// 1. Session structurée déjà vivante ? ⇒ rendez-vous direct.
if let Some(session) = structured.session_for_agent(&agent_id) {
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
return Ok(self.reply_outcome(agent_id, &target, content));
// Poser l'arête d'attente A→B (retirée en fin de tour par le RAII `_edge`).
let _edge = requester.map(|from| WaitEdgeGuard::new(self, from, agent_id));
// 1. Garantir la cible vivante en PTY pour CE fil ; lier sa session à la
// conversation, et brancher son handle d'entrée sur le médiateur (livraison).
let handle = self
.ensure_live_pty(project, agent_id, conversation_id, &target)
.await?;
// Arm prompt-ready detection (C5) with the target profile's literal marker, so a
// return-to-prompt frees the turn (the other OR signal being `idea_reply`).
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle.clone(), prompt_pattern);
// 2. Enregistrer le ticket (slot de réponse) + livrer le tour via le médiateur
// (écriture sérialisée dans le PTY — plus d'écriture ad hoc ici). Le ticket
// porte la source (Human/Agent) et la conversation cible.
let requester_label = self.requester_label(project, requester).await;
let ticket_id = TicketId::new_random();
let ticket = match requester {
Some(from) => {
Ticket::from_agent(ticket_id, from, conversation_id, requester_label, task)
}
None => Ticket::from_human(ticket_id, conversation_id, requester_label, task),
};
let pending = input.enqueue(agent_id, ticket);
// Delivery is the mediator's responsibility (`InputMediator::enqueue` writes the
// turn into the bound handle). The service no longer writes the PTY directly —
// no ad-hoc `[IdeA · tâche …]` line here, no `\r` band-aid (cadrage C3 §5.1).
// 3. Attendre la réponse, bornée. Timeout/canal fermé ⇒ retirer le ticket
// (cible laissée vivante) et renvoyer une erreur typée.
match tokio::time::timeout(ASK_AGENT_TIMEOUT, pending).await {
Ok(Ok(result)) => Ok(self.reply_outcome(agent_id, &target, result)),
Ok(Err(_cancelled)) => {
mailbox.cancel_head(agent_id, ticket_id);
Err(AppError::Process(format!(
"agent {target} : canal de réponse fermé avant un résultat"
)))
}
Err(_elapsed) => {
mailbox.cancel_head(agent_id, ticket_id);
Err(AppError::from(domain::ports::AgentSessionError::Timeout))
}
}
}
/// `SubmitHumanInput` (cadrage C4 §5.3) — the **human** Envoyer path.
///
/// The operator types into IdeA's mediated input; this resolves the `User↔Agent`
/// thread, ensures the target is live in the PTY registry, binds its handle on the
/// mediator, and **enqueues** a `Ticket::from_human` into the **same FIFO** the
/// inter-agent delegations use (`InputMediator::enqueue`) — so a human submit and a
/// delegation serialise on the same agent («1 agent = 1 employee»).
///
/// Unlike [`Self::ask_agent`], it is **fire-and-forget**: the human watches the
/// terminal for the answer, so we do **not** await the [`PendingReply`] (the reply
/// slot is registered and simply left to resolve/expire on its own). The busy
/// event is emitted at the mediator's source (Idle→Busy on the starting enqueue).
///
/// # Errors
/// - [`AppError::Invalid`] if the input mediator is not wired;
/// - [`AppError::NotFound`] if the target agent is unknown;
/// - [`AppError::Process`] on a launch/PTY failure while ensuring the live session.
pub async fn submit_human_input(
&self,
project: &Project,
agent_id: AgentId,
text: String,
) -> Result<OrchestratorOutcome, AppError> {
let input = self.input.as_ref().ok_or_else(|| {
AppError::Invalid(
"l'entrée médiée (submit_agent_input) n'est pas disponible : \
médiateur d'entrée non câblé"
.to_owned(),
)
})?;
// Display label for error/launch messages; the agent must exist in the
// manifest. A human submit to an unknown id is a NotFound, never a panic.
let target = self
.find_name_by_agent_id(project, agent_id)
.await
.ok_or_else(|| AppError::NotFound(format!("agent {agent_id}")))?;
let target = target.as_str();
// User↔Agent thread (no requester ⇒ left = User). Same lazy resolution as ask.
let conversation_id = self.resolve_conversation(None, agent_id);
// Ensure the target is live for this thread and bind its input handle on the
// mediator (delivery path). Same call the ask path uses.
let handle = self
.ensure_live_pty(project, agent_id, conversation_id, target)
.await?;
let prompt_pattern = self.prompt_pattern_for_agent(project, agent_id).await;
input.bind_handle_with_prompt(agent_id, handle, prompt_pattern);
// Enqueue a human-sourced ticket in the SAME FIFO as delegations. Fire-and-
// forget: we drop the PendingReply (the human reads the terminal). The
// mediator emits AgentBusyChanged at the source on a starting turn.
let ticket = Ticket::from_human(TicketId::new_random(), conversation_id, "vous", text);
let _pending = input.enqueue(agent_id, ticket);
Ok(OrchestratorOutcome {
detail: format!("submitted human input to agent {target}"),
reply: None,
})
}
/// Interrupt (cadrage C4 §5.3) — the **Interrompre** path (Échap/stop).
///
/// Resolves the target by name and calls [`InputMediator::preempt`], which signals
/// the running turn to stop (best-effort interrupt byte to the agent's bound PTY
/// handle). It is **not** an enqueue and resolves **no** ticket — a pending caller
/// is never silently answered. Idempotent: interrupting an idle agent is a no-op.
///
/// # Errors
/// - [`AppError::Invalid`] if the input mediator is not wired;
/// - [`AppError::NotFound`] if the target agent is unknown.
pub async fn interrupt_agent(
&self,
project: &Project,
agent_id: AgentId,
) -> Result<OrchestratorOutcome, AppError> {
let input = self.input.as_ref().ok_or_else(|| {
AppError::Invalid(
"l'interruption (interrupt_agent) n'est pas disponible : \
médiateur d'entrée non câblé"
.to_owned(),
)
})?;
// Confirm the agent exists (typed NotFound rather than a silent no-op on a
// bogus id). The manifest lookup also keeps the contract symmetric with submit.
if self
.find_name_by_agent_id(project, agent_id)
.await
.is_none()
{
return Err(AppError::NotFound(format!("agent {agent_id}")));
}
// 2. Pas de session structurée. Si l'agent est vivant en **PTY** (terminal
// brut), il n'est pas adressable en `ask` ⇒ erreur typée explicite (pas
// d'ACK « launched »).
if self.sessions.session_for_agent(&agent_id).is_some() {
return Err(AppError::Invalid(format!(
"agent {target} n'est pas pilotable en mode structuré \
(session terminal brut, pas de canal de réponse)"
)));
input.preempt(agent_id);
Ok(OrchestratorOutcome {
detail: format!("interrupted agent {agent_id}"),
reply: None,
})
}
/// Resolves the conversation thread id for an ask: `A↔B` when an agent requests,
/// else `User↔B` (cadrage C3 §5.2). Without a wired registry, falls back to a
/// stable per-agent id derived from the target (legacy routing — never panics).
fn resolve_conversation(
&self,
requester: Option<AgentId>,
target: AgentId,
) -> domain::conversation::ConversationId {
let left = match requester {
Some(from) => ConversationParty::agent(from),
None => ConversationParty::User,
};
let right = ConversationParty::agent(target);
match &self.conversations {
Some(reg) => reg.resolve(left, right).id,
None => domain::conversation::ConversationId::from_uuid(target.as_uuid()),
}
}
/// `agent.reply` / `idea_reply`: the target agent renders the result of the task
/// it is currently processing (Option 1, lot B-4).
///
/// Positional correlation: `from` is the **emitting** agent (its identity comes
/// from the MCP handshake, not from a model-managed id), so the result resolves
/// the ticket at the **head** of *that agent's* mailbox queue — the task it is
/// working on. ACK only: no inline payload, no `AgentReplied` here (that belongs
/// to the asking side's `ask_agent`).
///
/// # Errors
/// - [`AppError::Invalid`] if the mailbox is not wired;
/// - [`AppError::Invalid`] if `from` has no in-flight request (a reply with no
/// matching ask) — typed, never a panic.
fn reply(
&self,
from: AgentId,
ticket: Option<TicketId>,
result: String,
) -> Result<OrchestratorOutcome, AppError> {
let mailbox = self.mailbox.as_ref().ok_or_else(|| {
AppError::Invalid(
"idea_reply n'est pas disponible : file inter-agents non câblée".to_owned(),
)
})?;
// Corrélation par ticket quand l'agent l'a renvoyé (déterministe, multi-fil) ;
// sinon repli sur la tête de file de l'émetteur (compat agents mono-fil).
match ticket {
Some(ticket_id) => mailbox.resolve_ticket(from, ticket_id, result),
None => mailbox.resolve(from, result),
}
.map_err(|e| AppError::Invalid(e.to_string()))?;
// Explicit «end-of-turn» signal (cadrage §6, lot C5): an `idea_reply` means the
// emitting agent `from` finished its delegated task ⇒ mark it Idle so its FIFO
// advances to the next queued ticket. This is the deterministic OR signal that
// pairs with prompt-ready detection; whichever fires first frees the turn. No-op
// (and no spurious event) when the mediator is absent or `from` was already idle.
if let Some(input) = self.input.as_ref() {
input.mark_idle(from);
}
Ok(OrchestratorOutcome {
detail: format!("reply from agent {from} delivered"),
reply: None,
})
}
/// Ensures the target agent has a **live PTY session**, returning its handle.
///
/// Reuses the running terminal when present; otherwise launches the agent in the
/// background (a normal PTH launch — a live PTY is the delegation channel). After
/// a launch the handle is resolved from the registry; a missing handle is a
/// [`AppError::Process`] (the launch did not register a PTY session, e.g. a profile
/// IdeA cannot drive as a terminal).
async fn ensure_live_pty(
&self,
project: &Project,
agent_id: AgentId,
conversation_id: domain::conversation::ConversationId,
target: &str,
) -> Result<PtyHandle, AppError> {
// «1 session vivante / conversation» (cadrage C3 §5.2) : on cherche d'abord la
// session du **fil**, puis on retombe sur la session de l'agent (compat : un
// agent mono-fil dont la session n'a pas encore été liée à sa conversation).
let existing = self
.sessions
.session_for(conversation_id)
.or_else(|| self.sessions.session_for_agent(&agent_id));
if let Some(session_id) = existing {
if let Some(handle) = self.sessions.handle(&session_id) {
// (Re)lier le fil à cette session vivante (idempotent).
self.bind_conversation_session(conversation_id, session_id);
return Ok(handle);
}
}
// 3. Cible morte : on la lance en mode structuré (background) puis on envoie.
let launched = self
.launch_agent
// Dead target: launch it in the background (PTY). On injecte ici la déclaration
// MCP **réelle** via le [`McpRuntimeProvider`] câblé (app-tauri détient l'exe et
// l'endpoint) — c'est ce qui permet au pont MCP de la cible de se spawner et donc
// à la cible d'appeler `idea_reply`. Provider absent (ou `runtime_for` → `None`)
// ⇒ déclaration minimale comme avant (dégradation gracieuse).
self.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
@ -417,30 +912,71 @@ impl OrchestratorService {
cols: DEFAULT_COLS,
node_id: None,
conversation_id: None,
// ask_agent revival launch (inside `application`): MCP runtime not
// injected here (see the `spawn_agent` note above).
mcp_runtime: None,
mcp_runtime: self
.mcp_runtime_provider
.as_ref()
.and_then(|p| p.runtime_for(project, agent_id)),
})
.await?;
// Si le lancement n'a pas produit de session structurée, le profil de la cible
// est PTY-only (pas de `structured_adapter`) ⇒ non adressable en `ask`.
if launched.structured.is_none() {
return Err(AppError::Invalid(format!(
"agent {target} n'est pas pilotable en mode structuré \
(profil sans adaptateur structuré)"
)));
}
let session_id = self
.sessions
.session_for_agent(&agent_id)
.ok_or_else(|| {
AppError::Process(format!(
"agent {target} n'a pas de session terminal vivante après lancement"
))
})?;
// Lier la session fraîchement lancée à CE fil (registre terminal + registre de
// conversations) ⇒ un prochain ask sur le même fil la réutilise.
self.bind_conversation_session(conversation_id, session_id);
self.sessions.handle(&session_id).ok_or_else(|| {
AppError::Process(format!("handle PTY de l'agent {target} introuvable après lancement"))
})
}
// La session vient d'être enregistrée par LaunchAgent : on la récupère par
// agent (invariant « 1 session/agent » ⇒ non ambigu).
let session = structured.session_for_agent(&agent_id).ok_or_else(|| {
AppError::Process(format!(
"session structurée de l'agent {target} introuvable après lancement"
))
})?;
let content = send_blocking(session.as_ref(), &task, Some(ASK_AGENT_TIMEOUT)).await?;
Ok(self.reply_outcome(agent_id, &target, content))
/// Binds `conversation` to `session` in both the terminal registry (fast
/// `session_for`) and the [`ConversationRegistry`] (domain thread state), when the
/// latter is wired. Idempotent.
fn bind_conversation_session(
&self,
conversation: domain::conversation::ConversationId,
session: domain::SessionId,
) {
self.sessions.bind_conversation(conversation, session);
if let Some(reg) = &self.conversations {
reg.bind_session(conversation, SessionRef::new(session));
}
}
/// Resolves a human-friendly label for the **requesting** agent to prefix the
/// delegated task with. Best-effort: there is no requester id threaded into
/// [`OrchestratorCommand::AskAgent`] today, so this falls back to a stable
/// `"un autre agent"` label. Kept as a seam so a future requester-aware ask can
/// surface the real name without touching the call sites.
async fn requester_label(&self, project: &Project, requester: Option<AgentId>) -> String {
match requester {
None => "un autre agent".to_owned(),
Some(from) => self
.find_name_by_agent_id(project, from)
.await
.unwrap_or_else(|| "un autre agent".to_owned()),
}
}
/// Best-effort display name for an agent id (manifest lookup). `None` when the id
/// is unknown — the caller falls back to a generic label.
async fn find_name_by_agent_id(&self, project: &Project, id: AgentId) -> Option<String> {
self.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await
.ok()?
.agents
.into_iter()
.find(|a| a.id == id)
.map(|a| a.name)
}
/// Builds the success outcome of an `ask` and publishes [`DomainEvent::AgentReplied`]
@ -590,6 +1126,104 @@ impl OrchestratorService {
.map(|a| a.id))
}
/// Finds the full [`domain::Agent`] by display name (case-insensitive) in the
/// project manifest. Variante de [`Self::find_agent_id_by_name`] qui conserve
/// l'agent entier (notamment son `profile_id`), nécessaire à la garde F2.
async fn find_agent_by_name(
&self,
project: &Project,
name: &str,
) -> Result<Option<domain::Agent>, AppError> {
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
Ok(listed
.agents
.into_iter()
.find(|a| a.name.eq_ignore_ascii_case(name)))
}
/// Garde F2 : vérifie que le profil de la cible **sait consommer** le pont
/// `idea_*` matérialisé par IdeA, et renvoie sinon une [`AppError::Invalid`]
/// **immédiate** (au lieu d'un timeout 300s muet sur le round-trip).
///
/// **Critère retenu** (le plus robuste aujourd'hui) : le pont est honoré ssi le
/// profil porte une capacité MCP en stratégie `ConfigFile` ciblant `.mcp.json`
/// **ET** que son adaptateur structuré est `Claude`. En effet IdeA matérialise le
/// serveur MCP sous forme d'un fichier `.mcp.json` dans le run dir, ce que **seul**
/// Claude Code lit réellement ; Codex déclare pourtant la même stratégie
/// `ConfigFile(.mcp.json)` mais lit en pratique `~/.codex/config.toml` ⇒ le pont
/// n'est jamais branché et la cible ne peut pas appeler `idea_reply`. On exige donc
/// l'adaptateur `Claude` plutôt qu'une simple présence de capacité MCP, ce qui
/// exclut Codex de fait et reste valable pour tout futur profil non-Claude.
///
/// Profil introuvable ⇒ on **n'interdit pas** (laisse le flux suivre son cours
/// comme avant) : la garde ne fait que transformer un échec connu en erreur typée.
async fn guard_mcp_bridge_supported(
&self,
profile_id: &ProfileId,
target: &str,
) -> Result<(), AppError> {
use domain::profile::{McpConfigStrategy, StructuredAdapter};
let Some(profile) = self
.profiles
.list()
.await?
.into_iter()
.find(|p| &p.id == profile_id)
else {
return Ok(());
};
let honours_mcp_json = matches!(
profile.mcp.as_ref().map(|c| &c.config),
Some(McpConfigStrategy::ConfigFile { target }) if target == ".mcp.json"
);
let is_claude = profile.structured_adapter == Some(StructuredAdapter::Claude);
if honours_mcp_json && is_claude {
return Ok(());
}
Err(AppError::Invalid(format!(
"la cible '{target}' (profil '{}', adaptateur {:?}) ne supporte pas encore le \
pont idea_* : la délégation inter-agents passe par un serveur MCP déclaré en \
.mcp.json, que seul un profil Claude consomme aujourd'hui. Cible un agent au \
profil Claude.",
profile.name, profile.structured_adapter
)))
}
/// Resolves the **prompt-ready pattern** (cadrage §6, lot C5) of the agent's
/// profile, used to arm the [`InputMediator`]'s prompt detection at `bind_handle`
/// time. Returns `None` when the agent, its profile, or the pattern is absent —
/// the safe fallback: no pattern ⇒ Idle only via explicit signal/timeout.
async fn prompt_pattern_for_agent(
&self,
project: &Project,
agent_id: AgentId,
) -> Option<String> {
let agent = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await
.ok()?
.agents
.into_iter()
.find(|a| a.id == agent_id)?;
let profiles = self.profiles.list().await.ok()?;
profiles
.into_iter()
.find(|p| p.id == agent.profile_id)
.and_then(|p| p.prompt_ready_pattern)
}
/// Resolves a human-friendly profile reference (slug like `claude-code`,
/// command like `claude`, or display name like `Claude Code`) to a configured
/// [`ProfileId`]. Matching is universal — never hard-coded to one AI — by
@ -612,6 +1246,44 @@ impl OrchestratorService {
}
}
/// RAII guard that posts a wait-for edge `from → to` for the duration of an ask and
/// removes it on drop (reply, timeout, or any early return) — the same discipline as
/// the per-agent turn lock. Holds a raw pointer-free borrow via the shared mutex on
/// the service's [`WaitForGraph`]; constructed only inside `ask_agent` where the
/// service outlives the guard.
struct WaitEdgeGuard<'a> {
graph: &'a StdMutex<WaitForGraph>,
from: AgentId,
to: AgentId,
}
impl<'a> WaitEdgeGuard<'a> {
fn new(service: &'a OrchestratorService, from: AgentId, to: AgentId) -> Self {
{
let mut g = service
.wait_for
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.add_edge(from, to);
}
Self {
graph: &service.wait_for,
from,
to,
}
}
}
impl Drop for WaitEdgeGuard<'_> {
fn drop(&mut self) {
let mut g = self
.graph
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.remove_edge(self.from, self.to);
}
}
/// Normalises a profile reference for tolerant matching: lowercased, with spaces,
/// dashes and underscores stripped (`"Claude Code"`, `"claude-code"`, `"claude"`
/// → comparable forms; `claude` ⊂ ... handled by the command match above).

View File

@ -9,6 +9,7 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use domain::conversation::ConversationId;
use domain::ports::{AgentSession, PtyHandle};
use domain::{AgentId, NodeId, SessionId, SessionKind, TerminalSession};
@ -44,6 +45,11 @@ pub trait LiveAgentRegistry: Send + Sync {
#[derive(Default)]
pub struct TerminalSessions {
entries: Mutex<HashMap<SessionId, Entry>>,
/// Conversation → live session binding (cadrage C3 §5.2): «1 session vivante /
/// **conversation**» (remplace «1 / agent»). Populated by the orchestrator when an
/// ask resolves/launches the session for a given thread. Separate from `entries`
/// (the domain [`TerminalSession`] does not carry a conversation id).
conversations: Mutex<HashMap<ConversationId, SessionId>>,
}
impl LiveAgentRegistry for TerminalSessions {
@ -65,9 +71,50 @@ impl TerminalSessions {
pub fn new() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
conversations: Mutex::new(HashMap::new()),
}
}
/// Binds `conversation` to the live `session` (cadrage C3 §5.2). Idempotent:
/// re-binding the same conversation overwrites the target session.
pub fn bind_conversation(&self, conversation: ConversationId, session: SessionId) {
if let Ok(mut m) = self.conversations.lock() {
m.insert(conversation, session);
}
}
/// Returns the live [`SessionId`] bound to `conversation`, if any **and** still
/// registered (a stale binding to a closed session resolves to `None`).
///
/// «1 session vivante / conversation» — deterministic, replacing the ambiguous
/// per-agent lookup for the orchestrator's ask path.
#[must_use]
pub fn session_for(&self, conversation: ConversationId) -> Option<SessionId> {
let sid = self.conversations.lock().ok()?.get(&conversation).copied()?;
// Only return it if the session is still live in `entries`.
self.entries
.lock()
.ok()
.filter(|m| m.contains_key(&sid))
.map(|_| sid)
}
/// Lists every live [`SessionId`] hosting `agent_id` (cadrage C3 §1.2 — an agent
/// may take part in several threads, so this is the **plural** of
/// [`Self::session_for_agent`]).
#[must_use]
pub fn sessions_for_agent(&self, agent_id: &AgentId) -> Vec<SessionId> {
self.entries
.lock()
.map(|m| {
m.values()
.filter(|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id))
.map(|e| e.session.id)
.collect()
})
.unwrap_or_default()
}
/// Inserts a freshly-opened session.
pub fn insert(&self, handle: PtyHandle, session: TerminalSession) {
if let Ok(mut map) = self.entries.lock() {
@ -184,8 +231,12 @@ impl TerminalSessions {
.unwrap_or_default()
}
/// Removes a session from the registry, returning its handle if present.
/// Removes a session from the registry, returning its handle if present. Also
/// drops any conversation binding pointing at it (no stale `session_for`).
pub fn remove(&self, id: &SessionId) -> Option<PtyHandle> {
if let Ok(mut c) = self.conversations.lock() {
c.retain(|_, sid| sid != id);
}
self.entries
.lock()
.ok()

View File

@ -1312,11 +1312,11 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
"memory section present: {doc}"
);
assert!(
doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"),
doc.contains("- [Git optionnel](.ideai/memory/git-optional.md) — git reste un simple tool (project)"),
"first memory line exact format: {doc}"
);
assert!(
doc.contains("- [Permissions](perm-archi.md) — sandbox OS + résumé injecté (reference)"),
doc.contains("- [Permissions](.ideai/memory/perm-archi.md) — sandbox OS + résumé injecté (reference)"),
"second memory line exact format: {doc}"
);
// Recalled order preserved; section after the persona.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,431 @@
//! Conversation-by-pair domain model (cadrage « conversation par paire », lot C1/C2).
//!
//! A [`Conversation`] is a **thread between two distinct parties** with its own
//! I/O session. Its identity is the **unordered pair** `{left, right}`: the same
//! two parties always denote the same conversation (this is the key to the
//! *lazy materialisation* the [`ConversationRegistry`] performs — `resolve(a, b)`
//! and `resolve(b, a)` yield the same [`ConversationId`]).
//!
//! This module is **pure** (ARCHITECTURE dependency rule): no `tokio`, no
//! `std::fs`, no `std::process`. It owns the value objects and entities plus the
//! [`ConversationRegistry`] port (a trait the application depends on; the infra
//! provides `InMemoryConversationRegistry`). The session handle is held only as a
//! [`SessionRef`] (a reference to a [`crate::terminal::TerminalSession`]); the
//! domain never holds the PTY itself.
//!
//! ## Why this exists
//!
//! The previous coupling «1 live session / agent» was ambiguous once an agent can
//! take part in several threads (`session-registry-agent-ambiguity`). Here the live
//! session is keyed **by conversation**, so a delegation `A↔B` never borrows the
//! `User↔B` thread — the context leak is closed by construction.
use serde::{Deserialize, Serialize};
use crate::ids::{AgentId, SessionId};
/// Identifies one [`Conversation`].
///
/// Newtype around [`uuid::Uuid`], calqué sur [`crate::ids::AgentId`] /
/// [`crate::mailbox::TicketId`]. Minted by the [`ConversationRegistry`] when a pair
/// is first resolved; stable for the lifetime of that pair's thread.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ConversationId(pub uuid::Uuid);
impl ConversationId {
/// Wraps an existing [`uuid::Uuid`].
#[must_use]
pub const fn from_uuid(id: uuid::Uuid) -> Self {
Self(id)
}
/// Mints a fresh random conversation id.
#[must_use]
pub fn new_random() -> Self {
Self(uuid::Uuid::new_v4())
}
/// Returns the inner [`uuid::Uuid`].
#[must_use]
pub const fn as_uuid(&self) -> uuid::Uuid {
self.0
}
}
impl std::fmt::Display for ConversationId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// One end of a [`Conversation`].
///
/// Invariant (enforced by [`Conversation::try_new`]): a conversation relates **two
/// distinct** parties and carries **at most one** [`ConversationParty::User`]
/// (never `User↔User`, never `Agent(x)↔Agent(x)`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum ConversationParty {
/// The human operator — a single logical instance on IdeA's side.
User,
/// A project agent.
#[serde(rename_all = "camelCase")]
Agent {
/// The agent on this end.
agent_id: AgentId,
},
}
impl ConversationParty {
/// Convenience constructor for an agent party.
#[must_use]
pub const fn agent(agent_id: AgentId) -> Self {
Self::Agent { agent_id }
}
/// Returns the [`AgentId`] when this party is an agent.
#[must_use]
pub const fn as_agent(&self) -> Option<AgentId> {
match self {
Self::Agent { agent_id } => Some(*agent_id),
Self::User => None,
}
}
/// Whether this party is the human user.
#[must_use]
pub const fn is_user(&self) -> bool {
matches!(self, Self::User)
}
}
/// Errors raised when constructing a [`Conversation`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ConversationError {
/// The two ends are the **same** party (`left == right`).
#[error("a conversation must relate two distinct parties")]
SameParty,
/// **Both** ends are [`ConversationParty::User`] — at most one is allowed.
#[error("a conversation may carry at most one User party")]
TwoUsers,
}
/// The I/O state of a conversation thread.
///
/// The domain holds only a [`SessionRef`]; the live PTY/structured stream is an
/// infrastructure concern (`session-registry`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum ConversationSession {
/// Never launched, or suspended (only `resumable_id` is retained on the
/// [`Conversation`]).
Dormant,
/// A live I/O stream backs this thread.
#[serde(rename_all = "camelCase")]
Live {
/// Reference to the backing [`crate::terminal::TerminalSession`].
handle_ref: SessionRef,
},
}
impl ConversationSession {
/// Whether the thread currently has a live session.
#[must_use]
pub const fn is_live(&self) -> bool {
matches!(self, Self::Live { .. })
}
}
/// An opaque reference to a live session handle (a [`crate::terminal::TerminalSession`]).
///
/// The domain never owns the PTY; it only names the session by its [`SessionId`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionRef(pub SessionId);
impl SessionRef {
/// Wraps a [`SessionId`].
#[must_use]
pub const fn new(id: SessionId) -> Self {
Self(id)
}
/// Returns the referenced [`SessionId`].
#[must_use]
pub const fn session_id(&self) -> SessionId {
self.0
}
}
/// A thread between two distinct parties, with its own session and resumable id.
///
/// Identity is the **unordered pair** `{left, right}`: see [`Conversation::same_pair`].
/// Pure value/entity — no I/O.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Conversation {
/// Stable identifier (minted by the registry on first resolve).
pub id: ConversationId,
/// One end of the thread.
pub left: ConversationParty,
/// The other end of the thread.
pub right: ConversationParty,
/// Current I/O state.
pub session: ConversationSession,
/// CLI session-id to resume on restart (`suspend` stores it; `Dormant` keeps it).
pub resumable_id: Option<String>,
}
impl Conversation {
/// Builds a `Dormant` conversation for the pair `{left, right}`.
///
/// # Errors
/// - [`ConversationError::SameParty`] if `left == right`.
/// - [`ConversationError::TwoUsers`] if both ends are [`ConversationParty::User`].
pub fn try_new(
id: ConversationId,
left: ConversationParty,
right: ConversationParty,
) -> Result<Self, ConversationError> {
if left.is_user() && right.is_user() {
// More specific than SameParty: "at most one User" invariant.
return Err(ConversationError::TwoUsers);
}
if left == right {
return Err(ConversationError::SameParty);
}
Ok(Self {
id,
left,
right,
session: ConversationSession::Dormant,
resumable_id: None,
})
}
/// Whether this conversation relates the **same unordered pair** as `{a, b}`.
///
/// `{left, right}` is order-insensitive: `same_pair(a, b) == same_pair(b, a)`.
#[must_use]
pub fn same_pair(&self, a: ConversationParty, b: ConversationParty) -> bool {
(self.left == a && self.right == b) || (self.left == b && self.right == a)
}
}
/// A pure wait-for graph for deadlock (cycle) detection on inter-agent delegation.
///
/// Each edge `(A, B)` reads «A is waiting for B». A delegation `from → to` would
/// deadlock iff `to` already (transitively) waits on `from`; [`WaitForGraph::would_cycle`]
/// answers that **without** mutating the graph, so the caller can refuse the ask
/// **before** posting the edge. 100 % pure and testable.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WaitForGraph {
edges: Vec<(AgentId, AgentId)>,
}
impl WaitForGraph {
/// An empty graph.
#[must_use]
pub fn new() -> Self {
Self { edges: Vec::new() }
}
/// Records that `from` waits for `to` (no-op if the edge already exists).
pub fn add_edge(&mut self, from: AgentId, to: AgentId) {
if !self.edges.contains(&(from, to)) {
self.edges.push((from, to));
}
}
/// Removes the edge `from → to` (the wait resolved or timed out).
pub fn remove_edge(&mut self, from: AgentId, to: AgentId) {
self.edges.retain(|e| *e != (from, to));
}
/// Number of recorded edges (test/inspection helper).
#[must_use]
pub fn len(&self) -> usize {
self.edges.len()
}
/// Whether the graph holds no edges.
#[must_use]
pub fn is_empty(&self) -> bool {
self.edges.is_empty()
}
/// Whether adding `from → to` would close a cycle in the wait-for graph.
///
/// `true` when `to` already reaches `from` through existing edges (so `from`
/// waiting on `to` would deadlock), or when `from == to` (self-wait). The graph
/// is **not** modified.
#[must_use]
pub fn would_cycle(&self, from: AgentId, to: AgentId) -> bool {
if from == to {
return true;
}
// Does `to` already reach `from`? DFS over existing edges from `to`.
let mut stack = vec![to];
let mut seen = vec![to];
while let Some(node) = stack.pop() {
if node == from {
return true;
}
for (a, b) in &self.edges {
if *a == node && !seen.contains(b) {
seen.push(*b);
stack.push(*b);
}
}
}
false
}
}
/// Lazy, get-or-create registry of conversations keyed by **unordered pair**.
///
/// Pure port (no I/O); the infra adapter `InMemoryConversationRegistry` owns the
/// interior mutability (`HashMap` + sync mutex, never held across an `.await`).
/// Kept object-safe so the application holds it as `Arc<dyn ConversationRegistry>`.
pub trait ConversationRegistry: Send + Sync {
/// Get-or-create: returns the thread for the pair `{a, b}`, opening it
/// (`Dormant`) if it did not exist. Pure registry — opens **no** session.
/// The same unordered pair always yields the same [`ConversationId`].
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation;
/// Marks the conversation `id` `Live` with the given session reference.
fn bind_session(&self, id: ConversationId, session: SessionRef);
/// Suspends `id` (back to `Dormant`), retaining `resumable_id` for restart.
fn suspend(&self, id: ConversationId, resumable_id: Option<String>);
/// Returns the current snapshot of `id`, if it exists.
fn get(&self, id: ConversationId) -> Option<Conversation>;
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn conv_id(n: u128) -> ConversationId {
ConversationId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn conversation_id_roundtrips_through_uuid() {
let u = uuid::Uuid::from_u128(42);
let id = ConversationId::from_uuid(u);
assert_eq!(id.as_uuid(), u);
assert_eq!(id.to_string(), u.to_string());
}
#[test]
fn rejects_same_party_pair() {
let a = ConversationParty::agent(agent(1));
assert_eq!(
Conversation::try_new(conv_id(1), a, a),
Err(ConversationError::SameParty)
);
}
#[test]
fn rejects_two_users() {
assert_eq!(
Conversation::try_new(
conv_id(1),
ConversationParty::User,
ConversationParty::User
),
Err(ConversationError::TwoUsers)
);
}
#[test]
fn accepts_user_agent_and_agent_agent() {
assert!(Conversation::try_new(
conv_id(1),
ConversationParty::User,
ConversationParty::agent(agent(1))
)
.is_ok());
assert!(Conversation::try_new(
conv_id(2),
ConversationParty::agent(agent(1)),
ConversationParty::agent(agent(2))
)
.is_ok());
}
#[test]
fn fresh_conversation_is_dormant_without_resumable() {
let c = Conversation::try_new(
conv_id(1),
ConversationParty::User,
ConversationParty::agent(agent(1)),
)
.unwrap();
assert_eq!(c.session, ConversationSession::Dormant);
assert!(!c.session.is_live());
assert_eq!(c.resumable_id, None);
}
#[test]
fn identity_is_the_unordered_pair() {
let a = ConversationParty::agent(agent(1));
let b = ConversationParty::agent(agent(2));
let c = Conversation::try_new(conv_id(1), a, b).unwrap();
assert!(c.same_pair(a, b));
assert!(c.same_pair(b, a), "pair identity is order-insensitive");
let other = ConversationParty::agent(agent(3));
assert!(!c.same_pair(a, other));
}
#[test]
fn would_cycle_refuses_direct_back_edge() {
// A→B exists; B→A would close the cycle.
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2)); // A waits B
assert!(g.would_cycle(agent(2), agent(1)), "B→A closes A→B→A");
}
#[test]
fn would_cycle_allows_linear_chain() {
// A→B exists; B→C is fine (no path C→…→B).
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2)); // A waits B
assert!(
!g.would_cycle(agent(2), agent(3)),
"A→B→C is acyclic"
);
}
#[test]
fn would_cycle_detects_transitive_cycle() {
// A→B, B→C; C→A would close A→B→C→A.
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2));
g.add_edge(agent(2), agent(3));
assert!(g.would_cycle(agent(3), agent(1)), "transitive cycle");
}
#[test]
fn would_cycle_refuses_self_wait() {
let g = WaitForGraph::new();
assert!(g.would_cycle(agent(1), agent(1)));
}
#[test]
fn add_edge_is_idempotent_and_removable() {
let mut g = WaitForGraph::new();
g.add_edge(agent(1), agent(2));
g.add_edge(agent(1), agent(2));
assert_eq!(g.len(), 1);
g.remove_edge(agent(1), agent(2));
assert!(g.is_empty());
}
}

View File

@ -59,6 +59,17 @@ pub enum DomainEvent {
/// Exit code.
code: i32,
},
/// An agent's **busy/idle** state changed (cadrage C4 §4.2): it went `Busy` on
/// the enqueue that started a turn, or `Idle` on `mark_idle` (prompt-ready or an
/// explicit signal). Discrete, low-frequency beacon relayed to the front so the
/// mediated-input view can dim "Envoyer" while a turn is in flight (it never
/// blocks the enqueue — the FIFO keeps accepting; the flag is purely advisory).
AgentBusyChanged {
/// The agent whose state changed.
agent_id: AgentId,
/// `true` when a turn is in flight, `false` when the agent is idle.
busy: bool,
},
/// An agent's runtime profile was changed (hot-swap of the AI engine).
AgentProfileChanged {
/// The agent.

View File

@ -0,0 +1,236 @@
//! File-access guard (cadrage « FileGuard », lot C6).
//!
//! A **reader/writer lock per guarded resource**, bounded to the files IdeA owns:
//! an agent's `.md` context, the project's global context, and a memory note.
//! N concurrent readers **or** a single exclusive writer per resource; the global
//! [`GuardedResource::ProjectContext`] is additionally **single-writer**: only the
//! orchestrator may `acquire_write` it — any other party must *propose* instead and
//! receives [`GuardError::Forbidden`].
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! This guard corrects collisions **inside the IdeA path** (access through the MCP
//! tools and the UI). It is **cooperative**: an agent that keeps a raw shell can
//! still write these `.md`/memory files directly through the filesystem, bypassing
//! the lock. Real airtightness (revoking raw fs access to these paths) is an **OS
//! sandbox** concern (Landlock — `agent-permissions-architecture`) and is **out of
//! scope** here. The FileGuard fixes IdeA↔IdeA races; it does not sandbox an agent.
//!
//! This module is **pure** (no `tokio`, no `std::fs`): it owns the value objects
//! ([`GuardedResource`], [`GuardError`]), the RAII leases ([`ReadLease`],
//! [`WriteLease`]) and the [`FileGuard`] port. The infra adapter (`RwFileGuard`)
//! provides the actual reader/writer locks.
use async_trait::async_trait;
use crate::conversation::ConversationParty;
use crate::ids::AgentId;
use crate::memory::MemorySlug;
/// The **bounded, closed** set of resources the [`FileGuard`] arbitrates.
///
/// Closed by construction: only an agent's context, the global project context, and
/// a memory note are guardable. Anything outside this enum is **not** guarded (and
/// must not be routed through the guard).
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum GuardedResource {
/// One agent's `.md` context file.
AgentContext(AgentId),
/// The project's global context (`CLAUDE.md`/root context).
///
/// **Single-writer**: only the orchestrator may write it; every other party is
/// limited to *proposing* a change (cf. [`GuardError::Forbidden`]).
ProjectContext,
/// A single memory note, keyed by its slug.
Memory(MemorySlug),
}
impl GuardedResource {
/// Whether this resource is the single-writer global project context.
#[must_use]
pub const fn is_project_context(&self) -> bool {
matches!(self, Self::ProjectContext)
}
}
/// Errors raised when acquiring a guard lease.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum GuardError {
/// The lease could not be taken because the resource is held incompatibly
/// (a writer wanted while readers/a writer hold it, or vice-versa) and the
/// caller asked for a **non-blocking** acquire. Blocking acquires serialize
/// instead of returning this.
#[error("guarded resource is busy")]
Busy,
/// A party that is **not** the orchestrator attempted to `acquire_write` the
/// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the
/// change for validation rather than writing directly.
#[error("writing the global project context is reserved to the orchestrator; propose instead")]
Forbidden,
}
/// A RAII **read** lease: holding it guarantees shared read access to the resource;
/// dropping it releases the reader slot. N read leases may coexist on one resource.
///
/// The lease owns an opaque release guard (a boxed handle the adapter fills with its
/// lock guard). The domain only knows the lease releases on drop.
#[must_use = "a read lease releases the guard as soon as it is dropped"]
pub struct ReadLease {
_guard: Box<dyn Send + Sync>,
}
impl ReadLease {
/// Wraps an adapter-provided release guard into a domain [`ReadLease`].
///
/// The infra adapter passes its own lock guard (e.g. a tokio `OwnedRwLockReadGuard`);
/// the domain treats it opaquely and only relies on its `Drop` to release.
#[must_use]
pub fn new(guard: Box<dyn Send + Sync>) -> Self {
Self { _guard: guard }
}
}
impl std::fmt::Debug for ReadLease {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("ReadLease")
}
}
/// A RAII **write** lease: holding it guarantees exclusive write access to the
/// resource; dropping it releases the writer slot. At most one write lease exists
/// for a resource at a time, and it excludes all readers.
#[must_use = "a write lease releases the guard as soon as it is dropped"]
pub struct WriteLease {
_guard: Box<dyn Send + Sync>,
}
impl WriteLease {
/// Wraps an adapter-provided release guard into a domain [`WriteLease`].
#[must_use]
pub fn new(guard: Box<dyn Send + Sync>) -> Self {
Self { _guard: guard }
}
}
impl std::fmt::Debug for WriteLease {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("WriteLease")
}
}
/// A reader/writer guard over the bounded set of IdeA-owned files
/// ([`GuardedResource`]).
///
/// Contract:
/// - `acquire_read` grants a shared [`ReadLease`]; **N readers** may hold one
/// resource concurrently.
/// - `acquire_write` grants an exclusive [`WriteLease`]; it excludes all other
/// readers and writers of the **same** resource (different resources are
/// independent).
/// - [`GuardedResource::ProjectContext`] is **single-writer**: `acquire_write` by a
/// `who` that is not the orchestrator returns [`GuardError::Forbidden`].
///
/// Object-safe (held as `Arc<dyn FileGuard>`); blocking acquires serialize, so the
/// only error is `Forbidden` (the cooperative path never spuriously fails a wait).
#[async_trait]
pub trait FileGuard: Send + Sync {
/// Acquires a shared **read** lease on `res` for `who`. Serializes behind any
/// in-flight writer of the same resource, then returns a [`ReadLease`].
///
/// # Errors
/// [`GuardError`] — reading is never `Forbidden`, so a blocking adapter returns
/// `Ok` once the writer (if any) releases.
async fn acquire_read(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError>;
/// Acquires an exclusive **write** lease on `res` for `who`. Serializes behind
/// any in-flight readers/writer of the same resource.
///
/// # Errors
/// [`GuardError::Forbidden`] when `who` is not the orchestrator and `res` is the
/// single-writer [`GuardedResource::ProjectContext`].
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError>;
}
/// Whether `who` is allowed to **write** `res` directly (vs. having to propose).
///
/// Pure policy, shared by the port's documented contract and the adapter: only the
/// orchestrator may write the global [`GuardedResource::ProjectContext`]; everyone
/// may write the per-agent context and memory. The orchestrator is modelled as
/// [`ConversationParty::User`] — IdeA's single logical operator identity (the
/// human-driven orchestrator), never a project [`AgentId`].
#[must_use]
pub fn may_write_directly(who: ConversationParty, res: &GuardedResource) -> bool {
if res.is_project_context() {
is_orchestrator(who)
} else {
true
}
}
/// Whether `who` is the orchestrator identity for the single-writer rule.
///
/// The orchestrator is the human-driven operator ([`ConversationParty::User`]); a
/// project agent ([`ConversationParty::Agent`]) is never the orchestrator and must
/// propose changes to the global context.
#[must_use]
pub const fn is_orchestrator(who: ConversationParty) -> bool {
matches!(who, ConversationParty::User)
}
#[cfg(test)]
mod tests {
use super::*;
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
#[test]
fn project_context_is_single_writer_to_orchestrator_only() {
assert!(may_write_directly(
ConversationParty::User,
&GuardedResource::ProjectContext
));
assert!(!may_write_directly(
agent_party(1),
&GuardedResource::ProjectContext
));
}
#[test]
fn agent_context_and_memory_are_writable_by_anyone() {
let mem = GuardedResource::Memory(MemorySlug::new("note").unwrap());
let ctx = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(9)));
for who in [ConversationParty::User, agent_party(1)] {
assert!(may_write_directly(who, &mem));
assert!(may_write_directly(who, &ctx));
}
}
#[test]
fn is_orchestrator_only_for_user() {
assert!(is_orchestrator(ConversationParty::User));
assert!(!is_orchestrator(agent_party(1)));
}
#[test]
fn guarded_resource_equality_keys_on_identity() {
let a = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
let b = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(1)));
let c = GuardedResource::AgentContext(AgentId::from_uuid(uuid::Uuid::from_u128(2)));
assert_eq!(a, b);
assert_ne!(a, c);
assert_ne!(
GuardedResource::ProjectContext,
GuardedResource::Memory(MemorySlug::new("x").unwrap())
);
}
}

203
crates/domain/src/input.rs Normal file
View File

@ -0,0 +1,203 @@
//! Mediated agent input (cadrage « entrée médiée par IdeA », lot C1/C2).
//!
//! Every input to an agent — **human** keystrokes *and* **inter-agent** delegations
//! — converges on **one FIFO per agent**, with `enqueue` (Envoyer) and `preempt`
//! (Interrompre) kept distinct. This module owns the pure value objects
//! ([`InputSource`], [`AgentBusyState`]) and the [`InputMediator`] port.
//!
//! The mediator **absorbs** [`crate::mailbox::AgentMailbox`]: the existing mailbox
//! (FIFO + one-shot reply) is reused as the *correlation engine* inside the infra
//! adapter (`MediatedInbox`), rather than spawning a second concurrent queue. The
//! delegation mailbox is thus just **one input source among two**.
//!
//! Pure (no I/O): the FIFO, locks and busy bookkeeping live in the infra adapter.
use serde::{Deserialize, Serialize};
use crate::ids::AgentId;
use crate::mailbox::{PendingReply, Ticket, TicketId};
use crate::ports::PtyHandle;
/// Where a queued input came from.
///
/// Replaces the free-form `requester: String` as the **source of truth** (the
/// string remains a derived display label). Lets IdeA propagate the requester's
/// identity and feed the wait-for graph (cycle detection).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum InputSource {
/// The human operator (a `SubmitHumanInput` use case).
Human,
/// Another agent delegating via `idea_ask_agent`.
#[serde(rename_all = "camelCase")]
Agent {
/// The delegating agent.
agent_id: AgentId,
},
}
impl InputSource {
/// Convenience constructor for an agent source.
#[must_use]
pub const fn agent(agent_id: AgentId) -> Self {
Self::Agent { agent_id }
}
/// Returns the [`AgentId`] when this source is an agent.
#[must_use]
pub const fn as_agent(&self) -> Option<AgentId> {
match self {
Self::Agent { agent_id } => Some(*agent_id),
Self::Human => None,
}
}
/// Whether the source is the human operator.
#[must_use]
pub const fn is_human(&self) -> bool {
matches!(self, Self::Human)
}
}
/// Whether an agent is currently processing a task.
///
/// Derived state, published to the front (`AgentBusyChanged`). An agent goes `Busy`
/// on the enqueue that **starts a turn**, and returns `Idle` on prompt-ready OR an
/// explicit signal (cf. cadrage §6). In doubt it stays `Busy`, but the FIFO keeps
/// accepting enqueues (forward, never reject the sender).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "state")]
pub enum AgentBusyState {
/// No turn in flight; the next enqueued ticket can start.
Idle,
/// A turn is in flight.
#[serde(rename_all = "camelCase")]
Busy {
/// The ticket whose turn is running.
ticket: TicketId,
/// When the turn started (epoch millis), for UI age display.
since_ms: u64,
},
}
impl AgentBusyState {
/// Whether a turn is currently in flight.
#[must_use]
pub const fn is_busy(&self) -> bool {
matches!(self, Self::Busy { .. })
}
/// The in-flight ticket, if any.
#[must_use]
pub const fn ticket(&self) -> Option<TicketId> {
match self {
Self::Busy { ticket, .. } => Some(*ticket),
Self::Idle => None,
}
}
}
/// The single convergence point of **all** of an agent's input (one FIFO/agent),
/// with `enqueue` (Envoyer) and `preempt` (Interrompre) kept **distinct**, plus the
/// busy state.
///
/// Object-safe (held as `Arc<dyn InputMediator>`). The infra adapter `MediatedInbox`
/// composes the existing [`crate::mailbox::AgentMailbox`] (correlation by ticket) +
/// turn locks + busy bookkeeping — it does **not** create a second queue.
pub trait InputMediator: Send + Sync {
/// Envoyer = enqueue: appends `ticket` to `agent`'s FIFO and returns the
/// [`PendingReply`] to await (the mailbox reply type, reused).
///
/// **Delivery** (cadrage C3 §5.2): the implementation also **writes** the turn
/// into the agent's input stream — *this* is the single, serialized write path
/// that replaces the orchestrator's former ad-hoc PTY write. The write target is
/// the [`PtyHandle`] previously registered with [`InputMediator::bind_handle`];
/// without one, the enqueue still queues the ticket (forward, never reject) and
/// the orchestrator falls back to its own delivery.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Registers (or refreshes) the live input [`PtyHandle`] of `agent` so a later
/// [`InputMediator::enqueue`] delivers the turn through it (cadrage C3 §5.2).
///
/// Keyed by **agent** (one live input stream per agent — «1 agent = 1 employee»);
/// the orchestrator calls this once it has resolved/launched the agent's live
/// session for the target conversation. Default: no-op (a mediator that does not
/// own the delivery write).
fn bind_handle(&self, _agent: AgentId, _handle: PtyHandle) {}
/// Like [`InputMediator::bind_handle`], but also arms **prompt-ready detection**
/// (cadrage §6, lot C5): `prompt_ready_pattern` is the agent profile's optional
/// literal marker (`AgentProfile::prompt_ready_pattern`). When `Some`, the mediator
/// watches the bound handle's output stream and calls [`InputMediator::mark_idle`]
/// the first time the marker appears (one of the two OR signals; the other is an
/// explicit `idea_reply`). When `None`, no pattern detection is armed — the agent
/// only returns `Idle` on the explicit signal or the per-turn timeout (fallback
/// «en cas de doute → reste Busy mais la file accepte»; never a false `Idle`).
///
/// Default: delegates to [`InputMediator::bind_handle`], ignoring the pattern (a
/// mediator that does not observe the output stream). The infra adapter overrides
/// it to arm the watcher.
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
_prompt_ready_pattern: Option<String>,
) {
self.bind_handle(agent, handle);
}
/// Whether this mediator owns the turn-delivery write for `agent` (i.e. it has a
/// bound handle **and** a PTY port). When `false`, the orchestrator must deliver
/// the turn itself. Default: `false`.
#[must_use]
fn delivers_turn(&self, _agent: AgentId) -> bool {
false
}
/// Interrompre = preempt: signals the running turn to stop (Échap/stop). This
/// is **not** an enqueue and correlates **no** ticket.
fn preempt(&self, agent: AgentId);
/// Marks `agent` free (prompt-ready or explicit signal) so its FIFO advances.
fn mark_idle(&self, agent: AgentId);
/// The current [`AgentBusyState`] of `agent`.
fn busy_state(&self, agent: AgentId) -> AgentBusyState;
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn ticket(n: u128) -> TicketId {
TicketId::from_uuid(uuid::Uuid::from_u128(n))
}
#[test]
fn input_source_human_vs_agent() {
assert!(InputSource::Human.is_human());
assert_eq!(InputSource::Human.as_agent(), None);
let s = InputSource::agent(agent(1));
assert!(!s.is_human());
assert_eq!(s.as_agent(), Some(agent(1)));
}
#[test]
fn busy_state_transitions_and_accessors() {
let idle = AgentBusyState::Idle;
assert!(!idle.is_busy());
assert_eq!(idle.ticket(), None);
let busy = AgentBusyState::Busy {
ticket: ticket(7),
since_ms: 1000,
};
assert!(busy.is_busy());
assert_eq!(busy.ticket(), Some(ticket(7)));
assert_ne!(idle, busy);
}
}

View File

@ -31,11 +31,15 @@
#![warn(missing_docs)]
pub mod agent;
pub mod conversation;
pub mod error;
pub mod events;
pub mod fileguard;
pub mod git;
pub mod ids;
pub mod input;
pub mod layout;
pub mod mailbox;
pub mod markdown;
pub mod memory;
pub mod orchestrator;
@ -72,6 +76,20 @@ pub use profile::{
AgentProfile, ContextInjection, EmbedderProfile, EmbedderStrategy, SessionStrategy,
};
pub use mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
pub use conversation::{
Conversation, ConversationError, ConversationId, ConversationParty, ConversationRegistry,
ConversationSession, SessionRef, WaitForGraph,
};
pub use input::{AgentBusyState, InputMediator, InputSource};
pub use fileguard::{
is_orchestrator, may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease,
WriteLease,
};
pub use markdown::MarkdownDoc;
pub use memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType};

View File

@ -0,0 +1,330 @@
//! Inter-agent **mailbox** port (Option 1 « Terminal + MCP », lot B-1).
//!
//! A delegating agent calls `idea_ask_agent(target, task)` and **blocks** until the
//! target renders a result via `idea_reply(result)`. Between those two MCP calls,
//! IdeA must (1) hand the task to the target's single FIFO input and (2) hold the
//! caller's await on a reply slot that the target's later `idea_reply` resolves.
//!
//! This module owns the **pure** contract of that rendezvous: the [`AgentMailbox`]
//! port plus its value objects ([`Ticket`], [`TicketId`], [`MailboxError`]) and the
//! [`PendingReply`] handle the caller awaits. It is **I/O-free**: the actual queue,
//! its mutex and the one-shot reply channel are an infrastructure concern (the
//! `InMemoryMailbox` adapter). Keeping the contract here means the FIFO/resolution
//! invariants are expressed against a port the application layer depends on, never
//! against a concrete channel type (hexagonal + DIP).
//!
//! ## Model
//!
//! - **One queue per target agent** (`AgentId`). `enqueue` appends a [`Ticket`] and
//! returns a [`PendingReply`] the caller awaits.
//! - **`resolve(agent, result)` corrèle implicitement** the result with the ticket
//! at the **head** of that agent's queue — the agent is processing one task at a
//! time (1 agent = 1 employee, FIFO input), so its current `idea_reply` answers
//! the task it is currently working on. No ticket id is exposed to the model.
//! - **Timeout** is the caller's concern: it drops its [`PendingReply`] and calls
//! [`AgentMailbox::cancel_head`] to retire the stuck head ticket so the queue
//! advances.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use crate::conversation::ConversationId;
use crate::ids::AgentId;
use crate::input::InputSource;
/// Identifies one queued [`Ticket`] within a target agent's mailbox.
///
/// Newtype around [`uuid::Uuid`]; minted by the adapter on `enqueue`. It is **never
/// exposed to the model** (the MCP `idea_reply` schema carries only `result`):
/// correlation is positional (head of the FIFO), the id only lets the caller name
/// the exact ticket it wants retired on timeout ([`AgentMailbox::cancel_head`]).
#[derive(
Debug,
Clone,
Copy,
PartialEq,
Eq,
Hash,
PartialOrd,
Ord,
serde::Serialize,
serde::Deserialize,
)]
#[serde(transparent)]
pub struct TicketId(pub uuid::Uuid);
impl TicketId {
/// Wraps an existing [`uuid::Uuid`].
#[must_use]
pub const fn from_uuid(id: uuid::Uuid) -> Self {
Self(id)
}
/// Mints a fresh random ticket id.
#[must_use]
pub fn new_random() -> Self {
Self(uuid::Uuid::new_v4())
}
/// Returns the inner [`uuid::Uuid`].
#[must_use]
pub const fn as_uuid(&self) -> uuid::Uuid {
self.0
}
}
impl std::fmt::Display for TicketId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// A delegation request queued for a target agent: who asked, and what for.
///
/// The `id` lets the caller retire **this** ticket on timeout; `requester` and
/// `task` are carried so the application layer can prefix the task with the asking
/// agent's identity when it writes to the target's input (`[IdeA · tâche de A ·
/// ticket …]`). Pure value object (no behaviour, no I/O).
///
/// **Origin & target** (cadrage C1): a ticket also carries its [`InputSource`]
/// (Human or Agent — the *source of truth* for the requester, the `requester`
/// string being a derived display label) and the [`ConversationId`] of the thread
/// it enters. These are added through **additive** constructors ([`Ticket::from_human`],
/// [`Ticket::from_agent`]); [`Ticket::new`] keeps its signature (Open/Closed) and
/// defaults to a `Human` source with a nil conversation for back-compat.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Ticket {
/// Stable id of this queued request (minted by the adapter on `enqueue`).
pub id: TicketId,
/// The origin of this input (Human or a delegating Agent).
pub source: InputSource,
/// The conversation thread this task enters.
pub conversation: ConversationId,
/// Display name (or id) of the agent that issued the `idea_ask_agent`.
pub requester: String,
/// The task/message to deliver to the target agent.
pub task: String,
}
impl Ticket {
/// Builds a ticket from its parts (back-compat: `Human` source, nil conversation).
///
/// Preserved for existing call sites. Prefer [`Ticket::from_human`] /
/// [`Ticket::from_agent`] when the source and conversation are known.
#[must_use]
pub fn new(id: TicketId, requester: impl Into<String>, task: impl Into<String>) -> Self {
Self {
id,
source: InputSource::Human,
conversation: ConversationId::from_uuid(uuid::Uuid::nil()),
requester: requester.into(),
task: task.into(),
}
}
/// Builds a ticket originating from the **human** operator, for a given thread.
#[must_use]
pub fn from_human(
id: TicketId,
conversation: ConversationId,
requester: impl Into<String>,
task: impl Into<String>,
) -> Self {
Self {
id,
source: InputSource::Human,
conversation,
requester: requester.into(),
task: task.into(),
}
}
/// Builds a ticket originating from a delegating **agent**, for a given thread.
#[must_use]
pub fn from_agent(
id: TicketId,
source: AgentId,
conversation: ConversationId,
requester: impl Into<String>,
task: impl Into<String>,
) -> Self {
Self {
id,
source: InputSource::agent(source),
conversation,
requester: requester.into(),
task: task.into(),
}
}
}
/// Errors raised by the [`AgentMailbox`] port.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MailboxError {
/// `resolve` was called for an agent whose queue is **empty** — an
/// `idea_reply` with no matching `idea_ask_agent` in flight. Surfaced as a typed
/// error (never a panic) so the offending agent can read it and adjust.
#[error("no pending request to reply to for agent {0}")]
NoPendingRequest(AgentId),
/// The awaited reply channel closed before a result arrived — the target's
/// session ended (or was killed) without rendering an `idea_reply`. The caller's
/// await resolves to this instead of hanging forever.
#[error("reply channel closed before a result was rendered")]
Cancelled,
}
/// A handle the caller awaits to receive a target agent's reply.
///
/// Returned by [`AgentMailbox::enqueue`]. Awaiting it yields the `String` result a
/// later [`AgentMailbox::resolve`] feeds into this ticket's slot, or
/// [`MailboxError::Cancelled`] if the reply channel closes first. The future is
/// **opaque** (the adapter builds it from its one-shot receiver): the domain stays
/// free of any concrete channel type, and the await point lives in the application
/// layer's `ask_agent`.
pub struct PendingReply {
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
}
impl PendingReply {
/// Wraps a reply future built by the adapter (e.g. over a one-shot receiver).
#[must_use]
pub fn new(
inner: Pin<Box<dyn Future<Output = Result<String, MailboxError>> + Send>>,
) -> Self {
Self { inner }
}
}
impl Future for PendingReply {
type Output = Result<String, MailboxError>;
fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
self.inner.as_mut().poll(cx)
}
}
impl std::fmt::Debug for PendingReply {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PendingReply").finish_non_exhaustive()
}
}
/// The inter-agent rendezvous port: a per-agent FIFO of delegation tickets, each
/// awaiting a one-shot reply (Option 1, lot B-1).
///
/// **Per-agent FIFO** (1 agent = 1 employee): tickets for the **same** target are
/// served head-first; tickets for **different** targets never block one another.
/// `enqueue` returns the [`PendingReply`] the caller awaits; the target's later
/// `idea_reply` lands in [`AgentMailbox::resolve`], which corrèle it with the head
/// of that agent's queue.
///
/// Kept object-safe (`&self`, owned/`Copy` args) so the application layer holds it
/// as `Arc<dyn AgentMailbox>`; the implementation owns all interior mutability.
pub trait AgentMailbox: Send + Sync {
/// Appends `ticket` to `agent`'s FIFO and returns the handle to await its reply.
///
/// The returned [`PendingReply`] resolves when a later [`AgentMailbox::resolve`]
/// feeds a result to this ticket (once it reaches the head and is answered), or
/// to [`MailboxError::Cancelled`] if the reply channel closes first.
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply;
/// Resolves the request at the **head** of `agent`'s FIFO with `result`,
/// waking its awaiting [`PendingReply`] and removing it from the queue.
///
/// Positional correlation: the agent processes one task at a time, so its
/// current `idea_reply` answers the head ticket.
///
/// # Errors
/// [`MailboxError::NoPendingRequest`] when `agent` has no queued ticket (an
/// `idea_reply` with no matching ask in flight).
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError>;
/// Resolves the request identified by `ticket_id` **anywhere** in `agent`'s FIFO
/// with `result`, waking its awaiting [`PendingReply`] and removing it from the
/// queue (cadrage C3 §3.3 — deterministic, multi-thread correlation).
///
/// This is the **by-ticket** counterpart of [`AgentMailbox::resolve`]: when an
/// agent answers a delegation it echoes the ticket id from the `[IdeA · … ·
/// ticket <id>]` prefix, so IdeA correlates exactly that request even when the
/// agent has several threads in flight.
///
/// The default implementation falls back to [`AgentMailbox::resolve`] (head of
/// queue), so a mailbox that does not track ids stays correct for mono-thread
/// agents. The in-memory adapter overrides it with true id-keyed resolution.
///
/// # Errors
/// [`MailboxError::NoPendingRequest`] when no queued ticket matches `ticket_id`
/// (and, for the default, when `agent`'s queue is empty).
fn resolve_ticket(
&self,
agent: AgentId,
_ticket_id: TicketId,
result: String,
) -> Result<(), MailboxError> {
self.resolve(agent, result)
}
/// Retires the ticket `ticket_id` **iff** it is currently at the head of
/// `agent`'s FIFO (the caller timed out waiting for it), so the queue advances.
///
/// A no-op when the head is a different ticket (the timed-out one was already
/// resolved, or another caller's ticket is now in front) — idempotent and safe.
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ticket_carries_its_parts() {
let id = TicketId::new_random();
let t = Ticket::new(id, "Main", "do X");
assert_eq!(t.id, id);
assert_eq!(t.requester, "Main");
assert_eq!(t.task, "do X");
// Back-compat default: human source, nil conversation.
assert_eq!(t.source, InputSource::Human);
assert_eq!(
t.conversation,
ConversationId::from_uuid(uuid::Uuid::nil())
);
}
#[test]
fn from_human_carries_source_and_conversation() {
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(5));
let t = Ticket::from_human(TicketId::new_random(), conv, "User", "do X");
assert_eq!(t.source, InputSource::Human);
assert_eq!(t.conversation, conv);
assert_eq!(t.task, "do X");
}
#[test]
fn from_agent_carries_agent_source_and_conversation() {
let conv = ConversationId::from_uuid(uuid::Uuid::from_u128(6));
let from = AgentId::from_uuid(uuid::Uuid::from_u128(2));
let t = Ticket::from_agent(TicketId::new_random(), from, conv, "A", "delegate");
assert_eq!(t.source, InputSource::agent(from));
assert_eq!(t.source.as_agent(), Some(from));
assert_eq!(t.conversation, conv);
}
#[test]
fn ticket_id_roundtrips_through_uuid() {
let u = uuid::Uuid::from_u128(7);
let id = TicketId::from_uuid(u);
assert_eq!(id.as_uuid(), u);
assert_eq!(id.to_string(), u.to_string());
}
#[test]
fn mailbox_errors_are_distinct_and_typed() {
let a = AgentId::from_uuid(uuid::Uuid::from_u128(1));
assert_ne!(
MailboxError::NoPendingRequest(a),
MailboxError::Cancelled
);
}
}

View File

@ -13,7 +13,9 @@
use serde::{Deserialize, Serialize};
use crate::ids::NodeId;
use crate::conversation::ConversationParty;
use crate::ids::{AgentId, NodeId};
use crate::mailbox::TicketId;
use crate::skill::SkillScope;
/// Errors raised while validating a raw [`OrchestratorRequest`].
@ -46,6 +48,14 @@ pub enum OrchestratorError {
/// The offending visibility value.
visibility: String,
},
/// The emitting-agent id (`requestedBy`) for `agent.reply` is not a valid id.
#[error("invalid emitting-agent id `{value}` for action `{action}`")]
InvalidAgentId {
/// The action being validated.
action: String,
/// The offending id value.
value: String,
},
}
/// The raw, wire-level orchestrator request as deserialised from a request file.
@ -103,6 +113,25 @@ pub struct OrchestratorRequest {
/// other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
/// Result body for `agent.reply` (`idea_reply`): the content the emitting agent
/// renders for the requester. Required by `agent.reply`, ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub result: Option<String>,
/// Optional ticket id echoed by `agent.reply` (`idea_reply`): when present, the
/// reply correlates **by ticket** (deterministic, multi-thread); absent, it falls
/// back to the head of the emitting agent's queue (cadrage C3 §3.3). Ignored by
/// the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ticket: Option<String>,
/// Markdown body for the FileGuard-mediated context/memory tools (`context.propose`,
/// `memory.write`, cadrage C7). Required by those actions, ignored otherwise.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Target memory note slug for the memory tools (`memory.read`/`memory.write`,
/// cadrage C7). Required by `memory.write`; optional for `memory.read` (absent ⇒
/// the aggregated index). Ignored by the other actions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub slug: Option<String>,
}
/// A validated orchestrator command — the only thing the application layer acts on.
@ -149,6 +178,31 @@ pub enum OrchestratorCommand {
target: String,
/// The task/message to send and await a reply for.
task: String,
/// The **requesting** agent (handshake identity), when the ask originates
/// from another agent's `idea_ask_agent`. `None` for an ask with no carried
/// requester (e.g. a file-protocol request without `requestedBy`): the
/// orchestrator then routes it on the `User↔target` thread. This is what lets
/// `ask_agent` resolve the **A↔B** conversation and feed the wait-for graph
/// (cadrage C3 §5.2).
requester: Option<AgentId>,
},
/// The emitting agent renders the **result** of the task it is currently
/// processing (Option 1 « Terminal + MCP », `idea_reply`).
///
/// Correlation is **implicit and positional**: `from` is the emitting agent's id
/// (carried by the MCP handshake, never a model-managed value), so the result
/// resolves the ticket at the head of *that agent's* mailbox queue — the
/// delegation it is working on. No ticket id is exposed to the model.
Reply {
/// The emitting agent (handshake identity), whose request the result resolves.
from: AgentId,
/// The ticket the reply correlates to (echoed by the agent from the
/// `[IdeA · … · ticket <id>]` prefix). `Some` ⇒ correlate **by ticket**
/// (deterministic, multi-thread); `None` ⇒ fall back to the head of `from`'s
/// queue (compat mono-thread agents) (cadrage C3 §3.3).
ticket: Option<TicketId>,
/// The result content the requester awaits.
result: String,
},
/// List the project's agents (discovery) — exactly the data the UI reads from
/// the manifest. Carries no argument: the dispatch resolves the agents against
@ -163,6 +217,47 @@ pub enum OrchestratorCommand {
/// Scope the skill is created in (defaults to [`SkillScope::Project`]).
scope: SkillScope,
},
/// Read an IdeA-owned context `.md` under the [`crate::fileguard::FileGuard`]
/// (cadrage C7). `target` absent = the **global project context**; otherwise the
/// named agent's context. Reading is shared (N readers).
ReadContext {
/// Target agent display name; `None` ⇒ the global project context.
target: Option<String>,
/// The party that issued the read (handshake identity), so the guard knows
/// who acquires the read lease.
requester: ConversationParty,
},
/// Propose new content for a context `.md` under the
/// [`crate::fileguard::FileGuard`] (cadrage C7). For an **agent** context this is
/// a direct write under a write-lease; for the **global** project context it is a
/// *proposal* (the guard forbids a non-orchestrator direct write — the change is
/// materialised as a proposal for validation).
ProposeContext {
/// Target agent display name; `None` ⇒ the global project context.
target: Option<String>,
/// The proposed Markdown body.
content: String,
/// The proposing party (handshake identity).
requester: ConversationParty,
},
/// Read a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
/// `slug` absent = the aggregated `MEMORY.md` index; otherwise one note.
ReadMemory {
/// Target note slug; `None` ⇒ the aggregated index.
slug: Option<String>,
/// The reading party (handshake identity).
requester: ConversationParty,
},
/// Write a memory note under the [`crate::fileguard::FileGuard`] (cadrage C7).
/// Memory is project-shared; written directly under a write-lease.
WriteMemory {
/// Target note slug (required).
slug: String,
/// The Markdown body to store.
content: String,
/// The writing party (handshake identity).
requester: ConversationParty,
},
}
/// Where IdeA should place a launched agent session.
@ -230,6 +325,30 @@ impl OrchestratorRequest {
"agent.message" => Ok(OrchestratorCommand::AskAgent {
target: self.require_target_agent(action)?,
task: self.require("task", action, self.task.as_deref())?,
requester: self.optional_requester(action)?,
}),
"agent.reply" => Ok(OrchestratorCommand::Reply {
from: self.require_from(action)?,
ticket: self.optional_ticket(action)?,
result: self.require("result", action, self.result.as_deref())?,
}),
"context.read" => Ok(OrchestratorCommand::ReadContext {
target: self.optional_target_agent(),
requester: self.requester_party(),
}),
"context.propose" => Ok(OrchestratorCommand::ProposeContext {
target: self.optional_target_agent(),
content: self.require("content", action, self.content.as_deref())?,
requester: self.requester_party(),
}),
"memory.read" => Ok(OrchestratorCommand::ReadMemory {
slug: self.optional_slug(),
requester: self.requester_party(),
}),
"memory.write" => Ok(OrchestratorCommand::WriteMemory {
slug: self.require("slug", action, self.slug.as_deref())?,
content: self.require("content", action, self.content.as_deref())?,
requester: self.requester_party(),
}),
"list_agents" | "agent.list" => Ok(OrchestratorCommand::ListAgents),
"create_skill" | "skill.create" => Ok(OrchestratorCommand::CreateSkill {
@ -284,6 +403,90 @@ impl OrchestratorRequest {
self.require("name", action, self.name.as_deref())
}
/// Parses the emitting agent id (`requestedBy`) for `agent.reply` into an
/// [`AgentId`]. The MCP server injects this from the loopback **handshake**
/// `requester` (never a model-managed value), so a well-formed reply always
/// carries it; a missing/blank one is a `MissingField`, a non-uuid one an
/// `InvalidAgentId`.
fn require_from(&self, action: &str) -> Result<AgentId, OrchestratorError> {
let raw = self.require("requestedBy", action, self.requested_by.as_deref())?;
uuid::Uuid::parse_str(&raw)
.map(AgentId::from_uuid)
.map_err(|_| OrchestratorError::InvalidAgentId {
action: action.to_owned(),
value: raw,
})
}
/// Parses the **optional** requester id (`requestedBy`) for `agent.message` into
/// an [`AgentId`]. The MCP handshake injects a real agent uuid here; the legacy
/// file protocol may carry a free-form **display name** instead. So this is
/// **lenient**: absent/blank/non-uuid ⇒ `None` (the ask carries no machine
/// requester and routes on the `User↔target` thread); a well-formed uuid ⇒
/// `Some(agent)`, enabling A↔B conversation routing + the wait-for guard.
#[allow(clippy::unnecessary_wraps, clippy::unused_self)]
fn optional_requester(&self, _action: &str) -> Result<Option<AgentId>, OrchestratorError> {
Ok(self
.requested_by
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map(AgentId::from_uuid))
}
/// Parses the **optional** echoed ticket id for `agent.reply` into a [`TicketId`].
/// Absent/blank ⇒ `None` (head-of-queue fallback); present-but-non-uuid ⇒
/// [`OrchestratorError::InvalidAgentId`] (reused as a generic invalid-id error).
fn optional_ticket(&self, action: &str) -> Result<Option<TicketId>, OrchestratorError> {
match self.ticket.as_deref().map(str::trim) {
None | Some("") => Ok(None),
Some(raw) => uuid::Uuid::parse_str(raw)
.map(|u| Some(TicketId::from_uuid(u)))
.map_err(|_| OrchestratorError::InvalidAgentId {
action: action.to_owned(),
value: raw.to_owned(),
}),
}
}
/// The optional target agent name for the FileGuard context tools: `targetAgent`
/// (or legacy `name`), trimmed; absent/blank ⇒ `None` (the global project context).
fn optional_target_agent(&self) -> Option<String> {
self.target_agent
.as_deref()
.or(self.name.as_deref())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
/// The optional memory slug for `memory.read`: trimmed; absent/blank ⇒ `None`
/// (the aggregated index).
fn optional_slug(&self) -> Option<String> {
self.slug
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
}
/// The party that issued a FileGuard-mediated request, derived from `requestedBy`.
/// A well-formed agent uuid (the MCP handshake path) ⇒ [`ConversationParty::Agent`];
/// anything else (absent/blank/non-uuid, e.g. the orchestrator's own file-protocol
/// path) ⇒ [`ConversationParty::User`] — IdeA's single orchestrator identity, the
/// only party allowed to write the global project context directly.
fn requester_party(&self) -> ConversationParty {
self.requested_by
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map_or(ConversationParty::User, |u| {
ConversationParty::agent(AgentId::from_uuid(u))
})
}
fn require_target_agent(&self, action: &str) -> Result<String, OrchestratorError> {
self.require(
"targetAgent",
@ -447,6 +650,56 @@ mod tests {
OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
// `requestedBy: "Main"` is a display name, not a uuid ⇒ lenient `None`.
requester: None,
}
);
}
/// A well-formed uuid `requestedBy` is parsed into `Some(agent)` (the MCP
/// handshake path), enabling A↔B conversation routing.
#[test]
fn agent_message_carries_uuid_requester() {
let uid = uuid::Uuid::from_u128(7);
let r = req(&format!(
r#"{{ "type":"agent.message", "requestedBy":"{uid}", "targetAgent":"B", "task":"go" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::AskAgent {
target: "B".to_owned(),
task: "go".to_owned(),
requester: Some(AgentId::from_uuid(uid)),
}
);
}
/// `agent.reply` correlates by ticket when the agent echoes it.
#[test]
fn agent_reply_carries_optional_ticket() {
let from = uuid::Uuid::from_u128(3);
let tkt = uuid::Uuid::from_u128(99);
let r = req(&format!(
r#"{{ "type":"agent.reply", "requestedBy":"{from}", "ticket":"{tkt}", "result":"done" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::Reply {
from: AgentId::from_uuid(from),
ticket: Some(TicketId::from_uuid(tkt)),
result: "done".to_owned(),
}
);
// Without a ticket ⇒ None (head-of-queue fallback).
let r2 = req(&format!(
r#"{{ "type":"agent.reply", "requestedBy":"{from}", "result":"done" }}"#
));
assert_eq!(
r2.validate().unwrap(),
OrchestratorCommand::Reply {
from: AgentId::from_uuid(from),
ticket: None,
result: "done".to_owned(),
}
);
}
@ -575,6 +828,102 @@ mod tests {
);
}
#[test]
fn context_read_global_without_target_is_user_party() {
let r = req(r#"{ "type": "context.read" }"#);
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::ReadContext {
target: None,
requester: ConversationParty::User,
}
);
}
#[test]
fn context_read_with_uuid_requester_is_agent_party() {
let uid = uuid::Uuid::from_u128(5);
let r = req(&format!(
r#"{{ "type":"context.read", "requestedBy":"{uid}", "targetAgent":"Dev" }}"#
));
assert_eq!(
r.validate().unwrap(),
OrchestratorCommand::ReadContext {
target: Some("Dev".to_owned()),
requester: ConversationParty::agent(AgentId::from_uuid(uid)),
}
);
}
#[test]
fn context_propose_requires_content() {
let ok = req(r##"{ "type":"context.propose", "targetAgent":"Dev", "content":"# body" }"##);
assert_eq!(
ok.validate().unwrap(),
OrchestratorCommand::ProposeContext {
target: Some("Dev".to_owned()),
content: "# body".to_owned(),
requester: ConversationParty::User,
}
);
let missing = req(r#"{ "type":"context.propose", "targetAgent":"Dev" }"#);
assert_eq!(
missing.validate(),
Err(OrchestratorError::MissingField {
action: "context.propose".to_owned(),
field: "content".to_owned(),
})
);
}
#[test]
fn memory_read_optional_slug() {
assert_eq!(
req(r#"{ "type":"memory.read" }"#).validate().unwrap(),
OrchestratorCommand::ReadMemory {
slug: None,
requester: ConversationParty::User,
}
);
assert_eq!(
req(r#"{ "type":"memory.read", "slug":"note-a" }"#)
.validate()
.unwrap(),
OrchestratorCommand::ReadMemory {
slug: Some("note-a".to_owned()),
requester: ConversationParty::User,
}
);
}
#[test]
fn memory_write_requires_slug_and_content() {
assert_eq!(
req(r#"{ "type":"memory.write", "slug":"note-a", "content":"hi" }"#)
.validate()
.unwrap(),
OrchestratorCommand::WriteMemory {
slug: "note-a".to_owned(),
content: "hi".to_owned(),
requester: ConversationParty::User,
}
);
assert_eq!(
req(r#"{ "type":"memory.write", "content":"hi" }"#).validate(),
Err(OrchestratorError::MissingField {
action: "memory.write".to_owned(),
field: "slug".to_owned(),
})
);
assert_eq!(
req(r#"{ "type":"memory.write", "slug":"note-a" }"#).validate(),
Err(OrchestratorError::MissingField {
action: "memory.write".to_owned(),
field: "content".to_owned(),
})
);
}
#[test]
fn unknown_action_is_rejected() {
let r = req(r#"{ "action": "delete_everything", "name": "a" }"#);

View File

@ -283,6 +283,30 @@ pub struct AgentProfile {
/// sérialisation : un profil sans MCP sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp: Option<McpCapability>,
/// Motif de **retour-de-prompt** (cadrage « conversation par paire » §6, lot C5).
///
/// Donnée **déclarative** (pas de code par CLI — Open/Closed) : un motif **littéral**
/// recherché par sous-chaîne dans le flux de sortie PTY du tour courant. Quand il
/// apparaît, IdeA considère l'agent **revenu au prompt** et le marque `Idle`
/// (`InputMediator::mark_idle`), ce qui fait avancer sa file. C'est l'un des deux
/// signaux du « double signal OR » (l'autre étant un `idea_reply` explicite) ; le
/// **premier** des deux qui arrive libère le tour.
///
/// **Choix tranché : littéral, pas regex.** Une sous-chaîne littérale est
/// déterministe, sans dépendance (`regex`), suffisante pour un sigil de prompt
/// stable (ex. `"\n> "`), et ne risque pas le faux-positif d'un motif regex mal
/// échappé présent dans la sortie. Un moteur regex pourra être ajouté plus tard
/// comme variante déclarative (Open/Closed) si le besoin se confirme.
///
/// `None` (défaut, et valeur des profils existants) ⇒ **aucune** détection par
/// motif : l'agent ne repasse `Idle` que sur signal explicite (`idea_reply`) ou via
/// le garde-fou du timeout par tour. Conforme au fallback « en cas de doute → reste
/// `Busy` mais la file continue d'accepter » (jamais de faux `Idle`).
///
/// `skip_serializing_if = Option::is_none` ⇒ **zéro régression** de sérialisation :
/// un profil sans motif sérialise exactement comme avant.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_ready_pattern: Option<String>,
}
/// Embedding strategy of an [`EmbedderProfile`] (LOT C, étage 2 vectoriel).
@ -418,6 +442,7 @@ impl AgentProfile {
session,
structured_adapter: None,
mcp: None,
prompt_ready_pattern: None,
})
}
@ -439,6 +464,15 @@ impl AgentProfile {
self
}
/// Builder : fixe le motif de **retour-de-prompt** (§6, lot C5) et renvoie le
/// profil. Laisse [`AgentProfile::new`] stable (zéro régression d'appel) : les
/// profils sans détection de prompt ne l'appellent simplement pas.
#[must_use]
pub fn with_prompt_ready_pattern(mut self, pattern: impl Into<String>) -> Self {
self.prompt_ready_pattern = Some(pattern.into());
self
}
/// Indique si ce profil peut être **proposé à la sélection/création** d'un
/// agent (§17.3, lot D7). Source **unique** de vérité : un profil n'est
/// sélectionnable que s'il porte un [`StructuredAdapter`], c'est-à-dire s'il
@ -625,4 +659,48 @@ mod mcp_tests {
let s = McpConfigStrategy::env("IDEA_MCP_SERVER").expect("valid");
assert_eq!(s, McpConfigStrategy::Env { var: "IDEA_MCP_SERVER".to_owned() });
}
// -- Lot C5 : prompt_ready_pattern (détection retour-de-prompt) --------------
#[test]
fn profile_default_has_no_prompt_ready_pattern() {
// Existing profiles (built via `new`) carry no pattern ⇒ no false Idle.
assert!(profile_without_mcp().prompt_ready_pattern.is_none());
}
#[test]
fn profile_without_prompt_pattern_omits_key_in_json() {
let json = serde_json::to_string(&profile_without_mcp()).expect("serialise");
assert!(
!json.contains("promptReadyPattern"),
"a profile without a prompt pattern must NOT serialise the key (zero regression); got: {json}"
);
}
#[test]
fn legacy_json_without_prompt_pattern_deserialises_to_none() {
let legacy = r#"{
"id": "00000000-0000-0000-0000-000000000000",
"name": "Dev",
"command": "claude",
"args": [],
"contextInjection": { "strategy": "conventionFile", "target": "CLAUDE.md" },
"detect": null,
"cwdTemplate": "{agentRunDir}"
}"#;
let profile: AgentProfile = serde_json::from_str(legacy).expect("legacy deserialise");
assert!(profile.prompt_ready_pattern.is_none());
}
#[test]
fn with_prompt_ready_pattern_sets_and_round_trips() {
let profile = profile_without_mcp().with_prompt_ready_pattern("\n> ");
assert_eq!(profile.prompt_ready_pattern.as_deref(), Some("\n> "));
let json = serde_json::to_string(&profile).expect("serialise");
assert!(json.contains("promptReadyPattern"), "key present: {json}");
let back: AgentProfile = serde_json::from_str(&json).expect("deserialise");
assert_eq!(profile, back);
assert_eq!(back.prompt_ready_pattern.as_deref(), Some("\n> "));
}
}

View File

@ -0,0 +1,194 @@
//! [`InMemoryConversationRegistry`] — the [`ConversationRegistry`] adapter (lot C2).
//!
//! The driven side of conversation-by-pair: a `HashMap<ConversationId, Conversation>`
//! plus a pair→id index, so `resolve` is **lazy get-or-create** and the same
//! unordered pair `{a, b}` always maps to the same [`ConversationId`].
//!
//! ## Concurrency
//!
//! A single **synchronous** [`Mutex`] guards both maps; it is held only for the O(1)
//! lookups/mutations and **never across an `.await`** (this registry is fully sync,
//! cf. the `ask_locks` discipline of `service.rs`).
use std::collections::HashMap;
use std::sync::Mutex;
use domain::conversation::{
Conversation, ConversationId, ConversationParty, ConversationRegistry, ConversationSession,
SessionRef,
};
/// Order-insensitive key for a conversation pair `{a, b}`.
///
/// Normalised so that `{a, b}` and `{b, a}` hash/compare equal — this is what makes
/// `resolve(a, b)` and `resolve(b, a)` resolve to the same conversation.
fn pair_key(a: ConversationParty, b: ConversationParty) -> (ConversationParty, ConversationParty) {
// Stable, total ordering over parties so the smaller end is always `left` in the
// key. `User` sorts before any agent; agents order by their UUID.
fn rank(p: ConversationParty) -> (u8, u128) {
match p {
ConversationParty::User => (0, 0),
ConversationParty::Agent { agent_id } => (1, agent_id.as_uuid().as_u128()),
}
}
if rank(a) <= rank(b) {
(a, b)
} else {
(b, a)
}
}
/// In-memory, pair-keyed conversation registry (the production [`ConversationRegistry`]).
#[derive(Default)]
pub struct InMemoryConversationRegistry {
inner: Mutex<Inner>,
}
#[derive(Default)]
struct Inner {
by_id: HashMap<ConversationId, Conversation>,
by_pair: HashMap<(ConversationParty, ConversationParty), ConversationId>,
}
impl InMemoryConversationRegistry {
/// Creates an empty registry.
#[must_use]
pub fn new() -> Self {
Self {
inner: Mutex::new(Inner::default()),
}
}
/// Number of conversations currently held (test/inspection helper).
#[must_use]
pub fn len(&self) -> usize {
self.lock().by_id.len()
}
/// Whether the registry is empty.
#[must_use]
pub fn is_empty(&self) -> bool {
self.lock().by_id.is_empty()
}
fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
impl ConversationRegistry for InMemoryConversationRegistry {
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation {
let key = pair_key(a, b);
let mut inner = self.lock();
if let Some(id) = inner.by_pair.get(&key).copied() {
// Existing thread for this pair — return its current snapshot.
return inner
.by_id
.get(&id)
.cloned()
.expect("by_pair id always present in by_id");
}
// Lazy create: mint a fresh Dormant conversation for the pair. The pair is
// valid by construction at call sites (resolve is only asked for real pairs);
// `try_new` still guards the invariants, and on the (impossible) error we fall
// back to a normalised pair to never panic in production.
let id = ConversationId::new_random();
let conv = Conversation::try_new(id, key.0, key.1)
.expect("pair_key yields a valid distinct/≤1-user pair");
inner.by_pair.insert(key, id);
inner.by_id.insert(id, conv.clone());
conv
}
fn bind_session(&self, id: ConversationId, session: SessionRef) {
let mut inner = self.lock();
if let Some(conv) = inner.by_id.get_mut(&id) {
conv.session = ConversationSession::Live {
handle_ref: session,
};
}
}
fn suspend(&self, id: ConversationId, resumable_id: Option<String>) {
let mut inner = self.lock();
if let Some(conv) = inner.by_id.get_mut(&id) {
conv.session = ConversationSession::Dormant;
conv.resumable_id = resumable_id;
}
}
fn get(&self, id: ConversationId) -> Option<Conversation> {
self.lock().by_id.get(&id).cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::{AgentId, SessionId};
fn agent(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
#[test]
fn resolve_is_lazy_get_or_create() {
let reg = InMemoryConversationRegistry::new();
assert!(reg.is_empty());
let c = reg.resolve(ConversationParty::User, agent(1));
assert_eq!(reg.len(), 1);
// Same pair ⇒ same id, no new conversation created.
let c2 = reg.resolve(ConversationParty::User, agent(1));
assert_eq!(c.id, c2.id);
assert_eq!(reg.len(), 1);
}
#[test]
fn same_pair_unordered_yields_same_id() {
let reg = InMemoryConversationRegistry::new();
let c1 = reg.resolve(agent(1), agent(2));
let c2 = reg.resolve(agent(2), agent(1)); // swapped order
assert_eq!(c1.id, c2.id, "unordered pair identity");
assert_eq!(reg.len(), 1);
}
#[test]
fn distinct_pairs_get_distinct_ids() {
let reg = InMemoryConversationRegistry::new();
let user_b = reg.resolve(ConversationParty::User, agent(2));
let a_b = reg.resolve(agent(1), agent(2));
assert_ne!(user_b.id, a_b.id, "User↔B and A↔B are different threads");
assert_eq!(reg.len(), 2);
}
#[test]
fn fresh_resolve_is_dormant() {
let reg = InMemoryConversationRegistry::new();
let c = reg.resolve(ConversationParty::User, agent(1));
assert_eq!(c.session, ConversationSession::Dormant);
}
#[test]
fn bind_session_makes_it_live_then_suspend_restores_dormant() {
let reg = InMemoryConversationRegistry::new();
let c = reg.resolve(ConversationParty::User, agent(1));
let sref = SessionRef::new(SessionId::from_uuid(uuid::Uuid::from_u128(99)));
reg.bind_session(c.id, sref);
let live = reg.get(c.id).unwrap();
assert!(live.session.is_live());
assert_eq!(live.session, ConversationSession::Live { handle_ref: sref });
reg.suspend(c.id, Some("sess-abc".to_owned()));
let dormant = reg.get(c.id).unwrap();
assert_eq!(dormant.session, ConversationSession::Dormant);
assert_eq!(dormant.resumable_id.as_deref(), Some("sess-abc"));
}
#[test]
fn get_unknown_is_none() {
let reg = InMemoryConversationRegistry::new();
assert!(reg.get(ConversationId::new_random()).is_none());
}
}

View File

@ -0,0 +1,230 @@
//! [`RwFileGuard`] — the [`FileGuard`] adapter (lot C6).
//!
//! A reader/writer lock **per [`GuardedResource`]**, backed by one
//! [`tokio::sync::RwLock`] per resource (created lazily and shared via `Arc`). N
//! concurrent readers **or** one exclusive writer per resource; different resources
//! are independent (their locks are distinct).
//!
//! The single-writer rule for [`GuardedResource::ProjectContext`] is enforced
//! **before** taking any lock: a `who` that is not the orchestrator
//! ([`domain::may_write_directly`] returning `false`) is rejected with
//! [`GuardError::Forbidden`] — it must *propose* instead.
//!
//! ## Cooperative scope (cadrage §9.5)
//!
//! This guard serialises access **inside the IdeA path** (the MCP tools / use cases).
//! It is **cooperative**: it does not sandbox an agent that keeps a raw shell — real
//! airtightness (revoking raw fs access to these paths) is an OS-sandbox concern
//! (Landlock) and is **out of scope** here.
//!
//! ## Concurrency
//!
//! The resource → lock registry lives behind a **synchronous** [`Mutex`], held only
//! for the O(1) get-or-insert of an `Arc<RwLock<()>>` and **never across an
//! `.await`**. The actual wait happens on the tokio `RwLock`'s `owned` guard, whose
//! lifetime is `'static` (it owns its `Arc`), so it can be boxed into the domain's
//! RAII [`ReadLease`]/[`WriteLease`].
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use tokio::sync::RwLock;
use domain::conversation::ConversationParty;
use domain::fileguard::{
may_write_directly, FileGuard, GuardError, GuardedResource, ReadLease, WriteLease,
};
/// The [`FileGuard`] adapter: one reader/writer lock per guarded resource.
///
/// Held at the composition root as `Arc<dyn FileGuard>`; cloneable bookkeeping is
/// interior (the registry is a shared `Mutex<HashMap<…>>`).
#[derive(Debug, Default)]
pub struct RwFileGuard {
/// Lazily-created per-resource locks. The unit `()` payload is irrelevant — the
/// lock's *mode* (read vs write) is the whole point; the lease only proves the
/// slot is held until drop.
locks: Mutex<HashMap<GuardedResource, Arc<RwLock<()>>>>,
}
impl RwFileGuard {
/// Builds an empty guard (no resource is contended until first acquired).
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Returns the shared lock for `res`, creating it on first use. The registry
/// mutex is held only for this O(1) get-or-insert, never across an `.await`.
fn lock_for(&self, res: &GuardedResource) -> Arc<RwLock<()>> {
let mut registry = self
.locks
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
registry
.entry(res.clone())
.or_insert_with(|| Arc::new(RwLock::new(())))
.clone()
}
}
#[async_trait]
impl FileGuard for RwFileGuard {
async fn acquire_read(
&self,
_who: ConversationParty,
res: GuardedResource,
) -> Result<ReadLease, GuardError> {
// Reading is never forbidden (cooperative contract): serialise behind any
// in-flight writer of the same resource, then hand back the RAII lease.
let lock = self.lock_for(&res);
let guard = lock.read_owned().await;
Ok(ReadLease::new(Box::new(guard)))
}
async fn acquire_write(
&self,
who: ConversationParty,
res: GuardedResource,
) -> Result<WriteLease, GuardError> {
// Single-writer rule for the global project context: refuse *before* taking
// any lock so a forbidden writer never blocks readers.
if !may_write_directly(who, &res) {
return Err(GuardError::Forbidden);
}
let lock = self.lock_for(&res);
let guard = lock.write_owned().await;
Ok(WriteLease::new(Box::new(guard)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::ids::AgentId;
use domain::memory::MemorySlug;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
fn agent_party(n: u128) -> ConversationParty {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
fn mem(slug: &str) -> GuardedResource {
GuardedResource::Memory(MemorySlug::new(slug).unwrap())
}
#[tokio::test]
async fn many_readers_share_one_resource_concurrently() {
let guard = RwFileGuard::new();
// Three read leases held *at once* on the same resource must all be granted.
let a = guard
.acquire_read(ConversationParty::User, mem("note"))
.await
.unwrap();
let b = guard
.acquire_read(agent_party(1), mem("note"))
.await
.unwrap();
let c = guard
.acquire_read(agent_party(2), mem("note"))
.await
.unwrap();
// If reads were exclusive this would have deadlocked above; reaching here is
// the proof. Keep the leases alive until now.
drop((a, b, c));
}
#[tokio::test]
async fn writer_excludes_other_writers_serialising_them() {
let guard = Arc::new(RwFileGuard::new());
let counter = Arc::new(AtomicUsize::new(0));
let observed_max = Arc::new(AtomicUsize::new(0));
let mut handles = Vec::new();
for _ in 0..8 {
let guard = Arc::clone(&guard);
let counter = Arc::clone(&counter);
let observed_max = Arc::clone(&observed_max);
handles.push(tokio::spawn(async move {
let _lease = guard
.acquire_write(ConversationParty::User, mem("shared"))
.await
.unwrap();
// While holding the write lease, at most one task may be inside.
let inside = counter.fetch_add(1, Ordering::SeqCst) + 1;
observed_max.fetch_max(inside, Ordering::SeqCst);
tokio::time::sleep(Duration::from_millis(5)).await;
counter.fetch_sub(1, Ordering::SeqCst);
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(
observed_max.load(Ordering::SeqCst),
1,
"a write lease must be exclusive — never two writers inside at once"
);
}
#[tokio::test]
async fn agent_writing_project_context_is_forbidden() {
let guard = RwFileGuard::new();
let err = guard
.acquire_write(agent_party(1), GuardedResource::ProjectContext)
.await
.unwrap_err();
assert_eq!(err, GuardError::Forbidden);
}
#[tokio::test]
async fn orchestrator_may_write_project_context() {
let guard = RwFileGuard::new();
let lease = guard
.acquire_write(ConversationParty::User, GuardedResource::ProjectContext)
.await;
assert!(lease.is_ok());
}
#[tokio::test]
async fn write_lease_releases_on_drop_letting_the_next_writer_in() {
let guard = Arc::new(RwFileGuard::new());
{
let _lease = guard
.acquire_write(ConversationParty::User, mem("r"))
.await
.unwrap();
// Held here; a concurrent writer would block.
} // <- RAII release at end of scope.
// After the scope, a fresh writer acquires immediately (bounded wait).
let again = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(ConversationParty::User, mem("r")),
)
.await
.expect("lease must have released on drop")
.unwrap();
drop(again);
}
#[tokio::test]
async fn distinct_resources_do_not_block_each_other() {
let guard = RwFileGuard::new();
let _w1 = guard
.acquire_write(ConversationParty::User, mem("alpha"))
.await
.unwrap();
// A writer on a *different* resource is independent — must not block.
let w2 = tokio::time::timeout(
Duration::from_millis(200),
guard.acquire_write(ConversationParty::User, mem("beta")),
)
.await
.expect("independent resources must not contend")
.unwrap();
drop(w2);
}
}

View File

@ -0,0 +1,789 @@
//! [`MediatedInbox`] — the [`InputMediator`] adapter (lot C2).
//!
//! The single convergence point of an agent's input. It **composes** the existing
//! [`InMemoryMailbox`] (FIFO + one-shot reply — the correlation engine) and adds the
//! two things the mailbox alone does not express:
//!
//! - a **busy/turn** bookkeeping per agent ([`AgentBusyState`]), so the front can be
//! told when an agent is processing;
//! - a **preempt** signal distinct from `enqueue` (Interrompre ≠ Envoyer): it does
//! **not** queue anything and correlates no ticket.
//!
//! It does **not** spawn a second queue: the FIFO is the mailbox's. The first
//! enqueue while `Idle` starts a turn (agent → `Busy`); `mark_idle` ends it and lets
//! the next ticket start. In doubt we stay `Busy` but **keep accepting** enqueues
//! (forward, never reject — cf. cadrage §6 fallback).
//!
//! ## Concurrency
//!
//! Busy state lives behind a **synchronous** [`Mutex`], held only for O(1) reads and
//! mutations and **never across an `.await`** (the await is the caller's, on the
//! returned [`PendingReply`]). The mailbox owns its own locking.
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, Mutex};
use domain::events::DomainEvent;
use domain::ids::AgentId;
use domain::input::{AgentBusyState, InputMediator};
use domain::mailbox::{AgentMailbox, PendingReply, Ticket};
use domain::ports::{EventBus, PtyHandle, PtyPort};
use crate::mailbox::InMemoryMailbox;
/// Shared busy/idle bookkeeping for one set of agents.
///
/// Extracted so the **prompt-ready watcher** (a detached thread observing an agent's
/// PTY output, lot C5) can flip an agent back to `Idle` without holding the whole
/// [`MediatedInbox`]: it only needs the busy map + the event bus. This is the single
/// authority for the `Busy→Idle` transition and its `AgentBusyChanged` event, so
/// every path (explicit `mark_idle`, prompt-ready match) stays consistent.
struct BusyTracker {
busy: Mutex<HashMap<AgentId, AgentBusyState>>,
events: Option<Arc<dyn EventBus>>,
}
impl BusyTracker {
fn new(events: Option<Arc<dyn EventBus>>) -> Self {
Self {
busy: Mutex::new(HashMap::new()),
events,
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, AgentBusyState>> {
self.busy
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.lock()
.get(&agent)
.copied()
.unwrap_or(AgentBusyState::Idle)
}
/// Marks `agent` `Busy` if it was `Idle`, returning whether a turn actually
/// started (so the caller publishes `AgentBusyChanged{busy:true}` only once).
fn start_turn(&self, agent: AgentId, state: AgentBusyState) -> bool {
let mut busy = self.lock();
let entry = busy.entry(agent).or_insert(AgentBusyState::Idle);
if entry.is_busy() {
false
} else {
*entry = state;
true
}
}
/// Marks `agent` `Idle`, publishing `AgentBusyChanged{busy:false}` only on a real
/// `Busy→Idle` transition. Idempotent: a `mark_idle` on an already-idle agent is a
/// no-op and emits nothing.
fn mark_idle(&self, agent: AgentId) {
let was_busy = {
let mut busy = self.lock();
busy.insert(agent, AgentBusyState::Idle)
.is_some_and(|s| s.is_busy())
};
if was_busy {
if let Some(events) = &self.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
busy: false,
});
}
}
}
}
/// Supplies the epoch-millis stamp used for `AgentBusyState::Busy { since_ms }`.
///
/// Injectable so tests are deterministic and the adapter stays decoupled from the
/// wall clock (the composition root wires the real clock).
pub trait MillisClock: Send + Sync {
/// Current time as milliseconds since the Unix epoch.
fn now_ms(&self) -> u64;
}
/// Wall-clock implementation of [`MillisClock`] (composition-root default).
#[derive(Debug, Clone, Copy, Default)]
pub struct SystemMillisClock;
impl MillisClock for SystemMillisClock {
fn now_ms(&self) -> u64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
.unwrap_or(0)
}
}
/// In-memory mediated inbox: one FIFO per agent (the mailbox) plus busy state.
///
/// When a [`PtyPort`] is wired (cadrage C3 §5.2), `enqueue` **delivers** the turn —
/// it writes the prefixed task line into the agent's bound [`PtyHandle`]. This is the
/// single, serialized write path that replaces the orchestrator's former ad-hoc PTY
/// write (no more `[IdeA · tâche …]` line emitted from `ask_agent`, no `\r` band-aid).
pub struct MediatedInbox {
mailbox: Arc<InMemoryMailbox>,
/// Shared busy/idle authority (also handed to prompt-ready watcher threads, C5).
tracker: Arc<BusyTracker>,
clock: Arc<dyn MillisClock>,
/// Optional PTY port: present ⇒ the inbox owns the turn-delivery write **and** can
/// observe an agent's output stream for prompt-ready detection (lot C5).
pty: Option<Arc<dyn PtyPort>>,
/// Per-agent live input handle (one stream per agent), fed by `bind_handle`.
handles: Mutex<HashMap<AgentId, PtyHandle>>,
/// Agents whose prompt-ready watcher thread is already armed, so re-binding the same
/// handle does not spawn a duplicate watcher (lot C5). Shared (`Arc`) because each
/// watcher thread un-arms its own entry on exit.
watched: Arc<Mutex<HashSet<AgentId>>>,
}
impl MediatedInbox {
/// Builds an inbox over a shared [`InMemoryMailbox`] with the given clock, without
/// turn delivery (the orchestrator writes the turn itself).
#[must_use]
pub fn new(mailbox: Arc<InMemoryMailbox>, clock: Arc<dyn MillisClock>) -> Self {
Self {
mailbox,
tracker: Arc::new(BusyTracker::new(None)),
clock,
pty: None,
handles: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Wires an [`EventBus`] so busy/idle transitions publish
/// [`DomainEvent::AgentBusyChanged`] at their source (cadrage C4 §4.2). Builder
/// additive: callers that do not wire a bus stay silent.
#[must_use]
pub fn with_events(mut self, events: Arc<dyn EventBus>) -> Self {
self.tracker = Arc::new(BusyTracker::new(Some(events)));
self
}
/// Builds an inbox that **delivers** the turn through `pty` to each agent's bound
/// handle (cadrage C3 §5.2). Use [`MediatedInbox::bind_handle`] to register the
/// agent's live handle before/at enqueue time.
#[must_use]
pub fn with_pty(
mailbox: Arc<InMemoryMailbox>,
clock: Arc<dyn MillisClock>,
pty: Arc<dyn PtyPort>,
) -> Self {
Self {
mailbox,
tracker: Arc::new(BusyTracker::new(None)),
clock,
pty: Some(pty),
handles: Mutex::new(HashMap::new()),
watched: Arc::new(Mutex::new(HashSet::new())),
}
}
/// Convenience constructor over a fresh mailbox and the wall clock.
#[must_use]
pub fn in_memory() -> Self {
Self::new(Arc::new(InMemoryMailbox::new()), Arc::new(SystemMillisClock))
}
fn handles(&self) -> std::sync::MutexGuard<'_, HashMap<AgentId, PtyHandle>> {
self.handles
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The underlying mailbox (e.g. for `cancel_head` / `resolve` from the orchestrator).
#[must_use]
pub fn mailbox(&self) -> Arc<InMemoryMailbox> {
Arc::clone(&self.mailbox)
}
fn watched(&self) -> std::sync::MutexGuard<'_, HashSet<AgentId>> {
self.watched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Arms the **prompt-ready watcher** for `agent` (lot C5).
///
/// Requires a wired [`PtyPort`] **and** a non-empty literal `pattern`. Spawns a
/// detached thread that consumes the handle's output stream and calls
/// [`BusyTracker::mark_idle`] the **first** time `pattern` appears as a substring of
/// the cumulative output, then exits (one-shot per arming). A sliding buffer keeps
/// the last `pattern.len()-1` bytes so a marker split across two chunks still
/// matches. No watcher is armed when the port is absent or the pattern is empty
/// (no detection ⇒ Idle only via explicit signal/timeout — the safe fallback).
///
/// Re-arming the same agent is a no-op while a watcher is already live (tracked in
/// `watched`), so re-binding a handle never spawns duplicate watchers.
fn arm_prompt_watcher(&self, agent: AgentId, handle: &PtyHandle, pattern: String) {
if pattern.is_empty() {
return;
}
let Some(pty) = self.pty.clone() else {
return;
};
// Already watching this agent ⇒ keep the live watcher (avoid duplicates).
if !self.watched().insert(agent) {
return;
}
let stream = match pty.subscribe_output(handle) {
Ok(s) => s,
Err(_) => {
// Could not subscribe (unknown handle): un-arm so a later bind retries.
self.watched().remove(&agent);
return;
}
};
let tracker = Arc::clone(&self.tracker);
let watched = Arc::clone(&self.watched);
let needle = pattern.into_bytes();
std::thread::spawn(move || {
// Cumulative tail kept small: just enough to catch a marker split across two
// chunks (keep the last needle.len()-1 bytes between reads).
let keep = needle.len().saturating_sub(1);
let mut window: Vec<u8> = Vec::with_capacity(keep + 1);
for chunk in stream {
window.extend_from_slice(&chunk);
if window
.windows(needle.len())
.any(|w| w == needle.as_slice())
{
// Prompt-ready: first OR signal wins ⇒ Idle (advances the FIFO).
tracker.mark_idle(agent);
break;
}
if window.len() > keep {
let drop_to = window.len() - keep;
window.drain(..drop_to);
}
}
// Watcher done (matched or stream closed at EOF): un-arm so a future bind
// (e.g. after a relaunch) can re-arm a fresh watcher.
watched
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&agent);
});
}
}
impl InputMediator for MediatedInbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let ticket_id = ticket.id;
// If the agent is Idle, this enqueue starts its turn ⇒ go Busy. If already
// Busy, we still accept (queue grows; the turn advances on mark_idle) — never
// reject the sender (forward fallback).
let started_turn = self.tracker.start_turn(
agent,
AgentBusyState::Busy {
ticket: ticket_id,
since_ms: self.clock.now_ms(),
},
);
// Publish Busy only on the enqueue that **starts** a turn (Idle→Busy); a
// second enqueue while Busy queues behind without re-announcing (cadrage
// C4 §4.2). Published outside the busy mutex (the tracker released it above).
if started_turn {
if let Some(events) = &self.tracker.events {
events.publish(DomainEvent::AgentBusyChanged {
agent_id: agent,
busy: true,
});
}
}
// Delivery: write the prefixed task line into the agent's bound handle. The
// prefix carries the requester + ticket id so the target replies via
// `idea_reply(result, ticket)`. A best-effort write: a missing handle/port or
// a write error never drops the ticket (the orchestrator's await + timeout
// remain the safety net) — the reply slot is registered regardless.
if let Some(pty) = &self.pty {
if let Some(handle) = self.handles().get(&agent).cloned() {
let line = format!(
"[IdeA · tâche de {} · ticket {}] {}\n",
ticket.requester, ticket_id, ticket.task
);
let _ = pty.write(&handle, line.as_bytes());
}
}
self.mailbox.enqueue(agent, ticket)
}
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
self.handles().insert(agent, handle);
}
fn bind_handle_with_prompt(
&self,
agent: AgentId,
handle: PtyHandle,
prompt_ready_pattern: Option<String>,
) {
// Register the input handle (delivery path) exactly like `bind_handle`, then arm
// the prompt-ready watcher when the profile declares a literal marker (C5). A
// `None`/empty pattern arms nothing: Idle then comes only from the explicit
// signal or the per-turn timeout (safe fallback, never a false Idle).
self.handles().insert(agent, handle.clone());
if let Some(pattern) = prompt_ready_pattern {
self.arm_prompt_watcher(agent, &handle, pattern);
}
}
fn delivers_turn(&self, agent: AgentId) -> bool {
self.pty.is_some() && self.handles().get(&agent).is_some()
}
fn preempt(&self, agent: AgentId) {
// Interrompre: signals the running turn to stop. It is NOT an enqueue and
// correlates **no** ticket (we never pop/resolve a pending caller — preempt
// must never silently answer one). The only effect is a best-effort interrupt
// byte written into the agent's bound PTY handle: ESC (`\x1b`), the stop key
// CLI agents honour. A missing handle/port is a no-op (the agent simply has no
// live stream to interrupt). The busy state is left untouched: it returns to
// Idle through `mark_idle` (prompt-ready / explicit signal), not here.
if let Some(pty) = &self.pty {
if let Some(handle) = self.handles().get(&agent).cloned() {
let _ = pty.write(&handle, b"\x1b");
}
}
}
fn mark_idle(&self, agent: AgentId) {
// Single authority (also used by the prompt-ready watcher): real Busy→Idle only,
// publishing AgentBusyChanged{busy:false} once.
self.tracker.mark_idle(agent);
}
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
self.tracker.busy_state(agent)
}
}
#[cfg(test)]
mod tests {
use super::*;
use domain::conversation::ConversationId;
use domain::mailbox::TicketId;
/// Deterministic clock for assertions on `since_ms`.
struct FixedClock(u64);
impl MillisClock for FixedClock {
fn now_ms(&self) -> u64 {
self.0
}
}
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn ticket(n: u128, task: &str) -> Ticket {
Ticket::from_human(
TicketId::from_uuid(uuid::Uuid::from_u128(n)),
ConversationId::from_uuid(uuid::Uuid::from_u128(1000 + n)),
"User",
task,
)
}
fn inbox_at(now_ms: u64) -> MediatedInbox {
MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(now_ms)))
}
/// Records every [`DomainEvent`] published, for busy/idle assertions.
#[derive(Default)]
struct RecordingBus(Mutex<Vec<DomainEvent>>);
impl EventBus for RecordingBus {
fn publish(&self, event: DomainEvent) {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
}
fn subscribe(&self) -> domain::ports::EventStream {
unreachable!("RecordingBus is publish-only for these tests")
}
}
impl RecordingBus {
fn busy_events(&self) -> Vec<(AgentId, bool)> {
self.0
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.filter_map(|e| match e {
DomainEvent::AgentBusyChanged { agent_id, busy } => Some((*agent_id, *busy)),
_ => None,
})
.collect()
}
}
#[test]
fn busy_event_fires_on_turn_start_and_idle_on_mark_idle() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
// First enqueue starts a turn ⇒ exactly one Busy(true) event.
inbox.enqueue(a, ticket(10, "first"));
assert_eq!(bus.busy_events(), vec![(a, true)]);
// Second enqueue while Busy queues behind ⇒ NO new busy event.
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(bus.busy_events(), vec![(a, true)], "no re-announce while busy");
// mark_idle on a busy agent ⇒ exactly one Idle(false) event.
inbox.mark_idle(a);
assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]);
// mark_idle on an already-idle agent ⇒ no spurious event.
inbox.mark_idle(a);
assert_eq!(bus.busy_events(), vec![(a, true), (a, false)]);
}
#[test]
fn preempt_emits_no_busy_event() {
let bus = Arc::new(RecordingBus::default());
let inbox = MediatedInbox::new(Arc::new(InMemoryMailbox::new()), Arc::new(FixedClock(1)))
.with_events(Arc::clone(&bus) as Arc<dyn EventBus>);
let a = agent(1);
inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a);
// Only the enqueue's Busy(true); preempt does not toggle busy state.
assert_eq!(bus.busy_events(), vec![(a, true)]);
}
#[tokio::test]
async fn enqueue_returns_pending_reply_resolved_via_mailbox() {
let inbox = inbox_at(5);
let a = agent(1);
let pending = inbox.enqueue(a, ticket(10, "do X"));
// Resolve through the shared mailbox (the orchestrator's path).
inbox.mailbox().resolve(a, "done".to_owned()).unwrap();
assert_eq!(pending.await.unwrap(), "done");
}
#[test]
fn first_enqueue_marks_busy_with_ticket_and_stamp() {
let inbox = inbox_at(1234);
let a = agent(1);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
inbox.enqueue(a, ticket(10, "t"));
assert_eq!(
inbox.busy_state(a),
AgentBusyState::Busy {
ticket: TicketId::from_uuid(uuid::Uuid::from_u128(10)),
since_ms: 1234,
}
);
}
#[test]
fn second_enqueue_while_busy_keeps_first_ticket_and_does_not_reject() {
let inbox = inbox_at(1);
let a = agent(1);
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second")); // accepted, queues behind
// Still busy on the FIRST ticket (turn unchanged), both queued in the mailbox.
assert_eq!(
inbox.busy_state(a).ticket(),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10)))
);
assert_eq!(inbox.mailbox().pending(&a), 2, "forward, never reject");
}
#[test]
fn mark_idle_returns_to_idle_so_next_turn_can_start() {
let inbox = inbox_at(1);
let a = agent(1);
inbox.enqueue(a, ticket(10, "t"));
assert!(inbox.busy_state(a).is_busy());
inbox.mark_idle(a);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
// A subsequent enqueue starts a fresh turn.
inbox.enqueue(a, ticket(11, "t2"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(11)))
);
}
#[tokio::test]
async fn preempt_is_distinct_from_enqueue_and_resolves_no_ticket() {
let inbox = inbox_at(1);
let a = agent(1);
let pending = inbox.enqueue(a, ticket(10, "t"));
inbox.preempt(a);
// preempt did not pop/resolve the ticket: still pending in the mailbox.
assert_eq!(inbox.mailbox().pending(&a), 1);
// Nothing answered the caller via preempt.
inbox.mailbox().resolve(a, "real".to_owned()).unwrap();
assert_eq!(pending.await.unwrap(), "real");
}
#[test]
fn two_enqueues_same_agent_serialise_in_one_fifo() {
let inbox = inbox_at(1);
let a = agent(1);
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(inbox.mailbox().pending(&a), 2);
assert_eq!(
inbox.mailbox().head_ticket(&a),
Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))),
"FIFO order preserved"
);
}
#[test]
fn different_agents_are_independent_not_blocking() {
let inbox = inbox_at(1);
let a = agent(1);
let b = agent(2);
inbox.enqueue(a, ticket(10, "a"));
inbox.enqueue(b, ticket(20, "b"));
assert!(inbox.busy_state(a).is_busy());
assert!(inbox.busy_state(b).is_busy());
// Marking A idle leaves B untouched.
inbox.mark_idle(a);
assert_eq!(inbox.busy_state(a), AgentBusyState::Idle);
assert!(inbox.busy_state(b).is_busy());
assert_eq!(inbox.mailbox().pending(&a), 1);
assert_eq!(inbox.mailbox().pending(&b), 1);
}
// ====================================================================
// Lot C5 — prompt-ready detection on the PTY output stream
// ====================================================================
use domain::ports::{ExitStatus, OutputStream, PtyError, PtyHandle as Handle, SpawnSpec};
use domain::terminal::PtySize;
use domain::ids::SessionId;
/// A fake [`PtyPort`] whose `subscribe_output` replays a fixed list of chunks then
/// ends (EOF), so the watcher thread sees a deterministic, finite stream. `write` is
/// recorded; `spawn`/`resize`/`kill`/`scrollback` are unused stubs for these tests.
struct FakePty {
chunks: Mutex<HashMap<SessionId, Vec<Vec<u8>>>>,
writes: Mutex<Vec<Vec<u8>>>,
}
impl FakePty {
fn new() -> Self {
Self {
chunks: Mutex::new(HashMap::new()),
writes: Mutex::new(Vec::new()),
}
}
/// Seeds the chunks a later `subscribe_output(handle)` will replay.
fn seed(&self, handle: &Handle, chunks: Vec<Vec<u8>>) {
self.chunks
.lock()
.unwrap()
.insert(handle.session_id.clone(), chunks);
}
}
#[async_trait::async_trait]
impl PtyPort for FakePty {
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<Handle, PtyError> {
Ok(Handle {
session_id: SessionId::new_random(),
})
}
fn write(&self, _handle: &Handle, data: &[u8]) -> Result<(), PtyError> {
self.writes.lock().unwrap().push(data.to_vec());
Ok(())
}
fn resize(&self, _handle: &Handle, _size: PtySize) -> Result<(), PtyError> {
Ok(())
}
fn subscribe_output(&self, handle: &Handle) -> Result<OutputStream, PtyError> {
let chunks = self
.chunks
.lock()
.unwrap()
.get(&handle.session_id)
.cloned()
.unwrap_or_default();
Ok(Box::new(chunks.into_iter()))
}
fn scrollback(&self, _handle: &Handle) -> Result<Vec<u8>, PtyError> {
Ok(Vec::new())
}
async fn kill(&self, _handle: &Handle) -> Result<ExitStatus, PtyError> {
Ok(ExitStatus { code: Some(0) })
}
}
fn handle(n: u128) -> Handle {
Handle {
session_id: SessionId::from_uuid(uuid::Uuid::from_u128(n)),
}
}
/// Spins until `cond` holds or the deadline passes (the watcher runs on its own
/// thread, so the transition is observed asynchronously).
fn wait_until(mut cond: impl FnMut() -> bool) -> bool {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
while std::time::Instant::now() < deadline {
if cond() {
return true;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
cond()
}
fn inbox_with(pty: Arc<FakePty>) -> MediatedInbox {
MediatedInbox::with_pty(
Arc::new(InMemoryMailbox::new()),
Arc::new(FixedClock(1)),
pty as Arc<dyn PtyPort>,
)
}
#[test]
fn prompt_pattern_present_and_output_contains_it_flips_busy_to_idle() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(1);
pty.seed(&h, vec![b"working...\n".to_vec(), b"done\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "task"));
assert!(inbox.busy_state(a).is_busy(), "enqueue starts a turn");
// Arm prompt detection with the literal marker "\n> " (a stable prompt sigil).
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
"prompt marker in output ⇒ Busy→Idle"
);
}
#[test]
fn prompt_marker_split_across_two_chunks_still_matches() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(2);
// The marker "READY" straddles two chunks: "REA" | "DY done".
pty.seed(&h, vec![b"out REA".to_vec(), b"DY done".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("READY".to_owned()));
assert!(
wait_until(|| !inbox.busy_state(a).is_busy()),
"a marker split across chunks must still flip to Idle"
);
}
#[test]
fn no_pattern_in_profile_never_marks_idle_even_with_output() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(3);
pty.seed(&h, vec![b"lots of output\n> $ done\n".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
// No pattern: bind without arming a watcher (the safe fallback).
inbox.bind_handle_with_prompt(a, h, None);
// Give any (erroneously spawned) watcher a chance to fire — it must not.
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(
inbox.busy_state(a).is_busy(),
"no pattern ⇒ never a false Idle; the agent stays Busy"
);
// The FIFO still accepts a second enqueue (forward, never reject).
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(inbox.mailbox().pending(&a), 2, "enqueue accepted while Busy");
}
#[test]
fn pattern_absent_from_output_keeps_agent_busy_but_queue_accepts() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(4);
// Output never contains the marker ⇒ watcher runs to EOF without matching.
pty.seed(&h, vec![b"still thinking, no prompt here\n".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
// Even after the stream ends, no match ⇒ stays Busy (timeout is the ultimate
// guard, exercised at the orchestrator layer).
std::thread::sleep(std::time::Duration::from_millis(80));
assert!(
inbox.busy_state(a).is_busy(),
"marker absent from output ⇒ stays Busy (no false Idle)"
);
inbox.enqueue(a, ticket(11, "queued"));
assert_eq!(
inbox.mailbox().pending(&a),
2,
"in doubt → keep accepting (forward, never reject)"
);
}
#[test]
fn empty_pattern_arms_nothing() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(5);
pty.seed(&h, vec![b"anything\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
inbox.bind_handle_with_prompt(a, h, Some(String::new()));
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(
inbox.busy_state(a).is_busy(),
"an empty pattern must arm no watcher (treated as no detection)"
);
}
#[test]
fn prompt_match_advances_fifo_to_next_ticket() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(6);
pty.seed(&h, vec![b"done\n> ".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
// Two tickets queued; the turn is on the first.
inbox.enqueue(a, ticket(10, "first"));
inbox.enqueue(a, ticket(11, "second"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(10)))
);
inbox.bind_handle_with_prompt(a, h, Some("\n> ".to_owned()));
// Prompt-ready ⇒ Idle ⇒ the next enqueue can start a fresh turn.
assert!(wait_until(|| !inbox.busy_state(a).is_busy()));
inbox.enqueue(a, ticket(12, "third"));
assert_eq!(
inbox.busy_state(a).ticket(),
Some(domain::mailbox::TicketId::from_uuid(uuid::Uuid::from_u128(12))),
"after prompt-ready Idle, a new enqueue starts the next turn"
);
}
#[test]
fn rebinding_same_agent_does_not_spawn_duplicate_watcher() {
let pty = Arc::new(FakePty::new());
let a = agent(1);
let h = handle(7);
// A stream that never matches and never ends quickly: empty ⇒ immediate EOF.
pty.seed(&h, vec![b"noise".to_vec()]);
let inbox = inbox_with(Arc::clone(&pty));
inbox.enqueue(a, ticket(10, "t"));
// Arm twice in a row; the second must be a no-op while the first is live (no
// panic, no double-subscribe). After EOF the agent is un-armed and stays Busy.
inbox.bind_handle_with_prompt(a, h.clone(), Some("ZZZ".to_owned()));
inbox.bind_handle_with_prompt(a, h, Some("ZZZ".to_owned()));
std::thread::sleep(std::time::Duration::from_millis(60));
assert!(inbox.busy_state(a).is_busy(), "no match ⇒ stays Busy");
}
}

View File

@ -13,11 +13,15 @@
#![warn(missing_docs)]
pub mod clock;
pub mod conversation;
pub mod eventbus;
pub mod fileguard;
pub mod fs;
pub mod git;
pub mod id;
pub mod input;
pub mod inspector;
pub mod mailbox;
pub mod orchestrator;
pub mod process;
pub mod pty;
@ -27,11 +31,15 @@ pub mod session;
pub mod store;
pub use clock::SystemClock;
pub use conversation::InMemoryConversationRegistry;
pub use eventbus::TokioBroadcastEventBus;
pub use fileguard::RwFileGuard;
pub use input::{MediatedInbox, MillisClock, SystemMillisClock};
pub use fs::LocalFileSystem;
pub use git::Git2Repository;
pub use id::UuidGenerator;
pub use inspector::ClaudeTranscriptInspector;
pub use mailbox::InMemoryMailbox;
pub use orchestrator::mcp::{McpServer, MemoryTransport, StdioTransport};
pub use orchestrator::{
process_request_file, FsOrchestratorWatcher, OrchestratorResponse, OrchestratorWatchHandle,

View File

@ -0,0 +1,261 @@
//! [`InMemoryMailbox`] — the [`AgentMailbox`] adapter (Option 1, lot B-1).
//!
//! The driven side of the inter-agent rendezvous: a per-agent FIFO of
//! [`Ticket`]s, each paired with a one-shot reply channel. `enqueue` appends a
//! ticket and hands the caller a [`PendingReply`] over the receiver; `resolve`
//! feeds the target's `idea_reply` result into the **head** ticket's sender.
//!
//! All the concrete machinery the domain refuses to name lives here: the
//! [`std::collections::VecDeque`] queue, its [`std::sync::Mutex`], and the
//! [`tokio::sync::oneshot`] channel that bridges the resolving call to the awaiting
//! one. The domain port ([`domain::mailbox`]) stays I/O-free.
//!
//! ## Concurrency
//!
//! The map is guarded by a **synchronous** [`Mutex`] held only for the O(1)
//! enqueue/resolve/cancel mutations — **never across an `.await`** (the await is the
//! caller's, on the returned [`PendingReply`], outside the lock). Two `ask`s for the
//! **same** target serialise positionally in that target's `VecDeque`; two `ask`s
//! for **different** targets touch different queues and never contend on the data,
//! only briefly on the map mutex.
use std::collections::{HashMap, VecDeque};
use std::sync::Mutex;
use tokio::sync::oneshot;
use domain::ids::AgentId;
use domain::mailbox::{AgentMailbox, MailboxError, PendingReply, Ticket, TicketId};
/// One queued request plus the sender that resolves its awaiting [`PendingReply`].
struct Slot {
ticket: Ticket,
reply: oneshot::Sender<String>,
}
/// In-memory, per-agent FIFO mailbox (the production [`AgentMailbox`]).
#[derive(Default)]
pub struct InMemoryMailbox {
queues: Mutex<HashMap<AgentId, VecDeque<Slot>>>,
}
impl InMemoryMailbox {
/// Creates an empty mailbox.
#[must_use]
pub fn new() -> Self {
Self {
queues: Mutex::new(HashMap::new()),
}
}
/// Number of tickets currently queued for `agent` (test/inspection helper).
#[must_use]
pub fn pending(&self, agent: &AgentId) -> usize {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(agent)
.map_or(0, VecDeque::len)
}
/// The id of the ticket currently at the head of `agent`'s queue, if any
/// (test/inspection helper).
#[must_use]
pub fn head_ticket(&self, agent: &AgentId) -> Option<TicketId> {
self.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(agent)
.and_then(|q| q.front())
.map(|slot| slot.ticket.id)
}
}
impl AgentMailbox for InMemoryMailbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let (tx, rx) = oneshot::channel::<String>();
{
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
queues
.entry(agent)
.or_default()
.push_back(Slot { ticket, reply: tx });
}
// The await happens in the application layer, outside the map lock. A closed
// channel (sender dropped without a value, e.g. the head was cancelled or the
// session ended) maps to a typed `Cancelled` rather than a raw recv error.
PendingReply::new(Box::pin(async move {
rx.await.map_err(|_| MailboxError::Cancelled)
}))
}
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> {
// Pop the head slot under the lock, then send **outside** any await (the send
// is non-blocking). If the receiver already went away (caller timed out), the
// ticket is still correctly retired from the head — the queue advances.
let slot = {
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let queue = queues
.get_mut(&agent)
.filter(|q| !q.is_empty())
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.pop_front().expect("non-empty queue has a head")
};
// A dropped receiver (the awaiting caller timed out and went away) is fine:
// the reply is simply discarded, the head ticket is already retired.
let _ = slot.reply.send(result);
Ok(())
}
fn resolve_ticket(
&self,
agent: AgentId,
ticket_id: TicketId,
result: String,
) -> Result<(), MailboxError> {
// Remove the slot whose ticket id matches, anywhere in the queue (multi-thread
// correlation), then send outside any await. A missing match is a typed
// NoPendingRequest — never a panic.
let slot = {
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let queue = queues
.get_mut(&agent)
.filter(|q| !q.is_empty())
.ok_or(MailboxError::NoPendingRequest(agent))?;
let pos = queue
.iter()
.position(|s| s.ticket.id == ticket_id)
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.remove(pos).expect("position just found is in range")
};
let _ = slot.reply.send(result);
Ok(())
}
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
let mut queues = self
.queues
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(queue) = queues.get_mut(&agent) {
// Only retire the head, and only if it is *this* ticket: a head that has
// since changed (already resolved, or someone else's ticket now in front)
// must not be dropped. Idempotent and positional.
if queue.front().map(|s| s.ticket.id) == Some(ticket_id) {
queue.pop_front();
}
if queue.is_empty() {
queues.remove(&agent);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agent(n: u128) -> AgentId {
AgentId::from_uuid(uuid::Uuid::from_u128(n))
}
fn ticket(n: u128, task: &str) -> Ticket {
Ticket::new(TicketId::from_uuid(uuid::Uuid::from_u128(n)), "Main", task)
}
#[tokio::test]
async fn enqueue_then_resolve_wakes_the_pending_reply() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let pending = mb.enqueue(a, ticket(10, "do X"));
mb.resolve(a, "done X".to_owned()).expect("resolve ok");
let reply = pending.await.expect("reply received");
assert_eq!(reply, "done X");
assert_eq!(mb.pending(&a), 0, "queue drained after resolve");
}
#[tokio::test]
async fn two_asks_same_target_resolve_fifo_head_first() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let p1 = mb.enqueue(a, ticket(10, "first"));
let p2 = mb.enqueue(a, ticket(11, "second"));
assert_eq!(mb.pending(&a), 2);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
// First resolve goes to the FIRST (head) ticket.
mb.resolve(a, "r1".to_owned()).unwrap();
assert_eq!(p1.await.unwrap(), "r1");
// Now the second is at the head.
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
mb.resolve(a, "r2".to_owned()).unwrap();
assert_eq!(p2.await.unwrap(), "r2");
}
#[tokio::test]
async fn different_targets_do_not_block_each_other() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let b = agent(2);
let pa = mb.enqueue(a, ticket(10, "task a"));
let _pb = mb.enqueue(b, ticket(20, "task b"));
// Resolving B leaves A untouched; resolving A then completes pa.
mb.resolve(b, "rb".to_owned()).unwrap();
assert_eq!(mb.pending(&a), 1, "A's queue is independent of B's");
mb.resolve(a, "ra".to_owned()).unwrap();
assert_eq!(pa.await.unwrap(), "ra");
}
#[test]
fn resolve_without_pending_is_a_typed_error() {
let mb = InMemoryMailbox::new();
let a = agent(1);
assert_eq!(
mb.resolve(a, "orphan".to_owned()),
Err(MailboxError::NoPendingRequest(a))
);
}
#[tokio::test]
async fn cancel_head_retires_the_head_and_advances_the_queue() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let p1 = mb.enqueue(a, ticket(10, "stuck"));
let p2 = mb.enqueue(a, ticket(11, "next"));
// Caller of ticket 10 timed out: retire exactly its head ticket.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(10)));
assert_eq!(mb.pending(&a), 1);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(11))));
// The cancelled pending resolves to Cancelled (its sender was dropped).
assert_eq!(p1.await, Err(MailboxError::Cancelled));
// The next ticket is now resolvable normally.
mb.resolve(a, "r2".to_owned()).unwrap();
assert_eq!(p2.await.unwrap(), "r2");
}
#[test]
fn cancel_head_is_a_noop_when_head_is_a_different_ticket() {
let mb = InMemoryMailbox::new();
let a = agent(1);
let _p1 = mb.enqueue(a, ticket(10, "head"));
// Try to cancel a ticket that is NOT the head ⇒ nothing retired.
mb.cancel_head(a, TicketId::from_uuid(uuid::Uuid::from_u128(99)));
assert_eq!(mb.pending(&a), 1);
assert_eq!(mb.head_ticket(&a), Some(TicketId::from_uuid(uuid::Uuid::from_u128(10))));
}
}

View File

@ -225,7 +225,10 @@ impl McpServer {
.to_owned();
let arguments = params.get("arguments").cloned().unwrap_or(json!({}));
let command = tools::map_tool_call(&name, &arguments).map_err(map_err_to_jsonrpc)?;
// `idea_reply` needs the connected peer's identity as `from`; every other tool
// ignores `requester`. The handshake-provided requester is the source of truth.
let command =
tools::map_tool_call(&name, &arguments, &self.requester).map_err(map_err_to_jsonrpc)?;
let result = self.service.dispatch(&self.project, command).await;
// Surface the processed delegation on the bus, tagged as the MCP door — the

View File

@ -44,11 +44,15 @@ pub enum ToolMapError {
}
/// Whether a successful call of `tool` carries an inline reply payload back to the
/// caller: `idea_ask_agent` (the target's `Final`) and `idea_list_agents` (the
/// agent list as a JSON array).
/// caller: `idea_ask_agent` (the target's reply) and `idea_list_agents` (the agent
/// list as a JSON array). `idea_reply` is an **ACK only** (the result is routed to
/// the awaiting requester, not echoed inline), so it is **not** in this set.
#[must_use]
pub fn tool_returns_reply(tool: &str) -> bool {
matches!(tool, "idea_ask_agent" | "idea_list_agents")
matches!(
tool,
"idea_ask_agent" | "idea_list_agents" | "idea_context_read" | "idea_memory_read"
)
}
/// The full catalogue advertised on `tools/list`.
@ -82,6 +86,23 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_reply",
description: "Render the result of the task you are currently processing (the one IdeA \
delegated to you as `[IdeA · tâche … · ticket <id>]`). Call this — never \
answer in plain text — so IdeA can hand your answer back to the agent that \
asked. **Echo the `ticket` id** shown in that prefix so IdeA correlates your \
reply exactly, even when you handle several requests.",
input_schema: json!({
"type": "object",
"properties": {
"result": { "type": "string", "description": "The result/answer to deliver to the requester." },
"ticket": { "type": "string", "description": "The ticket id from the `[IdeA · … · ticket <id>]` prefix you are answering. Optional, but strongly recommended when you handle more than one request." }
},
"required": ["result"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_launch_agent",
description: "Launch (or attach) an IdeA agent. Fire-and-forget: returns once IdeA \
@ -124,6 +145,62 @@ pub fn catalogue() -> Vec<ToolDef> {
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_read",
description: "Read an IdeA-owned context (under IdeA's reader/writer file guard). \
Omit `target` for the global project context, or pass an agent's display \
name for that agent's `.md`. Reading is shared (never blocks other readers).",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Agent display name. Omit for the global project context." }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_context_propose",
description: "Propose new Markdown content for an IdeA-owned context (under the file \
guard). For an agent's `.md` this writes directly; for the **global** \
project context it is only a *proposal* (the global context is \
single-writer — reserved to the orchestrator — so your change is recorded \
for validation, not applied).",
input_schema: json!({
"type": "object",
"properties": {
"target": { "type": "string", "description": "Agent display name. Omit to target the global project context (proposal only)." },
"content": { "type": "string", "description": "The proposed Markdown body." }
},
"required": ["content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_read",
description: "Read project memory (under the file guard). Omit `slug` for the aggregated \
index, or pass a note slug for one note.",
input_schema: json!({
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Memory note slug. Omit for the aggregated index." }
},
"additionalProperties": false
}),
},
ToolDef {
name: "idea_memory_write",
description: "Write (create or replace) a project memory note under the file guard. \
Memory is shared across the project's agents.",
input_schema: json!({
"type": "object",
"properties": {
"slug": { "type": "string", "description": "Memory note slug (kebab-case)." },
"content": { "type": "string", "description": "The Markdown body to store." }
},
"required": ["slug", "content"],
"additionalProperties": false
}),
},
ToolDef {
name: "idea_create_skill",
description: "Create a reusable IdeA skill (Markdown) in the project or global scope.",
@ -146,6 +223,10 @@ pub fn catalogue() -> Vec<ToolDef> {
/// validation authority.
///
/// `arguments` is the raw object an MCP client passes under `params.arguments`.
/// `requester` is the **connected peer's agent id** (from the loopback handshake,
/// cadrage v5 §1.4): it is injected as the `from` of an `idea_reply` so correlation
/// uses the handshake identity, never a model-managed value. Every other tool
/// ignores it.
///
/// # Errors
/// - [`ToolMapError::UnknownTool`] for a name outside [`catalogue`],
@ -154,6 +235,7 @@ pub fn catalogue() -> Vec<ToolDef> {
pub fn map_tool_call(
name: &str,
arguments: &Value,
requester: &str,
) -> Result<OrchestratorCommand, ToolMapError> {
let args = arguments
.as_object()
@ -173,10 +255,25 @@ pub fn map_tool_call(
},
"idea_ask_agent" => OrchestratorRequest {
request_type: Some("agent.message".to_owned()),
// The **requester** is the connected peer's handshake identity (never a
// tool argument), injected as `requestedBy` so `validate` can route the
// ask on the A↔B conversation and feed the wait-for guard (cadrage C3).
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
task: s("task"),
..base()
},
"idea_reply" => OrchestratorRequest {
request_type: Some("agent.reply".to_owned()),
// `from` is the handshake identity, not a tool argument: inject the peer's
// requester id as `requestedBy` so `validate` builds `Reply { from, .. }`.
requested_by: Some(requester.to_owned()),
// The agent echoes the `ticket` it received in the `[IdeA · … · ticket
// <id>]` prefix ⇒ correlate by ticket (multi-thread); optional (cadrage C3 §3.3).
ticket: s("ticket"),
result: s("result"),
..base()
},
"idea_launch_agent" => OrchestratorRequest {
request_type: Some("agent.run".to_owned()),
target_agent: s("target"),
@ -197,6 +294,33 @@ pub fn map_tool_call(
context: s("context"),
..base()
},
"idea_context_read" => OrchestratorRequest {
request_type: Some("context.read".to_owned()),
// The requester (handshake identity) drives the read lease's `who`.
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
..base()
},
"idea_context_propose" => OrchestratorRequest {
request_type: Some("context.propose".to_owned()),
requested_by: Some(requester.to_owned()),
target_agent: s("target"),
content: s("content"),
..base()
},
"idea_memory_read" => OrchestratorRequest {
request_type: Some("memory.read".to_owned()),
requested_by: Some(requester.to_owned()),
slug: s("slug"),
..base()
},
"idea_memory_write" => OrchestratorRequest {
request_type: Some("memory.write".to_owned()),
requested_by: Some(requester.to_owned()),
slug: s("slug"),
content: s("content"),
..base()
},
"idea_create_skill" => OrchestratorRequest {
request_type: Some("skill.create".to_owned()),
name: s("name"),
@ -225,6 +349,10 @@ fn base() -> OrchestratorRequest {
node_id: None,
attach_to_cell: None,
scope: None,
result: None,
ticket: None,
content: None,
slug: None,
}
}
@ -241,9 +369,17 @@ mod tests {
use super::*;
use domain::OrchestratorVisibility;
/// A well-formed handshake requester id for the tests (parsed as an `AgentId`).
const REQ: &str = "11111111-1111-1111-1111-111111111111";
/// Maps a tool call with an empty requester (the default for tools that ignore it).
fn map(name: &str, args: &Value) -> Result<OrchestratorCommand, ToolMapError> {
map_tool_call(name, args, "")
}
#[test]
fn ask_agent_maps_to_ask_command() {
let cmd = map_tool_call(
let cmd = map(
"idea_ask_agent",
&json!({ "target": "Architect", "task": "Analyse §17" }),
)
@ -253,13 +389,15 @@ mod tests {
OrchestratorCommand::AskAgent {
target: "Architect".to_owned(),
task: "Analyse §17".to_owned(),
// `map` passes an empty requester ⇒ no machine requester carried.
requester: None,
}
);
}
#[test]
fn launch_agent_maps_to_spawn_background_by_default() {
let cmd = map_tool_call("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
let cmd = map("idea_launch_agent", &json!({ "target": "Dev" })).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::SpawnAgent {
@ -274,11 +412,11 @@ mod tests {
#[test]
fn stop_update_and_skill_map_to_their_commands() {
assert_eq!(
map_tool_call("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
map("idea_stop_agent", &json!({ "target": "Dev" })).unwrap(),
OrchestratorCommand::StopAgent { name: "Dev".to_owned() }
);
assert_eq!(
map_tool_call(
map(
"idea_update_context",
&json!({ "target": "Dev", "context": "# body" })
)
@ -289,7 +427,7 @@ mod tests {
}
);
assert_eq!(
map_tool_call(
map(
"idea_create_skill",
&json!({ "name": "deploy", "context": "# steps" })
)
@ -304,19 +442,19 @@ mod tests {
#[test]
fn missing_required_field_surfaces_validation_error() {
let err = map_tool_call("idea_ask_agent", &json!({ "target": "Architect" }));
let err = map("idea_ask_agent", &json!({ "target": "Architect" }));
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn list_agents_maps_to_list_command() {
let cmd = map_tool_call("idea_list_agents", &json!({})).unwrap();
let cmd = map("idea_list_agents", &json!({})).unwrap();
assert_eq!(cmd, OrchestratorCommand::ListAgents);
}
#[test]
fn unknown_tool_is_rejected() {
let err = map_tool_call("idea_made_up_tool", &json!({}));
let err = map("idea_made_up_tool", &json!({}));
assert_eq!(
err,
Err(ToolMapError::UnknownTool("idea_made_up_tool".to_owned()))
@ -325,10 +463,155 @@ mod tests {
#[test]
fn non_object_arguments_are_rejected() {
let err = map_tool_call("idea_ask_agent", &json!("nope"));
let err = map("idea_ask_agent", &json!("nope"));
assert_eq!(
err,
Err(ToolMapError::BadArguments("idea_ask_agent".to_owned()))
);
}
#[test]
fn reply_maps_with_handshake_requester_as_from() {
// `from` comes from the handshake requester, NOT a tool argument.
let cmd = map_tool_call("idea_reply", &json!({ "result": "the answer" }), REQ).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::Reply {
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
ticket: None,
result: "the answer".to_owned(),
}
);
}
#[test]
fn reply_echoes_ticket_when_provided() {
let tkt = "22222222-2222-2222-2222-222222222222";
let cmd = map_tool_call(
"idea_reply",
&json!({ "result": "done", "ticket": tkt }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::Reply {
from: domain::AgentId::from_uuid(uuid::Uuid::parse_str(REQ).unwrap()),
ticket: Some(domain::mailbox::TicketId::from_uuid(
uuid::Uuid::parse_str(tkt).unwrap()
)),
result: "done".to_owned(),
}
);
}
#[test]
fn ask_agent_injects_handshake_requester() {
let cmd = map_tool_call(
"idea_ask_agent",
&json!({ "target": "B", "task": "go" }),
REQ,
)
.unwrap();
assert_eq!(
cmd,
OrchestratorCommand::AskAgent {
target: "B".to_owned(),
task: "go".to_owned(),
requester: Some(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn reply_without_result_is_a_validation_error() {
let err = map_tool_call("idea_reply", &json!({}), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn reply_with_blank_or_missing_requester_is_a_validation_error() {
// No handshake identity ⇒ no `from` ⇒ typed validation error (never a panic).
let err = map_tool_call("idea_reply", &json!({ "result": "x" }), "");
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
// A non-uuid requester is rejected too.
let err2 = map_tool_call("idea_reply", &json!({ "result": "x" }), "not-a-uuid");
assert!(matches!(err2, Err(ToolMapError::Invalid(_))));
}
#[test]
fn context_read_maps_with_requester_party() {
// Global (no target) with a uuid requester ⇒ Agent party.
let cmd = map_tool_call("idea_context_read", &json!({}), REQ).unwrap();
assert_eq!(
cmd,
OrchestratorCommand::ReadContext {
target: None,
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
}
#[test]
fn context_propose_requires_content() {
let ok = map_tool_call(
"idea_context_propose",
&json!({ "target": "Dev", "content": "# body" }),
REQ,
)
.unwrap();
assert_eq!(
ok,
OrchestratorCommand::ProposeContext {
target: Some("Dev".to_owned()),
content: "# body".to_owned(),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
let err = map_tool_call("idea_context_propose", &json!({ "target": "Dev" }), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn memory_read_and_write_map_to_their_commands() {
assert_eq!(
map_tool_call("idea_memory_read", &json!({ "slug": "note-a" }), REQ).unwrap(),
OrchestratorCommand::ReadMemory {
slug: Some("note-a".to_owned()),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
assert_eq!(
map_tool_call(
"idea_memory_write",
&json!({ "slug": "note-a", "content": "hi" }),
REQ
)
.unwrap(),
OrchestratorCommand::WriteMemory {
slug: "note-a".to_owned(),
content: "hi".to_owned(),
requester: domain::ConversationParty::agent(domain::AgentId::from_uuid(
uuid::Uuid::parse_str(REQ).unwrap()
)),
}
);
// memory.write without content ⇒ validation error.
let err = map_tool_call("idea_memory_write", &json!({ "slug": "note-a" }), REQ);
assert!(matches!(err, Err(ToolMapError::Invalid(_))));
}
#[test]
fn reply_is_not_an_inline_reply_tool() {
// ACK only: the result is routed to the awaiting requester, not echoed.
assert!(!tool_returns_reply("idea_reply"));
}
}

View File

@ -32,12 +32,15 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::ids::NodeId;
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};
@ -47,9 +50,12 @@ use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{
InMemoryConversationRegistry, InMemoryMailbox, McpServer, MediatedInbox, MemoryTransport,
SystemMillisClock,
};
use infrastructure::{McpServer, MemoryTransport};
use infrastructure::orchestrator::mcp::jsonrpc::error_codes;
use serde_json::{json, Value};
@ -290,35 +296,6 @@ impl PtyPort for FakePty {
}
}
/// 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;
@ -354,6 +331,9 @@ fn project() -> Project {
/// registry). Returns the shared `TerminalSessions` so a test can pre-bind a live
/// PTY session for an agent (needed to make `stop_agent` succeed).
fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<TerminalSessions>) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
// d'`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",
@ -364,7 +344,12 @@ fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<Termi
"{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(
@ -405,18 +390,48 @@ fn build_service(contexts: FakeContexts) -> (Arc<OrchestratorService>, Arc<Termi
(service, sessions)
}
/// Like [`build_service`] but with the **structured sessions** registry wired, so
/// `idea_ask_agent` can find a live structured target. Returns the service and the
/// shared registry so a test can pre-insert a replying session.
fn build_service_with_structured(
/// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// « Terminal + MCP », B-3/B-4), so `idea_ask_agent` blocks on the mailbox and
/// `idea_reply` resolves it. Returns the service, the shared mailbox (to observe
/// pending tickets) and the PTY session registry (to pre-seed a live target).
fn build_service_with_mailbox(
contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) {
let structured = Arc::new(StructuredSessions::new());
let (base, _sessions) = build_service(contexts);
) -> (
Arc<OrchestratorService>,
Arc<InMemoryMailbox>,
Arc<TerminalSessions>,
) {
let mailbox = Arc::new(InMemoryMailbox::new());
let (base, sessions) = build_service(contexts);
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 = Arc::try_unwrap(base)
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
.with_structured(Arc::clone(&structured));
(Arc::new(service), structured)
.with_input_mediator(
input,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.with_conversations(conversations);
(Arc::new(service), mailbox, sessions)
}
/// 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 },
),
);
}
fn server(service: Arc<OrchestratorService>) -> McpServer {
@ -461,7 +476,7 @@ fn result_text(result: &Value) -> &str {
// ---------------------------------------------------------------------------
#[tokio::test]
async fn tools_list_advertises_the_six_idea_tools_with_schemas() {
async fn tools_list_advertises_the_seven_idea_tools_with_schemas() {
let (service, _s) = build_service(FakeContexts::new());
let server = server(service);
@ -483,14 +498,24 @@ async fn tools_list_advertises_the_six_idea_tools_with_schemas() {
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:?}"
);
// Every tool advertises an object input schema.
for t in tools {
@ -598,28 +623,47 @@ async fn update_context_and_create_skill_calls_succeed() {
#[tokio::test]
async fn ask_agent_returns_target_reply_inline() {
// Option 1 (B-3/B-4): `idea_ask_agent` writes the task into the target's live
// terminal and blocks on the mailbox; the target's `idea_reply` (carrying its
// handshake identity as requester) resolves it, and the ask returns the result
// inline. We drive both over the MCP server, the ask on a spawned task.
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, structured) = build_service_with_structured(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 server = server(service);
let (service, mailbox, sessions) = build_service_with_mailbox(contexts);
seed_live_pty(&sessions, agent_id, SessionId::from_uuid(Uuid::from_u128(4242)));
let raw = tools_call(
7,
"idea_ask_agent",
json!({ "target": "architect", "task": "What is the answer?" }),
);
let response = server.handle_raw(&raw).await.expect("reply owed");
let ask_server = server(Arc::clone(&service));
let ask = tokio::spawn(async move {
let raw = tools_call(
7,
"idea_ask_agent",
json!({ "target": "architect", "task": "What is the answer?" }),
);
ask_server.handle_raw(&raw).await.expect("reply owed")
});
// Wait until the ask has enqueued its ticket (it is now blocked awaiting a reply).
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while mailbox.pending(&agent_id) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("ask must enqueue a ticket");
// The target replies via idea_reply — its identity comes from the handshake
// requester, which `for_requester` injects as the `from` of the Reply command.
let reply_server = server(Arc::clone(&service)).for_requester(agent_id.to_string());
let reply_raw = tools_call(8, "idea_reply", json!({ "result": "the answer is 42" }));
let reply_resp = reply_server.handle_raw(&reply_raw).await.expect("reply owed");
assert_eq!(reply_resp.result.expect("result")["isError"], json!(false));
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
.expect("ask completes after reply")
.expect("join ok");
assert_eq!(response.id, json!(7), "id must be echoed");
assert!(response.error.is_none(), "transport error: {:?}", response.error);
let result = response.result.expect("result");
assert_eq!(result["isError"], json!(false), "got {result}");
assert_eq!(
@ -629,6 +673,22 @@ async fn ask_agent_returns_target_reply_inline() {
);
}
/// `idea_reply` with no in-flight ask for the connected peer ⇒ the IdeA command
/// fails, surfaced as a tool execution error (`isError: true`), never a panic.
#[tokio::test]
async fn reply_without_pending_ask_is_a_tool_error() {
let contexts = FakeContexts::new();
let agent_id = contexts.seed_agent("architect");
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let reply_server = server(service).for_requester(agent_id.to_string());
let raw = tools_call(9, "idea_reply", json!({ "result": "orphan" }));
let resp = reply_server.handle_raw(&raw).await.expect("reply owed");
// Mapped + dispatched, but the command failed ⇒ tool error, transport intact.
assert!(resp.error.is_none(), "no transport error: {:?}", resp.error);
assert_eq!(resp.result.expect("result")["isError"], json!(true));
}
// ---------------------------------------------------------------------------
// 4. idea_list_agents returns the agent list inline (JSON array)
// ---------------------------------------------------------------------------
@ -785,11 +845,10 @@ async fn initialize_answers_minimal_handshake() {
async fn ask_agent_missing_task_is_rejected_by_shared_validation_no_dispatch() {
let contexts = FakeContexts::new();
contexts.seed_agent("architect");
// No structured session inserted: if validation wrongly let this through to
// dispatch, ask_agent would try to launch/contact the target and the error
// would differ. A validation rejection here is INVALID_PARAMS, before dispatch.
let (service, structured) = build_service_with_structured(contexts);
let _ = &structured; // intentionally empty
// If validation wrongly let this through to dispatch, ask_agent would try to
// launch/contact the target and the error would differ. A validation rejection
// here is INVALID_PARAMS, before dispatch.
let (service, _mailbox, _sessions) = build_service_with_mailbox(contexts);
let server = server(service);
let raw = tools_call(5, "idea_ask_agent", json!({ "target": "architect" }));

View File

@ -21,23 +21,29 @@ use domain::ids::{AgentId, ProfileId, ProjectId};
use domain::markdown::MarkdownDoc;
use domain::ids::NodeId;
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};
use domain::terminal::{SessionKind, TerminalSession};
use domain::{PtySize, SessionId};
use uuid::Uuid;
use application::{
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
OrchestratorService, TerminalSessions, UpdateAgentContext,
};
use infrastructure::{
process_request_file, FsOrchestratorWatcher, InMemoryMailbox, OrchestratorResponse,
};
use infrastructure::{process_request_file, FsOrchestratorWatcher, OrchestratorResponse};
// --- temp dir (mirror local_fs.rs) ---
struct TempDir(PathBuf);
@ -289,36 +295,6 @@ impl PtyPort for FakePty {
}
}
/// A structured [`AgentSession`] whose turn deterministically ends on a `Final`
/// carrying a fixed reply. Lets us exercise the `ask`/`agent.message` path
/// (`OrchestratorOutcome.reply = Some(..)`) 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;
@ -351,6 +327,9 @@ fn project() -> Project {
}
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`) accepte comme cible
// d'`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",
@ -361,7 +340,12 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
"{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(
@ -401,24 +385,107 @@ fn build_service(contexts: FakeContexts) -> Arc<OrchestratorService> {
))
}
/// Like [`build_service`] but with the **structured sessions** registry wired
/// (`with_structured`), so `agent.message`/`ask` can find a live structured target.
/// Returns the service and the shared registry so a test can pre-insert a session.
fn build_service_with_structured(
/// Like [`build_service`] but with the **inter-agent mailbox** + PTY wired (Option 1
/// « Terminal + MCP », B-3/B-4), so an `agent.message` blocks on the mailbox and an
/// `agent.reply` resolves it. Returns the service, the shared mailbox (to observe
/// pending tickets) and the PTY registry (to pre-seed a live target).
fn build_service_with_mailbox(
contexts: FakeContexts,
) -> (Arc<OrchestratorService>, Arc<StructuredSessions>) {
let structured = Arc::new(StructuredSessions::new());
// `build_service` already assembles every use case over the same fakes; we only
// need to fold the structured registry onto the resulting service.
let base = build_service(contexts);
// `OrchestratorService` is not `Clone`; rebuild via the builder on a fresh one is
// overkill, so we reconstruct minimally is not possible — instead, the service
// exposes `with_structured` as a consuming builder. We rely on `Arc::try_unwrap`
// since `build_service` returns a freshly-created `Arc` with refcount 1.
let service = Arc::try_unwrap(base)
.unwrap_or_else(|_| panic!("freshly built service must be uniquely owned"))
.with_structured(Arc::clone(&structured));
(Arc::new(service), structured)
) -> (
Arc<OrchestratorService>,
Arc<InMemoryMailbox>,
Arc<TerminalSessions>,
) {
// Profil Claude **complet** (adaptateur structuré + capacité MCP `.mcp.json`) :
// seul profil que la garde F2 (`guard_mcp_bridge_supported`) accepte comme cible
// d'`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",
"claude",
Vec::new(),
ContextInjection::stdin(),
None,
"{agentRunDir}",
None,
)
.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 mailbox = Arc::new(InMemoryMailbox::new());
let bus = Arc::new(NoopBus);
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds(Mutex::new(1))),
bus.clone(),
));
let launch = Arc::new(LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(FakeFs),
Arc::new(FakePty),
Arc::new(FakeSkills),
Arc::clone(&sessions),
bus.clone(),
Arc::new(SeqIds(Mutex::new(1))),
Arc::new(FakeRecall),
None,
));
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(Arc::new(FakePty), Arc::clone(&sessions)));
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts)));
let create_skill = Arc::new(CreateSkill::new(
Arc::new(FakeSkills) as Arc<dyn SkillStore>,
Arc::new(SeqIds(Mutex::new(1))),
));
let service = Arc::new(
OrchestratorService::new(
create,
launch,
list,
close,
update,
create_skill,
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::clone(&sessions),
)
.with_input_mediator(
Arc::new(infrastructure::MediatedInbox::with_pty(
Arc::clone(&mailbox),
Arc::new(infrastructure::SystemMillisClock),
Arc::new(FakePty) as Arc<dyn PtyPort>,
)) as Arc<dyn domain::input::InputMediator>,
Arc::clone(&mailbox) as Arc<dyn domain::mailbox::AgentMailbox>,
)
.with_conversations(
Arc::new(infrastructure::InMemoryConversationRegistry::new())
as Arc<dyn domain::conversation::ConversationRegistry>,
),
);
(service, mailbox, sessions)
}
/// 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 },
),
);
}
fn read_response(request_path: &std::path::Path) -> OrchestratorResponse {
@ -573,19 +640,17 @@ async fn unknown_action_yields_error_response() {
/// alongside `ok: true`.
#[tokio::test]
async fn ask_request_surfaces_reply_alongside_detail() {
// Option 1 (B-3/B-4): an `agent.message` request blocks awaiting the target's
// `idea_reply`. Over the file protocol we drive the ask request on a task, wait
// for the mailbox to hold a ticket, then process a separate `agent.reply` request
// (carrying the target's id as `requestedBy`, the handshake identity) which
// resolves it. The ask's `*.response.json` then carries reply + detail.
let tmp = TempDir::new();
let contexts = FakeContexts::new();
// Seed an existing target agent and a live structured session that replies.
let agent_id = contexts.seed_agent("architect");
let (service, structured) = build_service_with_structured(contexts.clone());
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, mailbox, sessions) = build_service_with_mailbox(contexts.clone());
// 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 req = tmp.0.join("ask-req.json");
std::fs::write(
@ -594,11 +659,42 @@ async fn ask_request_surfaces_reply_alongside_detail() {
)
.unwrap();
let response = process_request_file(&req, &project(), &service).await;
let svc = Arc::clone(&service);
let proj = project();
let ask_req = req.clone();
let ask = tokio::spawn(async move { process_request_file(&ask_req, &proj, &svc).await });
// Wait until the ask has enqueued its ticket (blocked awaiting the reply).
tokio::time::timeout(std::time::Duration::from_secs(10), async {
while mailbox.pending(&agent_id) == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("ask must enqueue a ticket");
// The target renders its result via an `agent.reply` request whose `requestedBy`
// is its own id (the handshake identity the MCP server would inject as `from`).
let reply_req = tmp.0.join("reply-req.json");
std::fs::write(
&reply_req,
format!(
r#"{{ "type": "agent.reply", "requestedBy": "{agent_id}", "result": "the answer is 42" }}"#
)
.as_bytes(),
)
.unwrap();
let reply_resp = process_request_file(&reply_req, &project(), &service).await;
assert!(reply_resp.ok, "reply request ok: {reply_resp:?}");
let response = tokio::time::timeout(std::time::Duration::from_secs(10), ask)
.await
.expect("ask completes after reply")
.expect("join ok");
assert!(response.ok, "expected ok, got {response:?}");
assert_eq!(response.action.as_deref(), Some("agent.message"));
// reply carries the target's Final content...
// reply carries the target's rendered result...
assert_eq!(response.reply.as_deref(), Some("the answer is 42"));
// ...and detail still summarises what IdeA did (the two coexist).
assert!(