Comble le trou historique : un agent qui en interroge un autre reçoit enfin
le CONTENU de la réponse, plus seulement un ACK de cycle de vie.
- domain : OrchestratorCommand::AskAgent { target, task } + action wire
agent.message (target+task requis) ; DomainEvent::AgentReplied
{ agent_id, reply_len } (bus I/O-free, le contenu remonte par l'outcome).
- application : OrchestratorOutcome gagne reply: Option<String>. Méthode
ask_agent (§17.4) — cible structurée vivante ⇒ send_blocking direct ;
cible morte ⇒ LaunchAgent structuré puis send ; agent vivant en PTY ou
profil sans structured_adapter ⇒ Invalid (non adressable, jamais d'ACK
trompeur) ; agent inconnu ⇒ NotFound ; service non câblé ⇒ Invalid.
Timeout (300s) ⇒ erreur typée SANS tuer la session. Succès ⇒ publie
AgentReplied + reply: Some(content). Injection additive via builders
with_structured / with_events (call sites legacy intacts).
- app-tauri : state câble with_structured/with_events ; DTO AgentReplied.
Tests (QA) : +14 verts (11 app + 3 domaine), invariants validés par
mutation test (reply!=None, timeout ne shutdown pas). cargo test
--workspace : 817 passed, 0 failed.
Suivi (D6b) : surfacer reply dans le writer wire .response.json
(infrastructure/orchestrator) pour la délégation par protocole fichier.
Reste D7 : menu restreint Claude/Codex + retrait custom.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1279 lines
41 KiB
Rust
1279 lines
41 KiB
Rust
//! Integration tests for [`OrchestratorService`] (ARCHITECTURE §14.3).
|
|
//!
|
|
//! The service is wired over the *real* agent/terminal use cases, themselves
|
|
//! backed by in-memory fakes (the same fake patterns as `agent_lifecycle.rs`).
|
|
//! This proves the dispatch contract end-to-end without real I/O:
|
|
//!
|
|
//! - `spawn_agent` on an **unknown** agent → create + launch (manifest grows, PTY
|
|
//! spawns, `AgentLaunched` published),
|
|
//! - `spawn_agent` on a **known** agent → launch only (no second manifest entry),
|
|
//! - `stop_agent` → the agent's live session is killed and de-registered,
|
|
//! - `update_agent_context` → the agent `.md` is overwritten,
|
|
//! - unknown profile / unknown agent → `NotFound`, no spawn.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
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::markdown::MarkdownDoc;
|
|
use domain::ports::{
|
|
AgentContextStore, AgentRuntime, AgentSession, AgentSessionError, AgentSessionFactory,
|
|
ContextInjectionPlan, DirEntry, EventBus, EventStream, ExitStatus, FileSystem, FsError,
|
|
IdGenerator, OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort,
|
|
RemotePath, ReplyEvent, ReplyStream, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
|
|
StoreError,
|
|
};
|
|
use domain::profile::{AgentProfile, ContextInjection, StructuredAdapter};
|
|
use domain::project::{Project, ProjectPath};
|
|
use domain::remote::RemoteRef;
|
|
use domain::skill::{Skill, SkillScope};
|
|
use domain::terminal::{SessionKind, TerminalSession};
|
|
use domain::{OrchestratorCommand, OrchestratorRequest, PtySize, SessionId};
|
|
use uuid::Uuid;
|
|
|
|
use application::{
|
|
CloseTerminal, CreateAgentFromScratch, CreateSkill, LaunchAgent, ListAgents,
|
|
OrchestratorService, StructuredSessions, TerminalSessions, UpdateAgentContext,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes (mirror agent_lifecycle.rs)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Default)]
|
|
struct ContextsInner {
|
|
manifest: AgentManifest,
|
|
contents: HashMap<String, String>,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeContexts(Arc<Mutex<ContextsInner>>);
|
|
|
|
impl FakeContexts {
|
|
fn new() -> Self {
|
|
Self(Arc::new(Mutex::new(ContextsInner {
|
|
manifest: AgentManifest {
|
|
version: 1,
|
|
entries: Vec::new(),
|
|
},
|
|
contents: HashMap::new(),
|
|
})))
|
|
}
|
|
fn with_agent(agent: &Agent, content: &str) -> Self {
|
|
let me = Self::new();
|
|
{
|
|
let mut inner = me.0.lock().unwrap();
|
|
inner
|
|
.manifest
|
|
.entries
|
|
.push(ManifestEntry::from_agent(agent));
|
|
inner
|
|
.contents
|
|
.insert(agent.context_path.clone(), content.to_owned());
|
|
}
|
|
me
|
|
}
|
|
fn manifest(&self) -> AgentManifest {
|
|
self.0.lock().unwrap().manifest.clone()
|
|
}
|
|
fn content(&self, md_path: &str) -> Option<String> {
|
|
self.0.lock().unwrap().contents.get(md_path).cloned()
|
|
}
|
|
fn md_path_of(&self, agent: &AgentId) -> Option<String> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.manifest
|
|
.entries
|
|
.iter()
|
|
.find(|e| &e.agent_id == agent)
|
|
.map(|e| e.md_path.clone())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentContextStore for FakeContexts {
|
|
async fn read_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
) -> Result<MarkdownDoc, StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.content(&md_path)
|
|
.map(MarkdownDoc::new)
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn write_context(
|
|
&self,
|
|
_project: &Project,
|
|
agent: &AgentId,
|
|
md: &MarkdownDoc,
|
|
) -> Result<(), StoreError> {
|
|
let md_path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.contents
|
|
.insert(md_path, md.as_str().to_owned());
|
|
Ok(())
|
|
}
|
|
async fn load_manifest(&self, _project: &Project) -> Result<AgentManifest, StoreError> {
|
|
Ok(self.manifest())
|
|
}
|
|
async fn save_manifest(
|
|
&self,
|
|
_project: &Project,
|
|
manifest: &AgentManifest,
|
|
) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().manifest = manifest.clone();
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakeProfiles(Arc<Vec<AgentProfile>>);
|
|
impl FakeProfiles {
|
|
fn new(profiles: Vec<AgentProfile>) -> Self {
|
|
Self(Arc::new(profiles))
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok((*self.0).clone())
|
|
}
|
|
async fn save(&self, _profile: &AgentProfile) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
Ok(true)
|
|
}
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
// Empty skill store: the orchestrator tests spawn agents with no assigned skills.
|
|
#[derive(Default)]
|
|
struct FakeSkills;
|
|
#[async_trait]
|
|
impl SkillStore for FakeSkills {
|
|
async fn list(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
) -> Result<Vec<Skill>, StoreError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
Err(StoreError::NotFound)
|
|
}
|
|
async fn save(&self, _skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// An empty [`MemoryRecall`]: no project memory ⇒ no memory section injected,
|
|
/// leaving the launch behaviour unchanged for the service-level tests.
|
|
#[derive(Default)]
|
|
struct FakeRecall;
|
|
#[async_trait]
|
|
impl domain::ports::MemoryRecall for FakeRecall {
|
|
async fn recall(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_query: &domain::ports::MemoryQuery,
|
|
) -> Result<Vec<domain::MemoryIndexEntry>, domain::ports::MemoryError> {
|
|
Ok(Vec::new())
|
|
}
|
|
}
|
|
|
|
/// A [`SkillStore`] that records every `save` so a `create_skill` dispatch can be
|
|
/// asserted against the persisted skill (name + scope).
|
|
#[derive(Clone, Default)]
|
|
struct RecordingSkills(Arc<Mutex<Vec<Skill>>>);
|
|
|
|
#[async_trait]
|
|
impl SkillStore for RecordingSkills {
|
|
async fn list(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
) -> Result<Vec<Skill>, StoreError> {
|
|
Ok(self.0.lock().unwrap().clone())
|
|
}
|
|
async fn get(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
id: SkillId,
|
|
) -> Result<Skill, StoreError> {
|
|
self.0
|
|
.lock()
|
|
.unwrap()
|
|
.iter()
|
|
.find(|s| s.id == id)
|
|
.cloned()
|
|
.ok_or(StoreError::NotFound)
|
|
}
|
|
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
|
self.0.lock().unwrap().push(skill.clone());
|
|
Ok(())
|
|
}
|
|
async fn delete(
|
|
&self,
|
|
_scope: SkillScope,
|
|
_root: &ProjectPath,
|
|
_id: SkillId,
|
|
) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
struct FakeRuntime;
|
|
impl AgentRuntime for FakeRuntime {
|
|
fn detect(&self, _profile: &AgentProfile) -> Result<bool, RuntimeError> {
|
|
Ok(true)
|
|
}
|
|
fn prepare_invocation(
|
|
&self,
|
|
profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
) -> Result<SpawnSpec, RuntimeError> {
|
|
Ok(SpawnSpec {
|
|
command: profile.command.clone(),
|
|
args: profile.args.clone(),
|
|
cwd: cwd.clone(),
|
|
env: Vec::new(),
|
|
context_plan: Some(ContextInjectionPlan::Stdin),
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
struct FakeFs;
|
|
#[async_trait]
|
|
impl FileSystem for FakeFs {
|
|
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
|
Err(FsError::NotFound(path.as_str().to_owned()))
|
|
}
|
|
async fn write(&self, _path: &RemotePath, _data: &[u8]) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
async fn exists(&self, _path: &RemotePath) -> Result<bool, FsError> {
|
|
Ok(false)
|
|
}
|
|
async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
struct FakePty {
|
|
next_id: SessionId,
|
|
kills: Arc<Mutex<Vec<SessionId>>>,
|
|
spawns: Arc<Mutex<Vec<SessionId>>>,
|
|
}
|
|
impl FakePty {
|
|
fn new(next_id: SessionId) -> Self {
|
|
Self {
|
|
next_id,
|
|
kills: Arc::new(Mutex::new(Vec::new())),
|
|
spawns: Arc::new(Mutex::new(Vec::new())),
|
|
}
|
|
}
|
|
fn spawns(&self) -> Vec<SessionId> {
|
|
self.spawns.lock().unwrap().clone()
|
|
}
|
|
fn kills(&self) -> Vec<SessionId> {
|
|
self.kills.lock().unwrap().clone()
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl PtyPort for FakePty {
|
|
async fn spawn(&self, _spec: SpawnSpec, _size: PtySize) -> Result<PtyHandle, PtyError> {
|
|
self.spawns.lock().unwrap().push(self.next_id);
|
|
Ok(PtyHandle {
|
|
session_id: self.next_id,
|
|
})
|
|
}
|
|
fn write(&self, _handle: &PtyHandle, _data: &[u8]) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
fn resize(&self, _handle: &PtyHandle, _size: PtySize) -> Result<(), PtyError> {
|
|
Ok(())
|
|
}
|
|
fn subscribe_output(&self, _handle: &PtyHandle) -> Result<OutputStream, PtyError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
fn scrollback(&self, _handle: &PtyHandle) -> Result<Vec<u8>, PtyError> {
|
|
Ok(Vec::new())
|
|
}
|
|
async fn kill(&self, handle: &PtyHandle) -> Result<ExitStatus, PtyError> {
|
|
self.kills.lock().unwrap().push(handle.session_id);
|
|
Ok(ExitStatus { code: Some(0) })
|
|
}
|
|
}
|
|
|
|
#[derive(Default, Clone)]
|
|
struct SpyBus(Arc<Mutex<Vec<DomainEvent>>>);
|
|
impl SpyBus {
|
|
fn events(&self) -> Vec<DomainEvent> {
|
|
self.0.lock().unwrap().clone()
|
|
}
|
|
}
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// D6 fakes: structured session + factory (inter-agent messaging)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// What the scripted session does on its next `send`.
|
|
enum SendScript {
|
|
/// Emit this event stream verbatim (drained to its `Final`).
|
|
Stream(Vec<ReplyEvent>),
|
|
/// Sleep `delay` *before* emitting the stream — forces a `send_blocking` timeout.
|
|
Delayed {
|
|
delay: std::time::Duration,
|
|
events: Vec<ReplyEvent>,
|
|
},
|
|
}
|
|
|
|
/// A fake [`AgentSession`] that records every `send` prompt and every `shutdown`,
|
|
/// so a test can assert the rendezvous happened (send) and the session was *not*
|
|
/// killed (zero shutdowns) on timeout.
|
|
struct FakeSession {
|
|
id: SessionId,
|
|
script: Mutex<Option<SendScript>>,
|
|
sends: Arc<Mutex<Vec<String>>>,
|
|
shutdowns: Arc<Mutex<usize>>,
|
|
}
|
|
impl FakeSession {
|
|
fn new(id: SessionId, script: SendScript) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
id,
|
|
script: Mutex::new(Some(script)),
|
|
sends: Arc::new(Mutex::new(Vec::new())),
|
|
shutdowns: Arc::new(Mutex::new(0)),
|
|
})
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl AgentSession for FakeSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
fn conversation_id(&self) -> Option<String> {
|
|
None
|
|
}
|
|
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
self.sends.lock().unwrap().push(prompt.to_owned());
|
|
let script = self
|
|
.script
|
|
.lock()
|
|
.unwrap()
|
|
.take()
|
|
.expect("send scripted exactly once");
|
|
match script {
|
|
SendScript::Stream(events) => Ok(Box::new(events.into_iter())),
|
|
SendScript::Delayed { delay, events } => {
|
|
tokio::time::sleep(delay).await;
|
|
Ok(Box::new(events.into_iter()))
|
|
}
|
|
}
|
|
}
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
*self.shutdowns.lock().unwrap() += 1;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// A fake [`AgentSessionFactory`] that hands out a pre-built [`FakeSession`] on
|
|
/// `start`, so launching a *dead* target in structured mode registers a session
|
|
/// the orchestrator can then drive. `supports` is true for any profile bearing a
|
|
/// `structured_adapter` (mirrors the real factory's gate).
|
|
struct FakeFactory {
|
|
session: Arc<FakeSession>,
|
|
starts: Arc<Mutex<usize>>,
|
|
}
|
|
impl FakeFactory {
|
|
fn new(session: Arc<FakeSession>) -> Self {
|
|
Self {
|
|
session,
|
|
starts: Arc::new(Mutex::new(0)),
|
|
}
|
|
}
|
|
}
|
|
#[async_trait]
|
|
impl AgentSessionFactory for FakeFactory {
|
|
fn supports(&self, profile: &AgentProfile) -> bool {
|
|
profile.structured_adapter.is_some()
|
|
}
|
|
async fn start(
|
|
&self,
|
|
_profile: &AgentProfile,
|
|
_ctx: &PreparedContext,
|
|
_cwd: &ProjectPath,
|
|
_session: &SessionPlan,
|
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
|
*self.starts.lock().unwrap() += 1;
|
|
Ok(Arc::clone(&self.session) as Arc<dyn AgentSession>)
|
|
}
|
|
}
|
|
|
|
fn final_(c: &str) -> ReplyEvent {
|
|
ReplyEvent::Final {
|
|
content: c.to_owned(),
|
|
}
|
|
}
|
|
|
|
struct SeqIds(Mutex<u128>);
|
|
impl SeqIds {
|
|
fn new() -> Self {
|
|
Self(Mutex::new(1))
|
|
}
|
|
}
|
|
impl IdGenerator for SeqIds {
|
|
fn new_uuid(&self) -> Uuid {
|
|
let mut n = self.0.lock().unwrap();
|
|
let id = Uuid::from_u128(*n);
|
|
*n += 1;
|
|
id
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Builders
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn pid(n: u128) -> ProfileId {
|
|
ProfileId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn aid(n: u128) -> AgentId {
|
|
AgentId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn sid(n: u128) -> SessionId {
|
|
SessionId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
fn nid(n: u128) -> NodeId {
|
|
NodeId::from_uuid(Uuid::from_u128(n))
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1000)),
|
|
"demo",
|
|
ProjectPath::new("/home/me/proj").unwrap(),
|
|
RemoteRef::local(),
|
|
1_700_000_000_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn claude_profile() -> AgentProfile {
|
|
AgentProfile::new(
|
|
pid(9),
|
|
"Claude Code",
|
|
"claude",
|
|
Vec::new(),
|
|
ContextInjection::stdin(),
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn scratch_agent(id: AgentId, name: &str, md: &str) -> Agent {
|
|
Agent::new(id, name, md, pid(9), AgentOrigin::Scratch, false).unwrap()
|
|
}
|
|
|
|
/// Same id (`pid(9)`) as [`claude_profile`] so agents created with it resolve to a
|
|
/// structured-capable profile, but bearing a `structured_adapter` so `LaunchAgent`
|
|
/// routes through the (fake) [`AgentSessionFactory`] instead of the PTY.
|
|
fn structured_profile() -> AgentProfile {
|
|
AgentProfile::new(
|
|
pid(9),
|
|
"Claude Code",
|
|
"claude",
|
|
Vec::new(),
|
|
ContextInjection::stdin(),
|
|
Some("claude --version".to_owned()),
|
|
"{agentRunDir}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
.with_structured_adapter(StructuredAdapter::Claude)
|
|
}
|
|
|
|
/// Everything wired for a dispatch test.
|
|
struct Fixture {
|
|
service: OrchestratorService,
|
|
contexts: FakeContexts,
|
|
pty: FakePty,
|
|
bus: SpyBus,
|
|
sessions: Arc<TerminalSessions>,
|
|
skills: RecordingSkills,
|
|
}
|
|
|
|
fn fixture(contexts: FakeContexts) -> Fixture {
|
|
let profiles = Arc::new(FakeProfiles::new(vec![claude_profile()]));
|
|
let sessions = Arc::new(TerminalSessions::new());
|
|
let pty = FakePty::new(sid(777));
|
|
let bus = SpyBus::default();
|
|
|
|
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 list = Arc::new(ListAgents::new(Arc::new(contexts.clone())));
|
|
let close = Arc::new(CloseTerminal::new(
|
|
Arc::new(pty.clone()),
|
|
Arc::clone(&sessions),
|
|
));
|
|
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
|
let skills = RecordingSkills::default();
|
|
let create_skill = Arc::new(CreateSkill::new(
|
|
Arc::new(skills.clone()) as Arc<dyn SkillStore>,
|
|
Arc::new(SeqIds::new()),
|
|
));
|
|
|
|
let service = OrchestratorService::new(
|
|
create,
|
|
launch,
|
|
list,
|
|
close,
|
|
update,
|
|
create_skill,
|
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
|
Arc::clone(&sessions),
|
|
);
|
|
|
|
Fixture {
|
|
service,
|
|
contexts,
|
|
pty,
|
|
bus,
|
|
sessions,
|
|
skills,
|
|
}
|
|
}
|
|
|
|
fn cmd(json: &str) -> OrchestratorCommand {
|
|
serde_json::from_str::<OrchestratorRequest>(json)
|
|
.unwrap()
|
|
.validate()
|
|
.unwrap()
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// D6 fixture: orchestrator wired for `agent.message` (inter-agent rendezvous)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Everything wired for an `agent.message` dispatch test (§17.4).
|
|
struct AskFixture {
|
|
service: OrchestratorService,
|
|
structured: Arc<StructuredSessions>,
|
|
/// PTY (terminal) registry the service holds — lets a test register a raw
|
|
/// terminal session for an agent to exercise the PTY-only-live branch.
|
|
sessions: Arc<TerminalSessions>,
|
|
bus: SpyBus,
|
|
/// PTY spawn/kill spy of the underlying `LaunchAgent` — used to prove a
|
|
/// dead-target launch did *not* fall back to a raw PTY (zero spawns).
|
|
pty: FakePty,
|
|
/// `start` counter of the fake factory (dead-target launch path).
|
|
factory_starts: Arc<Mutex<usize>>,
|
|
}
|
|
|
|
/// Builds an orchestrator with the structured registry + event bus wired, and a
|
|
/// `LaunchAgent` whose structured routing uses `factory` over `profiles`.
|
|
///
|
|
/// `pty_only` controls the launched profile: `false` ⇒ structured-capable profile
|
|
/// (factory hands out a session); `true` ⇒ plain PTY profile (no
|
|
/// `structured_adapter`) so `launched.structured` stays `None`.
|
|
fn ask_fixture(
|
|
contexts: FakeContexts,
|
|
factory_session: Arc<FakeSession>,
|
|
pty_only: bool,
|
|
) -> AskFixture {
|
|
let profile = if pty_only {
|
|
claude_profile()
|
|
} else {
|
|
structured_profile()
|
|
};
|
|
let profiles = Arc::new(FakeProfiles::new(vec![profile]));
|
|
let sessions = Arc::new(TerminalSessions::new());
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
let pty = FakePty::new(sid(777));
|
|
let bus = SpyBus::default();
|
|
let factory = Arc::new(FakeFactory::new(factory_session));
|
|
let factory_starts = Arc::clone(&factory.starts);
|
|
|
|
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,
|
|
)
|
|
.with_structured(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()),
|
|
Arc::clone(&sessions),
|
|
));
|
|
let update = Arc::new(UpdateAgentContext::new(Arc::new(contexts.clone())));
|
|
let skills = RecordingSkills::default();
|
|
let create_skill = Arc::new(CreateSkill::new(
|
|
Arc::new(skills) as Arc<dyn SkillStore>,
|
|
Arc::new(SeqIds::new()),
|
|
));
|
|
|
|
let service = OrchestratorService::new(
|
|
create,
|
|
launch,
|
|
list,
|
|
close,
|
|
update,
|
|
create_skill,
|
|
Arc::clone(&profiles) as Arc<dyn ProfileStore>,
|
|
Arc::clone(&sessions),
|
|
)
|
|
.with_structured(Arc::clone(&structured))
|
|
.with_events(Arc::new(bus.clone()));
|
|
|
|
AskFixture {
|
|
service,
|
|
structured,
|
|
sessions,
|
|
bus,
|
|
pty,
|
|
factory_starts,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[tokio::test]
|
|
async fn spawn_unknown_agent_creates_then_launches() {
|
|
let fx = fixture(FakeContexts::new());
|
|
|
|
let out = fx
|
|
.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude-code" }"#),
|
|
)
|
|
.await
|
|
.expect("dispatch ok");
|
|
assert!(out.detail.contains("dev-backend"));
|
|
|
|
// The agent was created (manifest grew to one entry) and launched (session
|
|
// registered as an agent, AgentLaunched published).
|
|
let manifest = fx.contexts.manifest();
|
|
assert_eq!(manifest.entries.len(), 1);
|
|
assert_eq!(manifest.entries[0].name, "dev-backend");
|
|
|
|
assert!(fx.sessions.session(&sid(777)).is_some());
|
|
let launched = fx.bus.events().into_iter().any(
|
|
|e| matches!(e, DomainEvent::AgentLaunched { session_id, .. } if session_id == sid(777)),
|
|
);
|
|
assert!(launched, "AgentLaunched must be published");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_known_agent_launches_without_recreating() {
|
|
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
|
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
|
|
|
fx.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude-code" }"#),
|
|
)
|
|
.await
|
|
.expect("dispatch ok");
|
|
|
|
// No second manifest entry — the existing agent was reused, just launched.
|
|
assert_eq!(fx.contexts.manifest().entries.len(), 1);
|
|
assert_eq!(fx.contexts.manifest().entries[0].agent_id, agent.id);
|
|
assert!(fx.sessions.session(&sid(777)).is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_run_existing_agent_does_not_require_profile() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
|
|
|
fx.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"Analyse" }"#),
|
|
)
|
|
.await
|
|
.expect("dispatch ok");
|
|
|
|
assert_eq!(fx.contexts.manifest().entries.len(), 1);
|
|
assert!(fx.sessions.session(&sid(777)).is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn agent_run_visible_reattaches_existing_session() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
|
let first_cell = nid(10);
|
|
let next_cell = nid(20);
|
|
|
|
fx.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(&format!(
|
|
r#"{{ "action":"spawn_agent", "name":"architect", "profile":"claude", "visibility":"visible", "nodeId":"{first_cell}" }}"#
|
|
)),
|
|
)
|
|
.await
|
|
.expect("first launch ok");
|
|
|
|
let out = fx
|
|
.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(&format!(
|
|
r#"{{ "type":"agent.run", "targetAgent":"architect", "visibility":"visible", "attachToCell":"{next_cell}" }}"#
|
|
)),
|
|
)
|
|
.await
|
|
.expect("reattach ok");
|
|
|
|
assert!(out.detail.contains("attached agent architect"));
|
|
let session = fx.sessions.session(&sid(777)).expect("live session");
|
|
assert_eq!(session.node_id, next_cell);
|
|
assert_eq!(fx.pty.spawns().len(), 1, "reattach must not respawn");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stop_agent_kills_the_right_session() {
|
|
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
|
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
|
|
|
// Launch it first so a session is registered for the agent.
|
|
fx.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "action":"spawn_agent", "name":"dev-backend", "profile":"claude" }"#),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(fx.sessions.session(&sid(777)).is_some());
|
|
|
|
// Now stop it.
|
|
fx.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#),
|
|
)
|
|
.await
|
|
.expect("stop ok");
|
|
|
|
// The PTY for that session was killed and the session de-registered.
|
|
assert_eq!(fx.pty.kills(), vec![sid(777)]);
|
|
assert!(fx.sessions.session(&sid(777)).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stop_agent_without_live_session_is_not_found() {
|
|
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
|
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
|
|
|
let err = fx
|
|
.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "action":"stop_agent", "name":"dev-backend" }"#),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_agent_context_overwrites_md() {
|
|
let agent = scratch_agent(aid(1), "dev-backend", "agents/dev-backend.md");
|
|
let fx = fixture(FakeContexts::with_agent(&agent, "# old"));
|
|
|
|
fx.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(
|
|
r##"{ "action":"update_agent_context", "name":"dev-backend", "context":"# new body" }"##,
|
|
),
|
|
)
|
|
.await
|
|
.expect("update ok");
|
|
|
|
assert_eq!(
|
|
fx.contexts.content("agents/dev-backend.md").as_deref(),
|
|
Some("# new body")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn spawn_with_unknown_profile_is_not_found_and_does_not_create() {
|
|
let fx = fixture(FakeContexts::new());
|
|
|
|
let err = fx
|
|
.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "action":"spawn_agent", "name":"x", "profile":"does-not-exist" }"#),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
|
|
// No agent was created when the profile could not be resolved.
|
|
assert!(fx.contexts.manifest().entries.is_empty());
|
|
assert!(fx.sessions.session(&sid(777)).is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_skill_persists_a_project_scoped_skill_by_default() {
|
|
let fx = fixture(FakeContexts::new());
|
|
|
|
let out = fx
|
|
.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r##"{ "action":"create_skill", "name":"deploy", "context":"# Deploy" }"##),
|
|
)
|
|
.await
|
|
.expect("create_skill ok");
|
|
|
|
assert!(out.detail.contains("deploy"));
|
|
|
|
let saved = fx.skills.0.lock().unwrap();
|
|
assert_eq!(saved.len(), 1);
|
|
assert_eq!(saved[0].name, "deploy");
|
|
assert_eq!(saved[0].scope, SkillScope::Project);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn create_skill_honours_global_scope() {
|
|
let fx = fixture(FakeContexts::new());
|
|
|
|
fx.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(
|
|
r##"{ "action":"create_skill", "name":"shared", "context":"# Shared", "scope":"global" }"##,
|
|
),
|
|
)
|
|
.await
|
|
.expect("create_skill ok");
|
|
|
|
let saved = fx.skills.0.lock().unwrap();
|
|
assert_eq!(saved.len(), 1);
|
|
assert_eq!(saved[0].scope, SkillScope::Global);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// D6 — agent.message (synchronous inter-agent rendezvous, §17.4)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ASK_JSON: &str =
|
|
r#"{ "type":"agent.message", "requestedBy":"Main", "targetAgent":"architect", "task":"Analyse §17" }"#;
|
|
|
|
/// Zone 1 — live structured target ⇒ `send_blocking` is driven and the outcome's
|
|
/// `reply` carries the turn's `Final` content. Anti-always-green: the assertion is
|
|
/// on the *exact* content, so it fails for `None` or any other string.
|
|
#[tokio::test]
|
|
async fn ask_live_structured_target_returns_final_content() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
// A live session already registered for the agent in the *structured* registry.
|
|
let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("the §17 answer")]));
|
|
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
throwaway,
|
|
false,
|
|
);
|
|
fx.structured
|
|
.insert(Arc::clone(&session) as Arc<dyn AgentSession>, aid(1), nid(1));
|
|
|
|
let out = fx
|
|
.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.expect("ask ok");
|
|
|
|
// The exact Final content is returned (would fail on None or any other text).
|
|
assert_eq!(out.reply.as_deref(), Some("the §17 answer"));
|
|
// The prompt actually reached the live session.
|
|
assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]);
|
|
// Live session reused: factory never started a new one, no PTY spawned.
|
|
assert_eq!(*fx.factory_starts.lock().unwrap(), 0);
|
|
assert!(fx.pty.spawns().is_empty(), "must not spawn a PTY");
|
|
// Session not killed.
|
|
assert_eq!(*session.shutdowns.lock().unwrap(), 0);
|
|
}
|
|
|
|
/// Anti-always-green guard, explicit negative: the same fixture but the session
|
|
/// returns a *different* Final ⇒ the strict equality from the test above must NOT
|
|
/// hold. Proves the assertion has teeth.
|
|
#[tokio::test]
|
|
async fn ask_reply_assertion_is_content_sensitive() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_("SOMETHING ELSE")]));
|
|
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
throwaway,
|
|
false,
|
|
);
|
|
fx.structured
|
|
.insert(session as Arc<dyn AgentSession>, aid(1), nid(1));
|
|
|
|
let out = fx
|
|
.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.expect("ask ok");
|
|
assert_ne!(out.reply.as_deref(), Some("the §17 answer"));
|
|
assert_eq!(out.reply.as_deref(), Some("SOMETHING ELSE"));
|
|
}
|
|
|
|
/// Zone 4 — on success, `DomainEvent::AgentReplied` is published with the right
|
|
/// agent id and `reply_len` (byte length of the content).
|
|
#[tokio::test]
|
|
async fn ask_success_publishes_agent_replied_event() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let content = "réponse"; // multibyte: len() in bytes, not chars
|
|
let session = FakeSession::new(sid(500), SendScript::Stream(vec![final_(content)]));
|
|
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
throwaway,
|
|
false,
|
|
);
|
|
fx.structured
|
|
.insert(session as Arc<dyn AgentSession>, aid(1), nid(1));
|
|
|
|
fx.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.expect("ask ok");
|
|
|
|
let replied = fx
|
|
.bus
|
|
.events()
|
|
.into_iter()
|
|
.find_map(|e| match e {
|
|
DomainEvent::AgentReplied {
|
|
agent_id,
|
|
reply_len,
|
|
} => Some((agent_id, reply_len)),
|
|
_ => None,
|
|
})
|
|
.expect("AgentReplied must be published");
|
|
assert_eq!(replied.0, aid(1));
|
|
assert_eq!(replied.1, content.len());
|
|
}
|
|
|
|
/// Zone 2 (orchestrator seam) — a failing turn (stream ends without a `Final`)
|
|
/// surfaces a typed `PROCESS` error WITHOUT the orchestrator killing the session,
|
|
/// and the session stays registered (retry possible). The *genuine* timeout
|
|
/// variant of this invariant is asserted in
|
|
/// [`timeout_invariant_send_blocking_does_not_kill_session`] (the 300s prod bound
|
|
/// cannot be awaited here, so we drive the same "never shutdown on ask" branch via
|
|
/// the no-`Final` error and the bounded `send_blocking` directly).
|
|
#[tokio::test]
|
|
async fn ask_failed_turn_does_not_kill_session_and_errors() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let session = FakeSession::new(sid(500), SendScript::Stream(vec![/* no Final */]));
|
|
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
throwaway,
|
|
false,
|
|
);
|
|
fx.structured
|
|
.insert(Arc::clone(&session) as Arc<dyn AgentSession>, aid(1), nid(1));
|
|
|
|
let err = fx
|
|
.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
// Typed PROCESS error (send_blocking's AgentSessionError → AppError::Process).
|
|
assert_eq!(err.code(), "PROCESS", "got {err:?}");
|
|
// Session NOT killed and STILL registered (retry possible).
|
|
assert_eq!(
|
|
*session.shutdowns.lock().unwrap(),
|
|
0,
|
|
"ask must never shutdown the target session"
|
|
);
|
|
assert!(
|
|
fx.structured.session_for_agent(&aid(1)).is_some(),
|
|
"session must remain in the registry after a failed turn"
|
|
);
|
|
}
|
|
|
|
/// Zone 2 (real timeout) — uses a *short-bounded* `send_blocking` directly against
|
|
/// the orchestrator's session is impossible (bound is internal/300s); instead we
|
|
/// prove the genuine timeout-does-not-kill invariant at the `send_blocking` seam
|
|
/// with a real delayed session, mirroring how the orchestrator awaits it.
|
|
#[tokio::test]
|
|
async fn timeout_invariant_send_blocking_does_not_kill_session() {
|
|
use application::send_blocking;
|
|
let session = FakeSession::new(
|
|
sid(500),
|
|
SendScript::Delayed {
|
|
delay: std::time::Duration::from_secs(1),
|
|
events: vec![final_("too late")],
|
|
},
|
|
);
|
|
let out = send_blocking(
|
|
session.as_ref(),
|
|
"Analyse §17",
|
|
Some(std::time::Duration::from_millis(20)),
|
|
)
|
|
.await;
|
|
assert_eq!(out, Err(AgentSessionError::Timeout));
|
|
assert_eq!(
|
|
*session.shutdowns.lock().unwrap(),
|
|
0,
|
|
"timeout must not kill the session"
|
|
);
|
|
// Negative control: had we waited (no bound) the call would succeed — proves the
|
|
// timeout above is the cause of the error, not a broken session.
|
|
let session2 = FakeSession::new(
|
|
sid(501),
|
|
SendScript::Delayed {
|
|
delay: std::time::Duration::from_millis(5),
|
|
events: vec![final_("eventually")],
|
|
},
|
|
);
|
|
let ok = send_blocking(session2.as_ref(), "x", None).await;
|
|
assert_eq!(ok, Ok("eventually".to_owned()));
|
|
}
|
|
|
|
/// Zone 3 — dead target ⇒ `LaunchAgent` is invoked in structured mode (factory
|
|
/// `start` called, structured session registered) *then* `send_blocking`; the
|
|
/// reply is returned and no raw PTY is spawned.
|
|
#[tokio::test]
|
|
async fn ask_dead_target_launches_structured_then_sends() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
// The session the fake factory will hand out on launch.
|
|
let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("launched reply")]));
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
Arc::clone(&session),
|
|
false,
|
|
);
|
|
// No live session up front.
|
|
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
|
|
|
|
let out = fx
|
|
.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.expect("ask ok");
|
|
|
|
assert_eq!(out.reply.as_deref(), Some("launched reply"));
|
|
// Factory was started exactly once (structured launch), the prompt was sent.
|
|
assert_eq!(*fx.factory_starts.lock().unwrap(), 1);
|
|
assert_eq!(session.sends.lock().unwrap().as_slice(), &["Analyse §17"]);
|
|
// Structured-mode launch ⇒ NO raw PTY spawn.
|
|
assert!(fx.pty.spawns().is_empty(), "structured launch must not spawn a PTY");
|
|
// The session is now registered (1 session/agent).
|
|
assert!(fx.structured.session_for_agent(&aid(1)).is_some());
|
|
}
|
|
|
|
/// Zone 5a — target already live in the **PTY** registry (raw terminal, no
|
|
/// structured channel) ⇒ typed `INVALID`, never an ACK, never a launch.
|
|
#[tokio::test]
|
|
async fn ask_pty_live_target_is_invalid_no_ack() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
throwaway,
|
|
false,
|
|
);
|
|
// Register the agent as live in the **PTY** (terminal) registry only — a raw
|
|
// terminal with no structured reply channel.
|
|
let pty_session = TerminalSession::starting(
|
|
sid(800),
|
|
nid(2),
|
|
project().root.clone(),
|
|
SessionKind::Agent { agent_id: aid(1) },
|
|
PtySize::new(24, 80).unwrap(),
|
|
);
|
|
fx.sessions
|
|
.insert(PtyHandle { session_id: sid(800) }, pty_session);
|
|
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
|
|
|
|
let err = fx
|
|
.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(
|
|
err.code(),
|
|
"INVALID",
|
|
"PTY-live target is not addressable by ask: {err:?}"
|
|
);
|
|
// Never launched, never sent: factory untouched, no PTY spawned.
|
|
assert_eq!(*fx.factory_starts.lock().unwrap(), 0);
|
|
assert!(fx.pty.spawns().is_empty());
|
|
// No structured session conjured.
|
|
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
|
|
}
|
|
|
|
/// Zone 5b — after launching, the target turns out PTY-only (profile without a
|
|
/// `structured_adapter` ⇒ `launched.structured` is `None`) ⇒ typed `INVALID`, no
|
|
/// ACK, and the orchestrator does not pretend a reply.
|
|
#[tokio::test]
|
|
async fn ask_pty_only_profile_after_launch_is_invalid() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
|
|
// pty_only = true ⇒ launched profile has no structured_adapter ⇒ structured None.
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
throwaway,
|
|
true,
|
|
);
|
|
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
|
|
|
|
let err = fx
|
|
.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.unwrap_err();
|
|
|
|
assert_eq!(err.code(), "INVALID", "PTY-only target must error typed: {err:?}");
|
|
// No structured session was registered, no reply pretended.
|
|
assert!(fx.structured.session_for_agent(&aid(1)).is_none());
|
|
}
|
|
|
|
/// Zone 6/7 — structured registry not wired ⇒ `ask` is INVALID (legacy service).
|
|
#[tokio::test]
|
|
async fn ask_without_structured_wired_is_invalid() {
|
|
// The default `fixture` builds the service via `OrchestratorService::new`
|
|
// WITHOUT `.with_structured(...)`, so AskAgent cannot be served.
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let fx = fixture(FakeContexts::with_agent(&agent, "# persona"));
|
|
let err = fx
|
|
.service
|
|
.dispatch(&project(), cmd(ASK_JSON))
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
|
}
|
|
|
|
/// Zone 6/7 — unknown target agent ⇒ typed `NOT_FOUND`, no launch, no reply.
|
|
#[tokio::test]
|
|
async fn ask_unknown_target_is_not_found() {
|
|
let throwaway = FakeSession::new(sid(999), SendScript::Stream(vec![final_("unused")]));
|
|
let fx = ask_fixture(FakeContexts::new(), throwaway, false);
|
|
let err = fx
|
|
.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "type":"agent.message", "targetAgent":"ghost", "task":"hi" }"#),
|
|
)
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
|
assert_eq!(*fx.factory_starts.lock().unwrap(), 0);
|
|
assert!(fx.pty.spawns().is_empty());
|
|
}
|
|
|
|
/// Zone 7 — non-regression: `agent.run` (fire-and-forget) still yields `reply:
|
|
/// None` even through a structured-wired orchestrator.
|
|
#[tokio::test]
|
|
async fn non_regression_agent_run_reply_is_none() {
|
|
let agent = scratch_agent(aid(1), "architect", "agents/architect.md");
|
|
let session = FakeSession::new(sid(700), SendScript::Stream(vec![final_("x")]));
|
|
let fx = ask_fixture(
|
|
FakeContexts::with_agent(&agent, "# persona"),
|
|
session,
|
|
false,
|
|
);
|
|
let out = fx
|
|
.service
|
|
.dispatch(
|
|
&project(),
|
|
cmd(r#"{ "type":"agent.run", "targetAgent":"architect", "task":"go" }"#),
|
|
)
|
|
.await
|
|
.expect("run ok");
|
|
assert_eq!(out.reply, None, "agent.run must not carry a reply");
|
|
}
|