fix(runtime): isolate agent state by project (#101)
This commit is contained in:
@ -18,7 +18,7 @@ use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId, RuntimeAgentKey};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, BackgroundCompletionStream, BackgroundTaskHandle,
|
||||
@ -647,6 +647,12 @@ fn pid(n: u128) -> ProfileId {
|
||||
fn aid(n: u128) -> AgentId {
|
||||
AgentId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
fn project_id() -> ProjectId {
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000))
|
||||
}
|
||||
fn rkey(n: u128) -> RuntimeAgentKey {
|
||||
RuntimeAgentKey::new(project_id(), aid(n))
|
||||
}
|
||||
fn sid(n: u128) -> SessionId {
|
||||
SessionId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
@ -656,7 +662,7 @@ fn nid(n: u128) -> NodeId {
|
||||
|
||||
fn project() -> Project {
|
||||
Project::new(
|
||||
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
||||
project_id(),
|
||||
"demo",
|
||||
ProjectPath::new("/home/me/proj").unwrap(),
|
||||
RemoteRef::local(),
|
||||
@ -1201,68 +1207,69 @@ impl CompletionBus {
|
||||
}
|
||||
|
||||
impl AgentMailbox for TestMailbox {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
let (tx, rx) = tokio::sync::oneshot::channel::<TurnResolution>();
|
||||
self.queues
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entry(agent)
|
||||
.entry(agent.agent_id)
|
||||
.or_default()
|
||||
.push_back((ticket, tx));
|
||||
PendingReply::new(Box::pin(async move {
|
||||
rx.await.map_err(|_| MailboxError::Cancelled)
|
||||
}))
|
||||
}
|
||||
fn resolve(&self, agent: AgentId, result: String) -> Result<(), MailboxError> {
|
||||
fn resolve(&self, agent: RuntimeAgentKey, result: String) -> Result<(), MailboxError> {
|
||||
let slot = {
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
let queue = q
|
||||
.get_mut(&agent)
|
||||
.get_mut(&agent.agent_id)
|
||||
.filter(|q| !q.is_empty())
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
.ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
|
||||
queue.pop_front().expect("non-empty")
|
||||
};
|
||||
self.completions
|
||||
.push(agent, TestCompletion::Replied(result.clone()));
|
||||
.push(agent.agent_id, TestCompletion::Replied(result.clone()));
|
||||
let _ = slot.1.send(TurnResolution::Replied(result));
|
||||
Ok(())
|
||||
}
|
||||
fn resolve_ticket(
|
||||
&self,
|
||||
agent: AgentId,
|
||||
agent: RuntimeAgentKey,
|
||||
ticket_id: TicketId,
|
||||
result: String,
|
||||
) -> Result<(), MailboxError> {
|
||||
let slot = {
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
let queue = q
|
||||
.get_mut(&agent)
|
||||
.get_mut(&agent.agent_id)
|
||||
.filter(|q| !q.is_empty())
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
.ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
|
||||
let pos = queue
|
||||
.iter()
|
||||
.position(|(t, _)| t.id == ticket_id)
|
||||
.ok_or(MailboxError::NoPendingRequest(agent))?;
|
||||
.ok_or(MailboxError::NoPendingRequest(agent.agent_id))?;
|
||||
queue.remove(pos).expect("found position")
|
||||
};
|
||||
self.completions
|
||||
.push(agent, TestCompletion::Replied(result.clone()));
|
||||
.push(agent.agent_id, TestCompletion::Replied(result.clone()));
|
||||
let _ = slot.1.send(TurnResolution::Replied(result));
|
||||
Ok(())
|
||||
}
|
||||
fn cancel_head(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
fn cancel_head(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
if let Some(queue) = q.get_mut(&agent) {
|
||||
if let Some(queue) = q.get_mut(&agent.agent_id) {
|
||||
if queue.front().map(|(t, _)| t.id) == Some(ticket_id) {
|
||||
queue.pop_front();
|
||||
self.completions.push(agent, TestCompletion::Cancelled);
|
||||
self.completions
|
||||
.push(agent.agent_id, TestCompletion::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn complete_without_reply(&self, agent: AgentId, ticket_id: TicketId) {
|
||||
fn complete_without_reply(&self, agent: RuntimeAgentKey, ticket_id: TicketId) {
|
||||
// Mirror the production adapter: head-only, idempotent, fire-and-forget-safe.
|
||||
let mut q = self.queues.lock().unwrap();
|
||||
if let Some(queue) = q.get_mut(&agent) {
|
||||
if let Some(queue) = q.get_mut(&agent.agent_id) {
|
||||
if queue.front().map(|(t, _)| t.id) != Some(ticket_id) {
|
||||
return;
|
||||
}
|
||||
@ -1270,7 +1277,8 @@ 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);
|
||||
self.completions
|
||||
.push(agent.agent_id, TestCompletion::NoReply);
|
||||
let _ = tx.send(TurnResolution::ReturnedToPromptNoReply);
|
||||
}
|
||||
}
|
||||
@ -1304,11 +1312,11 @@ impl TestMediator {
|
||||
}
|
||||
}
|
||||
impl InputMediator for TestMediator {
|
||||
fn enqueue(&self, agent: AgentId, ticket: Ticket) -> PendingReply {
|
||||
fn enqueue(&self, agent: RuntimeAgentKey, ticket: Ticket) -> PendingReply {
|
||||
let ticket_id = ticket.id;
|
||||
{
|
||||
let mut b = self.busy.lock().unwrap();
|
||||
let st = b.entry(agent).or_insert(AgentBusyState::Idle);
|
||||
let st = b.entry(agent.agent_id).or_insert(AgentBusyState::Idle);
|
||||
if !st.is_busy() {
|
||||
*st = AgentBusyState::Busy {
|
||||
ticket: ticket_id,
|
||||
@ -1316,7 +1324,7 @@ impl InputMediator for TestMediator {
|
||||
};
|
||||
}
|
||||
}
|
||||
if let Some(handle) = self.handles.lock().unwrap().get(&agent).cloned() {
|
||||
if let Some(handle) = self.handles.lock().unwrap().get(&agent.agent_id).cloned() {
|
||||
let line = format!(
|
||||
"[IdeA · tâche de {} · ticket {}] {}\n",
|
||||
ticket.requester, ticket_id, ticket.task
|
||||
@ -1325,26 +1333,26 @@ impl InputMediator for TestMediator {
|
||||
}
|
||||
self.mailbox.enqueue(agent, ticket)
|
||||
}
|
||||
fn bind_handle(&self, agent: AgentId, handle: PtyHandle) {
|
||||
self.handles.lock().unwrap().insert(agent, handle);
|
||||
fn bind_handle(&self, agent: RuntimeAgentKey, handle: PtyHandle) {
|
||||
self.handles.lock().unwrap().insert(agent.agent_id, handle);
|
||||
}
|
||||
fn delivers_turn(&self, agent: AgentId) -> bool {
|
||||
self.handles.lock().unwrap().contains_key(&agent)
|
||||
fn delivers_turn(&self, agent: RuntimeAgentKey) -> bool {
|
||||
self.handles.lock().unwrap().contains_key(&agent.agent_id)
|
||||
}
|
||||
fn preempt(&self, agent: AgentId) {
|
||||
self.preempts.lock().unwrap().push(agent);
|
||||
fn preempt(&self, agent: RuntimeAgentKey) {
|
||||
self.preempts.lock().unwrap().push(agent.agent_id);
|
||||
}
|
||||
fn mark_idle(&self, agent: AgentId) {
|
||||
fn mark_idle(&self, agent: RuntimeAgentKey) {
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(agent, AgentBusyState::Idle);
|
||||
.insert(agent.agent_id, AgentBusyState::Idle);
|
||||
}
|
||||
fn busy_state(&self, agent: AgentId) -> AgentBusyState {
|
||||
fn busy_state(&self, agent: RuntimeAgentKey) -> AgentBusyState {
|
||||
self.busy
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&agent)
|
||||
.get(&agent.agent_id)
|
||||
.copied()
|
||||
.unwrap_or(AgentBusyState::Idle)
|
||||
}
|
||||
@ -1354,29 +1362,38 @@ impl InputMediator for TestMediator {
|
||||
/// `infrastructure::InMemoryConversationRegistry`.
|
||||
#[derive(Default)]
|
||||
struct TestConversations {
|
||||
by_pair: Mutex<HashMap<(String, String), ConversationId>>,
|
||||
by_pair: Mutex<HashMap<(ProjectId, String, String), ConversationId>>,
|
||||
by_id: Mutex<HashMap<ConversationId, Conversation>>,
|
||||
}
|
||||
impl TestConversations {
|
||||
fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
fn key(a: ConversationParty, b: ConversationParty) -> (String, String) {
|
||||
fn key(
|
||||
project_id: ProjectId,
|
||||
a: ConversationParty,
|
||||
b: ConversationParty,
|
||||
) -> (ProjectId, String, String) {
|
||||
let s = |p: ConversationParty| match p {
|
||||
ConversationParty::User => "user".to_owned(),
|
||||
ConversationParty::Agent { agent_id } => agent_id.to_string(),
|
||||
};
|
||||
let (ka, kb) = (s(a), s(b));
|
||||
if ka <= kb {
|
||||
(ka, kb)
|
||||
(project_id, ka, kb)
|
||||
} else {
|
||||
(kb, ka)
|
||||
(project_id, kb, ka)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ConversationRegistry for TestConversations {
|
||||
fn resolve(&self, a: ConversationParty, b: ConversationParty) -> Conversation {
|
||||
let key = Self::key(a, b);
|
||||
fn resolve(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
a: ConversationParty,
|
||||
b: ConversationParty,
|
||||
) -> Conversation {
|
||||
let key = Self::key(project_id, a, b);
|
||||
let mut pairs = self.by_pair.lock().unwrap();
|
||||
if let Some(id) = pairs.get(&key).copied() {
|
||||
return self.by_id.lock().unwrap().get(&id).cloned().unwrap();
|
||||
@ -1696,7 +1713,7 @@ fn seed_live_pty(sessions: &TerminalSessions, agent_id: AgentId, session_id: Ses
|
||||
SessionKind::Agent { agent_id },
|
||||
PtySize::new(24, 80).unwrap(),
|
||||
);
|
||||
sessions.insert(PtyHandle { session_id }, session);
|
||||
sessions.insert_in_project(project_id(), PtyHandle { session_id }, session);
|
||||
}
|
||||
|
||||
/// Waits (bounded) for a condition to hold, yielding between polls.
|
||||
@ -2105,7 +2122,7 @@ async fn ask_target_returns_to_prompt_without_reply_is_a_typed_error() {
|
||||
|
||||
// Simulate the grace-window completion (target back at prompt, no idea_reply).
|
||||
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.complete_without_reply(aid(1), ticket);
|
||||
fx.mailbox.complete_without_reply(rkey(1), ticket);
|
||||
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2135,7 +2152,7 @@ async fn ask_reply_wins_then_late_completion_is_noop() {
|
||||
.await
|
||||
.expect("reply ok");
|
||||
// …then a late grace completion for the same ticket is a no-op.
|
||||
fx.mailbox.complete_without_reply(aid(1), ticket);
|
||||
fx.mailbox.complete_without_reply(rkey(1), ticket);
|
||||
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2263,7 +2280,7 @@ async fn ask_cancelled_turn_does_not_harvest() {
|
||||
// Cancel the head ticket (drops the reply sender) ⇒ the ask resolves as a
|
||||
// channel-closed error: no Response turn, hence no harvest.
|
||||
let ticket = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.cancel_head(aid(1), ticket);
|
||||
fx.mailbox.cancel_head(rkey(1), ticket);
|
||||
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2287,7 +2304,7 @@ async fn idea_reply_marks_emitter_idle() {
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
// The delegated turn started ⇒ the target is Busy.
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
fx.mediator.busy_state(rkey(1)).is_busy(),
|
||||
"target Busy while processing the delegated turn"
|
||||
);
|
||||
|
||||
@ -2304,7 +2321,7 @@ async fn idea_reply_marks_emitter_idle() {
|
||||
|
||||
// C5: the reply marked the emitter Idle (FIFO can advance) — no prompt pattern needed.
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"idea_reply is the explicit signal that frees the turn"
|
||||
);
|
||||
@ -2326,7 +2343,7 @@ async fn dropped_ask_future_frees_busy_target() {
|
||||
// Le tour a démarré : la cible est Busy, un ticket est en file.
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
fx.mediator.busy_state(rkey(1)).is_busy(),
|
||||
"cible Busy pendant le tour délégué"
|
||||
);
|
||||
|
||||
@ -2335,9 +2352,9 @@ async fn dropped_ask_future_frees_busy_target() {
|
||||
let _ = ask.await; // récolte la JoinError(Cancelled), ignorée.
|
||||
|
||||
// Le garde RAII a ramené la cible Idle ET retiré le ticket fantôme de la FIFO.
|
||||
await_until(|| !fx.mediator.busy_state(aid(1)).is_busy()).await;
|
||||
await_until(|| !fx.mediator.busy_state(rkey(1)).is_busy()).await;
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"futur dropped ⇒ la cible est ramenée Idle par le garde (fix cause racine)"
|
||||
);
|
||||
@ -2372,7 +2389,7 @@ async fn second_delegation_delivered_after_dropped_ask() {
|
||||
let ask2 = tokio::spawn(async move { svc2.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
assert!(
|
||||
fx.mediator.busy_state(aid(1)).is_busy(),
|
||||
fx.mediator.busy_state(rkey(1)).is_busy(),
|
||||
"le 2e tour démarre bien (cible Busy) — preuve qu'elle n'était pas coincée"
|
||||
);
|
||||
|
||||
@ -2387,7 +2404,7 @@ async fn second_delegation_delivered_after_dropped_ask() {
|
||||
.expect("ask ok");
|
||||
assert_eq!(out.reply.as_deref(), Some("réponse au 2e tour"));
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"cible Idle après résolution du 2e tour"
|
||||
);
|
||||
@ -2406,12 +2423,12 @@ async fn cancelled_ask_marks_target_idle() {
|
||||
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;
|
||||
assert!(fx.mediator.busy_state(aid(1)).is_busy());
|
||||
assert!(fx.mediator.busy_state(rkey(1)).is_busy());
|
||||
|
||||
// Retire le ticket de tête (drop du sender) ⇒ l'ask voit un canal fermé et part en
|
||||
// erreur typée (PROCESS), le MÊME nettoyage que le timeout de tour.
|
||||
let t = fx.mailbox.ticket_ids(&aid(1))[0];
|
||||
fx.mailbox.cancel_head(aid(1), t);
|
||||
fx.mailbox.cancel_head(rkey(1), t);
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask retourne vite sur canal fermé")
|
||||
@ -2423,7 +2440,7 @@ async fn cancelled_ask_marks_target_idle() {
|
||||
);
|
||||
// Le garde a ramené la cible Idle (la FIFO peut avancer).
|
||||
assert_eq!(
|
||||
fx.mediator.busy_state(aid(1)),
|
||||
fx.mediator.busy_state(rkey(1)),
|
||||
AgentBusyState::Idle,
|
||||
"branche erreur ⇒ cible Idle (garde RAII)"
|
||||
);
|
||||
@ -2449,7 +2466,7 @@ async fn ask_dead_target_launches_pty_then_writes_and_replies() {
|
||||
);
|
||||
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "launched reply".to_owned())
|
||||
.resolve(rkey(1), "launched reply".to_owned())
|
||||
.expect("structured Final");
|
||||
let out = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -2922,7 +2939,7 @@ async fn f1_ask_dead_target_injects_provider_runtime_into_mcp_json() {
|
||||
|
||||
// Débloque l'ask via le Final structured.
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "done".to_owned())
|
||||
.resolve(rkey(1), "done".to_owned())
|
||||
.expect("structured completion ok");
|
||||
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
|
||||
}
|
||||
@ -2957,7 +2974,7 @@ async fn f1_ask_without_provider_writes_minimal_mcp_json() {
|
||||
);
|
||||
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "done".to_owned())
|
||||
.resolve(rkey(1), "done".to_owned())
|
||||
.expect("structured completion ok");
|
||||
timeout(TEST_GUARD, ask).await.unwrap().unwrap().unwrap();
|
||||
}
|
||||
@ -2991,7 +3008,7 @@ async fn f2_ask_codex_target_is_invalid_no_launch() {
|
||||
});
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "codex ok".to_owned())
|
||||
.resolve(rkey(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"));
|
||||
@ -3015,7 +3032,7 @@ async fn f2_ask_claude_target_passes_guard() {
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(ASK_JSON)).await });
|
||||
await_until(|| fx.mailbox.pending(&aid(1)) == 1).await;
|
||||
fx.mailbox
|
||||
.resolve(aid(1), "ok claude".to_owned())
|
||||
.resolve(rkey(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"));
|
||||
@ -3153,12 +3170,15 @@ async fn ask_agent_routes_into_a_to_b_conversation_not_user_b() {
|
||||
|
||||
// The registry now holds the A↔B thread (agent 1 ↔ agent 2), not User↔B.
|
||||
let a_to_b = fx.conversations.resolve(
|
||||
project_id(),
|
||||
ConversationParty::agent(aid(1)),
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
let user_b = fx
|
||||
.conversations
|
||||
.resolve(ConversationParty::User, ConversationParty::agent(aid(2)));
|
||||
let user_b = fx.conversations.resolve(
|
||||
project_id(),
|
||||
ConversationParty::User,
|
||||
ConversationParty::agent(aid(2)),
|
||||
);
|
||||
assert_ne!(a_to_b.id, user_b.id, "A↔B is a distinct thread from User↔B");
|
||||
assert!(
|
||||
a_to_b.same_pair(
|
||||
@ -3371,7 +3391,7 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
|
||||
// Retire the head ticket (drops its sender) ⇒ the awaiting ask sees a closed
|
||||
// channel and returns a typed error, the SAME cleanup the turn timeout performs.
|
||||
fx.mailbox.cancel_head(aid(2), t);
|
||||
fx.mailbox.cancel_head(rkey(2), t);
|
||||
let err = timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
.expect("ask returns promptly once the channel closes")
|
||||
@ -3388,7 +3408,8 @@ async fn timeout_path_frees_queue_and_keeps_target_alive() {
|
||||
"queue freed after retirement"
|
||||
);
|
||||
assert_eq!(
|
||||
fx.sessions.session_for_agent(&aid(2)),
|
||||
fx.sessions
|
||||
.session_for_agent_in_project(project_id(), &aid(2)),
|
||||
Some(sid(802)),
|
||||
"target stays alive for the next turn"
|
||||
);
|
||||
@ -3527,7 +3548,7 @@ async fn submit_and_ask_share_one_fifo_per_agent() {
|
||||
|
||||
// Unblock the delegation so the spawned task ends cleanly.
|
||||
fx.mailbox
|
||||
.cancel_head(aid(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
.cancel_head(rkey(1), fx.mailbox.ticket_ids(&aid(1))[0]);
|
||||
let _ = timeout(TEST_GUARD, ask).await;
|
||||
}
|
||||
|
||||
@ -3821,7 +3842,10 @@ async fn run_ask_roundtrip(
|
||||
let ask = tokio::spawn(async move { svc.dispatch(&project(), cmd(&json)).await });
|
||||
await_until(|| fx.mailbox.pending(&reply_from) == 1).await;
|
||||
fx.mailbox
|
||||
.resolve(reply_from, result.to_owned())
|
||||
.resolve(
|
||||
RuntimeAgentKey::new(project_id(), reply_from),
|
||||
result.to_owned(),
|
||||
)
|
||||
.expect("structured completion ok");
|
||||
timeout(TEST_GUARD, ask)
|
||||
.await
|
||||
@ -3933,6 +3957,7 @@ async fn p6b_agent_requester_records_pair_on_a_b_thread() {
|
||||
let convs = TestConversations::new();
|
||||
let expected = convs
|
||||
.resolve(
|
||||
project_id(),
|
||||
ConversationParty::agent(a),
|
||||
ConversationParty::agent(aid(1)),
|
||||
)
|
||||
@ -4168,6 +4193,7 @@ struct NoopResumer;
|
||||
impl AgentResumer for NoopResumer {
|
||||
async fn resume(
|
||||
&self,
|
||||
_project_id: ProjectId,
|
||||
_agent_id: AgentId,
|
||||
_node_id: NodeId,
|
||||
_conversation_id: Option<String>,
|
||||
@ -4291,7 +4317,9 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||
|
||||
// Pré-condition : aucune session structurée vivante pour la cible (elle est froide).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_none(),
|
||||
fx.structured
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_none(),
|
||||
"cible structurée froide : aucune session avant l'ask"
|
||||
);
|
||||
|
||||
@ -4315,7 +4343,9 @@ async fn ask_cold_structured_target_autolaunches_session_and_final_unblocks() {
|
||||
);
|
||||
// La session est désormais vivante dans le registre partagé (insérée par le launcher).
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||
fx.structured
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_some(),
|
||||
"la session auto-lancée est enregistrée dans StructuredSessions"
|
||||
);
|
||||
// Chemin structuré ⇒ **aucun** PTY spawné (la cible n'a pas de terminal).
|
||||
@ -4375,11 +4405,15 @@ async fn ask_structured_target_live_as_pty_keeps_visible_terminal() {
|
||||
assert_eq!(fx.factory.start_count(), 1, "headless session started");
|
||||
assert!(fx.pty.kills().is_empty(), "visible PTY must not be stopped");
|
||||
assert!(
|
||||
fx.structured.session_for_agent(&aid(1)).is_some(),
|
||||
fx.structured
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_some(),
|
||||
"target now has a structured session"
|
||||
);
|
||||
assert!(
|
||||
fx.sessions.session_for_agent(&aid(1)).is_some(),
|
||||
fx.sessions
|
||||
.session_for_agent_in_project(project_id(), &aid(1))
|
||||
.is_some(),
|
||||
"target still has its visible PTY session"
|
||||
);
|
||||
}
|
||||
@ -4392,7 +4426,8 @@ async fn ask_structured_rate_limit_arms_target_resume_events() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
let resets_at_ms = 9_999_999;
|
||||
fx.structured.insert(
|
||||
fx.structured.insert_in_project(
|
||||
project_id(),
|
||||
Arc::new(RateLimitedSession {
|
||||
id: sid(9700),
|
||||
conversation_id: Some("engine-target".to_owned()),
|
||||
@ -4443,7 +4478,8 @@ async fn ask_structured_rate_limit_arms_target_resume_events() {
|
||||
async fn ask_structured_rate_limit_without_conversation_uses_human_fallback_no_resume() {
|
||||
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
||||
let fx = structured_ask_fixture(FakeContexts::with_agent(&agent, "# persona"));
|
||||
fx.structured.insert(
|
||||
fx.structured.insert_in_project(
|
||||
project_id(),
|
||||
Arc::new(RateLimitedSession {
|
||||
id: sid(9701),
|
||||
conversation_id: None,
|
||||
|
||||
Reference in New Issue
Block a user