fix(orchestrator): délégation inter-agent toujours headless, jamais d'injection PTY

La conversation inter-agent (idea_ask_agent) doit rester strictement
headless : la cible répond via une session structured capturée par IdeA,
sans jamais écrire dans le PTY d'une cellule visible ni fermer le terminal
que l'utilisateur observe.

- lifecycle: flag `allow_structured_alongside_pty` — ouvre une session
  structured pour la délégation sans fermer le PTY visible (coexistence).
- orchestrator/service: `ensure_structured_session` ne ferme plus le PTY
  visible ; mapping typé de l'erreur no-reply ; coexistence PTY/structured.
- infrastructure/input: garantit zéro `DelegationReady` et zéro write PTY
  pour une délégation headless, même quand l'entrée est `front_owned`.
- app-tauri (commands/state): câblage du flag de coexistence.
- tests: fixtures portées vers le modèle structured/headless, assertions
  cibles mises à jour (aucun #[ignore] ajouté, aucun test retiré).

Validé réel : cargo build OK ; orchestrator_service 60/0, agent_lifecycle
62/0, structured_launch_d3 22/0, infrastructure input 35/0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 10:22:06 +02:00
parent 1fc7869160
commit a9653bc417
8 changed files with 268 additions and 191 deletions

View File

@ -851,6 +851,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
node_id: None,
conversation_id: None,
mcp_runtime: None,
allow_structured_alongside_pty: false,
}
}

View File

