agent conversation fix
This commit is contained in:
@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user