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

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