fix: fix some ui displays and features miss implemented
This commit is contained in:
@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::{AgentId, ProfileId, TemplateId};
|
||||
use crate::skill::SkillRef;
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
/// Origin of an agent: created from scratch, or derived from a template.
|
||||
@ -65,6 +66,10 @@ pub struct Agent {
|
||||
pub origin: AgentOrigin,
|
||||
/// Whether the agent tracks its template (only valid for template origins).
|
||||
pub synchronized: bool,
|
||||
/// Skills assigned to this agent, injected into its convention file at
|
||||
/// activation (ARCHITECTURE §14.2). Empty by default.
|
||||
#[serde(default)]
|
||||
pub skills: Vec<SkillRef>,
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
@ -98,8 +103,37 @@ impl Agent {
|
||||
profile_id,
|
||||
origin,
|
||||
synchronized,
|
||||
skills: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a copy of this agent carrying the given assigned skills,
|
||||
/// deduplicated by `skill_id` (keeping first occurrence).
|
||||
#[must_use]
|
||||
pub fn with_skills(mut self, skills: Vec<SkillRef>) -> Self {
|
||||
self.skills = Vec::new();
|
||||
for skill in skills {
|
||||
self.assign_skill(skill);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Assigns a skill to this agent. Idempotent: re-assigning the same
|
||||
/// `skill_id` is a no-op (returns `false`); a new assignment returns `true`.
|
||||
pub fn assign_skill(&mut self, skill: SkillRef) -> bool {
|
||||
if self.skills.iter().any(|s| s.skill_id == skill.skill_id) {
|
||||
return false;
|
||||
}
|
||||
self.skills.push(skill);
|
||||
true
|
||||
}
|
||||
|
||||
/// Removes a skill assignment by id. Returns `true` if a skill was removed.
|
||||
pub fn unassign_skill(&mut self, skill_id: crate::ids::SkillId) -> bool {
|
||||
let before = self.skills.len();
|
||||
self.skills.retain(|s| s.skill_id != skill_id);
|
||||
self.skills.len() != before
|
||||
}
|
||||
}
|
||||
|
||||
/// One entry in the project agent manifest (`.ideai/agents.json`).
|
||||
@ -135,6 +169,10 @@ pub struct ManifestEntry {
|
||||
/// Template version recorded at the last sync.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub synced_template_version: Option<TemplateVersion>,
|
||||
/// Skills assigned to this agent (ARCHITECTURE §14.2). Defaults to empty for
|
||||
/// backward-compatible deserialisation of pre-L12 manifests.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub skills: Vec<SkillRef>,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
@ -172,6 +210,7 @@ impl ManifestEntry {
|
||||
template_id,
|
||||
synchronized,
|
||||
synced_template_version,
|
||||
skills: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -196,6 +235,7 @@ impl ManifestEntry {
|
||||
template_id,
|
||||
synchronized: agent.synchronized,
|
||||
synced_template_version,
|
||||
skills: agent.skills.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -213,14 +253,15 @@ impl ManifestEntry {
|
||||
},
|
||||
_ => AgentOrigin::Scratch,
|
||||
};
|
||||
Agent::new(
|
||||
Ok(Agent::new(
|
||||
self.agent_id,
|
||||
self.name.clone(),
|
||||
self.md_path.clone(),
|
||||
self.profile_id,
|
||||
origin,
|
||||
self.synchronized,
|
||||
)
|
||||
)?
|
||||
.with_skills(self.skills.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
//! Domain events published on the [`crate::ports::EventBus`] and relayed to the
|
||||
//! presentation layer (ARCHITECTURE §3.2).
|
||||
|
||||
use crate::ids::{AgentId, ProjectId, SessionId, TemplateId};
|
||||
use crate::ids::{AgentId, ProjectId, SessionId, SkillId, TemplateId};
|
||||
use crate::template::TemplateVersion;
|
||||
|
||||
/// Events emitted by the domain/application as state changes occur.
|
||||
@ -54,6 +54,15 @@ pub enum DomainEvent {
|
||||
/// Version it was brought up to.
|
||||
to: TemplateVersion,
|
||||
},
|
||||
/// A skill was assigned to (or unassigned from) an agent.
|
||||
SkillAssigned {
|
||||
/// The agent whose skill set changed.
|
||||
agent_id: AgentId,
|
||||
/// The skill involved.
|
||||
skill_id: SkillId,
|
||||
/// `true` if assigned, `false` if unassigned.
|
||||
assigned: bool,
|
||||
},
|
||||
/// A tab's layout changed.
|
||||
LayoutChanged {
|
||||
/// The project whose layout changed.
|
||||
|
||||
@ -68,6 +68,10 @@ typed_id!(
|
||||
/// Identifies an [`crate::profile::AgentProfile`].
|
||||
ProfileId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::skill::Skill`].
|
||||
SkillId
|
||||
);
|
||||
typed_id!(
|
||||
/// Identifies a [`crate::terminal::TerminalSession`].
|
||||
SessionId
|
||||
|
||||
@ -41,6 +41,7 @@ pub mod ports;
|
||||
pub mod profile;
|
||||
pub mod project;
|
||||
pub mod remote;
|
||||
pub mod skill;
|
||||
pub mod template;
|
||||
pub mod terminal;
|
||||
|
||||
@ -53,13 +54,16 @@ mod validation;
|
||||
pub use error::DomainError;
|
||||
|
||||
pub use ids::{
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, TabId, TemplateId, WindowId,
|
||||
AgentId, LayoutId, NodeId, ProfileId, ProjectId, SessionId, SkillId, TabId, TemplateId,
|
||||
WindowId,
|
||||
};
|
||||
|
||||
pub use project::{Project, ProjectPath};
|
||||
|
||||
pub use agent::{Agent, AgentManifest, AgentOrigin, ManifestEntry};
|
||||
|
||||
pub use skill::{Skill, SkillRef, SkillScope};
|
||||
|
||||
pub use template::{AgentTemplate, TemplateVersion};
|
||||
|
||||
pub use profile::{AgentProfile, ContextInjection};
|
||||
|
||||
111
crates/domain/src/skill.rs
Normal file
111
crates/domain/src/skill.rs
Normal file
@ -0,0 +1,111 @@
|
||||
//! Skill entity — reusable, model-agnostic workflows assignable to agents.
|
||||
//!
|
||||
//! A [`Skill`] is IdeA's universal equivalent of a CLI's slash-command, but
|
||||
//! without any dependency on a particular model's `/command` syntax
|
||||
//! (ARCHITECTURE §14.2). Assigned skills are injected as plain text into the
|
||||
//! agent's generated convention file at activation — there is no proprietary
|
||||
//! CLI mechanism involved.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::DomainError;
|
||||
use crate::ids::SkillId;
|
||||
use crate::markdown::MarkdownDoc;
|
||||
|
||||
/// Where a skill lives, which also selects the store used to resolve it.
|
||||
///
|
||||
/// - [`SkillScope::Global`] skills are stored in the global IDE store
|
||||
/// (`<app_data>/IdeA/skills/`) and reusable across projects.
|
||||
/// - [`SkillScope::Project`] skills are stored under `.ideai/skills/` and are
|
||||
/// specific to one project.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SkillScope {
|
||||
/// Reusable across projects (global IDE store).
|
||||
Global,
|
||||
/// Specific to a single project (`.ideai/skills/`).
|
||||
Project,
|
||||
}
|
||||
|
||||
/// A reusable workflow assignable to one or more agents.
|
||||
///
|
||||
/// Invariants enforced here:
|
||||
/// - `name` non-empty,
|
||||
/// - `content_md` non-empty (an empty skill carries no behaviour).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Skill {
|
||||
/// Stable identifier.
|
||||
pub id: SkillId,
|
||||
/// Display name (also used as the `.md` file stem on disk).
|
||||
pub name: String,
|
||||
/// Markdown body — the workflow injected into an agent's convention file.
|
||||
pub content_md: MarkdownDoc,
|
||||
/// Scope (selects the backing store).
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
impl Skill {
|
||||
/// Builds a validated skill.
|
||||
///
|
||||
/// # Errors
|
||||
/// - [`DomainError::EmptyField`] if `name` is empty,
|
||||
/// - [`DomainError::EmptyField`] if `content_md` is empty.
|
||||
pub fn new(
|
||||
id: SkillId,
|
||||
name: impl Into<String>,
|
||||
content_md: MarkdownDoc,
|
||||
scope: SkillScope,
|
||||
) -> Result<Self, DomainError> {
|
||||
let name = name.into();
|
||||
crate::validation::non_empty(&name, "skill.name")?;
|
||||
if content_md.is_empty() {
|
||||
return Err(DomainError::EmptyField {
|
||||
field: "skill.content_md",
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
id,
|
||||
name,
|
||||
content_md,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns a copy of this skill with replaced content, re-validating the
|
||||
/// non-empty invariant.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`DomainError::EmptyField`] if `content_md` is empty.
|
||||
pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
|
||||
Skill::new(self.id, self.name.clone(), content_md, self.scope)
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference from an agent to one assigned skill.
|
||||
///
|
||||
/// Stored in the [`crate::agent::ManifestEntry`]: an agent carries 0..N of these.
|
||||
/// The `scope` is kept alongside the id so the application layer knows which
|
||||
/// store to resolve the skill from without a global lookup.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SkillRef {
|
||||
/// The assigned skill.
|
||||
pub skill_id: SkillId,
|
||||
/// Scope of the assigned skill (selects its store).
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
impl SkillRef {
|
||||
/// Builds a reference to an assigned skill.
|
||||
#[must_use]
|
||||
pub const fn new(skill_id: SkillId, scope: SkillScope) -> Self {
|
||||
Self { skill_id, scope }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&Skill> for SkillRef {
|
||||
fn from(skill: &Skill) -> Self {
|
||||
Self::new(skill.id, skill.scope)
|
||||
}
|
||||
}
|
||||
@ -5,8 +5,8 @@ mod helpers;
|
||||
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, DomainError,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, SshAuth,
|
||||
TemplateId, TemplateVersion,
|
||||
ManifestEntry, MarkdownDoc, ProfileId, Project, ProjectPath, PtySize, RemoteRef, Skill, SkillId,
|
||||
SkillRef, SkillScope, SshAuth, TemplateId, TemplateVersion,
|
||||
};
|
||||
use helpers::{AtomicSeqIdGenerator, FixedClock};
|
||||
use uuid::Uuid;
|
||||
@ -409,3 +409,121 @@ fn manifest_unique_md_paths_ok() {
|
||||
.unwrap();
|
||||
assert!(AgentManifest::new(1, vec![e1, e2]).is_ok());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill invariants (L12, ARCHITECTURE §14.2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn skill_id(n: u128) -> SkillId {
|
||||
SkillId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_valid_construction() {
|
||||
let s = Skill::new(
|
||||
skill_id(1),
|
||||
"code-review",
|
||||
MarkdownDoc::new("review the diff"),
|
||||
SkillScope::Global,
|
||||
);
|
||||
assert!(s.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_rejects_empty_name() {
|
||||
let err = Skill::new(
|
||||
skill_id(1),
|
||||
"",
|
||||
MarkdownDoc::new("body"),
|
||||
SkillScope::Project,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(err, DomainError::EmptyField { field: "skill.name" });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_rejects_empty_content() {
|
||||
let err = Skill::new(skill_id(1), "x", MarkdownDoc::new(""), SkillScope::Global).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
DomainError::EmptyField {
|
||||
field: "skill.content_md"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_with_content_revalidates() {
|
||||
let s = Skill::new(skill_id(1), "x", MarkdownDoc::new("a"), SkillScope::Global).unwrap();
|
||||
assert!(s.with_content(MarkdownDoc::new("b")).is_ok());
|
||||
assert!(s.with_content(MarkdownDoc::new("")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_assign_skill_is_idempotent() {
|
||||
let mut a = Agent::new(
|
||||
agent_id(1),
|
||||
"dev",
|
||||
"agents/dev.md",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let r = SkillRef::new(skill_id(7), SkillScope::Global);
|
||||
assert!(a.assign_skill(r)); // first assignment
|
||||
assert!(!a.assign_skill(r)); // duplicate ignored
|
||||
assert_eq!(a.skills, vec![r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_unassign_skill() {
|
||||
let mut a = Agent::new(
|
||||
agent_id(1),
|
||||
"dev",
|
||||
"agents/dev.md",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let r = SkillRef::new(skill_id(7), SkillScope::Project);
|
||||
a.assign_skill(r);
|
||||
assert!(a.unassign_skill(skill_id(7)));
|
||||
assert!(!a.unassign_skill(skill_id(7))); // already gone
|
||||
assert!(a.skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_with_skills_dedups() {
|
||||
let r = SkillRef::new(skill_id(7), SkillScope::Global);
|
||||
let a = Agent::new(
|
||||
agent_id(1),
|
||||
"dev",
|
||||
"agents/dev.md",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
.with_skills(vec![r, r]);
|
||||
assert_eq!(a.skills, vec![r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_preserves_skills_through_agent_roundtrip() {
|
||||
let r = SkillRef::new(skill_id(7), SkillScope::Project);
|
||||
let agent = Agent::new(
|
||||
agent_id(1),
|
||||
"dev",
|
||||
"agents/dev.md",
|
||||
profile_id(),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
.with_skills(vec![r]);
|
||||
let entry = ManifestEntry::from_agent(&agent);
|
||||
assert_eq!(entry.skills, vec![r]);
|
||||
assert_eq!(entry.to_agent().unwrap(), agent);
|
||||
}
|
||||
|
||||
@ -6,7 +6,7 @@ mod helpers;
|
||||
use domain::{
|
||||
Agent, AgentManifest, AgentOrigin, AgentProfile, AgentTemplate, ContextInjection, Direction,
|
||||
LayoutNode, LayoutTree, LeafCell, ManifestEntry, MarkdownDoc, Project, ProjectPath, RemoteRef,
|
||||
SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
||||
Skill, SkillId, SkillRef, SkillScope, SplitContainer, SshAuth, TemplateVersion, WeightedChild,
|
||||
};
|
||||
use helpers::{node, session};
|
||||
use uuid::Uuid;
|
||||
@ -229,6 +229,61 @@ fn manifest_roundtrip_and_camel_case() {
|
||||
assert!(!json.contains("\"templateId\":null"), "json was {json}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Skill (L12) — round-trip, camelCase scope tag, manifest skills back-compat
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn sid(n: u128) -> SkillId {
|
||||
SkillId::from_uuid(Uuid::from_u128(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_roundtrip_and_camel_case_scope() {
|
||||
let s = Skill::new(sid(1), "code-review", MarkdownDoc::new("body"), SkillScope::Global).unwrap();
|
||||
assert_eq!(roundtrip(&s), s);
|
||||
let json = serde_json::to_string(&s).unwrap();
|
||||
assert!(json.contains("\"scope\":\"global\""), "json was {json}");
|
||||
assert!(json.contains("\"contentMd\""), "json was {json}");
|
||||
|
||||
let p = Skill::new(sid(2), "simplify", MarkdownDoc::new("b"), SkillScope::Project).unwrap();
|
||||
let pj = serde_json::to_string(&p).unwrap();
|
||||
assert!(pj.contains("\"scope\":\"project\""), "json was {pj}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_skills_roundtrip_and_camel_case() {
|
||||
let entry = ManifestEntry::from_agent(
|
||||
&Agent::new(
|
||||
aid(1),
|
||||
"dev",
|
||||
"agents/dev.md",
|
||||
profid(9),
|
||||
AgentOrigin::Scratch,
|
||||
false,
|
||||
)
|
||||
.unwrap()
|
||||
.with_skills(vec![SkillRef::new(sid(5), SkillScope::Project)]),
|
||||
);
|
||||
assert_eq!(roundtrip(&entry), entry);
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(json.contains("\"skillId\""), "json was {json}");
|
||||
assert!(json.contains("\"scope\":\"project\""), "json was {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_entry_without_skills_omits_field_and_deserialises() {
|
||||
// An entry with no skills must not emit "skills" (skip_serializing_if),
|
||||
// and a pre-L12 manifest JSON (no skills key) must deserialise to empty.
|
||||
let entry =
|
||||
ManifestEntry::new(aid(1), "dev", "agents/dev.md", profid(9), None, false, None).unwrap();
|
||||
let json = serde_json::to_string(&entry).unwrap();
|
||||
assert!(!json.contains("\"skills\""), "json was {json}");
|
||||
|
||||
let legacy = r#"{"agentId":"00000000-0000-0000-0000-000000000001","name":"dev","mdPath":"agents/dev.md","profileId":"00000000-0000-0000-0000-000000000009","synchronized":false}"#;
|
||||
let parsed: ManifestEntry = serde_json::from_str(legacy).unwrap();
|
||||
assert!(parsed.skills.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LayoutTree (tagged enum: type/node)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user