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

@ -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!(