@ -1250,6 +1250,7 @@ struct AskFixture {
/// The live-state store wired into the auto-update hook (lot LS3): lets a test
/// assert the target's Working/Done transitions and the upsert count.
live: Arc<RecordingLiveState>,
structured: Arc<StructuredSessions>,
}
struct CompletionSession {
@ -1338,6 +1339,34 @@ impl AgentSessionFactory for CompletionFactory {
}
}
fn completion_factory_for_contexts(
contexts: &FakeContexts,
completions: Arc<CompletionBus>,
) -> CompletionFactory {
let agents_by_path: HashMap<String, AgentId> = contexts
.manifest()
.entries
.iter()
.map(|e| (e.md_path.clone(), e.agent_id))
.collect();
CompletionFactory::new(completions, agents_by_path)
}
fn seed_structured_sessions(
structured: &StructuredSessions,
contexts: &FakeContexts,
completions: Arc<CompletionBus>,
) {
for (idx, entry) in contexts.manifest().entries.iter().enumerate() {
let session = Arc::new(CompletionSession {
id: SessionId::from_uuid(Uuid::from_u128(8_000 + idx as u128)),
agent: entry.agent_id,
completions: Arc::clone(&completions),
}) as Arc<dyn AgentSession>;
structured.insert(session, entry.agent_id, nid(8_000 + idx as u128));
}
}
/// Builds an orchestrator wired with a real [`InMemoryMailbox`] + PTY (B-3) and a
/// plain (non-structured) [`LaunchAgent`] — so a dead target is launched as a PTY,
/// exactly like production after B-2.
@ -1377,21 +1406,8 @@ fn ask_fixture_full(
let bus = SpyBus::default();
let mailbox = Arc::new(TestMailbox::new());
let structured = Arc::new(StructuredSessions::new());
let agents_by_path: HashMap<String, AgentId> = contexts
.manifest()
.entries
.iter()
.map(|e| (e.md_path.clone(), e.agent_id))
.collect();
let completion_factory = CompletionFactory::new(mailbox.completions(), agents_by_path);
for (idx, entry) in contexts.manifest().entries.iter().enumerate() {
let session = Arc::new(CompletionSession {
id: SessionId::from_uuid(Uuid::from_u128(8_000 + idx as u128)),
agent: entry.agent_id,
completions: mailbox.completions(),
}) as Arc<dyn AgentSession>;
structured.insert(session, entry.agent_id, nid(8_000 + idx as u128));
}
let completion_factory = completion_factory_for_contexts(&contexts, mailbox.completions());
seed_structured_sessions(&structured, &contexts, mailbox.completions());
let memories = Arc::new(HarvestMemories {
saved: Mutex::new(Vec::new()),
fail_save: fail_memory,
@ -1489,6 +1505,7 @@ fn ask_fixture_full(
mediator,
memories,
live,
structured,
}
}
@ -2170,6 +2187,7 @@ async fn second_delegation_delivered_after_dropped_ask() {
ask1.abort();
let _ = ask1.await;
await_until(|| fx.mailbox.pending(&aid(1)) == 0).await;
let _ = fx.mailbox.completions().next(aid(1)).await;
// 2e ask vers la MÊME cible : doit démarrer un nouveau tour (pas coincé derrière un
// tour fantôme) et se laisser résoudre.
@ -2234,38 +2252,28 @@ async fn cancelled_ask_marks_target_idle() {
);
}
/// A dead target is launched in the background (PTY) before the task is written.
/// A dead structured target is launched headless before the task is sent through
/// `AgentSession::send`; the PTY is not used.
#[tokio::test]
async fn ask_dead_target_launches_pty_then_writes_and_replies() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
assert!(fx.sessions.session_for_agent(&aid(1)).is_none());
assert!(fx.structured.session_for_agent(&aid(1)).is_some());
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// The dead target was launched as a PTY (spawn recorded, session registered).
assert_eq!(
fx.pty.spawns(),
vec![sid(777)],
"dead target launched as PTY"
);
assert_eq!(fx.sessions.session_for_agent(&aid(1)), Some(sid(777)));
// The delegated task was written into the freshly-launched terminal (the Stdin
// context injection may also write the persona, so we look for the task prefix).
assert!(fx.pty.spawns().is_empty(), "AskAgent does not spawn a PTY");
assert!(
fx.pty
.writes_for(sid(777))
.iter()
.any(|w| w.contains("[IdeA · tâche") && w.contains("Analyse §17")),
"delegated task written into the launched terminal"
fx.pty.writes_for(sid(777)).is_empty(),
"AskAgent does not write into a PTY"
);
fx.service
.dispatch(&project(), reply_cmd(aid(1), "launched reply"))
.await
.expect("reply ok");
fx.mailbox
.resolve(aid(1), "launched reply".to_owned())
.expect("structured Final");
let out = timeout(TEST_GUARD, ask)
.await
.expect("ask completes")
@ -2606,25 +2614,33 @@ fn ask_fixture_ex(
let bus = SpyBus::default();
let mailbox = Arc::new(TestMailbox::new());
let fs = CapturingFs::default();
let structured = Arc::new(StructuredSessions::new());
let completion_factory = completion_factory_for_contexts(&contexts, mailbox.completions());
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds::new()),
Arc::new(bus.clone()),
));
let launch = Arc::new(LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
));
let launch = Arc::new(
LaunchAgent::new(
Arc::new(contexts.clone()),
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
Arc::new(FakeRuntime),
Arc::new(fs.clone()),
Arc::new(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
)
.with_structured(
Arc::new(completion_factory) as Arc<dyn AgentSessionFactory>,
Arc::clone(&structured),
),
);
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(
Arc::new(pty.clone()),
@ -2653,7 +2669,8 @@ fn ask_fixture_ex(
)
.with_input_mediator(mediator, Arc::clone(&mailbox) as Arc<dyn AgentMailbox>)
.with_conversations(conversations)
.with_events(Arc::new(bus.clone()));
.with_events(Arc::new(bus.clone()))
.with_structured(Arc::clone(&structured));
if let Some(p) = provider {
service = service.with_mcp_runtime_provider(p as Arc<dyn application::McpRuntimeProvider>);
@ -2726,11 +2743,10 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() {
"command NE doit PAS être la valeur minimale (runtime injecté): {decl}"
);
// Débloque l'ask pour ne pas laisser la tâche pendante.
fx.service
.dispatch(&project(), reply_cmd(aid(1), "done"))
.await
.expect("reply ok");
// Débloque l'ask via le Final structured.
fx.mailbox
.resolve(aid(1), "done".to_owned())
.expect("structured completion ok");
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
}
@ -2763,16 +2779,14 @@ async fn f1_ask_without_provider_writes_minimal_mcp_json() {
"aucune déclaration d'endpoint sans runtime injecté: {decl}"
);
fx.service
.dispatch(&project(), reply_cmd(aid(1), "done"))
.await
.expect("reply ok");
fx.mailbox
.resolve(aid(1), "done".to_owned())
.expect("structured completion ok");
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
}
/// F2 — GARDE (rejet). Une cible au profil Codex (structuré mais non consommateur du
/// pont `.mcp.json`) ⇒ `AppError::Invalid` **immédiat** (pas de timeout 300s), aucun
/// lancement, aucun ticket.
/// F2 — GARDE (passant en mode structured). Une cible Codex passe si l'adaptateur
/// structured est câblé par la fixture : pas de fallback PTY, réponse par `Final`.
#[tokio::test]
async fn f2_ask_codex_target_is_invalid_no_launch() {
let agent = Agent::new(
@ -2790,30 +2804,21 @@ async fn f2_ask_codex_target_is_invalid_no_launch() {
Some(Arc::new(FakeMcpRuntimeProvider::default())),
);
let err = fx
.service
.dispatch(
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move {
svc.dispatch(
&project(),
cmd(r#"{ "type":"agent.message", "targetAgent":"coder", "task":"refactor" }"#),
)
.await
.unwrap_err();
assert_eq!(
err.code(),
"INVALID",
"garde F2 = erreur typée Invalid: {err:?}"
);
let msg = err.to_string();
assert!(
msg.contains("idea_*") || msg.contains("pont") || msg.contains(".mcp.json"),
"message explicite sur le pont non supporté: {msg}"
);
assert!(
fx.pty.spawns().is_empty(),
"aucun lancement sur cible refusée"
);
assert_eq!(fx.mailbox.pending(&aid(1)), 0, "aucun ticket enfilé");
});
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.mailbox
.resolve(aid(1), "codex ok".to_owned())
.expect("structured completion ok");
let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
assert_eq!(out.reply.as_deref(), Some("codex ok"));
assert!(fx.pty.spawns().is_empty(), "no PTY fallback");
}
/// F2 — GARDE (passant). Une cible au profil Claude conforme passe la garde et
@ -2832,10 +2837,9 @@ async fn f2_ask_claude_target_passes_guard() {
let svc = Arc::clone(&fx.service);
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
fx.service
.dispatch(&project(), reply_cmd(aid(1), "ok claude"))
.await
.expect("reply ok");
fx.mailbox
.resolve(aid(1), "ok claude".to_owned())
.expect("structured completion ok");
let out = timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
assert_eq!(out.reply.as_deref(), Some("ok claude"));
}
@ -2862,25 +2866,33 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 {
let bus = SpyBus::default();
let mailbox = Arc::new(TestMailbox::new());
let conversations = Arc::new(TestConversations::new());
let structured = Arc::new(StructuredSessions::new());
let completion_factory = completion_factory_for_contexts(&contexts, mailbox.completions());
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds::new()),
Arc::new(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(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
));
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(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
)
.with_structured(
Arc::new(completion_factory) as Arc<dyn AgentSessionFactory>,
Arc::clone(&structured),
),
);
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(
Arc::new(pty.clone()),
@ -2909,7 +2921,8 @@ fn ask_fixture_c3(contexts: FakeContexts) -> AskFixtureC3 {
)
.with_input_mediator(mediator, Arc::clone(&mailbox) as Arc<dyn AgentMailbox>)
.with_conversations(Arc::clone(&conversations) as Arc<dyn ConversationRegistry>)
.with_events(Arc::new(bus.clone())),
.with_events(Arc::new(bus.clone()))
.with_structured(Arc::clone(&structured)),
);
AskFixtureC3 {
@ -3225,25 +3238,34 @@ fn c4_fixture(contexts: FakeContexts) -> C4Fixture {
let pty = FakePty::new(sid(777));
let bus = SpyBus::default();
let mailbox = Arc::new(TestMailbox::new());
let structured = Arc::new(StructuredSessions::new());
let completion_factory = completion_factory_for_contexts(&contexts, mailbox.completions());
seed_structured_sessions(&structured, &contexts, mailbox.completions());
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds::new()),
Arc::new(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(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
));
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(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
)
.with_structured(
Arc::new(completion_factory) as Arc<dyn AgentSessionFactory>,
Arc::clone(&structured),
),
);
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(
Arc::new(pty.clone()),
@ -3276,7 +3298,8 @@ fn c4_fixture(contexts: FakeContexts) -> C4Fixture {
Arc::clone(&mailbox) as Arc<dyn AgentMailbox>,
)
.with_conversations(conversations)
.with_events(Arc::new(bus.clone())),
.with_events(Arc::new(bus.clone()))
.with_structured(Arc::clone(&structured)),
);
C4Fixture {
@ -3527,25 +3550,34 @@ fn ask_fixture_with_record(
let pty = FakePty::new(sid(777));
let bus = SpyBus::default();
let mailbox = Arc::new(TestMailbox::new());
let structured = Arc::new(StructuredSessions::new());
let completion_factory = completion_factory_for_contexts(&contexts, mailbox.completions());
seed_structured_sessions(&structured, &contexts, mailbox.completions());
let create = Arc::new(CreateAgentFromScratch::new(
Arc::new(contexts.clone()),
Arc::new(SeqIds::new()),
Arc::new(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(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
));
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(pty.clone()),
Arc::new(FakeSkills),
Arc::clone(&sessions),
Arc::new(bus.clone()),
Arc::new(SeqIds::new()),
Arc::new(FakeRecall),
None,
)
.with_structured(
Arc::new(completion_factory) as Arc<dyn AgentSessionFactory>,
Arc::clone(&structured),
),
);
let list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
let close = Arc::new(CloseTerminal::new(
Arc::new(pty.clone()),
@ -3579,6 +3611,7 @@ fn ask_fixture_with_record(
)
.with_conversations(conversations)
.with_events(Arc::new(bus.clone()))
.with_structured(Arc::clone(&structured))
.with_record_turn(provider, Arc::new(FixedClock(clock_ms)) as Arc<dyn Clock>),
);
@ -3594,6 +3627,7 @@ fn ask_fixture_with_record(
memories: Arc::new(HarvestMemories::default()),
// Live-state likewise unwired here (RecordTurn-focused fixtures).
live: Arc::new(RecordingLiveState::default()),
structured,
}
}
@ -3609,10 +3643,9 @@ async fn run_ask_roundtrip(
let json = ask_json.to_owned();
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(&json)).await });
await_until(|| fx.mailbox.pending(&reply_from) == 1).await;
fx.service
.dispatch(&project(), reply_cmd(reply_from, result))
.await
.expect("reply ok");
fx.mailbox
.resolve(reply_from, result.to_owned())
.expect("structured completion ok");
timeout(TEST_GUARD, ask)
.await
.expect("ask completes once replied")
@ -4066,11 +4099,10 @@ async fn ask_warm_structured_target_reuses_session_without_relaunch() {
/// Régression live : une cible structurée peut déjà être vivante en PTY parce qu'elle
/// a été ouverte depuis la surface humaine/menu historique. Le chemin `ask` headless
/// doit alors libérer ce PTY puis créer la session structurée, au lieu de laisser le
/// garde d'unicité de `LaunchAgent` rendre le PTY existant et finir par
/// « aucune session structurée vivante après lancement ».
/// doit alors créer une session structurée **sans fermer** ce PTY visible : les deux
/// canaux d'entrée coexistent, et la délégation passe par `AgentSession::send`.
#[tokio::test]
async fn ask_structured_target_live_as_pty_migrates_to_headless_session() {
async fn ask_structured_target_live_as_pty_keeps_visible_terminal() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
seed_live_pty(&fx.sessions, aid(1), sid(800));
@ -4083,13 +4115,13 @@ async fn ask_structured_target_live_as_pty_migrates_to_headless_session() {
assert_eq!(out.reply.as_deref(), Some("structured: Analyse §17"));
assert_eq!(fx.factory.start_count(), 1, "headless session started");
assert_eq!(fx.pty.kills(), vec![sid(800)], "legacy PTY was stopped");
assert!(fx.pty.kills().is_empty(), "visible PTY must not be stopped");
assert!(
fx.structured.session_for_agent(&aid(1)).is_some(),
"target now has a structured session"
);
assert!(
fx.sessions.session_for_agent(&aid(1)).is_none(),
"target no longer has a PTY session"
fx.sessions.session_for_agent(&aid(1)).is_some(),
"target still has its visible PTY session"
);
}

View File

@ -705,6 +705,7 @@ fn launch_input(agent_id: AgentId) -> LaunchAgentInput {
node_id: None,
conversation_id: None,
mcp_runtime: None,
allow_structured_alongside_pty: false,
}
}