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:
@ -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" }));
|
||||
|
||||
Reference in New Issue
Block a user