fix: fix some displays and features

This commit is contained in:
2026-06-06 17:06:45 +02:00
parent 2332b7f815
commit 3be55795a6
31 changed files with 3118 additions and 30 deletions

View File

@ -15,11 +15,11 @@ use std::sync::Arc;
use domain::ports::{
AgentContextStore, AgentRuntime, ContextInjectionPlan, EventBus, FileSystem, PreparedContext,
ProfileStore, PtyPort, RemotePath, SpawnSpec,
ProfileStore, PtyPort, RemotePath, SkillStore, SpawnSpec, StoreError,
};
use domain::{
Agent, AgentId, AgentManifest, AgentOrigin, DomainEvent, ManifestEntry, MarkdownDoc, NodeId,
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, TerminalSession,
Project, ProfileId, ProjectPath, PtySize, SessionKind, SessionStatus, Skill, TerminalSession,
};
use crate::error::AppError;
@ -350,6 +350,7 @@ pub struct LaunchAgent {
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
}
@ -364,6 +365,7 @@ impl LaunchAgent {
runtime: Arc<dyn AgentRuntime>,
fs: Arc<dyn FileSystem>,
pty: Arc<dyn PtyPort>,
skills: Arc<dyn SkillStore>,
sessions: Arc<TerminalSessions>,
events: Arc<dyn EventBus>,
) -> Self {
@ -373,11 +375,35 @@ impl LaunchAgent {
runtime,
fs,
pty,
skills,
sessions,
events,
}
}
/// Resolves the Markdown bodies of an agent's assigned skills, in the
/// **manifest order** (deterministic). A skill that no longer exists in its
/// store (deleted out from under the assignment) is silently skipped — a
/// dangling [`domain::SkillRef`] must not block a launch.
///
/// # Errors
/// [`AppError::Store`] on any store failure other than a missing skill.
async fn resolve_skills(
&self,
agent: &Agent,
root: &ProjectPath,
) -> 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 {
Ok(skill) => out.push(skill),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
}
}
Ok(out)
}
/// Executes the launch.
///
/// Step order is contractually significant (and unit-tested): resolve the
@ -444,8 +470,10 @@ impl LaunchAgent {
.runtime
.prepare_invocation(&profile, &prepared, &run_dir)?;
// 5. Apply the injection plan side effects *before* spawning.
self.apply_injection(&input.project, &agent.context_path, &content, &mut spec)
// 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?;
self.apply_injection(&input.project, &agent.context_path, &content, &skills, &mut spec)
.await?;
// 6. Spawn the PTY at the resolved cwd; adopt its session id everywhere.
@ -487,6 +515,7 @@ impl LaunchAgent {
project: &Project,
context_rel_path: &str,
content: &MarkdownDoc,
skills: &[Skill],
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
match spec.context_plan.clone() {
@ -496,9 +525,10 @@ impl LaunchAgent {
// run directory — `spec.cwd` is that run dir, never the project
// root, so there is zero collision between agents. The document is
// composed: an absolute project-root header (so the agent knows
// where to operate, since its cwd is *not* the root) followed by
// the agent's persona `.md`.
let document = compose_convention_file(project.root.as_str(), content.as_str());
// where to operate, since its cwd is *not* the root), the agent's
// persona `.md`, then the bodies of its assigned skills (§14.2).
let document =
compose_convention_file(project.root.as_str(), content.as_str(), skills);
let path = RemotePath::new(join(&spec.cwd, &target));
self.fs.write(&path, document.as_bytes()).await?;
}
@ -537,13 +567,17 @@ fn agent_run_dir(root: &ProjectPath, agent_id: &AgentId) -> Result<ProjectPath,
/// 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) followed by the agent's persona `.md`.
/// 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).
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation, and
/// deliberately structured so future blocks (assigned skills, shared project
/// context — ARCHITECTURE §14.2) can be appended without touching the launcher.
/// Skills are emitted in the order given (the caller passes them in manifest
/// order, making the output deterministic); each is introduced by a `##` header
/// carrying its name. When `skills` is empty the section is omitted entirely, so
/// an agent with no skills gets exactly the previous document.
///
/// Kept as a **pure** function (no I/O) so it is unit-testable in isolation.
#[must_use]
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> String {
pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str, skills: &[Skill]) -> String {
let mut out = String::new();
out.push_str("# Project root\n\n");
out.push_str(project_root);
@ -554,6 +588,17 @@ pub(crate) fn compose_convention_file(project_root: &str, agent_md: &str) -> Str
);
out.push_str("---\n\n");
out.push_str(agent_md);
if !skills.is_empty() {
out.push_str("\n\n---\n\n# Skills\n");
for skill in skills {
out.push_str("\n## ");
out.push_str(&skill.name);
out.push_str("\n\n");
out.push_str(skill.content_md.as_str());
out.push('\n');
}
}
out
}
@ -610,7 +655,7 @@ mod tests {
#[test]
fn compose_convention_file_carries_root_then_persona() {
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.");
let doc = compose_convention_file("/abs/project/root", "# Persona\n\nDo things.", &[]);
// Absolute project root present.
assert!(doc.contains("/abs/project/root"));
@ -621,5 +666,38 @@ mod tests {
let root_at = doc.find("/abs/project/root").unwrap();
let persona_at = doc.find("# Persona").unwrap();
assert!(root_at < persona_at, "root header must precede the persona");
// No skills ⇒ no Skills section.
assert!(!doc.contains("# Skills"));
}
#[test]
fn compose_convention_file_appends_assigned_skills_in_order() {
let s = |n: u128, name: &str, body: &str| {
Skill::new(
domain::SkillId::from_uuid(uuid::Uuid::from_u128(n)),
name,
MarkdownDoc::new(body),
domain::SkillScope::Global,
)
.unwrap()
};
let doc = compose_convention_file(
"/root",
"# Persona",
&[s(1, "refactor", "REFAC_BODY"), s(2, "review", "REVIEW_BODY")],
);
// Both skill bodies present, after the persona.
assert!(doc.contains("REFAC_BODY"));
assert!(doc.contains("REVIEW_BODY"));
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, "skills come after the persona");
// Deterministic order: first assigned skill precedes the second.
assert!(refac_at < review_at, "skills emitted in the given order");
// Skill names surface as sub-headers.
assert!(doc.contains("## refactor"));
assert!(doc.contains("## review"));
}
}