feat(memory): config embedders (LOT C2) + suggestion contextuelle (LOT C3) + contexte projet partagé
- LOT C2 (§14.5.3) : use cases de configuration des embedders déclaratifs (List/Save/Delete + DescribeEmbedderEngines : modèles ONNX recommandés, environnement local détecté, stratégies compilées). UI EmbedderSettings. - LOT C3 (§14.5.5) : suggestion contextuelle best-effort à l'activation quand la mémoire dépasse le budget de recall sans embedder configuré (event EmbedderSuggested, anti-spam 1×/session, « ne plus demander »). - Contexte projet partagé .ideai/CONTEXT.md (model-agnostic) injecté à tous les agents/profils au lancement, avant la persona. UI ProjectContextPanel. Tests : backend workspace vert (0 échec) ; frontend 306/306. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -14,17 +14,18 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, IdGenerator,
|
||||
MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath, SessionPlan,
|
||||
SkillStore, SpawnSpec, StoreError,
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, FsError,
|
||||
IdGenerator, MemoryQuery, MemoryRecall, PreparedContext, ProfileStore, PtyPort, RemotePath,
|
||||
SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::{
|
||||
Agent, AgentId, AgentManifest, AgentOrigin, AgentProfile, ContextInjection, DomainEvent,
|
||||
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, Project, ProfileId,
|
||||
ManifestEntry, MarkdownDoc, MemoryIndexEntry, MemoryType, NodeId, ProfileId, Project,
|
||||
ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
use crate::project::project_context_path;
|
||||
use crate::terminal::TerminalSessions;
|
||||
|
||||
/// Directory (relative to `.ideai/`) under which agent contexts are written.
|
||||
@ -35,7 +36,7 @@ const AGENTS_SUBDIR: &str = "agents";
|
||||
/// (étage 1) handed to the agent. Internal and intentionally **not yet exposed in
|
||||
/// config**: it may later become a per-project setting without changing the
|
||||
/// contract.
|
||||
const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
|
||||
pub const AGENT_MEMORY_RECALL_BUDGET: usize = 2_048;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CreateAgentFromScratch
|
||||
@ -115,7 +116,9 @@ impl CreateAgentFromScratch {
|
||||
entries.push(ManifestEntry::from_agent(&agent));
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
|
||||
// Now the path resolves: write the initial context.
|
||||
let md = MarkdownDoc::new(input.initial_content.unwrap_or_default());
|
||||
@ -171,7 +174,10 @@ impl ListAgents {
|
||||
let agents = manifest
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| e.to_agent().map_err(|err| AppError::Invalid(err.to_string())))
|
||||
.map(|e| {
|
||||
e.to_agent()
|
||||
.map_err(|err| AppError::Invalid(err.to_string()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ListAgentsOutput { agents })
|
||||
}
|
||||
@ -311,7 +317,9 @@ impl DeleteAgent {
|
||||
}
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::LayoutChanged {
|
||||
project_id: input.project.id,
|
||||
});
|
||||
@ -380,6 +388,11 @@ pub struct LaunchAgent {
|
||||
/// file at activation (ARCHITECTURE §14.5.4). Best-effort by contract: an absent
|
||||
/// or empty memory yields an empty list, never blocking a launch.
|
||||
recall: Arc<dyn MemoryRecall>,
|
||||
/// Optional contextual embedder-suggestion check (LOT C3, §14.5.5), run
|
||||
/// best-effort right after the memory recall at activation — the moment an agent
|
||||
/// reads the project memory. `None` keeps the launcher independent of it (legacy
|
||||
/// wiring / tests). A failure here never affects the launch.
|
||||
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
||||
}
|
||||
|
||||
impl LaunchAgent {
|
||||
@ -397,6 +410,7 @@ impl LaunchAgent {
|
||||
events: Arc<dyn EventBus>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
recall: Arc<dyn MemoryRecall>,
|
||||
embedder_suggestion: Option<Arc<crate::embedder::CheckEmbedderSuggestion>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
contexts,
|
||||
@ -409,6 +423,7 @@ impl LaunchAgent {
|
||||
events,
|
||||
ids,
|
||||
recall,
|
||||
embedder_suggestion,
|
||||
}
|
||||
}
|
||||
|
||||
@ -426,7 +441,11 @@ impl LaunchAgent {
|
||||
) -> Result<Vec<Skill>, AppError> {
|
||||
let mut out = Vec::with_capacity(agent.skills.len());
|
||||
for skill_ref in &agent.skills {
|
||||
match self.skills.get(skill_ref.scope, root, skill_ref.skill_id).await {
|
||||
match self
|
||||
.skills
|
||||
.get(skill_ref.scope, root, skill_ref.skill_id)
|
||||
.await
|
||||
{
|
||||
Ok(skill) => out.push(skill),
|
||||
Err(StoreError::NotFound) => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
@ -453,6 +472,19 @@ impl LaunchAgent {
|
||||
self.recall.recall(root, &query).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Reads the shared project context from `.ideai/CONTEXT.md`.
|
||||
///
|
||||
/// A missing file is normal for existing projects and simply omits the
|
||||
/// project-context section from the generated model context.
|
||||
async fn resolve_project_context(&self, project: &Project) -> Result<String, AppError> {
|
||||
match self.fs.read(&project_context_path(project)).await {
|
||||
Ok(bytes) => String::from_utf8(bytes)
|
||||
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}"))),
|
||||
Err(FsError::NotFound(_)) => Ok(String::new()),
|
||||
Err(e) => Err(AppError::FileSystem(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes the launch.
|
||||
///
|
||||
/// Step order is contractually significant (and unit-tested): resolve the
|
||||
@ -486,26 +518,22 @@ impl LaunchAgent {
|
||||
// runs AFTER the NotFound resolution above (so an unknown agent still
|
||||
// errors NotFound) but BEFORE any I/O (run dir, seed, spawn). If the
|
||||
// agent already owns a live session:
|
||||
// - on a *different* (or unspecified) node → refuse the launch with
|
||||
// `AgentAlreadyRunning`, pointing at the existing host cell;
|
||||
// - on the *same* node → idempotent (a concurrent double-launch of the
|
||||
// very same cell): return the existing session without respawning,
|
||||
// assigning no new conversation id.
|
||||
// - with a requested node → rebind the live session to that cell and
|
||||
// return it without respawning;
|
||||
// - without a requested node → idempotent background/no-op launch:
|
||||
// return the existing session without respawning.
|
||||
// The resume path (agent dead ⇒ no live session) is unaffected.
|
||||
if let Some(existing_id) = self.sessions.session_for_agent(&input.agent_id) {
|
||||
let host_node = self
|
||||
.sessions
|
||||
.node_for_agent(&input.agent_id)
|
||||
.expect("a live session always has a host node");
|
||||
let same_node = input.node_id.as_ref() == Some(&host_node);
|
||||
if !same_node {
|
||||
return Err(AppError::AgentAlreadyRunning {
|
||||
agent_id: input.agent_id,
|
||||
node_id: host_node,
|
||||
});
|
||||
if let Some(node_id) = input.node_id {
|
||||
if let Some(session) = self.sessions.rebind_agent_node(&input.agent_id, node_id) {
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
assigned_conversation_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Same node: idempotent — hand back the already-registered session,
|
||||
// no respawn, nothing new to persist.
|
||||
// Idempotent — hand back the already-registered session, no respawn,
|
||||
// nothing new to persist.
|
||||
if let Some(session) = self.sessions.session(&existing_id) {
|
||||
return Ok(LaunchAgentOutput {
|
||||
session,
|
||||
@ -525,9 +553,7 @@ impl LaunchAgent {
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|p| p.id == agent.profile_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!("profile {} for agent", agent.profile_id))
|
||||
})?;
|
||||
.ok_or_else(|| AppError::NotFound(format!("profile {} for agent", agent.profile_id)))?;
|
||||
|
||||
// 3. Compute and create the agent's isolated run directory
|
||||
// `<root>/.ideai/run/<agent-id>/` (ARCHITECTURE §14.1). The PTY cwd is
|
||||
@ -571,13 +597,27 @@ impl LaunchAgent {
|
||||
// 5. Resolve the agent's assigned skills (their `.md` bodies), then apply
|
||||
// the injection plan side effects *before* spawning.
|
||||
let skills = self.resolve_skills(&agent, &input.project.root).await?;
|
||||
let project_context = self.resolve_project_context(&input.project).await?;
|
||||
let memory = self
|
||||
.resolve_memory(&input.project.root, content.as_str())
|
||||
.await;
|
||||
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
|
||||
// has just read the project memory, so this is the moment to check whether a
|
||||
// semantic embedder would now help. Fully isolated from the launch outcome —
|
||||
// an error or absence of the check never affects activation.
|
||||
if let Some(check) = &self.embedder_suggestion {
|
||||
let _ = check
|
||||
.execute(crate::embedder::CheckEmbedderSuggestionInput {
|
||||
project_id: input.project.id,
|
||||
project_root: input.project.root.clone(),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
self.apply_injection(
|
||||
&input.project,
|
||||
&agent.context_path,
|
||||
&content,
|
||||
&project_context,
|
||||
&skills,
|
||||
&memory,
|
||||
&mut spec,
|
||||
@ -598,9 +638,7 @@ impl LaunchAgent {
|
||||
session_id,
|
||||
node_id,
|
||||
spec.cwd.clone(),
|
||||
SessionKind::Agent {
|
||||
agent_id: agent.id,
|
||||
},
|
||||
SessionKind::Agent { agent_id: agent.id },
|
||||
size,
|
||||
);
|
||||
session.status = SessionStatus::Running;
|
||||
@ -718,6 +756,7 @@ impl LaunchAgent {
|
||||
project: &Project,
|
||||
context_rel_path: &str,
|
||||
content: &MarkdownDoc,
|
||||
project_context: &str,
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
spec: &mut SpawnSpec,
|
||||
@ -733,6 +772,7 @@ impl LaunchAgent {
|
||||
// persona `.md`, then the bodies of its assigned skills (§14.2).
|
||||
let document = compose_convention_file(
|
||||
project.root.as_str(),
|
||||
project_context,
|
||||
content.as_str(),
|
||||
skills,
|
||||
memory,
|
||||
@ -769,7 +809,10 @@ fn join(base: &ProjectPath, rel: &str) -> String {
|
||||
/// # Errors
|
||||
/// Propagates [`DomainError`](domain::error::DomainError) if the joined path is
|
||||
/// not a valid [`ProjectPath`] (should not happen for an absolute project root).
|
||||
pub(crate) fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath, domain::error::DomainError> {
|
||||
pub(crate) fn agent_run_dir(
|
||||
root: &ProjectPath,
|
||||
agent_id: &AgentId,
|
||||
) -> Result<ProjectPath, domain::error::DomainError> {
|
||||
ProjectPath::new(join(root, &format!(".ideai/run/{agent_id}")))
|
||||
}
|
||||
|
||||
@ -841,8 +884,9 @@ fn json_escape(s: &str) -> String {
|
||||
|
||||
/// Composes the convention file IdeA writes into an agent's run directory: an
|
||||
/// absolute project-root header (the agent's cwd is the run dir, *not* the root,
|
||||
/// so it must be told where to work), the agent's persona `.md`, then the bodies
|
||||
/// of its assigned `skills` under a `# Skills` section (ARCHITECTURE §14.2).
|
||||
/// so it must be told where to work), the IdeA orchestration contract, the
|
||||
/// agent's persona `.md`, then the bodies of its assigned `skills` under a
|
||||
/// `# Skills` section (ARCHITECTURE §14.2).
|
||||
///
|
||||
/// Skills are emitted in the order given (the caller passes them in manifest
|
||||
/// order, making the output deterministic); each is introduced by a `##` header
|
||||
@ -858,6 +902,7 @@ fn json_escape(s: &str) -> String {
|
||||
#[must_use]
|
||||
pub(crate) fn compose_convention_file(
|
||||
project_root: &str,
|
||||
project_context: &str,
|
||||
agent_md: &str,
|
||||
skills: &[Skill],
|
||||
memory: &[MemoryIndexEntry],
|
||||
@ -871,6 +916,21 @@ pub(crate) fn compose_convention_file(
|
||||
opère sur le project root, pas sur ce dossier.\n\n",
|
||||
);
|
||||
out.push_str("---\n\n");
|
||||
out.push_str("# Orchestration IdeA\n\n");
|
||||
out.push_str(
|
||||
"Pour déléguer une tâche à un autre agent, n'utilise jamais les subagents \
|
||||
natifs du fournisseur IA. Écris une requête d'orchestration IdeA dans \
|
||||
`.ideai/requests/<ton-agent>/` ; IdeA lancera ou réattachera l'agent cible \
|
||||
avec son propre AI Profile, son contexte et sa mémoire.\n\n",
|
||||
);
|
||||
out.push_str("---\n\n");
|
||||
|
||||
if !project_context.trim().is_empty() {
|
||||
out.push_str("# Contexte projet\n\n");
|
||||
out.push_str(project_context.trim());
|
||||
out.push_str("\n\n---\n\n");
|
||||
}
|
||||
|
||||
out.push_str(agent_md);
|
||||
|
||||
if !skills.is_empty() {
|
||||
@ -918,7 +978,11 @@ fn memory_type_label(kind: MemoryType) -> &'static str {
|
||||
/// suffix when needed. Shared with the template-driven agent creation (L7).
|
||||
pub(crate) fn unique_md_path(name: &str, manifest: &AgentManifest) -> String {
|
||||
let slug = slugify(name);
|
||||
let base = if slug.is_empty() { "agent".to_owned() } else { slug };
|
||||
let base = if slug.is_empty() {
|
||||
"agent".to_owned()
|
||||
} else {
|
||||
slug
|
||||
};
|
||||
let mut candidate = format!("{AGENTS_SUBDIR}/{base}.md");
|
||||
let mut n = 2;
|
||||
while manifest.entries.iter().any(|e| e.md_path == candidate) {
|
||||
@ -967,7 +1031,7 @@ mod tests {
|
||||
#[test]
|
||||
fn compose_convention_file_carries_root_then_persona() {
|
||||
let doc =
|
||||
compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[], &[]);
|
||||
compose_convention_file("/abs/project/root", "", "# Persona\n\nDo things.", &[], &[]);
|
||||
|
||||
// Absolute project root present.
|
||||
assert!(doc.contains("/abs/project/root"));
|
||||
@ -982,6 +1046,26 @@ mod tests {
|
||||
assert!(!doc.contains("# Skills"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_includes_project_context_before_persona() {
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"# Shared project context\n\nUse pnpm.",
|
||||
"# Persona\n\nDo X.",
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
|
||||
assert!(doc.contains("# Contexte projet"));
|
||||
assert!(doc.contains("Use pnpm."));
|
||||
let project_context_at = doc.find("# Contexte projet").unwrap();
|
||||
let persona_at = doc.find("# Persona").unwrap();
|
||||
assert!(
|
||||
project_context_at < persona_at,
|
||||
"shared project context must precede agent persona"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compose_convention_file_appends_assigned_skills_in_order() {
|
||||
let s = |n: u128, name: &str, body: &str| {
|
||||
@ -995,8 +1079,12 @@ mod tests {
|
||||
};
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
|
||||
&[
|
||||
s(1, "refactor", "REFAC_BODY"),
|
||||
s(2, "review", "REVIEW_BODY"),
|
||||
],
|
||||
&[],
|
||||
);
|
||||
|
||||
@ -1028,7 +1116,7 @@ mod tests {
|
||||
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
|
||||
// An empty `memory` must yield exactly the previous document: no section,
|
||||
// byte-for-byte identical to the no-skills/no-memory composition.
|
||||
let with_empty = compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[]);
|
||||
let with_empty = compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[]);
|
||||
assert!(
|
||||
!with_empty.contains("# Mémoire projet"),
|
||||
"no memory ⇒ no memory section"
|
||||
@ -1037,7 +1125,7 @@ mod tests {
|
||||
// 4-arg call; this pins the omission contract explicitly).
|
||||
assert_eq!(
|
||||
with_empty,
|
||||
compose_convention_file("/root", "# Persona\n\nDo X.", &[], &[])
|
||||
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[])
|
||||
);
|
||||
}
|
||||
|
||||
@ -1045,11 +1133,17 @@ mod tests {
|
||||
fn compose_convention_file_appends_memory_entries_in_order() {
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
&[],
|
||||
&[
|
||||
mem("alpha-note", "Alpha", "the first hook", MemoryType::User),
|
||||
mem("beta-note", "Beta", "the second hook", MemoryType::Reference),
|
||||
mem(
|
||||
"beta-note",
|
||||
"Beta",
|
||||
"the second hook",
|
||||
MemoryType::Reference,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -1066,7 +1160,10 @@ mod tests {
|
||||
// Deterministic order: first entry precedes the second.
|
||||
let alpha_at = doc.find("[Alpha]").unwrap();
|
||||
let beta_at = doc.find("[Beta]").unwrap();
|
||||
assert!(alpha_at < beta_at, "memory entries emitted in the given order");
|
||||
assert!(
|
||||
alpha_at < beta_at,
|
||||
"memory entries emitted in the given order"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -1080,6 +1177,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let doc = compose_convention_file(
|
||||
"/root",
|
||||
"",
|
||||
"# Persona",
|
||||
std::slice::from_ref(&skill),
|
||||
&[mem("note", "Note", "a hook", MemoryType::Project)],
|
||||
|
||||
@ -14,14 +14,12 @@ mod usecases;
|
||||
pub(crate) use lifecycle::unique_md_path;
|
||||
|
||||
pub use catalogue::{reference_profile_id, reference_profiles};
|
||||
pub use inspect::{
|
||||
InspectConversation, InspectConversationInput, InspectConversationOutput,
|
||||
};
|
||||
pub use inspect::{InspectConversation, InspectConversationInput, InspectConversationOutput};
|
||||
pub use lifecycle::{
|
||||
CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
|
||||
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, UpdateAgentContext,
|
||||
UpdateAgentContextInput,
|
||||
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
|
||||
ListAgentsOutput, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
|
||||
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use usecases::{
|
||||
ConfigureProfiles, ConfigureProfilesInput, ConfigureProfilesOutput, DeleteProfile,
|
||||
|
||||
24
crates/application/src/embedder/mod.rs
Normal file
24
crates/application/src/embedder/mod.rs
Normal file
@ -0,0 +1,24 @@
|
||||
//! Embedder configuration use cases (LOT C2 — §14.5.3).
|
||||
//!
|
||||
//! CRUD over the declarative [`domain::profile::EmbedderProfile`]s
|
||||
//! ([`ListEmbedderProfiles`], [`SaveEmbedderProfile`], [`DeleteEmbedderProfile`])
|
||||
//! plus a read-only description of the available engines ([`DescribeEmbedderEngines`]):
|
||||
//! the recommended local ONNX models, the detected local environment, and which
|
||||
//! strategies are actually compiled into this binary.
|
||||
//!
|
||||
//! A changed embedder takes effect at the **next IDE start** (the composition root
|
||||
//! freezes the recall for the session): these use cases only persist/inspect config,
|
||||
//! they never reconfigure a live recall.
|
||||
|
||||
mod suggestion;
|
||||
mod usecases;
|
||||
|
||||
pub use suggestion::{
|
||||
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
|
||||
DismissChoice, DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, SuggestedThisSession,
|
||||
};
|
||||
pub use usecases::{
|
||||
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
|
||||
EmbedderEnginesView, ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView,
|
||||
SaveEmbedderProfile, SaveEmbedderProfileInput, SaveEmbedderProfileOutput,
|
||||
};
|
||||
269
crates/application/src/embedder/suggestion.rs
Normal file
269
crates/application/src/embedder/suggestion.rs
Normal file
@ -0,0 +1,269 @@
|
||||
//! Contextual embedder-suggestion use cases (LOT C3, ARCHITECTURE §14.5.5).
|
||||
//!
|
||||
//! - [`CheckEmbedderSuggestion`] — the **best-effort** check run when an agent
|
||||
//! reads the project memory at activation: the *first* time a project's memory
|
||||
//! outgrows the recall budget while no embedder is configured (strategy `none`),
|
||||
//! it publishes a one-time [`DomainEvent::EmbedderSuggested`]. Anti-spam: at most
|
||||
//! once per session per project, and never again once the user chose
|
||||
//! [`EmbedderPromptDismissal::Never`].
|
||||
//! - [`DismissEmbedderSuggestion`] — persists the user's "plus tard" / "ne plus
|
||||
//! demander" response.
|
||||
//!
|
||||
//! By construction this never blocks the activation flow: every error degrades to
|
||||
//! "no suggestion", and a `none`-strategy/under-budget project is a cheap no-op.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use domain::ports::{
|
||||
EmbedderEnvInspector, EmbedderProfileStore, EmbedderPromptDismissal, EmbedderPromptStore,
|
||||
EventBus, MemoryStore,
|
||||
};
|
||||
use domain::profile::EmbedderStrategy;
|
||||
use domain::{DomainEvent, ProjectId, ProjectPath};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// In-memory "already suggested this session" guard, shared with the composition
|
||||
/// root (held in `AppState`). One [`ProjectId`] per project that has already seen
|
||||
/// the suggestion since the IDE started. Plain std types so the application stays
|
||||
/// dependency-free and the guard is trivially testable.
|
||||
pub type SuggestedThisSession = Arc<Mutex<HashSet<ProjectId>>>;
|
||||
|
||||
/// Sums the approximate token size of the index, mirroring the infrastructure
|
||||
/// `index_token_size` so the application does not depend on the infra crate. One
|
||||
/// entry's payload is its rendered index line (title + hook + slug), and the rule
|
||||
/// only needs a *monotone* measure consistent with the recall's budget unit
|
||||
/// (~1 token / 4 chars), so we count characters / 4 (min 1 per entry).
|
||||
fn index_token_size(entries: &[domain::MemoryIndexEntry]) -> usize {
|
||||
entries
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let chars = e.title.len() + e.hook.len() + e.slug.as_str().len();
|
||||
(chars / 4).max(1)
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CheckEmbedderSuggestion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`CheckEmbedderSuggestion::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckEmbedderSuggestionInput {
|
||||
/// The project whose memory is being read at agent activation.
|
||||
pub project_id: ProjectId,
|
||||
/// That project's root (where its `.ideai/memory/` lives).
|
||||
pub project_root: ProjectPath,
|
||||
}
|
||||
|
||||
/// Output of [`CheckEmbedderSuggestion::execute`]: whether a suggestion event was
|
||||
/// published on this call (useful for tests; the caller ignores it — the flow is
|
||||
/// best-effort).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CheckEmbedderSuggestionOutput {
|
||||
/// `true` iff an [`DomainEvent::EmbedderSuggested`] was published.
|
||||
pub suggested: bool,
|
||||
}
|
||||
|
||||
/// Publishes a one-time embedder suggestion the first time a project's memory
|
||||
/// outgrows the recall budget while no embedder is configured (LOT C3).
|
||||
///
|
||||
/// Composes — each port only for its slice (Interface Segregation):
|
||||
/// - [`EmbedderProfileStore`] to read the active strategy (no non-`none` profile ⇒
|
||||
/// strategy `none`),
|
||||
/// - [`MemoryStore`] to measure the memory index size,
|
||||
/// - [`EmbedderPromptStore`] for the persistent dismissal (`never` ⇒ silence),
|
||||
/// - [`EmbedderEnvInspector`] to enrich the event with the detected environment,
|
||||
/// - [`EventBus`] to publish, plus the shared [`SuggestedThisSession`] guard.
|
||||
///
|
||||
/// **Never blocks the caller**: any port error degrades to "no suggestion".
|
||||
pub struct CheckEmbedderSuggestion {
|
||||
profiles: Arc<dyn EmbedderProfileStore>,
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
prompts: Arc<dyn EmbedderPromptStore>,
|
||||
inspector: Arc<dyn EmbedderEnvInspector>,
|
||||
events: Arc<dyn EventBus>,
|
||||
suggested_this_session: SuggestedThisSession,
|
||||
/// Recall token budget the memory must exceed (injected so it stays aligned
|
||||
/// with [`crate::AGENT_MEMORY_RECALL_BUDGET`] without re-hardcoding it).
|
||||
budget: usize,
|
||||
/// Compiled-in HTTP capability flag (carried into the event).
|
||||
vector_http_enabled: bool,
|
||||
/// Compiled-in ONNX capability flag (carried into the event).
|
||||
vector_onnx_enabled: bool,
|
||||
}
|
||||
|
||||
impl CheckEmbedderSuggestion {
|
||||
/// Builds the use case from its ports + the session guard + the budget and
|
||||
/// compiled-capability flags (the latter injected at the composition root, the
|
||||
/// application stays infra-free).
|
||||
#[must_use]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
profiles: Arc<dyn EmbedderProfileStore>,
|
||||
memories: Arc<dyn MemoryStore>,
|
||||
prompts: Arc<dyn EmbedderPromptStore>,
|
||||
inspector: Arc<dyn EmbedderEnvInspector>,
|
||||
events: Arc<dyn EventBus>,
|
||||
suggested_this_session: SuggestedThisSession,
|
||||
budget: usize,
|
||||
vector_http_enabled: bool,
|
||||
vector_onnx_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
profiles,
|
||||
memories,
|
||||
prompts,
|
||||
inspector,
|
||||
events,
|
||||
suggested_this_session,
|
||||
budget,
|
||||
vector_http_enabled,
|
||||
vector_onnx_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any configured profile selects a non-`none` strategy. No profile (or
|
||||
/// a store error) ⇒ `none` (the dependency-free default posture).
|
||||
async fn strategy_is_none(&self) -> bool {
|
||||
match self.profiles.list().await {
|
||||
Ok(profiles) => !profiles
|
||||
.iter()
|
||||
.any(|p| p.strategy != EmbedderStrategy::None),
|
||||
// A store failure must not turn into a suggestion: behave as configured
|
||||
// (i.e. *not* `none`) so we stay silent.
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the check best-effort.
|
||||
///
|
||||
/// Order (each step short-circuits to "no suggestion"):
|
||||
/// 1. already suggested this session ⇒ stop (no I/O beyond the guard);
|
||||
/// 2. a non-`none` strategy is configured ⇒ stop;
|
||||
/// 3. the persisted dismissal is `never` ⇒ stop;
|
||||
/// 4. the memory index size does not exceed the budget ⇒ stop;
|
||||
/// 5. otherwise mark the session guard, probe the environment, and publish
|
||||
/// [`DomainEvent::EmbedderSuggested`].
|
||||
///
|
||||
/// # Errors
|
||||
/// Never in practice — the signature keeps a uniform `Result` with the other
|
||||
/// use cases; every failing port read degrades to `Ok(suggested: false)`.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: CheckEmbedderSuggestionInput,
|
||||
) -> Result<CheckEmbedderSuggestionOutput, AppError> {
|
||||
let not_suggested = Ok(CheckEmbedderSuggestionOutput { suggested: false });
|
||||
|
||||
// 1. Once per session per project (cheap guard check before any I/O).
|
||||
if self
|
||||
.suggested_this_session
|
||||
.lock()
|
||||
.map(|set| set.contains(&input.project_id))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return not_suggested;
|
||||
}
|
||||
|
||||
// 2. Only when no embedder is configured (strategy `none`).
|
||||
if !self.strategy_is_none().await {
|
||||
return not_suggested;
|
||||
}
|
||||
|
||||
// 3. Persistent "ne plus demander" silences the suggestion forever.
|
||||
if matches!(
|
||||
self.prompts.read(&input.project_root).await,
|
||||
Ok(Some(EmbedderPromptDismissal::Never))
|
||||
) {
|
||||
return not_suggested;
|
||||
}
|
||||
|
||||
// 4. Memory must have outgrown the recall budget (a fresh project has 0).
|
||||
let size = match self.memories.read_index(&input.project_root).await {
|
||||
Ok(entries) => index_token_size(&entries),
|
||||
Err(_) => return not_suggested,
|
||||
};
|
||||
if size <= self.budget {
|
||||
return not_suggested;
|
||||
}
|
||||
|
||||
// 5. Mark the guard *before* publishing so a concurrent activation cannot
|
||||
// double-fire; if another thread won the race, stay silent.
|
||||
match self.suggested_this_session.lock() {
|
||||
Ok(mut set) => {
|
||||
if !set.insert(input.project_id) {
|
||||
return not_suggested;
|
||||
}
|
||||
}
|
||||
Err(_) => return not_suggested,
|
||||
}
|
||||
|
||||
let report = self.inspector.inspect().await;
|
||||
self.events.publish(DomainEvent::EmbedderSuggested {
|
||||
project_id: input.project_id,
|
||||
ollama_detected: report.ollama_detected,
|
||||
onnx_cached: report.onnx_cached_models,
|
||||
vector_http_enabled: self.vector_http_enabled,
|
||||
vector_onnx_enabled: self.vector_onnx_enabled,
|
||||
});
|
||||
Ok(CheckEmbedderSuggestionOutput { suggested: true })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DismissEmbedderSuggestion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The user's response to the embedder suggestion popup.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DismissChoice {
|
||||
/// "Plus tard" — re-proposable on a future session.
|
||||
Later,
|
||||
/// "Ne plus demander" — never again (persistent).
|
||||
Never,
|
||||
}
|
||||
|
||||
impl From<DismissChoice> for EmbedderPromptDismissal {
|
||||
fn from(c: DismissChoice) -> Self {
|
||||
match c {
|
||||
DismissChoice::Later => Self::Later,
|
||||
DismissChoice::Never => Self::Never,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`DismissEmbedderSuggestion::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DismissEmbedderSuggestionInput {
|
||||
/// The project the suggestion concerned.
|
||||
pub project_root: ProjectPath,
|
||||
/// The user's choice.
|
||||
pub choice: DismissChoice,
|
||||
}
|
||||
|
||||
/// Persists the user's dismissal of the embedder suggestion (LOT C3).
|
||||
pub struct DismissEmbedderSuggestion {
|
||||
prompts: Arc<dyn EmbedderPromptStore>,
|
||||
}
|
||||
|
||||
impl DismissEmbedderSuggestion {
|
||||
/// Builds the use case from the [`EmbedderPromptStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(prompts: Arc<dyn EmbedderPromptStore>) -> Self {
|
||||
Self { prompts }
|
||||
}
|
||||
|
||||
/// Writes the dismissal state. A `later` keeps the suggestion re-proposable on
|
||||
/// a future session; a `never` silences it for good.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on a persistence failure.
|
||||
pub async fn execute(&self, input: DismissEmbedderSuggestionInput) -> Result<(), AppError> {
|
||||
self.prompts
|
||||
.write(&input.project_root, input.choice.into())
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
245
crates/application/src/embedder/usecases.rs
Normal file
245
crates/application/src/embedder/usecases.rs
Normal file
@ -0,0 +1,245 @@
|
||||
//! Embedder configuration use cases (LOT C2). Each is a single-responsibility
|
||||
//! struct carrying its ports as `Arc<dyn Port>` and exposing one `execute`.
|
||||
//!
|
||||
//! - [`ListEmbedderProfiles`] / [`SaveEmbedderProfile`] / [`DeleteEmbedderProfile`]
|
||||
//! — CRUD over the persisted [`EmbedderProfile`]s through the
|
||||
//! [`EmbedderProfileStore`] port.
|
||||
//! - [`DescribeEmbedderEngines`] — a read-only view of the engines available to the
|
||||
//! "configure an embedder?" UI: the recommended ONNX model catalogue, a best-effort
|
||||
//! snapshot of the local environment ([`EmbedderEnvInspector`]), and which strategies
|
||||
//! are actually compiled into this binary.
|
||||
//!
|
||||
//! Hexagonal boundary: the application depends on the domain ports and on plain
|
||||
//! data only. The static engine catalogue and the compiled-capability flags are
|
||||
//! **injected** at the composition root (the infrastructure owns `reqwest`/`fastembed`,
|
||||
//! never the application).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EmbedderEnvInspector, EmbedderProfileStore};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListEmbedderProfiles
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Output of [`ListEmbedderProfiles::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ListEmbedderProfilesOutput {
|
||||
/// All configured embedder profiles (empty when none configured ⇒ `none`).
|
||||
pub profiles: Vec<EmbedderProfile>,
|
||||
}
|
||||
|
||||
/// Lists the configured embedder profiles from the store.
|
||||
pub struct ListEmbedderProfiles {
|
||||
store: Arc<dyn EmbedderProfileStore>,
|
||||
}
|
||||
|
||||
impl ListEmbedderProfiles {
|
||||
/// Builds the use case from the [`EmbedderProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Lists configured embedder profiles.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self) -> Result<ListEmbedderProfilesOutput, AppError> {
|
||||
Ok(ListEmbedderProfilesOutput {
|
||||
profiles: self.store.list().await?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SaveEmbedderProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`SaveEmbedderProfile::execute`]: the raw fields of the profile to
|
||||
/// upsert (validated into an [`EmbedderProfile`] entity).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveEmbedderProfileInput {
|
||||
/// Stable identifier (e.g. `"local-onnx-minilm"`). Non-empty.
|
||||
pub id: String,
|
||||
/// Display name. Non-empty.
|
||||
pub name: String,
|
||||
/// Embedding strategy driving which concrete adapter is used.
|
||||
pub strategy: EmbedderStrategy,
|
||||
/// Model identifier, when the strategy needs one.
|
||||
pub model: Option<String>,
|
||||
/// Endpoint URL for a server/API strategy.
|
||||
pub endpoint: Option<String>,
|
||||
/// Name of the env var carrying the API key (never the key itself).
|
||||
pub api_key_env: Option<String>,
|
||||
/// Length of the vectors this engine produces. Non-zero.
|
||||
pub dimension: usize,
|
||||
}
|
||||
|
||||
/// Output of [`SaveEmbedderProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SaveEmbedderProfileOutput {
|
||||
/// The saved (validated) profile, echoed back.
|
||||
pub profile: EmbedderProfile,
|
||||
}
|
||||
|
||||
/// Persists (creates or replaces by id) a single embedder profile, after building
|
||||
/// and validating the entity.
|
||||
pub struct SaveEmbedderProfile {
|
||||
store: Arc<dyn EmbedderProfileStore>,
|
||||
}
|
||||
|
||||
impl SaveEmbedderProfile {
|
||||
/// Builds the use case from the [`EmbedderProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Validates then saves the profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] if the profile's invariants are violated (empty
|
||||
/// `id`/`name`, zero `dimension`),
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: SaveEmbedderProfileInput,
|
||||
) -> Result<SaveEmbedderProfileOutput, AppError> {
|
||||
let profile = EmbedderProfile::new(
|
||||
input.id,
|
||||
input.name,
|
||||
input.strategy,
|
||||
input.model,
|
||||
input.endpoint,
|
||||
input.api_key_env,
|
||||
input.dimension,
|
||||
)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.store.save(&profile).await?;
|
||||
Ok(SaveEmbedderProfileOutput { profile })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteEmbedderProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Input for [`DeleteEmbedderProfile::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DeleteEmbedderProfileInput {
|
||||
/// Id of the embedder profile to delete.
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
/// Deletes an embedder profile by id.
|
||||
pub struct DeleteEmbedderProfile {
|
||||
store: Arc<dyn EmbedderProfileStore>,
|
||||
}
|
||||
|
||||
impl DeleteEmbedderProfile {
|
||||
/// Builds the use case from the [`EmbedderProfileStore`] port.
|
||||
#[must_use]
|
||||
pub fn new(store: Arc<dyn EmbedderProfileStore>) -> Self {
|
||||
Self { store }
|
||||
}
|
||||
|
||||
/// Deletes the profile.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`AppError::NotFound`] if the id is unknown, [`AppError::Store`] on
|
||||
/// persistence failure.
|
||||
pub async fn execute(&self, input: DeleteEmbedderProfileInput) -> Result<(), AppError> {
|
||||
self.store.delete(&input.id).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DescribeEmbedderEngines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A recommendable local ONNX model, as a plain application value (mirrors the
|
||||
/// infrastructure `OnnxModelInfo` data, injected at the composition root so the
|
||||
/// application never depends on the infrastructure crate).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OnnxModelView {
|
||||
/// Stable model id accepted by a `localOnnx` profile's `model` field.
|
||||
pub id: String,
|
||||
/// Human-readable name for the UI.
|
||||
pub display_name: String,
|
||||
/// Length of the vectors this model produces.
|
||||
pub dimension: usize,
|
||||
/// Approximate download/disk size in megabytes.
|
||||
pub approx_size_mb: u32,
|
||||
/// Whether this is the recommended default model.
|
||||
pub recommended: bool,
|
||||
}
|
||||
|
||||
/// A read-only description of the embedding engines available to the
|
||||
/// "configure an embedder?" UI (C2/C3).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct EmbedderEnginesView {
|
||||
/// The curated catalogue of recommendable local ONNX models.
|
||||
pub recommended_onnx: Vec<OnnxModelView>,
|
||||
/// Whether an Ollama-style local embedding server was detected (best-effort).
|
||||
pub ollama_detected: bool,
|
||||
/// Ids of the recommended ONNX models already present in the local cache.
|
||||
pub onnx_cached_models: Vec<String>,
|
||||
/// Whether the HTTP capability (`localServer`/`api`) is compiled into this binary.
|
||||
pub vector_http_enabled: bool,
|
||||
/// Whether the in-process ONNX capability (`localOnnx`) is compiled into this binary.
|
||||
pub vector_onnx_enabled: bool,
|
||||
}
|
||||
|
||||
/// Describes the engines available to the embedder-configuration UI: the static
|
||||
/// ONNX catalogue + compiled-capability flags (injected at construction), enriched
|
||||
/// with a best-effort live snapshot of the local environment via the
|
||||
/// [`EmbedderEnvInspector`] port. Read-only — emits no event, never fails on the
|
||||
/// environment probe (the port is best-effort by contract).
|
||||
pub struct DescribeEmbedderEngines {
|
||||
inspector: Arc<dyn EmbedderEnvInspector>,
|
||||
recommended_onnx: Vec<OnnxModelView>,
|
||||
vector_http_enabled: bool,
|
||||
vector_onnx_enabled: bool,
|
||||
}
|
||||
|
||||
impl DescribeEmbedderEngines {
|
||||
/// Builds the use case from the [`EmbedderEnvInspector`] port plus the static
|
||||
/// engine catalogue and compiled-capability flags (data owned by infrastructure
|
||||
/// and injected at the composition root, so the application stays infra-free).
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
inspector: Arc<dyn EmbedderEnvInspector>,
|
||||
recommended_onnx: Vec<OnnxModelView>,
|
||||
vector_http_enabled: bool,
|
||||
vector_onnx_enabled: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
inspector,
|
||||
recommended_onnx,
|
||||
vector_http_enabled,
|
||||
vector_onnx_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the engines view. Infallible in practice: the environment probe is
|
||||
/// best-effort and degrades to "nothing detected".
|
||||
///
|
||||
/// # Errors
|
||||
/// Never; the `Result` keeps the call site uniform with the other use cases.
|
||||
#[allow(clippy::unused_async)]
|
||||
pub async fn execute(&self) -> Result<EmbedderEnginesView, AppError> {
|
||||
let report = self.inspector.inspect().await;
|
||||
Ok(EmbedderEnginesView {
|
||||
recommended_onnx: self.recommended_onnx.clone(),
|
||||
ollama_detected: report.ollama_detected,
|
||||
onnx_cached_models: report.onnx_cached_models,
|
||||
vector_http_enabled: self.vector_http_enabled,
|
||||
vector_onnx_enabled: self.vector_onnx_enabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@ mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
||||
GitStatusOutput, GitUnstage,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
|
||||
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
|
||||
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||
};
|
||||
|
||||
@ -47,12 +47,12 @@ pub struct HealthUseCase {
|
||||
impl HealthUseCase {
|
||||
/// Builds the use case from its injected ports.
|
||||
#[must_use]
|
||||
pub fn new(clock: Arc<dyn Clock>, ids: Arc<dyn IdGenerator>, events: Arc<dyn EventBus>) -> Self {
|
||||
Self {
|
||||
clock,
|
||||
ids,
|
||||
events,
|
||||
}
|
||||
pub fn new(
|
||||
clock: Arc<dyn Clock>,
|
||||
ids: Arc<dyn IdGenerator>,
|
||||
events: Arc<dyn EventBus>,
|
||||
) -> Self {
|
||||
Self { clock, ids, events }
|
||||
}
|
||||
|
||||
/// Executes the health check.
|
||||
|
||||
@ -25,14 +25,14 @@ mod snapshot;
|
||||
mod store;
|
||||
mod usecases;
|
||||
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use management::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, ListLayouts, ListLayoutsInput, ListLayoutsOutput, RenameLayout,
|
||||
RenameLayoutInput, SetActiveLayout, SetActiveLayoutInput,
|
||||
};
|
||||
pub use snapshot::{
|
||||
SnapshotRunningAgents, SnapshotRunningAgentsInput, SnapshotRunningAgentsOutput,
|
||||
};
|
||||
pub use store::{LayoutKind, LayoutsDoc, NamedLayout, LAYOUTS_FILE};
|
||||
pub use usecases::{
|
||||
LayoutOperation, LoadLayout, LoadLayoutInput, LoadLayoutOutput, MutateLayout,
|
||||
|
||||
@ -151,7 +151,8 @@ pub async fn persist_doc(
|
||||
) -> Result<(), AppError> {
|
||||
let ideai_dir = RemotePath::new(join_root(&project.root, IDEAI_DIR));
|
||||
fs.create_dir_all(&ideai_dir).await?;
|
||||
fs.write(&layouts_path(project), &to_json_bytes(doc)?).await?;
|
||||
fs.write(&layouts_path(project), &to_json_bytes(doc)?)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,10 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{EventBus, FileSystem, ProjectStore};
|
||||
use domain::{AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId, ProjectId, SessionId};
|
||||
use domain::{
|
||||
AgentId, Direction, DomainEvent, LayoutError, LayoutId, LayoutTree, LeafCell, NodeId,
|
||||
ProjectId, SessionId,
|
||||
};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
@ -69,6 +72,14 @@ pub enum LayoutOperation {
|
||||
/// Session to host, or `None` to clear.
|
||||
session: Option<SessionId>,
|
||||
},
|
||||
/// Attach an existing live/background session to a leaf, clearing any
|
||||
/// previous visible host of the same session.
|
||||
AttachSession {
|
||||
/// The hosting leaf.
|
||||
target: NodeId,
|
||||
/// Session to make visible in this leaf.
|
||||
session: SessionId,
|
||||
},
|
||||
/// Attach or detach an agent to/from a leaf (per-cell agent, feature #3).
|
||||
SetCellAgent {
|
||||
/// The hosting leaf.
|
||||
@ -117,6 +128,7 @@ impl LayoutOperation {
|
||||
Self::Resize { container, weights } => tree.resize(*container, weights),
|
||||
Self::Move { from, to } => tree.move_session(*from, *to),
|
||||
Self::SetSession { target, session } => tree.set_session(*target, *session),
|
||||
Self::AttachSession { target, session } => tree.attach_session(*target, *session),
|
||||
Self::SetCellAgent { target, agent } => tree.set_cell_agent(*target, *agent),
|
||||
Self::SetCellConversation {
|
||||
target,
|
||||
|
||||
@ -12,6 +12,7 @@
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod agent;
|
||||
pub mod embedder;
|
||||
pub mod error;
|
||||
pub mod git;
|
||||
pub mod health;
|
||||
@ -35,17 +36,23 @@ pub use agent::{
|
||||
ListProfiles, ListProfilesOutput, ProfileAvailability, ReadAgentContext, ReadAgentContextInput,
|
||||
ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput, SaveProfile,
|
||||
SaveProfileInput, SaveProfileOutput, UpdateAgentContext, UpdateAgentContextInput,
|
||||
AGENT_MEMORY_RECALL_BUDGET,
|
||||
};
|
||||
pub use embedder::{
|
||||
CheckEmbedderSuggestion, CheckEmbedderSuggestionInput, CheckEmbedderSuggestionOutput,
|
||||
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines, DismissChoice,
|
||||
DismissEmbedderSuggestion, DismissEmbedderSuggestionInput, EmbedderEnginesView,
|
||||
ListEmbedderProfiles, ListEmbedderProfilesOutput, OnnxModelView, SaveEmbedderProfile,
|
||||
SaveEmbedderProfileInput, SaveEmbedderProfileOutput, SuggestedThisSession,
|
||||
};
|
||||
pub use error::AppError;
|
||||
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
|
||||
pub use git::{
|
||||
GitBranches, GitBranchesInput, GitBranchesOutput, GitCheckout, GitCheckoutInput, GitCommit,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit, GitInitInput,
|
||||
GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus, GitStatusInput,
|
||||
GitStatusOutput, GitUnstage,
|
||||
GitCommitInput, GitCommitOutput, GitGraph, GitGraphInput, GitGraphOutput, GitInit,
|
||||
GitInitInput, GitLog, GitLogInput, GitLogOutput, GitStage, GitStagePathInput, GitStatus,
|
||||
GitStatusInput, GitStatusOutput, GitUnstage,
|
||||
};
|
||||
pub use health::{HealthInput, HealthReport, HealthUseCase};
|
||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
pub use layout::{
|
||||
CreateLayout, CreateLayoutInput, CreateLayoutOutput, DeleteLayout, DeleteLayoutInput,
|
||||
DeleteLayoutOutput, LayoutInfo, LayoutKind, LayoutOperation, LayoutsDoc, ListLayouts,
|
||||
@ -56,15 +63,25 @@ pub use layout::{
|
||||
};
|
||||
pub use memory::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput,
|
||||
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
|
||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
|
||||
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
|
||||
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
|
||||
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
pub use orchestrator::{OrchestratorOutcome, OrchestratorService};
|
||||
pub use project::{
|
||||
CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput, CreateProject,
|
||||
CreateProjectInput, CreateProjectOutput, ListProjects, ListProjectsOutput, OpenProject,
|
||||
OpenProjectInput, OpenProjectOutput, ProjectMeta,
|
||||
OpenProjectInput, OpenProjectOutput, ProjectMeta, ReadProjectContext, ReadProjectContextInput,
|
||||
ReadProjectContextOutput, UpdateProjectContext, UpdateProjectContextInput,
|
||||
PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
pub use remote::{ConnectRemote, ConnectRemoteInput, ConnectRemoteOutput};
|
||||
pub use skill::{
|
||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||
UpdateSkillOutput,
|
||||
};
|
||||
pub use template::{
|
||||
AgentDrift, CreateAgentFromTemplate, CreateAgentFromTemplateInput,
|
||||
@ -74,16 +91,9 @@ pub use template::{
|
||||
SyncAgentWithTemplateInput, SyncAgentWithTemplateOutput, UpdateTemplate, UpdateTemplateInput,
|
||||
UpdateTemplateOutput,
|
||||
};
|
||||
pub use skill::{
|
||||
AssignSkillToAgent, AssignSkillToAgentInput, CreateSkill, CreateSkillInput, CreateSkillOutput,
|
||||
DeleteSkill, DeleteSkillInput, ListSkills, ListSkillsInput, ListSkillsOutput,
|
||||
UnassignSkillFromAgent, UnassignSkillFromAgentInput, UpdateSkill, UpdateSkillInput,
|
||||
UpdateSkillOutput,
|
||||
};
|
||||
pub use terminal::{
|
||||
LiveAgentRegistry,
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, OpenTerminal, OpenTerminalInput,
|
||||
OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions, WriteToTerminal,
|
||||
WriteToTerminalInput,
|
||||
CloseTerminal, CloseTerminalInput, CloseTerminalOutput, LiveAgentRegistry, OpenTerminal,
|
||||
OpenTerminalInput, OpenTerminalOutput, ResizeTerminal, ResizeTerminalInput, TerminalSessions,
|
||||
WriteToTerminal, WriteToTerminalInput,
|
||||
};
|
||||
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
|
||||
@ -16,8 +16,8 @@ mod usecases;
|
||||
|
||||
pub use usecases::{
|
||||
CreateMemory, CreateMemoryInput, CreateMemoryOutput, DeleteMemory, DeleteMemoryInput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput, ListMemoriesOutput,
|
||||
ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory, RecallMemoryInput,
|
||||
RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput, ResolveMemoryLinksOutput,
|
||||
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
GetMemory, GetMemoryInput, GetMemoryOutput, ListMemories, ListMemoriesInput,
|
||||
ListMemoriesOutput, ReadMemoryIndex, ReadMemoryIndexInput, ReadMemoryIndexOutput, RecallMemory,
|
||||
RecallMemoryInput, RecallMemoryOutput, ResolveMemoryLinks, ResolveMemoryLinksInput,
|
||||
ResolveMemoryLinksOutput, UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
|
||||
};
|
||||
|
||||
@ -136,7 +136,8 @@ impl UpdateMemory {
|
||||
let memory = Memory::new(frontmatter, MarkdownDoc::new(input.content))
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.memories.save(&input.project_root, &memory).await?;
|
||||
self.events.publish(DomainEvent::MemorySaved { slug: input.slug });
|
||||
self.events
|
||||
.publish(DomainEvent::MemorySaved { slug: input.slug });
|
||||
Ok(UpdateMemoryOutput { memory })
|
||||
}
|
||||
}
|
||||
@ -262,8 +263,11 @@ impl DeleteMemory {
|
||||
/// - [`AppError::NotFound`] if no note carries that slug,
|
||||
/// - [`AppError::Store`] on persistence failure.
|
||||
pub async fn execute(&self, input: DeleteMemoryInput) -> Result<(), AppError> {
|
||||
self.memories.delete(&input.project_root, &input.slug).await?;
|
||||
self.events.publish(DomainEvent::MemoryDeleted { slug: input.slug });
|
||||
self.memories
|
||||
.delete(&input.project_root, &input.slug)
|
||||
.await?;
|
||||
self.events
|
||||
.publish(DomainEvent::MemoryDeleted { slug: input.slug });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::ProfileStore;
|
||||
use domain::{OrchestratorCommand, Project, ProfileId};
|
||||
use domain::{OrchestratorCommand, OrchestratorVisibility, ProfileId, Project};
|
||||
|
||||
use crate::agent::{
|
||||
CreateAgentFromScratch, CreateAgentInput, LaunchAgent, LaunchAgentInput, ListAgents,
|
||||
@ -95,7 +95,11 @@ impl OrchestratorService {
|
||||
name,
|
||||
profile,
|
||||
context,
|
||||
} => self.spawn_agent(project, name, profile, context).await,
|
||||
visibility,
|
||||
} => {
|
||||
self.spawn_agent(project, name, profile, context, visibility)
|
||||
.await
|
||||
}
|
||||
OrchestratorCommand::StopAgent { name } => self.stop_agent(project, name).await,
|
||||
OrchestratorCommand::UpdateAgentContext { name, context } => {
|
||||
self.update_agent_context(project, name, context).await
|
||||
@ -115,15 +119,19 @@ impl OrchestratorService {
|
||||
&self,
|
||||
project: &Project,
|
||||
name: String,
|
||||
profile: String,
|
||||
profile: Option<String>,
|
||||
context: Option<String>,
|
||||
visibility: OrchestratorVisibility,
|
||||
) -> Result<OrchestratorOutcome, AppError> {
|
||||
let existing = self.find_agent_id_by_name(project, &name).await?;
|
||||
|
||||
let agent_id = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let profile_id = self.resolve_profile(&profile).await?;
|
||||
let profile = profile.as_deref().ok_or_else(|| {
|
||||
AppError::Invalid("profile is required to create an agent".to_owned())
|
||||
})?;
|
||||
let profile_id = self.resolve_profile(profile).await?;
|
||||
let created = self
|
||||
.create_agent
|
||||
.execute(CreateAgentInput {
|
||||
@ -137,19 +145,54 @@ impl OrchestratorService {
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(session_id) = self.sessions.session_for_agent(&agent_id) {
|
||||
match visibility {
|
||||
OrchestratorVisibility::Background => {
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!("agent {name} already running in background"),
|
||||
});
|
||||
}
|
||||
OrchestratorVisibility::Visible { node_id } => {
|
||||
let session = self
|
||||
.sessions
|
||||
.rebind_agent_node(&agent_id, node_id)
|
||||
.ok_or_else(|| {
|
||||
AppError::NotFound(format!(
|
||||
"running session {session_id} for agent {name}"
|
||||
))
|
||||
})?;
|
||||
return Ok(OrchestratorOutcome {
|
||||
detail: format!("attached agent {name} to cell {}", session.node_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let node_id = match visibility {
|
||||
OrchestratorVisibility::Background => None,
|
||||
OrchestratorVisibility::Visible { node_id } => Some(node_id),
|
||||
};
|
||||
|
||||
self.launch_agent
|
||||
.execute(LaunchAgentInput {
|
||||
project: project.clone(),
|
||||
agent_id,
|
||||
rows: DEFAULT_ROWS,
|
||||
cols: DEFAULT_COLS,
|
||||
node_id: None,
|
||||
node_id,
|
||||
conversation_id: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(OrchestratorOutcome {
|
||||
detail: format!("launched agent {name}"),
|
||||
detail: match visibility {
|
||||
OrchestratorVisibility::Background => {
|
||||
format!("launched agent {name} in background")
|
||||
}
|
||||
OrchestratorVisibility::Visible { node_id } => {
|
||||
format!("launched agent {name} in cell {node_id}")
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
115
crates/application/src/project/context.rs
Normal file
115
crates/application/src/project/context.rs
Normal file
@ -0,0 +1,115 @@
|
||||
//! Project-level context stored under `.ideai/CONTEXT.md`.
|
||||
//!
|
||||
//! This context is model-agnostic and shared by every agent/profile launch. It is
|
||||
//! deliberately project-local: deleting `.ideai/` removes the context together
|
||||
//! with every other IdeA artefact for that project.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::ports::{FileSystem, FsError, RemotePath};
|
||||
use domain::Project;
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::meta::{join_root, IDEAI_DIR};
|
||||
|
||||
/// Project-context file name inside `.ideai/`.
|
||||
pub const PROJECT_CONTEXT_FILE: &str = "CONTEXT.md";
|
||||
|
||||
/// Input for [`ReadProjectContext::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadProjectContextInput {
|
||||
/// Project whose `.ideai/CONTEXT.md` should be read.
|
||||
pub project: Project,
|
||||
}
|
||||
|
||||
/// Output of [`ReadProjectContext::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReadProjectContextOutput {
|
||||
/// Markdown content. Empty when the file does not exist yet.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Reads `.ideai/CONTEXT.md` tolerantly.
|
||||
pub struct ReadProjectContext {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl ReadProjectContext {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
/// Executes the read.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`AppError::FileSystem`] on non-missing filesystem failures, and
|
||||
/// [`AppError::Store`] if the file is not valid UTF-8.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: ReadProjectContextInput,
|
||||
) -> Result<ReadProjectContextOutput, AppError> {
|
||||
match self.fs.read(&project_context_path(&input.project)).await {
|
||||
Ok(bytes) => {
|
||||
let content = String::from_utf8(bytes)
|
||||
.map_err(|e| AppError::Store(format!("project context is not UTF-8: {e}")))?;
|
||||
Ok(ReadProjectContextOutput { content })
|
||||
}
|
||||
Err(FsError::NotFound(_)) => Ok(ReadProjectContextOutput {
|
||||
content: String::new(),
|
||||
}),
|
||||
Err(e) => Err(AppError::FileSystem(e.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Input for [`UpdateProjectContext::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct UpdateProjectContextInput {
|
||||
/// Project whose `.ideai/CONTEXT.md` should be overwritten.
|
||||
pub project: Project,
|
||||
/// New Markdown content.
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
/// Overwrites `.ideai/CONTEXT.md`.
|
||||
pub struct UpdateProjectContext {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
|
||||
impl UpdateProjectContext {
|
||||
/// Builds the use case.
|
||||
#[must_use]
|
||||
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
||||
Self { fs }
|
||||
}
|
||||
|
||||
/// Executes the update.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`AppError::FileSystem`] if `.ideai/` cannot be created or the
|
||||
/// context file cannot be written.
|
||||
pub async fn execute(&self, input: UpdateProjectContextInput) -> Result<(), AppError> {
|
||||
self.fs
|
||||
.create_dir_all(&RemotePath::new(join_root(&input.project.root, IDEAI_DIR)))
|
||||
.await?;
|
||||
self.fs
|
||||
.write(
|
||||
&project_context_path(&input.project),
|
||||
input.content.as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute path to `<root>/.ideai/CONTEXT.md`.
|
||||
#[must_use]
|
||||
pub fn project_context_path(project: &Project) -> RemotePath {
|
||||
RemotePath::new(join_root(
|
||||
&project.root,
|
||||
&format!("{IDEAI_DIR}/{PROJECT_CONTEXT_FILE}"),
|
||||
))
|
||||
}
|
||||
@ -14,11 +14,18 @@
|
||||
//! - [`ListProjects`] — list known projects from the registry.
|
||||
|
||||
mod close;
|
||||
mod context;
|
||||
mod create;
|
||||
pub(crate) mod meta;
|
||||
mod open;
|
||||
|
||||
pub use close::{CloseProject, CloseProjectInput, CloseProjectOutput, CloseTab, CloseTabInput};
|
||||
pub use context::{
|
||||
project_context_path, ReadProjectContext, ReadProjectContextInput, ReadProjectContextOutput,
|
||||
UpdateProjectContext, UpdateProjectContextInput, PROJECT_CONTEXT_FILE,
|
||||
};
|
||||
pub use create::{CreateProject, CreateProjectInput, CreateProjectOutput};
|
||||
pub use meta::ProjectMeta;
|
||||
pub use open::{ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput};
|
||||
pub use open::{
|
||||
ListProjects, ListProjectsOutput, OpenProject, OpenProjectInput, OpenProjectOutput,
|
||||
};
|
||||
|
||||
@ -7,9 +7,7 @@ use domain::{AgentManifest, Project, ProjectId};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
use super::meta::{
|
||||
from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE,
|
||||
};
|
||||
use super::meta::{from_json_bytes, join_root, ProjectMeta, AGENTS_FILE, IDEAI_DIR, PROJECT_FILE};
|
||||
|
||||
/// Input for [`OpenProject::execute`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
||||
@ -249,13 +249,21 @@ impl AssignSkillToAgent {
|
||||
|
||||
// Mutate through the domain entity so the dedup invariant is enforced in
|
||||
// one place, then fold the result back into the manifest entry.
|
||||
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let mut agent = entry
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let changed = agent.assign_skill(input.skill);
|
||||
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||
|
||||
if changed {
|
||||
self.persist_and_announce(&input.project, manifest, input.agent_id, input.skill.skill_id, true)
|
||||
.await?;
|
||||
self.persist_and_announce(
|
||||
&input.project,
|
||||
manifest,
|
||||
input.agent_id,
|
||||
input.skill.skill_id,
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@ -320,14 +328,18 @@ impl UnassignSkillFromAgent {
|
||||
.find(|e| e.agent_id == input.agent_id)
|
||||
.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
|
||||
|
||||
let mut agent = entry.to_agent().map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let mut agent = entry
|
||||
.to_agent()
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let changed = agent.unassign_skill(input.skill_id);
|
||||
*entry = domain::ManifestEntry::from_agent(&agent);
|
||||
|
||||
if changed {
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
self.events.publish(DomainEvent::SkillAssigned {
|
||||
agent_id: input.agent_id,
|
||||
skill_id: input.skill_id,
|
||||
|
||||
@ -286,7 +286,9 @@ impl CreateAgentFromTemplate {
|
||||
entries.push(ManifestEntry::from_agent(&agent));
|
||||
let manifest = AgentManifest::new(manifest.version, entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
|
||||
// Seed the agent context with the template content.
|
||||
self.contexts
|
||||
@ -477,7 +479,9 @@ impl SyncAgentWithTemplate {
|
||||
// Persist the manifest (revalidated) and overwrite the agent context.
|
||||
let manifest = AgentManifest::new(manifest.version, manifest.entries)
|
||||
.map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
self.contexts.save_manifest(&input.project, &manifest).await?;
|
||||
self.contexts
|
||||
.save_manifest(&input.project, &manifest)
|
||||
.await?;
|
||||
self.contexts
|
||||
.write_context(&input.project, &input.agent_id, &template.content_md)
|
||||
.await?;
|
||||
|
||||
@ -128,19 +128,21 @@ impl TerminalSessions {
|
||||
})
|
||||
}
|
||||
|
||||
/// Lists every currently-live agent and the cell hosting it.
|
||||
/// Lists every currently-live agent, its current host cell and session id.
|
||||
///
|
||||
/// One `(AgentId, NodeId)` pair per session tagged [`SessionKind::Agent`].
|
||||
/// One `(AgentId, NodeId, SessionId)` tuple per session tagged [`SessionKind::Agent`].
|
||||
/// Used by the `list_live_agents` query so the UI can disable an agent that
|
||||
/// is already running elsewhere (it cannot be launched in a second cell).
|
||||
#[must_use]
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId)> {
|
||||
pub fn live_agents(&self) -> Vec<(AgentId, NodeId, SessionId)> {
|
||||
self.entries
|
||||
.lock()
|
||||
.map(|m| {
|
||||
m.values()
|
||||
.filter_map(|e| match e.session.kind {
|
||||
SessionKind::Agent { agent_id } => Some((agent_id, e.session.node_id)),
|
||||
SessionKind::Agent { agent_id } => {
|
||||
Some((agent_id, e.session.node_id, e.session.id))
|
||||
}
|
||||
SessionKind::Plain => None,
|
||||
})
|
||||
.collect()
|
||||
@ -148,6 +150,28 @@ impl TerminalSessions {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebinds a live agent session to a new visible layout node without
|
||||
/// respawning the CLI process.
|
||||
///
|
||||
/// This is the application-level "cell is a view" operation: closing a cell
|
||||
/// can leave an agent running in the background, and later opening that agent
|
||||
/// in another cell updates only the view binding (`node_id`). The PTY handle,
|
||||
/// session id, scrollback and process stay untouched.
|
||||
#[must_use]
|
||||
pub fn rebind_agent_node(
|
||||
&self,
|
||||
agent_id: &AgentId,
|
||||
node_id: NodeId,
|
||||
) -> Option<TerminalSession> {
|
||||
self.entries.lock().ok().and_then(|mut m| {
|
||||
let entry = m.values_mut().find(
|
||||
|e| matches!(e.session.kind, SessionKind::Agent { agent_id: a } if &a == agent_id),
|
||||
)?;
|
||||
entry.session.node_id = node_id;
|
||||
Some(entry.session.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the [`PtyHandle`]s of every currently-registered session.
|
||||
///
|
||||
/// Used at application shutdown to kill all live PTYs cleanly (the
|
||||
|
||||
@ -67,10 +67,7 @@ impl OpenTerminal {
|
||||
/// # Errors
|
||||
/// - [`AppError::Invalid`] for a non-absolute cwd or a zero-sized terminal,
|
||||
/// - [`AppError::Process`] if the PTY fails to spawn.
|
||||
pub async fn execute(
|
||||
&self,
|
||||
input: OpenTerminalInput,
|
||||
) -> Result<OpenTerminalOutput, AppError> {
|
||||
pub async fn execute(&self, input: OpenTerminalInput) -> Result<OpenTerminalOutput, AppError> {
|
||||
let cwd = ProjectPath::new(input.cwd).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
let size =
|
||||
PtySize::new(input.rows, input.cols).map_err(|e| AppError::Invalid(e.to_string()))?;
|
||||
|
||||
@ -192,7 +192,15 @@ fn profile(id: ProfileId) -> AgentProfile {
|
||||
}
|
||||
|
||||
fn agent(id: AgentId, profile_id: ProfileId) -> Agent {
|
||||
Agent::new(id, "Bob", "agents/bob.md", profile_id, AgentOrigin::Scratch, false).unwrap()
|
||||
Agent::new(
|
||||
id,
|
||||
"Bob",
|
||||
"agents/bob.md",
|
||||
profile_id,
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn input(agent_id: AgentId) -> InspectConversationInput {
|
||||
@ -228,7 +236,10 @@ async fn supporting_inspector_propagates_details() {
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details.last_topic.as_deref(), Some("refactor the parser"));
|
||||
assert_eq!(
|
||||
out.details.last_topic.as_deref(),
|
||||
Some("refactor the parser")
|
||||
);
|
||||
assert_eq!(out.details.token_count, Some(4242));
|
||||
|
||||
// Routed the conversation id and the agent's isolated run dir (not the root).
|
||||
@ -270,8 +281,10 @@ async fn read_error_degrades_to_empty_details() {
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(42));
|
||||
let a = agent(aid, pid);
|
||||
|
||||
let (inspector, _seen) =
|
||||
FakeInspector::new(true, InspectResult::Err(InspectError::Read("boom".to_owned())));
|
||||
let (inspector, _seen) = FakeInspector::new(
|
||||
true,
|
||||
InspectResult::Err(InspectError::Read("boom".to_owned())),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
Arc::new(FakeContexts::with_agent(&a)),
|
||||
@ -280,7 +293,13 @@ async fn read_error_degrades_to_empty_details() {
|
||||
);
|
||||
|
||||
let out = uc.execute(input(aid)).await.unwrap();
|
||||
assert_eq!(out.details, ConversationDetails { last_topic: None, token_count: None });
|
||||
assert_eq!(
|
||||
out.details,
|
||||
ConversationDetails {
|
||||
last_topic: None,
|
||||
token_count: None
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -344,7 +363,10 @@ async fn unknown_agent_errors() {
|
||||
let aid = AgentId::from_uuid(Uuid::from_u128(99));
|
||||
let (inspector, _seen) = FakeInspector::new(
|
||||
true,
|
||||
InspectResult::Ok(ConversationDetails { last_topic: None, token_count: None }),
|
||||
InspectResult::Ok(ConversationDetails {
|
||||
last_topic: None,
|
||||
token_count: None,
|
||||
}),
|
||||
);
|
||||
|
||||
let uc = InspectConversation::new(
|
||||
|
||||
@ -29,16 +29,16 @@ use domain::ports::{
|
||||
OutputStream, PreparedContext, ProfileStore, PtyError, PtyHandle, PtyPort, RemotePath,
|
||||
RuntimeError, SessionPlan, SkillStore, SpawnSpec, StoreError,
|
||||
};
|
||||
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
||||
use domain::profile::{AgentProfile, ContextInjection, SessionStrategy};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
use domain::skill::{Skill, SkillScope};
|
||||
use domain::{MemoryIndexEntry, MemorySlug, MemoryType};
|
||||
use domain::{PtySize, SessionId, SkillId, SkillRef};
|
||||
use uuid::Uuid;
|
||||
|
||||
use application::{
|
||||
AppError, CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
CreateAgentFromScratch, CreateAgentInput, DeleteAgent, DeleteAgentInput, LaunchAgent,
|
||||
LaunchAgentInput, ListAgents, ListAgentsInput, ReadAgentContext, ReadAgentContextInput,
|
||||
TerminalSessions, UpdateAgentContext, UpdateAgentContextInput,
|
||||
};
|
||||
@ -84,7 +84,10 @@ impl FakeContexts {
|
||||
let me = Self::new();
|
||||
{
|
||||
let mut inner = me.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
|
||||
inner
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(agent));
|
||||
inner
|
||||
.contents
|
||||
.insert(agent.context_path.clone(), content.to_owned());
|
||||
@ -196,7 +199,12 @@ impl FakeSkills {
|
||||
#[async_trait]
|
||||
impl SkillStore for FakeSkills {
|
||||
async fn list(&self, scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||
Ok(self.0.iter().filter(|s| s.scope == scope).cloned().collect())
|
||||
Ok(self
|
||||
.0
|
||||
.iter()
|
||||
.filter(|s| s.scope == scope)
|
||||
.cloned()
|
||||
.collect())
|
||||
}
|
||||
async fn get(
|
||||
&self,
|
||||
@ -333,6 +341,7 @@ struct FakeFs {
|
||||
trace: Trace,
|
||||
writes: WriteLog<String>,
|
||||
created_dirs: Arc<Mutex<Vec<String>>>,
|
||||
project_context: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl FakeFs {
|
||||
@ -341,8 +350,12 @@ impl FakeFs {
|
||||
trace,
|
||||
writes: Arc::new(Mutex::new(Vec::new())),
|
||||
created_dirs: Arc::new(Mutex::new(Vec::new())),
|
||||
project_context: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
fn set_project_context(&self, content: &str) {
|
||||
*self.project_context.lock().unwrap() = Some(content.to_owned());
|
||||
}
|
||||
fn writes(&self) -> Vec<(String, Vec<u8>)> {
|
||||
self.writes.lock().unwrap().clone()
|
||||
}
|
||||
@ -376,6 +389,15 @@ impl FakeFs {
|
||||
#[async_trait]
|
||||
impl FileSystem for FakeFs {
|
||||
async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
|
||||
if path.as_str().ends_with("/.ideai/CONTEXT.md") {
|
||||
return self
|
||||
.project_context
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.map(|s| s.into_bytes())
|
||||
.ok_or_else(|| FsError::NotFound(path.as_str().to_owned()));
|
||||
}
|
||||
Err(FsError::NotFound(path.as_str().to_owned()))
|
||||
}
|
||||
async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
|
||||
@ -648,7 +670,10 @@ async fn read_then_update_context_roundtrips() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(contexts.content("agents/backend.md").as_deref(), Some("edited"));
|
||||
assert_eq!(
|
||||
contexts.content("agents/backend.md").as_deref(),
|
||||
Some("edited")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -696,7 +721,10 @@ type LaunchFixture = (
|
||||
);
|
||||
|
||||
/// Wires a LaunchAgent over fakes for a given injection strategy/plan.
|
||||
fn launch_fixture(injection: ContextInjection, plan: Option<ContextInjectionPlan>) -> LaunchFixture {
|
||||
fn launch_fixture(
|
||||
injection: ContextInjection,
|
||||
plan: Option<ContextInjectionPlan>,
|
||||
) -> LaunchFixture {
|
||||
launch_fixture_with_profile(profile(pid(9), injection), plan)
|
||||
}
|
||||
|
||||
@ -738,6 +766,7 @@ fn launch_fixture_with_profile_and_recall(
|
||||
Arc::new(bus.clone()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(recall),
|
||||
None,
|
||||
);
|
||||
(launch, agent, fs, pty, bus, sessions, tr, session_probe)
|
||||
}
|
||||
@ -763,7 +792,10 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
}),
|
||||
);
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
let out = launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
// Ordering contract. The Claude permission seed is written first (right after
|
||||
// the run dir is created), then prepare → injection (convention file) → spawn.
|
||||
@ -802,14 +834,18 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
assert_eq!(seeds.len(), 1);
|
||||
assert_eq!(seeds[0].0, format!("{run_dir}/.claude/settings.local.json"));
|
||||
let seed = String::from_utf8(seeds[0].1.clone()).unwrap();
|
||||
assert!(seed.contains("bypassPermissions"), "seed grants autonomy: {seed}");
|
||||
assert!(seed.contains("/home/me/proj"), "seed grants the project root");
|
||||
assert!(
|
||||
seed.contains("bypassPermissions"),
|
||||
"seed grants autonomy: {seed}"
|
||||
);
|
||||
assert!(
|
||||
seed.contains("/home/me/proj"),
|
||||
"seed grants the project root"
|
||||
);
|
||||
|
||||
// The run directory (and the seed's `.claude` subdir) were created before spawn.
|
||||
assert_eq!(fs.created_run_dirs(), vec![run_dir.clone()]);
|
||||
assert!(fs
|
||||
.created_dirs()
|
||||
.contains(&format!("{run_dir}/.claude")));
|
||||
assert!(fs.created_dirs().contains(&format!("{run_dir}/.claude")));
|
||||
|
||||
// Spawn happened at the isolated run dir with the profile command.
|
||||
let spawns = pty.spawns();
|
||||
@ -835,6 +871,34 @@ async fn launch_orders_prepare_then_injection_then_spawn() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_injects_shared_project_context_from_ideai_context_md() {
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) = launch_fixture(
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
);
|
||||
fs.set_project_context("# Shared project\n\nUse pnpm.");
|
||||
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
let writes = fs.context_writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||
assert!(doc.contains("# Contexte projet"));
|
||||
assert!(doc.contains("Use pnpm."));
|
||||
let project_context_at = doc.find("# Contexte projet").unwrap();
|
||||
let persona_at = doc.find("# ctx body").unwrap();
|
||||
assert!(
|
||||
project_context_at < persona_at,
|
||||
"project context must precede the agent persona"
|
||||
);
|
||||
}
|
||||
|
||||
/// Inserts a live agent session into the registry, simulating an already-running
|
||||
/// agent pinned on `node`. Returns the session id.
|
||||
fn seed_live_agent_session(
|
||||
@ -860,11 +924,10 @@ fn nid(n: u128) -> domain::NodeId {
|
||||
}
|
||||
|
||||
/// **Singleton invariant (T1)**: launching an agent that already owns a live
|
||||
/// session in *another* cell is refused with `AgentAlreadyRunning`, pointing at
|
||||
/// the existing host node. The registry is left untouched and the PTY is never
|
||||
/// spawned.
|
||||
/// session in another cell re-attaches the existing session to the requested
|
||||
/// node. The PTY is never spawned a second time.
|
||||
#[tokio::test]
|
||||
async fn launch_refuses_agent_already_running_elsewhere() {
|
||||
async fn launch_reattaches_agent_already_running_elsewhere() {
|
||||
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -877,28 +940,24 @@ async fn launch_refuses_agent_already_running_elsewhere() {
|
||||
seed_live_agent_session(&sessions, agent.id, host, sid(42));
|
||||
let before = sessions.len();
|
||||
|
||||
// Attempt to launch the same agent into a *different* cell B.
|
||||
// Open the same running agent in a different cell B.
|
||||
let mut input = launch_input(agent.id);
|
||||
input.node_id = Some(nid(2));
|
||||
let err = launch.execute(input).await.expect_err("must be refused");
|
||||
let target = nid(2);
|
||||
input.node_id = Some(target);
|
||||
let out = launch.execute(input).await.expect("reattach succeeds");
|
||||
|
||||
match err {
|
||||
AppError::AgentAlreadyRunning { agent_id, node_id } => {
|
||||
assert_eq!(agent_id, agent.id);
|
||||
assert_eq!(node_id, host, "reports the existing host cell");
|
||||
}
|
||||
other => panic!("expected AgentAlreadyRunning, got {other:?}"),
|
||||
}
|
||||
assert_eq!(err.code(), "AGENT_ALREADY_RUNNING");
|
||||
// Invariant preserved: registry unchanged, no spawn happened.
|
||||
assert_eq!(sessions.len(), before, "registry must be unchanged");
|
||||
assert!(pty.spawns().is_empty(), "no PTY spawn on a refused launch");
|
||||
assert_eq!(out.session.id, sid(42), "returns the existing session");
|
||||
assert_eq!(out.session.node_id, target, "session is rebound to target");
|
||||
assert_eq!(sessions.node_for_agent(&agent.id), Some(target));
|
||||
assert_eq!(sessions.len(), before, "registry size is unchanged");
|
||||
assert!(pty.spawns().is_empty(), "no PTY spawn on reattach");
|
||||
}
|
||||
|
||||
/// A launch with an unspecified node (`node_id: None`) for an already-live agent
|
||||
/// is also refused — it cannot be the same cell.
|
||||
/// is a background/idempotent no-op: return the existing session without
|
||||
/// respawning or changing its current host.
|
||||
#[tokio::test]
|
||||
async fn launch_refuses_already_running_agent_with_no_node() {
|
||||
async fn launch_existing_agent_with_no_node_is_background_noop() {
|
||||
let (launch, agent, _fs, pty, _bus, sessions, _tr, _session) = launch_fixture(
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
Some(ContextInjectionPlan::File {
|
||||
@ -908,11 +967,12 @@ async fn launch_refuses_already_running_agent_with_no_node() {
|
||||
seed_live_agent_session(&sessions, agent.id, nid(1), sid(42));
|
||||
|
||||
// node_id defaults to None in launch_input.
|
||||
let err = launch
|
||||
let out = launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect_err("must be refused");
|
||||
assert!(matches!(err, AppError::AgentAlreadyRunning { .. }));
|
||||
.expect("background relaunch is idempotent");
|
||||
assert_eq!(out.session.id, sid(42));
|
||||
assert_eq!(out.session.node_id, nid(1));
|
||||
assert!(pty.spawns().is_empty());
|
||||
}
|
||||
|
||||
@ -937,7 +997,10 @@ async fn launch_same_node_is_idempotent_no_respawn() {
|
||||
let out = launch.execute(input).await.expect("idempotent launch");
|
||||
|
||||
assert_eq!(out.session.id, sid(42), "returns the existing session");
|
||||
assert!(out.assigned_conversation_id.is_none(), "nothing new assigned");
|
||||
assert!(
|
||||
out.assigned_conversation_id.is_none(),
|
||||
"nothing new assigned"
|
||||
);
|
||||
assert_eq!(sessions.len(), before, "no new session registered");
|
||||
assert!(pty.spawns().is_empty(), "no respawn on same-node relaunch");
|
||||
}
|
||||
@ -963,7 +1026,11 @@ async fn launch_succeeds_after_session_removed() {
|
||||
let out = launch.execute(input).await.expect("relaunch must succeed");
|
||||
|
||||
assert_eq!(out.session.id, sid(777), "spawned a fresh PTY session");
|
||||
assert_eq!(pty.spawns().len(), 1, "exactly one spawn after the agent died");
|
||||
assert_eq!(
|
||||
pty.spawns().len(),
|
||||
1,
|
||||
"exactly one spawn after the agent died"
|
||||
);
|
||||
}
|
||||
|
||||
/// **Anti-collision (ARCHITECTURE §14.1)**: two distinct agents of the *same*
|
||||
@ -1009,6 +1076,7 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall::default()),
|
||||
None,
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent_a.id)).await.unwrap();
|
||||
@ -1016,7 +1084,10 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
||||
|
||||
let dir_a = format!("/home/me/proj/.ideai/run/{}", agent_a.id);
|
||||
let dir_b = format!("/home/me/proj/.ideai/run/{}", agent_b.id);
|
||||
assert_ne!(dir_a, dir_b, "the two agents must map to different run dirs");
|
||||
assert_ne!(
|
||||
dir_a, dir_b,
|
||||
"the two agents must map to different run dirs"
|
||||
);
|
||||
|
||||
// Two distinct run dirs were created (ignoring each seed's `.claude` subdir).
|
||||
assert_eq!(fs.created_run_dirs(), vec![dir_a.clone(), dir_b.clone()]);
|
||||
@ -1038,8 +1109,12 @@ async fn two_agents_same_root_get_distinct_run_dirs_no_collision() {
|
||||
assert!(writes.iter().all(|(p, _)| p != "/home/me/proj/CLAUDE.md"));
|
||||
|
||||
// Each convention file carries its own persona.
|
||||
assert!(String::from_utf8(writes[0].1.clone()).unwrap().contains("# alpha"));
|
||||
assert!(String::from_utf8(writes[1].1.clone()).unwrap().contains("# bravo"));
|
||||
assert!(String::from_utf8(writes[0].1.clone())
|
||||
.unwrap()
|
||||
.contains("# alpha"));
|
||||
assert!(String::from_utf8(writes[1].1.clone())
|
||||
.unwrap()
|
||||
.contains("# bravo"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -1048,7 +1123,13 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
||||
// carry both skill bodies, after the persona, in assignment order (§14.2).
|
||||
let skill_id = |n: u128| SkillId::from_uuid(Uuid::from_u128(n));
|
||||
let skill = |n: u128, name: &str, body: &str| {
|
||||
Skill::new(skill_id(n), name, MarkdownDoc::new(body), SkillScope::Global).unwrap()
|
||||
Skill::new(
|
||||
skill_id(n),
|
||||
name,
|
||||
MarkdownDoc::new(body),
|
||||
SkillScope::Global,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
@ -1083,6 +1164,7 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall::default()),
|
||||
None,
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
@ -1097,7 +1179,10 @@ async fn launch_conventionfile_injects_assigned_skills_in_order() {
|
||||
let persona_at = doc.find("# persona").unwrap();
|
||||
let refac_at = doc.find("REFAC_BODY").unwrap();
|
||||
let review_at = doc.find("REVIEW_BODY").unwrap();
|
||||
assert!(persona_at < refac_at && refac_at < review_at, "ordering: {doc}");
|
||||
assert!(
|
||||
persona_at < refac_at && refac_at < review_at,
|
||||
"ordering: {doc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -1106,12 +1191,25 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
||||
// `# Mémoire projet` section with one line per entry, in the recalled order
|
||||
// and the exact `- [Title](slug.md) — hook (type)` format (§14.5.4).
|
||||
let recall = FakeRecall::returning(vec![
|
||||
mem_entry("git-optional", "Git optionnel", "git reste un simple tool", MemoryType::Project),
|
||||
mem_entry("perm-archi", "Permissions", "sandbox OS + résumé injecté", MemoryType::Reference),
|
||||
mem_entry(
|
||||
"git-optional",
|
||||
"Git optionnel",
|
||||
"git reste un simple tool",
|
||||
MemoryType::Project,
|
||||
),
|
||||
mem_entry(
|
||||
"perm-archi",
|
||||
"Permissions",
|
||||
"sandbox OS + résumé injecté",
|
||||
MemoryType::Reference,
|
||||
),
|
||||
]);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile_and_recall(
|
||||
profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()),
|
||||
profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
@ -1124,7 +1222,10 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
||||
assert_eq!(writes.len(), 1);
|
||||
let doc = String::from_utf8(writes[0].1.clone()).unwrap();
|
||||
|
||||
assert!(doc.contains("# Mémoire projet"), "memory section present: {doc}");
|
||||
assert!(
|
||||
doc.contains("# Mémoire projet"),
|
||||
"memory section present: {doc}"
|
||||
);
|
||||
assert!(
|
||||
doc.contains("- [Git optionnel](git-optional.md) — git reste un simple tool (project)"),
|
||||
"first memory line exact format: {doc}"
|
||||
@ -1139,7 +1240,10 @@ async fn launch_conventionfile_injects_project_memory_in_order() {
|
||||
let first_at = doc.find("[Git optionnel]").unwrap();
|
||||
let second_at = doc.find("[Permissions]").unwrap();
|
||||
assert!(persona_at < section_at, "memory after persona: {doc}");
|
||||
assert!(first_at < second_at, "entries kept in recalled order: {doc}");
|
||||
assert!(
|
||||
first_at < second_at,
|
||||
"entries kept in recalled order: {doc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -1155,7 +1259,10 @@ async fn launch_conventionfile_without_memory_omits_section() {
|
||||
launch.execute(launch_input(agent.id)).await.unwrap();
|
||||
|
||||
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
|
||||
assert!(!doc.contains("# Mémoire projet"), "no memory ⇒ no section: {doc}");
|
||||
assert!(
|
||||
!doc.contains("# Mémoire projet"),
|
||||
"no memory ⇒ no section: {doc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -1164,7 +1271,10 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() {
|
||||
// succeeds and the convention file simply carries no memory section.
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile_and_recall(
|
||||
profile(pid(9), ContextInjection::convention_file("CLAUDE.md").unwrap()),
|
||||
profile(
|
||||
pid(9),
|
||||
ContextInjection::convention_file("CLAUDE.md").unwrap(),
|
||||
),
|
||||
Some(ContextInjectionPlan::File {
|
||||
target: "CLAUDE.md".to_owned(),
|
||||
}),
|
||||
@ -1172,7 +1282,10 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() {
|
||||
);
|
||||
|
||||
// Launch still succeeds despite the recall error.
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch must succeed");
|
||||
|
||||
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
|
||||
assert!(
|
||||
@ -1185,12 +1298,7 @@ async fn launch_recall_error_degrades_to_no_section_without_failing() {
|
||||
async fn launch_env_strategy_injects_no_memory_section() {
|
||||
// For the `env` strategy IdeA writes no convention file, so no memory section is
|
||||
// injected — aligned with how skills are only injected for `conventionFile`.
|
||||
let recall = FakeRecall::returning(vec![mem_entry(
|
||||
"note",
|
||||
"Note",
|
||||
"a hook",
|
||||
MemoryType::User,
|
||||
)]);
|
||||
let recall = FakeRecall::returning(vec![mem_entry("note", "Note", "a hook", MemoryType::User)]);
|
||||
let (launch, agent, fs, _pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture_with_profile_and_recall(
|
||||
profile(pid(9), ContextInjection::env("IDEA_CONTEXT").unwrap()),
|
||||
@ -1214,7 +1322,10 @@ async fn launch_skips_dangling_skill_ref_without_failing() {
|
||||
// The agent references a skill that no longer exists in the store: launch must
|
||||
// still succeed and simply omit it (no Skills section for a sole dangling ref).
|
||||
let mut agent = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
|
||||
agent.assign_skill(SkillRef::new(SkillId::from_uuid(Uuid::from_u128(99)), SkillScope::Global));
|
||||
agent.assign_skill(SkillRef::new(
|
||||
SkillId::from_uuid(Uuid::from_u128(99)),
|
||||
SkillScope::Global,
|
||||
));
|
||||
|
||||
let contexts = FakeContexts::with_agent(&agent, "# persona");
|
||||
let profiles = FakeProfiles::new(vec![profile(
|
||||
@ -1240,11 +1351,18 @@ async fn launch_skips_dangling_skill_ref_without_failing() {
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall::default()),
|
||||
None,
|
||||
);
|
||||
|
||||
launch.execute(launch_input(agent.id)).await.expect("launch must succeed");
|
||||
launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch must succeed");
|
||||
let doc = String::from_utf8(fs.context_writes()[0].1.clone()).unwrap();
|
||||
assert!(!doc.contains("# Skills"), "no Skills section for a dangling ref: {doc}");
|
||||
assert!(
|
||||
!doc.contains("# Skills"),
|
||||
"no Skills section for a dangling ref: {doc}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@ -1281,7 +1399,10 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||
|
||||
// No file written for stdin; content is piped to the PTY post-spawn.
|
||||
assert!(fs.writes().is_empty(), "stdin must not write a file");
|
||||
assert_eq!(*tr.lock().unwrap(), vec!["prepare".to_owned(), "spawn".to_owned()]);
|
||||
assert_eq!(
|
||||
*tr.lock().unwrap(),
|
||||
vec!["prepare".to_owned(), "spawn".to_owned()]
|
||||
);
|
||||
let writes = pty.writes();
|
||||
assert_eq!(writes.len(), 1);
|
||||
assert_eq!(writes[0].0, sid(777));
|
||||
@ -1290,10 +1411,8 @@ async fn launch_stdin_strategy_pipes_context_after_spawn() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn launch_unknown_agent_is_not_found() {
|
||||
let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) = launch_fixture(
|
||||
ContextInjection::stdin(),
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
let (launch, _agent, _fs, pty, _bus, _sessions, _tr, _session) =
|
||||
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
|
||||
let err = launch.execute(launch_input(aid(404))).await.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
assert!(pty.spawns().is_empty(), "no spawn for unknown agent");
|
||||
@ -1310,7 +1429,10 @@ async fn launch_unknown_profile_is_not_found() {
|
||||
let launch = LaunchAgent::new(
|
||||
Arc::new(contexts),
|
||||
Arc::new(profiles),
|
||||
Arc::new(FakeRuntime::new(Arc::clone(&tr), Some(ContextInjectionPlan::Stdin))),
|
||||
Arc::new(FakeRuntime::new(
|
||||
Arc::clone(&tr),
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
)),
|
||||
Arc::new(FakeFs::new(Arc::clone(&tr))),
|
||||
Arc::new(pty.clone()),
|
||||
Arc::new(FakeSkills::default()),
|
||||
@ -1318,6 +1440,7 @@ async fn launch_unknown_profile_is_not_found() {
|
||||
Arc::new(SpyBus::default()),
|
||||
Arc::new(SeqIds::new()),
|
||||
Arc::new(FakeRecall::default()),
|
||||
None,
|
||||
);
|
||||
|
||||
let err = launch.execute(launch_input(agent.id)).await.unwrap_err();
|
||||
@ -1358,18 +1481,26 @@ async fn launch_first_time_with_assign_flag_mints_and_exposes_conversation_id()
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
let out = launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
// SeqIds yields Uuid::from_u128(1) on its first call.
|
||||
let expected = Uuid::from_u128(1).to_string();
|
||||
|
||||
// The use case exposes the assigned id (caller persists it on the leaf).
|
||||
assert_eq!(out.assigned_conversation_id.as_deref(), Some(expected.as_str()));
|
||||
assert_eq!(
|
||||
out.assigned_conversation_id.as_deref(),
|
||||
Some(expected.as_str())
|
||||
);
|
||||
|
||||
// The runtime received an Assign plan carrying exactly that id.
|
||||
assert_eq!(
|
||||
*session.lock().unwrap(),
|
||||
Some(SessionPlan::Assign { conversation_id: expected }),
|
||||
Some(SessionPlan::Assign {
|
||||
conversation_id: expected
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1403,7 +1534,10 @@ async fn launch_profile_without_session_block_passes_none() {
|
||||
let (launch, agent, _fs, _pty, _bus, _sessions, _tr, session) =
|
||||
launch_fixture(ContextInjection::stdin(), Some(ContextInjectionPlan::Stdin));
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
let out = launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert_eq!(out.assigned_conversation_id, None);
|
||||
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
|
||||
@ -1418,8 +1552,14 @@ async fn launch_degraded_mode_without_assign_flag_first_launch_is_none() {
|
||||
Some(ContextInjectionPlan::Stdin),
|
||||
);
|
||||
|
||||
let out = launch.execute(launch_input(agent.id)).await.expect("launch");
|
||||
let out = launch
|
||||
.execute(launch_input(agent.id))
|
||||
.await
|
||||
.expect("launch");
|
||||
|
||||
assert_eq!(out.assigned_conversation_id, None, "degraded mode mints no id");
|
||||
assert_eq!(
|
||||
out.assigned_conversation_id, None,
|
||||
"degraded mode mints no id"
|
||||
);
|
||||
assert_eq!(*session.lock().unwrap(), Some(SessionPlan::None));
|
||||
}
|
||||
|
||||
256
crates/application/tests/embedder_usecases.rs
Normal file
256
crates/application/tests/embedder_usecases.rs
Normal file
@ -0,0 +1,256 @@
|
||||
//! L5 tests for the embedder-configuration use cases (LOT C2).
|
||||
//!
|
||||
//! Ports are faked in-memory so the use cases run without any I/O:
|
||||
//! - [`InMemoryEmbedderProfileStore`] — an [`EmbedderProfileStore`] backed by a
|
||||
//! `Vec` with the same upsert/delete-NotFound semantics as the real
|
||||
//! `FsEmbedderProfileStore`,
|
||||
//! - [`FixedEnvInspector`] — an [`EmbedderEnvInspector`] returning a constant
|
||||
//! [`EmbedderEnvReport`] (best-effort port, never errors).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::ports::{EmbedderEnvInspector, EmbedderEnvReport, EmbedderProfileStore, StoreError};
|
||||
use domain::profile::{EmbedderProfile, EmbedderStrategy};
|
||||
|
||||
use application::{
|
||||
DeleteEmbedderProfile, DeleteEmbedderProfileInput, DescribeEmbedderEngines,
|
||||
ListEmbedderProfiles, OnnxModelView, SaveEmbedderProfile, SaveEmbedderProfileInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fakes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// In-memory [`EmbedderProfileStore`] mirroring the real store's semantics:
|
||||
/// upsert-by-id (no duplicate), delete-absent ⇒ [`StoreError::NotFound`].
|
||||
#[derive(Default, Clone)]
|
||||
struct InMemoryEmbedderProfileStore(Arc<Mutex<Vec<EmbedderProfile>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderProfileStore for InMemoryEmbedderProfileStore {
|
||||
async fn list(&self) -> Result<Vec<EmbedderProfile>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn save(&self, profile: &EmbedderProfile) -> Result<(), StoreError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
if let Some(slot) = v.iter_mut().find(|p| p.id == profile.id) {
|
||||
*slot = profile.clone();
|
||||
} else {
|
||||
v.push(profile.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete(&self, id: &str) -> Result<(), StoreError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
let before = v.len();
|
||||
v.retain(|p| p.id != id);
|
||||
if v.len() == before {
|
||||
return Err(StoreError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// An [`EmbedderEnvInspector`] returning a fixed report (best-effort, infallible).
|
||||
struct FixedEnvInspector(EmbedderEnvReport);
|
||||
|
||||
#[async_trait]
|
||||
impl EmbedderEnvInspector for FixedEnvInspector {
|
||||
async fn inspect(&self) -> EmbedderEnvReport {
|
||||
self.0.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn save_input(id: &str, name: &str, dimension: usize) -> SaveEmbedderProfileInput {
|
||||
SaveEmbedderProfileInput {
|
||||
id: id.to_owned(),
|
||||
name: name.to_owned(),
|
||||
strategy: EmbedderStrategy::LocalOnnx,
|
||||
model: Some("multilingual-e5-small".to_owned()),
|
||||
endpoint: None,
|
||||
api_key_env: None,
|
||||
dimension,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ListEmbedderProfiles / SaveEmbedderProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_is_empty_then_contains_saved_profile() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
|
||||
assert!(
|
||||
list.execute().await.unwrap().profiles.is_empty(),
|
||||
"no profile configured ⇒ empty list (default `none` posture)"
|
||||
);
|
||||
|
||||
let saved = save
|
||||
.execute(save_input("local-onnx", "Local ONNX", 384))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(saved.profile.id, "local-onnx");
|
||||
assert_eq!(saved.profile.dimension, 384);
|
||||
|
||||
let listed = list.execute().await.unwrap().profiles;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0], saved.profile);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_upserts_by_id_without_duplicating() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
save.execute(save_input("e", "before", 384)).await.unwrap();
|
||||
let updated = save.execute(save_input("e", "after", 768)).await.unwrap();
|
||||
|
||||
let listed = list.execute().await.unwrap().profiles;
|
||||
assert_eq!(listed.len(), 1, "same id replaces, no duplicate");
|
||||
assert_eq!(listed[0], updated.profile);
|
||||
assert_eq!(listed[0].name, "after");
|
||||
assert_eq!(listed[0].dimension, 768);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_invalid_dimension_is_invalid_and_writes_nothing() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
let err = save
|
||||
.execute(save_input("bad", "Bad", 0))
|
||||
.await
|
||||
.expect_err("dimension 0 must be rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
|
||||
assert!(
|
||||
list.execute().await.unwrap().profiles.is_empty(),
|
||||
"a rejected profile must not be persisted"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn save_empty_name_is_invalid_and_writes_nothing() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
let err = save
|
||||
.execute(save_input("id", "", 384))
|
||||
.await
|
||||
.expect_err("empty name must be rejected");
|
||||
assert_eq!(err.code(), "INVALID", "got {err:?}");
|
||||
|
||||
assert!(
|
||||
list.execute().await.unwrap().profiles.is_empty(),
|
||||
"a rejected profile must not be persisted"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeleteEmbedderProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_existing_profile() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let save = SaveEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let delete = DeleteEmbedderProfile::new(Arc::new(store.clone()));
|
||||
let list = ListEmbedderProfiles::new(Arc::new(store.clone()));
|
||||
|
||||
save.execute(save_input("a", "A", 384)).await.unwrap();
|
||||
save.execute(save_input("b", "B", 384)).await.unwrap();
|
||||
|
||||
delete
|
||||
.execute(DeleteEmbedderProfileInput { id: "a".to_owned() })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let listed = list.execute().await.unwrap().profiles;
|
||||
assert_eq!(listed.len(), 1);
|
||||
assert_eq!(listed[0].id, "b");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_id_is_not_found() {
|
||||
let store = InMemoryEmbedderProfileStore::default();
|
||||
let delete = DeleteEmbedderProfile::new(Arc::new(store));
|
||||
|
||||
let err = delete
|
||||
.execute(DeleteEmbedderProfileInput {
|
||||
id: "ghost".to_owned(),
|
||||
})
|
||||
.await
|
||||
.expect_err("deleting an unknown id errors");
|
||||
assert_eq!(
|
||||
err.code(),
|
||||
"NOT_FOUND",
|
||||
"StoreError::NotFound ⇒ AppError::NotFound, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DescribeEmbedderEngines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn onnx_view() -> OnnxModelView {
|
||||
OnnxModelView {
|
||||
id: "multilingual-e5-small".to_owned(),
|
||||
display_name: "Multilingual E5 Small".to_owned(),
|
||||
dimension: 384,
|
||||
approx_size_mb: 118,
|
||||
recommended: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_engines_merges_catalogue_report_and_flags() {
|
||||
let report = EmbedderEnvReport {
|
||||
ollama_detected: true,
|
||||
onnx_cached_models: vec!["multilingual-e5-small".to_owned()],
|
||||
};
|
||||
let inspector = Arc::new(FixedEnvInspector(report));
|
||||
let describe = DescribeEmbedderEngines::new(
|
||||
inspector,
|
||||
vec![onnx_view()],
|
||||
/* vector_http_enabled */ true,
|
||||
/* vector_onnx_enabled */ false,
|
||||
);
|
||||
|
||||
let view = describe.execute().await.unwrap();
|
||||
|
||||
// Catalogue injected verbatim.
|
||||
assert_eq!(view.recommended_onnx, vec![onnx_view()]);
|
||||
// Report fields surfaced.
|
||||
assert!(view.ollama_detected);
|
||||
assert_eq!(view.onnx_cached_models, vec!["multilingual-e5-small"]);
|
||||
// Compiled-capability flags surfaced as injected.
|
||||
assert!(view.vector_http_enabled);
|
||||
assert!(!view.vector_onnx_enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn describe_engines_with_nothing_detected() {
|
||||
let inspector = Arc::new(FixedEnvInspector(EmbedderEnvReport {
|
||||
ollama_detected: false,
|
||||
onnx_cached_models: vec![],
|
||||
}));
|
||||
let describe = DescribeEmbedderEngines::new(inspector, vec![], false, true);
|
||||
|
||||
let view = describe.execute().await.unwrap();
|
||||
|
||||
assert!(view.recommended_onnx.is_empty());
|
||||
assert!(!view.ollama_detected);
|
||||
assert!(view.onnx_cached_models.is_empty());
|
||||
assert!(!view.vector_http_enabled);
|
||||
assert!(view.vector_onnx_enabled);
|
||||
}
|
||||
@ -28,7 +28,10 @@ fn fs_not_found_maps_to_not_found_other_to_filesystem() {
|
||||
AppError::from(FsError::PermissionDenied("/tmp/x".into())).code(),
|
||||
"FILESYSTEM"
|
||||
);
|
||||
assert_eq!(AppError::from(FsError::Io("boom".into())).code(), "FILESYSTEM");
|
||||
assert_eq!(
|
||||
AppError::from(FsError::Io("boom".into())).code(),
|
||||
"FILESYSTEM"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -67,7 +70,10 @@ fn memory_errors_map_per_variant() {
|
||||
assert_eq!(invalid.code(), "INVALID");
|
||||
assert_eq!(invalid, AppError::Invalid("bad yaml".to_owned()));
|
||||
// Io → Store.
|
||||
assert_eq!(AppError::from(MemoryError::Io("disk full".into())).code(), "STORE");
|
||||
assert_eq!(
|
||||
AppError::from(MemoryError::Io("disk full".into())).code(),
|
||||
"STORE"
|
||||
);
|
||||
// Serialization → Store (the catch-all arm).
|
||||
assert_eq!(
|
||||
AppError::from(MemoryError::Serialization("bad index".into())).code(),
|
||||
|
||||
@ -82,11 +82,7 @@ impl GitPort for FakeGit {
|
||||
self.record(&format!("checkout:{branch}"));
|
||||
Ok(())
|
||||
}
|
||||
async fn log(
|
||||
&self,
|
||||
_r: &ProjectPath,
|
||||
_limit: usize,
|
||||
) -> Result<Vec<GitCommitInfo>, GitError> {
|
||||
async fn log(&self, _r: &ProjectPath, _limit: usize) -> Result<Vec<GitCommitInfo>, GitError> {
|
||||
self.record("log");
|
||||
Ok(Vec::new())
|
||||
}
|
||||
@ -135,7 +131,9 @@ async fn status_passes_through_to_port() {
|
||||
staged: true,
|
||||
}]);
|
||||
let out = GitStatus::new(Arc::new(git.clone()))
|
||||
.execute(GitStatusInput { root: ROOT.to_owned() })
|
||||
.execute(GitStatusInput {
|
||||
root: ROOT.to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.entries.len(), 1);
|
||||
@ -229,9 +227,14 @@ async fn checkout_publishes_event() {
|
||||
#[tokio::test]
|
||||
async fn branches_returns_list_and_current() {
|
||||
let git = FakeGit::default();
|
||||
git.set_branches(vec!["main".to_owned(), "dev".to_owned()], Some("main".to_owned()));
|
||||
git.set_branches(
|
||||
vec!["main".to_owned(), "dev".to_owned()],
|
||||
Some("main".to_owned()),
|
||||
);
|
||||
let out = GitBranches::new(Arc::new(git.clone()))
|
||||
.execute(GitBranchesInput { root: ROOT.to_owned() })
|
||||
.execute(GitBranchesInput {
|
||||
root: ROOT.to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.branches, vec!["main", "dev"]);
|
||||
|
||||
@ -180,8 +180,14 @@ fn lid(n: u128) -> LayoutId {
|
||||
}
|
||||
|
||||
async fn register_project(store: &FakeStore, id: ProjectId) -> ProjectId {
|
||||
let project =
|
||||
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
|
||||
let project = Project::new(
|
||||
id,
|
||||
"Demo",
|
||||
ProjectPath::new(ROOT).unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
store.save_project(&project).await.unwrap();
|
||||
id
|
||||
}
|
||||
@ -264,7 +270,10 @@ async fn load_migrates_a_legacy_layout_json() {
|
||||
let fs = FakeFs::default();
|
||||
let id = register_project(&store, pid(2)).await;
|
||||
// Only the legacy single-layout file exists.
|
||||
fs.put(LEGACY_PATH, &serde_json::to_vec(&single_leaf(nid(7))).unwrap());
|
||||
fs.put(
|
||||
LEGACY_PATH,
|
||||
&serde_json::to_vec(&single_leaf(nid(7))).unwrap(),
|
||||
);
|
||||
|
||||
let load = LoadLayout::new(Arc::new(store), Arc::new(fs.clone()));
|
||||
let out = load
|
||||
@ -300,7 +309,10 @@ async fn load_defaults_to_single_empty_leaf_when_absent() {
|
||||
_ => panic!("expected a single default leaf"),
|
||||
}
|
||||
// Default was written through; two loads are deterministic.
|
||||
assert_eq!(root_leaf_id(&read_active_tree(&fs)), root_leaf_id(&out.layout));
|
||||
assert_eq!(
|
||||
root_leaf_id(&read_active_tree(&fs)),
|
||||
root_leaf_id(&out.layout)
|
||||
);
|
||||
assert!(fs.has_dir("/home/me/proj/.ideai"));
|
||||
}
|
||||
|
||||
@ -415,7 +427,10 @@ async fn mutate_split_persists_camelcase_layout_and_announces() {
|
||||
let tree = active_tree_json(&env.fs);
|
||||
assert_eq!(tree["root"]["type"], "split", "tagged on `type`");
|
||||
assert_eq!(tree["root"]["node"]["direction"], "row");
|
||||
assert_eq!(tree["root"]["node"]["children"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(
|
||||
tree["root"]["node"]["children"].as_array().unwrap().len(),
|
||||
2
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
env.bus.events(),
|
||||
@ -499,6 +514,62 @@ async fn mutate_set_session_attaches_and_clears() {
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_attach_session_moves_session_between_cells() {
|
||||
let env = mut_env(pid(13)).await;
|
||||
env.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::Split {
|
||||
target: nid(1),
|
||||
direction: Direction::Row,
|
||||
new_leaf: nid(2),
|
||||
container: nid(9),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("split");
|
||||
env.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::SetSession {
|
||||
target: nid(1),
|
||||
session: Some(sid(77)),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("set initial session");
|
||||
|
||||
let out = env
|
||||
.mutate
|
||||
.execute(MutateLayoutInput {
|
||||
project_id: env.project_id,
|
||||
layout_id: None,
|
||||
operation: LayoutOperation::AttachSession {
|
||||
target: nid(2),
|
||||
session: sid(77),
|
||||
},
|
||||
})
|
||||
.await
|
||||
.expect("attach existing session");
|
||||
|
||||
match &out.layout.root {
|
||||
LayoutNode::Split(split) => {
|
||||
match &split.children[0].node {
|
||||
LayoutNode::Leaf(leaf) => assert!(leaf.session.is_none()),
|
||||
_ => panic!("expected first leaf"),
|
||||
}
|
||||
match &split.children[1].node {
|
||||
LayoutNode::Leaf(leaf) => assert_eq!(leaf.session, Some(sid(77))),
|
||||
_ => panic!("expected second leaf"),
|
||||
}
|
||||
}
|
||||
_ => panic!("expected split root"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mutate_invalid_op_errors_and_does_not_persist() {
|
||||
let env = mut_env(pid(14)).await;
|
||||
@ -744,7 +815,9 @@ async fn create_layout_appends_and_activates_it() {
|
||||
.unwrap();
|
||||
|
||||
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(ListLayoutsInput { project_id: pid(30) })
|
||||
.execute(ListLayoutsInput {
|
||||
project_id: pid(30),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.layouts.len(), 2, "Default + Backend");
|
||||
@ -774,21 +847,19 @@ async fn create_layout_rejects_empty_name() {
|
||||
#[tokio::test]
|
||||
async fn rename_layout_changes_the_name() {
|
||||
let (store, fs, bus) = mgmt_env(pid(32)).await;
|
||||
RenameLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus),
|
||||
)
|
||||
.execute(RenameLayoutInput {
|
||||
project_id: pid(32),
|
||||
layout_id: lid(1),
|
||||
name: "Main".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
RenameLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
|
||||
.execute(RenameLayoutInput {
|
||||
project_id: pid(32),
|
||||
layout_id: lid(1),
|
||||
name: "Main".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(ListLayoutsInput { project_id: pid(32) })
|
||||
.execute(ListLayoutsInput {
|
||||
project_id: pid(32),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.layouts[0].name, "Main");
|
||||
@ -825,21 +896,23 @@ async fn delete_active_layout_reassigns_active() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let out = DeleteLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus),
|
||||
)
|
||||
.execute(DeleteLayoutInput {
|
||||
project_id: pid(34),
|
||||
layout_id: created.layout_id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.active_id, lid(1), "active fell back to the Default layout");
|
||||
let out = DeleteLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
|
||||
.execute(DeleteLayoutInput {
|
||||
project_id: pid(34),
|
||||
layout_id: created.layout_id,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
out.active_id,
|
||||
lid(1),
|
||||
"active fell back to the Default layout"
|
||||
);
|
||||
|
||||
let list = ListLayouts::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(ListLayoutsInput { project_id: pid(34) })
|
||||
.execute(ListLayoutsInput {
|
||||
project_id: pid(34),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.layouts.len(), 1);
|
||||
@ -863,17 +936,13 @@ async fn set_active_layout_switches_and_load_follows() {
|
||||
.unwrap();
|
||||
|
||||
// Switch back to the Default layout.
|
||||
SetActiveLayout::new(
|
||||
Arc::new(store.clone()),
|
||||
Arc::new(fs.clone()),
|
||||
Arc::new(bus),
|
||||
)
|
||||
.execute(SetActiveLayoutInput {
|
||||
project_id: pid(35),
|
||||
layout_id: lid(1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
SetActiveLayout::new(Arc::new(store.clone()), Arc::new(fs.clone()), Arc::new(bus))
|
||||
.execute(SetActiveLayoutInput {
|
||||
project_id: pid(35),
|
||||
layout_id: lid(1),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let loaded = LoadLayout::new(Arc::new(store), Arc::new(fs))
|
||||
.execute(LoadLayoutInput {
|
||||
|
||||
@ -17,7 +17,8 @@ use domain::{
|
||||
use application::{
|
||||
CreateMemory, CreateMemoryInput, DeleteMemory, DeleteMemoryInput, GetMemory, GetMemoryInput,
|
||||
ListMemories, ListMemoriesInput, ReadMemoryIndex, ReadMemoryIndexInput, RecallMemory,
|
||||
RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory, UpdateMemoryInput,
|
||||
RecallMemoryInput, ResolveMemoryLinks, ResolveMemoryLinksInput, UpdateMemory,
|
||||
UpdateMemoryInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -65,10 +66,7 @@ impl MemoryStore for FakeMemories {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_index(
|
||||
&self,
|
||||
_root: &ProjectPath,
|
||||
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
||||
Ok(self
|
||||
.0
|
||||
.lock()
|
||||
@ -292,7 +290,15 @@ async fn update_revalidates_invariants_and_rejects_empty_content() {
|
||||
|
||||
assert_eq!(err.code(), "INVALID");
|
||||
// Original note untouched, no event.
|
||||
assert_eq!(store.get(&root(), &slug("note-a")).await.unwrap().body.as_str(), "v1");
|
||||
assert_eq!(
|
||||
store
|
||||
.get(&root(), &slug("note-a"))
|
||||
.await
|
||||
.unwrap()
|
||||
.body
|
||||
.as_str(),
|
||||
"v1"
|
||||
);
|
||||
assert!(bus.events().is_empty());
|
||||
}
|
||||
|
||||
|
||||
@ -17,7 +17,8 @@ use std::sync::{Arc, Mutex};
|
||||
use async_trait::async_trait;
|
||||
use domain::agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
use domain::events::DomainEvent;
|
||||
use domain::ids::{AgentId, ProfileId, ProjectId};
|
||||
use domain::ids::SkillId;
|
||||
use domain::ids::{AgentId, NodeId, ProfileId, ProjectId};
|
||||
use domain::markdown::MarkdownDoc;
|
||||
use domain::ports::{
|
||||
AgentContextStore, AgentRuntime, ContextInjectionPlan, DirEntry, EventBus, EventStream,
|
||||
@ -25,7 +26,6 @@ use domain::ports::{
|
||||
PtyError, PtyHandle, PtyPort, RemotePath, RuntimeError, SessionPlan, SkillStore, SpawnSpec,
|
||||
StoreError,
|
||||
};
|
||||
use domain::ids::SkillId;
|
||||
use domain::profile::{AgentProfile, ContextInjection};
|
||||
use domain::project::{Project, ProjectPath};
|
||||
use domain::remote::RemoteRef;
|
||||
@ -65,7 +65,10 @@ impl FakeContexts {
|
||||
let me = Self::new();
|
||||
{
|
||||
let mut inner = me.0.lock().unwrap();
|
||||
inner.manifest.entries.push(ManifestEntry::from_agent(agent));
|
||||
inner
|
||||
.manifest
|
||||
.entries
|
||||
.push(ManifestEntry::from_agent(agent));
|
||||
inner
|
||||
.contents
|
||||
.insert(agent.context_path.clone(), content.to_owned());
|
||||
@ -160,7 +163,11 @@ impl ProfileStore for FakeProfiles {
|
||||
struct FakeSkills;
|
||||
#[async_trait]
|
||||
impl SkillStore for FakeSkills {
|
||||
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||
async fn list(
|
||||
&self,
|
||||
_scope: SkillScope,
|
||||
_root: &ProjectPath,
|
||||
) -> Result<Vec<Skill>, StoreError> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
async fn get(
|
||||
@ -206,7 +213,11 @@ struct RecordingSkills(Arc<Mutex<Vec<Skill>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl SkillStore for RecordingSkills {
|
||||
async fn list(&self, _scope: SkillScope, _root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
|
||||
async fn list(
|
||||
&self,
|
||||
_scope: SkillScope,
|
||||
_root: &ProjectPath,
|
||||
) -> Result<Vec<Skill>, StoreError> {
|
||||
Ok(self.0.lock().unwrap().clone())
|
||||
}
|
||||
async fn get(
|
||||
@ -287,14 +298,19 @@ impl FileSystem for FakeFs {
|
||||
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()
|
||||
}
|
||||
@ -302,6 +318,7 @@ impl FakePty {
|
||||
#[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,
|
||||
})
|
||||
@ -368,6 +385,9 @@ fn aid(n: u128) -> AgentId {
|
||||
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(
|
||||
@ -430,6 +450,7 @@ fn fixture(contexts: FakeContexts) -> Fixture {
|
||||
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(
|
||||
@ -496,11 +517,9 @@ async fn spawn_unknown_agent_creates_then_launches() {
|
||||
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)));
|
||||
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");
|
||||
}
|
||||
|
||||
@ -523,6 +542,57 @@ async fn spawn_known_agent_launches_without_recreating() {
|
||||
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");
|
||||
|
||||
@ -92,9 +92,7 @@ impl AgentRuntime for StubRuntime {
|
||||
match self.by_command.get(&profile.command) {
|
||||
Some(DetectResult::Available) => Ok(true),
|
||||
Some(DetectResult::Missing) | None => Ok(false),
|
||||
Some(DetectResult::Error) => {
|
||||
Err(RuntimeError::Detection("boom".to_owned()))
|
||||
}
|
||||
Some(DetectResult::Error) => Err(RuntimeError::Detection("boom".to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,7 +161,10 @@ async fn detect_error_degrades_to_unavailable_not_hard_failure() {
|
||||
.await
|
||||
.expect("detection error must not fail the use case");
|
||||
|
||||
assert!(!out.results[0].available, "errored detection ⇒ available:false");
|
||||
assert!(
|
||||
!out.results[0].available,
|
||||
"errored detection ⇒ available:false"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -248,7 +249,10 @@ async fn save_then_list_then_delete() {
|
||||
|
||||
assert_eq!(list.execute().await.unwrap().profiles, vec![p.clone()]);
|
||||
|
||||
delete.execute(DeleteProfileInput { id: p.id }).await.unwrap();
|
||||
delete
|
||||
.execute(DeleteProfileInput { id: p.id })
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(list.execute().await.unwrap().profiles.is_empty());
|
||||
}
|
||||
|
||||
|
||||
@ -22,7 +22,8 @@ use domain::{Project, ProjectId, ProjectPath, RemoteRef};
|
||||
|
||||
use application::{
|
||||
CloseProject, CloseProjectInput, CloseTab, CloseTabInput, CreateProject, CreateProjectInput,
|
||||
ListProjects, OpenProject, OpenProjectInput,
|
||||
ListProjects, OpenProject, OpenProjectInput, ReadProjectContext, ReadProjectContextInput,
|
||||
UpdateProjectContext, UpdateProjectContextInput,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -75,11 +76,7 @@ impl FileSystem for FakeFs {
|
||||
}
|
||||
|
||||
async fn create_dir_all(&self, path: &RemotePath) -> Result<(), FsError> {
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.dirs
|
||||
.insert(path.as_str().to_owned());
|
||||
self.0.lock().unwrap().dirs.insert(path.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -221,6 +218,8 @@ struct Env {
|
||||
bus: SpyBus,
|
||||
create: CreateProject,
|
||||
open: OpenProject,
|
||||
read_context: ReadProjectContext,
|
||||
update_context: UpdateProjectContext,
|
||||
}
|
||||
|
||||
fn env() -> Env {
|
||||
@ -238,6 +237,8 @@ fn env() -> Env {
|
||||
Arc::new(bus.clone()),
|
||||
);
|
||||
let open = OpenProject::new(Arc::new(store.clone()), Arc::new(fs.clone()));
|
||||
let read_context = ReadProjectContext::new(Arc::new(fs.clone()));
|
||||
let update_context = UpdateProjectContext::new(Arc::new(fs.clone()));
|
||||
|
||||
Env {
|
||||
store,
|
||||
@ -245,6 +246,8 @@ fn env() -> Env {
|
||||
bus,
|
||||
create,
|
||||
open,
|
||||
read_context,
|
||||
update_context,
|
||||
}
|
||||
}
|
||||
|
||||
@ -301,6 +304,44 @@ async fn create_registers_project_in_store() {
|
||||
assert_eq!(stored[0].name, "Demo");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_context_lives_under_ideai_and_missing_reads_empty() {
|
||||
let env = env();
|
||||
let out = env.create.execute(input("Demo", "/p")).await.unwrap();
|
||||
|
||||
let missing = env
|
||||
.read_context
|
||||
.execute(ReadProjectContextInput {
|
||||
project: out.project.clone(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.content, "");
|
||||
|
||||
env.update_context
|
||||
.execute(UpdateProjectContextInput {
|
||||
project: out.project.clone(),
|
||||
content: "# Shared\n\nUse pnpm.".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let bytes = env
|
||||
.fs
|
||||
.read_file("/p/.ideai/CONTEXT.md")
|
||||
.expect("project context written under .ideai");
|
||||
assert_eq!(String::from_utf8(bytes).unwrap(), "# Shared\n\nUse pnpm.");
|
||||
|
||||
let read_back = env
|
||||
.read_context
|
||||
.execute(ReadProjectContextInput {
|
||||
project: out.project,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(read_back.content, "# Shared\n\nUse pnpm.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_publishes_project_created_event() {
|
||||
let env = env();
|
||||
@ -543,7 +584,10 @@ async fn close_without_workspace_skips_persistence() {
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(store.saved_workspace().is_none(), "no persistence when None");
|
||||
assert!(
|
||||
store.saved_workspace().is_none(),
|
||||
"no persistence when None"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@ -50,7 +50,11 @@ struct FakeHost {
|
||||
fs: Arc<dyn FileSystem>,
|
||||
}
|
||||
impl FakeHost {
|
||||
fn make(kind: RemoteKind, connect_ok: bool, existing_root: Option<&str>) -> Arc<dyn RemoteHost> {
|
||||
fn make(
|
||||
kind: RemoteKind,
|
||||
connect_ok: bool,
|
||||
existing_root: Option<&str>,
|
||||
) -> Arc<dyn RemoteHost> {
|
||||
Arc::new(Self {
|
||||
kind,
|
||||
connect_ok,
|
||||
|
||||
@ -58,7 +58,10 @@ impl SkillStore for FakeSkills {
|
||||
}
|
||||
async fn save(&self, skill: &Skill, _root: &ProjectPath) -> Result<(), StoreError> {
|
||||
let mut v = self.0.lock().unwrap();
|
||||
if let Some(slot) = v.iter_mut().find(|s| s.scope == skill.scope && s.id == skill.id) {
|
||||
if let Some(slot) = v
|
||||
.iter_mut()
|
||||
.find(|s| s.scope == skill.scope && s.id == skill.id)
|
||||
{
|
||||
*slot = skill.clone();
|
||||
} else {
|
||||
v.push(skill.clone());
|
||||
@ -85,7 +88,10 @@ impl SkillStore for FakeSkills {
|
||||
struct FakeContexts(Arc<Mutex<AgentManifest>>);
|
||||
impl FakeContexts {
|
||||
fn new(entries: Vec<ManifestEntry>) -> Self {
|
||||
Self(Arc::new(Mutex::new(AgentManifest { version: 1, entries })))
|
||||
Self(Arc::new(Mutex::new(AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
})))
|
||||
}
|
||||
fn manifest(&self) -> AgentManifest {
|
||||
self.0.lock().unwrap().clone()
|
||||
@ -197,7 +203,11 @@ async fn create_skill_persists_in_its_scope() {
|
||||
|
||||
assert_eq!(out.skill.scope, SkillScope::Project);
|
||||
assert_eq!(
|
||||
store.list(SkillScope::Project, &root()).await.unwrap().len(),
|
||||
store
|
||||
.list(SkillScope::Project, &root())
|
||||
.await
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(store
|
||||
|
||||
@ -186,8 +186,14 @@ fn agent_leaf(node: NodeId, agent: Option<AgentId>) -> LeafCell {
|
||||
}
|
||||
|
||||
async fn register_project(store: &FakeStore, id: ProjectId) {
|
||||
let project =
|
||||
Project::new(id, "Demo", ProjectPath::new(ROOT).unwrap(), RemoteRef::Local, 0).unwrap();
|
||||
let project = Project::new(
|
||||
id,
|
||||
"Demo",
|
||||
ProjectPath::new(ROOT).unwrap(),
|
||||
RemoteRef::Local,
|
||||
0,
|
||||
)
|
||||
.unwrap();
|
||||
store.save_project(&project).await.unwrap();
|
||||
}
|
||||
|
||||
@ -222,11 +228,7 @@ fn was_running(fs: &FakeFs, node: NodeId) -> Option<bool> {
|
||||
find(&tree.root, node)
|
||||
}
|
||||
|
||||
fn make_use_case(
|
||||
store: &FakeStore,
|
||||
fs: &FakeFs,
|
||||
live: &FakeLive,
|
||||
) -> SnapshotRunningAgents {
|
||||
fn make_use_case(store: &FakeStore, fs: &FakeFs, live: &FakeLive) -> SnapshotRunningAgents {
|
||||
SnapshotRunningAgents::new(
|
||||
Arc::new(store.clone()) as Arc<dyn ProjectStore>,
|
||||
Arc::new(fs.clone()) as Arc<dyn FileSystem>,
|
||||
@ -247,7 +249,11 @@ async fn live_agent_is_marked_running() {
|
||||
|
||||
let leaf = nid(10);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
seed_layouts(
|
||||
&fs,
|
||||
lid(1),
|
||||
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
|
||||
);
|
||||
|
||||
let live = FakeLive::with_nodes(&[leaf]);
|
||||
let uc = make_use_case(&store, &fs, &live);
|
||||
@ -271,7 +277,11 @@ async fn stopped_agent_is_marked_not_running() {
|
||||
|
||||
let leaf = nid(10);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
seed_layouts(
|
||||
&fs,
|
||||
lid(1),
|
||||
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
|
||||
);
|
||||
|
||||
// Registry is empty: the agent has no live session.
|
||||
let live = FakeLive::default();
|
||||
@ -347,7 +357,11 @@ async fn snapshot_reads_liveness_before_kill() {
|
||||
|
||||
let leaf = nid(10);
|
||||
let agent = aid(100);
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
seed_layouts(
|
||||
&fs,
|
||||
lid(1),
|
||||
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
|
||||
);
|
||||
|
||||
let live = FakeLive::with_nodes(&[leaf]);
|
||||
let uc = make_use_case(&store, &fs, &live);
|
||||
@ -365,7 +379,11 @@ async fn snapshot_reads_liveness_before_kill() {
|
||||
|
||||
// Re-seed and demonstrate the opposite order yields `false` — proving the
|
||||
// flag is sensitive to registry state at call time (hence order matters).
|
||||
seed_layouts(&fs, lid(1), &LayoutTree::single(agent_leaf(leaf, Some(agent))));
|
||||
seed_layouts(
|
||||
&fs,
|
||||
lid(1),
|
||||
&LayoutTree::single(agent_leaf(leaf, Some(agent))),
|
||||
);
|
||||
uc.execute(SnapshotRunningAgentsInput { project_id: pid(1) })
|
||||
.await
|
||||
.unwrap();
|
||||
@ -443,7 +461,10 @@ async fn duplicate_leaves_same_agent_only_live_node_is_running() {
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.running, 1, "only the live cell counts as running");
|
||||
assert_eq!(out.stopped, 1, "the duplicate (dead) cell counts as stopped");
|
||||
assert_eq!(
|
||||
out.stopped, 1,
|
||||
"the duplicate (dead) cell counts as stopped"
|
||||
);
|
||||
assert_eq!(was_running(&fs, leaf_a), Some(true));
|
||||
assert_eq!(
|
||||
was_running(&fs, leaf_b),
|
||||
|
||||
@ -69,7 +69,10 @@ struct FakeContexts(Arc<Mutex<(AgentManifest, HashMap<String, String>)>>);
|
||||
impl FakeContexts {
|
||||
fn new(entries: Vec<ManifestEntry>) -> Self {
|
||||
Self(Arc::new(Mutex::new((
|
||||
AgentManifest { version: 1, entries },
|
||||
AgentManifest {
|
||||
version: 1,
|
||||
entries,
|
||||
},
|
||||
HashMap::new(),
|
||||
))))
|
||||
}
|
||||
@ -92,13 +95,11 @@ impl FakeContexts {
|
||||
}
|
||||
#[async_trait]
|
||||
impl AgentContextStore for FakeContexts {
|
||||
async fn read_context(
|
||||
&self,
|
||||
_p: &Project,
|
||||
agent: &AgentId,
|
||||
) -> Result<MarkdownDoc, StoreError> {
|
||||
async fn read_context(&self, _p: &Project, agent: &AgentId) -> Result<MarkdownDoc, StoreError> {
|
||||
let md = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.content(&md).map(MarkdownDoc::new).ok_or(StoreError::NotFound)
|
||||
self.content(&md)
|
||||
.map(MarkdownDoc::new)
|
||||
.ok_or(StoreError::NotFound)
|
||||
}
|
||||
async fn write_context(
|
||||
&self,
|
||||
@ -107,7 +108,11 @@ impl AgentContextStore for FakeContexts {
|
||||
md: &MarkdownDoc,
|
||||
) -> Result<(), StoreError> {
|
||||
let path = self.md_path_of(agent).ok_or(StoreError::NotFound)?;
|
||||
self.0.lock().unwrap().1.insert(path, md.as_str().to_owned());
|
||||
self.0
|
||||
.lock()
|
||||
.unwrap()
|
||||
.1
|
||||
.insert(path, md.as_str().to_owned());
|
||||
Ok(())
|
||||
}
|
||||
async fn load_manifest(&self, _p: &Project) -> Result<AgentManifest, StoreError> {
|
||||
@ -186,7 +191,16 @@ fn template(id: TemplateId, name: &str, content: &str, version: u64) -> AgentTem
|
||||
}
|
||||
/// A synchronized, template-backed manifest entry synced at `synced`.
|
||||
fn synced_entry(agent: AgentId, md: &str, template: TemplateId, synced: u64) -> ManifestEntry {
|
||||
ManifestEntry::new(agent, "A", md, pid(1), Some(template), true, Some(v(synced))).unwrap()
|
||||
ManifestEntry::new(
|
||||
agent,
|
||||
"A",
|
||||
md,
|
||||
pid(1),
|
||||
Some(template),
|
||||
true,
|
||||
Some(v(synced)),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -234,13 +248,16 @@ async fn update_template_bumps_version_and_publishes_event() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_unknown_template_is_not_found() {
|
||||
let err = UpdateTemplate::new(Arc::new(FakeTemplates::default()), Arc::new(SpyBus::default()))
|
||||
.execute(UpdateTemplateInput {
|
||||
template_id: tid(404),
|
||||
content: "x".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
let err = UpdateTemplate::new(
|
||||
Arc::new(FakeTemplates::default()),
|
||||
Arc::new(SpyBus::default()),
|
||||
)
|
||||
.execute(UpdateTemplateInput {
|
||||
template_id: tid(404),
|
||||
content: "x".to_owned(),
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
|
||||
}
|
||||
|
||||
@ -279,7 +296,10 @@ async fn create_agent_from_template_links_origin_and_seeds_context() {
|
||||
}
|
||||
);
|
||||
// Context seeded with the template content under the agent's md path.
|
||||
assert_eq!(contexts.content(&out.agent.context_path).as_deref(), Some("# body"));
|
||||
assert_eq!(
|
||||
contexts.content(&out.agent.context_path).as_deref(),
|
||||
Some("# body")
|
||||
);
|
||||
assert_eq!(contexts.manifest().entries.len(), 1);
|
||||
}
|
||||
|
||||
@ -295,8 +315,16 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
|
||||
// a2: synchronized, synced at v3 → up to date, no drift.
|
||||
// a3: from template but NOT synchronized → ignored.
|
||||
// a4: scratch (no template) → ignored.
|
||||
let a3 = ManifestEntry::new(aid(3), "A3", "agents/a3.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
||||
.unwrap();
|
||||
let a3 = ManifestEntry::new(
|
||||
aid(3),
|
||||
"A3",
|
||||
"agents/a3.md",
|
||||
pid(1),
|
||||
Some(tid(1)),
|
||||
false,
|
||||
Some(v(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let a4 = ManifestEntry::new(aid(4), "A4", "agents/a4.md", pid(1), None, false, None).unwrap();
|
||||
let contexts = FakeContexts::new(vec![
|
||||
synced_entry(aid(1), "agents/a1.md", tid(1), 1),
|
||||
@ -306,14 +334,10 @@ async fn detect_drift_flags_only_synchronized_agents_behind() {
|
||||
]);
|
||||
let bus = SpyBus::default();
|
||||
|
||||
let out = DetectAgentDrift::new(
|
||||
Arc::new(store),
|
||||
Arc::new(contexts),
|
||||
Arc::new(bus.clone()),
|
||||
)
|
||||
.execute(DetectAgentDriftInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
let out = DetectAgentDrift::new(Arc::new(store), Arc::new(contexts), Arc::new(bus.clone()))
|
||||
.execute(DetectAgentDriftInput { project: project() })
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(out.drifts.len(), 1, "only a1 drifts");
|
||||
assert_eq!(out.drifts[0].agent_id, aid(1));
|
||||
@ -375,7 +399,10 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
|
||||
assert!(out.synced);
|
||||
assert_eq!(out.version, Some(v(3)));
|
||||
// Context replaced by the template content.
|
||||
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("newest body"));
|
||||
assert_eq!(
|
||||
contexts.content("agents/a1.md").as_deref(),
|
||||
Some("newest body")
|
||||
);
|
||||
// Manifest synced version advanced to 3.
|
||||
let entry = &contexts.manifest().entries[0];
|
||||
assert_eq!(entry.synced_template_version, Some(v(3)));
|
||||
@ -392,9 +419,16 @@ async fn sync_applies_to_synchronized_and_updates_version_and_context() {
|
||||
async fn sync_ignores_non_synchronized_agent() {
|
||||
let store = FakeTemplates::with(vec![template(tid(1), "T", "body", 3)]);
|
||||
// Non-synchronized agent from a template.
|
||||
let entry =
|
||||
ManifestEntry::new(aid(1), "A", "agents/a1.md", pid(1), Some(tid(1)), false, Some(v(1)))
|
||||
.unwrap();
|
||||
let entry = ManifestEntry::new(
|
||||
aid(1),
|
||||
"A",
|
||||
"agents/a1.md",
|
||||
pid(1),
|
||||
Some(tid(1)),
|
||||
false,
|
||||
Some(v(1)),
|
||||
)
|
||||
.unwrap();
|
||||
let contexts = FakeContexts::new(vec![entry]);
|
||||
contexts
|
||||
.write_context(&project(), &aid(1), &MarkdownDoc::new("keep me"))
|
||||
@ -417,5 +451,8 @@ async fn sync_ignores_non_synchronized_agent() {
|
||||
assert!(!out.synced, "non-synchronized agent is left untouched");
|
||||
assert_eq!(out.version, None);
|
||||
assert_eq!(contexts.content("agents/a1.md").as_deref(), Some("keep me"));
|
||||
assert!(bus.events().is_empty(), "no sync event for an ignored agent");
|
||||
assert!(
|
||||
bus.events().is_empty(),
|
||||
"no sync event for an ignored agent"
|
||||
);
|
||||
}
|
||||
|
||||
@ -447,16 +447,16 @@ async fn close_kills_pty_removes_session_and_returns_code() {
|
||||
|
||||
let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
|
||||
let out = close
|
||||
.execute(CloseTerminalInput {
|
||||
session_id: sid(5),
|
||||
})
|
||||
.execute(CloseTerminalInput { session_id: sid(5) })
|
||||
.await
|
||||
.expect("close succeeds");
|
||||
|
||||
assert_eq!(out.code, Some(0));
|
||||
assert!(sessions.is_empty(), "session removed from registry");
|
||||
assert!(
|
||||
pty.calls().iter().any(|c| matches!(c, Call::Kill { id } if *id == sid(5))),
|
||||
pty.calls()
|
||||
.iter()
|
||||
.any(|c| matches!(c, Call::Kill { id } if *id == sid(5))),
|
||||
"kill called for the session"
|
||||
);
|
||||
}
|
||||
@ -477,9 +477,7 @@ async fn close_surfaces_signal_exit_as_none_code() {
|
||||
|
||||
let close = CloseTerminal::new(Arc::new(pty.clone()), Arc::clone(&sessions));
|
||||
let out = close
|
||||
.execute(CloseTerminalInput {
|
||||
session_id: sid(6),
|
||||
})
|
||||
.execute(CloseTerminalInput { session_id: sid(6) })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(out.code, None);
|
||||
|
||||
Reference in New Issue
Block a user