agent conversation fix

This commit is contained in:
2026-06-27 12:42:37 +02:00
parent c2d1a669c5
commit 1fc7869160
38 changed files with 1173 additions and 1549 deletions

View File

@ -955,11 +955,13 @@ const TEST_GUARD: Duration = Duration::from_secs(10);
struct TestMailbox {
queues:
Mutex<HashMap<AgentId, VecDeque<(Ticket, tokio::sync::oneshot::Sender<TurnResolution>)>>>,
completions: Arc<CompletionBus>,
}
impl TestMailbox {
fn new() -> Self {
Self {
queues: Mutex::new(HashMap::new()),
completions: Arc::new(CompletionBus::default()),
}
}
fn pending(&self, agent: &AgentId) -> usize {
@ -978,7 +980,51 @@ impl TestMailbox {
.map(|q| q.iter().map(|(t, _)| t.id).collect())
.unwrap_or_default()
}
fn completions(&self) -> Arc<CompletionBus> {
Arc::clone(&self.completions)
}
}
#[derive(Clone)]
enum TestCompletion {
Replied(String),
NoReply,
Cancelled,
}
#[derive(Default)]
struct CompletionBus {
by_agent: Mutex<HashMap<AgentId, VecDeque<TestCompletion>>>,
notify: tokio::sync::Notify,
}
impl CompletionBus {
fn push(&self, agent: AgentId, completion: TestCompletion) {
self.by_agent
.lock()
.unwrap()
.entry(agent)
.or_default()
.push_back(completion);
self.notify.notify_waiters();
}
async fn next(&self, agent: AgentId) -> TestCompletion {
loop {
if let Some(item) = self
.by_agent
.lock()
.unwrap()
.get_mut(&agent)
.and_then(VecDeque::pop_front)
{
return item;
}
self.notify.notified().await;
}
}
}
impl AgentMailbox for TestMailbox {
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
let (tx, rx) = tokio::sync::oneshot::channel::<TurnResolution>();
@ -1001,6 +1047,8 @@ impl AgentMailbox for TestMailbox {
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.pop_front().expect("non-empty")
};
self.completions
.push(agent, TestCompletion::Replied(result.clone()));
let _ = slot.1.send(TurnResolution::Replied(result));
Ok(())
}
@ -1022,6 +1070,8 @@ impl AgentMailbox for TestMailbox {
.ok_or(MailboxError::NoPendingRequest(agent))?;
queue.remove(pos).expect("found position")
};
self.completions
.push(agent, TestCompletion::Replied(result.clone()));
let _ = slot.1.send(TurnResolution::Replied(result));
Ok(())
}
@ -1030,6 +1080,7 @@ impl AgentMailbox for TestMailbox {
if let Some(queue) = q.get_mut(&agent) {
if queue.front().map(|(t, _)| t.id) == Some(ticket_id) {
queue.pop_front();
self.completions.push(agent, TestCompletion::Cancelled);
}
}
}
@ -1044,6 +1095,7 @@ impl AgentMailbox for TestMailbox {
return; // receiver gone (human submit / timed-out caller): preserve head.
}
let (_, tx) = queue.pop_front().expect("head just matched");
self.completions.push(agent, TestCompletion::NoReply);
let _ = tx.send(TurnResolution::ReturnedToPromptNoReply);
}
}
@ -1200,6 +1252,92 @@ struct AskFixture {
live: Arc<RecordingLiveState>,
}
struct CompletionSession {
id: SessionId,
agent: AgentId,
completions: Arc<CompletionBus>,
}
#[async_trait]
impl AgentSession for CompletionSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
None
}
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
match self.completions.next(self.agent).await {
TestCompletion::Replied(content) => {
Ok(Box::new(vec![ReplyEvent::Final { content }].into_iter()))
}
TestCompletion::NoReply => Err(AgentSessionError::Io(
"target returned without a structured final".to_owned(),
)),
TestCompletion::Cancelled => Err(AgentSessionError::Io(
"structured test turn cancelled".to_owned(),
)),
}
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
#[derive(Clone)]
struct CompletionFactory {
completions: Arc<CompletionBus>,
agents_by_path: HashMap<String, AgentId>,
next_id: Arc<Mutex<u128>>,
}
impl CompletionFactory {
fn new(completions: Arc<CompletionBus>, agents_by_path: HashMap<String, AgentId>) -> Self {
Self {
completions,
agents_by_path,
next_id: Arc::new(Mutex::new(7000)),
}
}
}
#[async_trait]
impl AgentSessionFactory for CompletionFactory {
fn supports(&self, profile: &AgentProfile) -> bool {
profile.structured_adapter.is_some()
}
async fn start(
&self,
_profile: &AgentProfile,
ctx: &PreparedContext,
_cwd: &ProjectPath,
_session: &SessionPlan,
_sandbox: Option<&domain::sandbox::SandboxPlan>,
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
let id = {
let mut n = self.next_id.lock().unwrap();
let id = SessionId::from_uuid(Uuid::from_u128(*n));
*n += 1;
id
};
let agent = self
.agents_by_path
.get(&ctx.relative_path)
.copied()
.ok_or_else(|| {
AgentSessionError::Start(format!(
"test completion session cannot resolve agent for {}",
ctx.relative_path
))
})?;
Ok(Arc::new(CompletionSession {
id,
agent,
completions: Arc::clone(&self.completions),
}))
}
}
/// 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.
@ -1238,6 +1376,22 @@ fn ask_fixture_full(
let pty = FakePty::new(sid(777));
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 memories = Arc::new(HarvestMemories {
saved: Mutex::new(Vec::new()),
fail_save: fail_memory,
@ -1252,19 +1406,25 @@ fn ask_fixture_full(
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()),
@ -1297,6 +1457,7 @@ fn ask_fixture_full(
)
.with_conversations(conversations)
.with_events(Arc::new(bus.clone()))
.with_structured(Arc::clone(&structured))
.with_memory_harvest(Arc::new(HarvestMemoryFromTurn::new(
Arc::clone(&memories) as Arc<dyn MemoryStore>,
Arc::new(bus.clone()),
@ -1378,8 +1539,8 @@ fn reply_cmd_ticket(from: AgentId, ticket: TicketId, result: &str) -> Orchestrat
// --- B-3: ask blocks on the mailbox, reply unblocks it ---------------------
/// A live-PTY target: `ask` writes the prefixed task into its terminal and AWAITS;
/// a matching `idea_reply` resolves the head ticket and the ask returns its content.
/// A structured target: `ask` enqueues a bookkeeping ticket, sends the turn to the
/// headless session, and returns the session's `Final`. The PTY is not used.
#[tokio::test]
async fn ask_live_pty_target_writes_task_and_returns_reply() {
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
@ -1392,21 +1553,12 @@ async fn ask_live_pty_target_writes_task_and_returns_reply() {
// The ask enqueues a ticket and blocks awaiting the reply.
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
// The task was written into the target's terminal, prefixed for idea_reply.
// The PTY is no longer part of the inter-agent conversation path.
let writes = fx.pty.writes_for(sid(800));
assert_eq!(
writes.len(),
1,
"exactly one task write to the live terminal"
);
assert!(writes[0].contains("Analyse §17"), "task body written");
assert!(
writes[0].contains("[IdeA · tâche"),
"delegated-task prefix present"
);
assert!(
writes[0].contains("idea_reply") || writes[0].contains("ticket"),
"carries ticket/idea_reply cue"
0,
"inter-agent conversation must not write to the PTY"
);
// No PTY spawned: the live terminal was reused.
assert!(
@ -3750,6 +3902,7 @@ impl AgentSessionFactory for CountingFactory {
struct StructuredAskFixture {
service: Arc<OrchestratorService>,
structured: Arc<StructuredSessions>,
sessions: Arc<TerminalSessions>,
factory: CountingFactory,
pty: FakePty,
}
@ -3831,6 +3984,7 @@ fn structured_ask_fixture(contexts: FakeContexts) -> StructuredAskFixture {
StructuredAskFixture {
service,
structured,
sessions,
factory,
pty,
}
@ -3909,3 +4063,33 @@ async fn ask_warm_structured_target_reuses_session_without_relaunch() {
"cible chaude : la session est réutilisée, aucune relance"
);
}
/// 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 ».
#[tokio::test]
async fn ask_structured_target_live_as_pty_migrates_to_headless_session() {
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));
let out = fx
.service
.dispatch(&project(), cmd(ASK_JSON))
.await
.expect("ask ok");
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.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"
);
}