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:
@ -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!(
|
||||
|
||||
Reference in New Issue
Block a user